Skip to main content

icydb_core/db/sql/ddl/
mod.rs

1//! Module: db::sql::ddl
2//! Responsibility: bind parsed SQL DDL to accepted schema catalog contracts.
3//! Does not own: mutation planning, physical index rebuilds, or SQL execution.
4//! Boundary: translates parser-owned DDL syntax into catalog-native requests.
5
6mod admission;
7mod field;
8pub(in crate::db) use field::{
9    BoundSqlAddColumnRequest, BoundSqlAlterColumnDefaultRequest,
10    BoundSqlAlterColumnNullabilityRequest, BoundSqlDropColumnRequest, BoundSqlRenameColumnRequest,
11};
12use field::{
13    bind_alter_table_add_column_statement, bind_alter_table_alter_column_statement,
14    bind_alter_table_drop_column_statement, bind_alter_table_rename_column_statement,
15};
16
17mod index;
18pub(in crate::db) use index::{BoundSqlCreateIndexRequest, BoundSqlDropIndexRequest};
19use index::{bind_create_index_statement, bind_drop_index_statement, ddl_key_item_report};
20
21use crate::db::{
22    schema::{
23        AcceptedSchemaSnapshot, SchemaDdlAcceptedSnapshotDerivation,
24        SchemaDdlMutationAdmissionError, SchemaInfo, SchemaVersion,
25        derive_sql_ddl_expression_index_accepted_after,
26        derive_sql_ddl_field_addition_accepted_after, derive_sql_ddl_field_default_accepted_after,
27        derive_sql_ddl_field_drop_accepted_after, derive_sql_ddl_field_nullability_accepted_after,
28        derive_sql_ddl_field_path_index_accepted_after, derive_sql_ddl_field_rename_accepted_after,
29        derive_sql_ddl_secondary_index_drop_accepted_after,
30    },
31    sql::parser::{SqlDdlSchemaVersionContract, SqlDdlStatement, SqlStatement},
32};
33use thiserror::Error as ThisError;
34
35#[cfg(test)]
36use crate::db::schema::{
37    SchemaDdlMutationAdmission, admit_sql_ddl_expression_index_candidate,
38    admit_sql_ddl_field_addition_candidate, admit_sql_ddl_field_default_candidate,
39    admit_sql_ddl_field_drop_candidate, admit_sql_ddl_field_nullability_candidate,
40    admit_sql_ddl_field_path_index_candidate, admit_sql_ddl_field_rename_candidate,
41    admit_sql_ddl_secondary_index_drop_candidate,
42};
43
44///
45/// PreparedSqlDdlCommand
46///
47/// Fully prepared SQL DDL command. This is intentionally not executable yet:
48/// it packages the accepted-catalog binding, accepted-after derivation, and
49/// schema mutation admission proof for the future execution boundary.
50///
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub(in crate::db) struct PreparedSqlDdlCommand {
53    bound: BoundSqlDdlRequest,
54    derivation: Option<SchemaDdlAcceptedSnapshotDerivation>,
55    report: SqlDdlPreparationReport,
56}
57
58impl PreparedSqlDdlCommand {
59    /// Borrow the accepted-catalog-bound DDL request.
60    #[must_use]
61    pub(in crate::db) const fn bound(&self) -> &BoundSqlDdlRequest {
62        &self.bound
63    }
64
65    /// Borrow the accepted-after derivation proof.
66    #[must_use]
67    pub(in crate::db) const fn derivation(&self) -> Option<&SchemaDdlAcceptedSnapshotDerivation> {
68        self.derivation.as_ref()
69    }
70
71    /// Borrow the developer-facing preparation report.
72    #[must_use]
73    pub(in crate::db) const fn report(&self) -> &SqlDdlPreparationReport {
74        &self.report
75    }
76
77    /// Return whether this prepared command needs schema or storage mutation.
78    #[must_use]
79    pub(in crate::db) const fn mutates_schema(&self) -> bool {
80        self.derivation.is_some()
81    }
82}
83
84///
85/// SqlDdlPreparationReport
86///
87/// Compact report for a DDL command that has passed all pre-execution
88/// frontend and schema-mutation checks.
89///
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct SqlDdlPreparationReport {
92    mutation_kind: SqlDdlMutationKind,
93    target_index: String,
94    target_store: String,
95    field_path: Vec<String>,
96    execution_status: SqlDdlExecutionStatus,
97    rows_scanned: usize,
98    index_keys_written: usize,
99}
100
101impl SqlDdlPreparationReport {
102    /// Return the prepared DDL mutation kind.
103    #[must_use]
104    pub const fn mutation_kind(&self) -> SqlDdlMutationKind {
105        self.mutation_kind
106    }
107
108    /// Borrow the target accepted index name.
109    #[must_use]
110    pub const fn target_index(&self) -> &str {
111        self.target_index.as_str()
112    }
113
114    /// Borrow the target accepted index store path.
115    #[must_use]
116    pub const fn target_store(&self) -> &str {
117        self.target_store.as_str()
118    }
119
120    /// Borrow the target field path.
121    #[must_use]
122    pub const fn field_path(&self) -> &[String] {
123        self.field_path.as_slice()
124    }
125
126    /// Return the execution status captured by this DDL report.
127    #[must_use]
128    pub const fn execution_status(&self) -> SqlDdlExecutionStatus {
129        self.execution_status
130    }
131
132    /// Return rows scanned by DDL execution.
133    #[must_use]
134    pub const fn rows_scanned(&self) -> usize {
135        self.rows_scanned
136    }
137
138    /// Return index keys written by DDL execution.
139    #[must_use]
140    pub const fn index_keys_written(&self) -> usize {
141        self.index_keys_written
142    }
143
144    pub(in crate::db) const fn with_execution_status(
145        mut self,
146        execution_status: SqlDdlExecutionStatus,
147    ) -> Self {
148        self.execution_status = execution_status;
149        self
150    }
151
152    pub(in crate::db) const fn with_execution_metrics(
153        mut self,
154        rows_scanned: usize,
155        index_keys_written: usize,
156    ) -> Self {
157        self.rows_scanned = rows_scanned;
158        self.index_keys_written = index_keys_written;
159        self
160    }
161}
162
163///
164/// SqlDdlMutationKind
165///
166/// Developer-facing SQL DDL mutation kind.
167///
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum SqlDdlMutationKind {
170    AddDefaultedField,
171    AddNullableField,
172    SetFieldDefault,
173    DropFieldDefault,
174    SetFieldNotNull,
175    DropFieldNotNull,
176    DropField,
177    RenameField,
178    AddFieldPathIndex,
179    AddExpressionIndex,
180    DropSecondaryIndex,
181}
182
183impl SqlDdlMutationKind {
184    /// Return the stable diagnostic label for this DDL mutation kind.
185    #[must_use]
186    pub const fn as_str(self) -> &'static str {
187        match self {
188            Self::AddDefaultedField => "add_defaulted_field",
189            Self::AddNullableField => "add_nullable_field",
190            Self::SetFieldDefault => "set_field_default",
191            Self::DropFieldDefault => "drop_field_default",
192            Self::SetFieldNotNull => "set_field_not_null",
193            Self::DropFieldNotNull => "drop_field_not_null",
194            Self::DropField => "drop_field",
195            Self::RenameField => "rename_field",
196            Self::AddFieldPathIndex => "add_field_path_index",
197            Self::AddExpressionIndex => "add_expression_index",
198            Self::DropSecondaryIndex => "drop_secondary_index",
199        }
200    }
201}
202
203///
204/// SqlDdlExecutionStatus
205///
206/// SQL DDL execution state at the current boundary.
207///
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum SqlDdlExecutionStatus {
210    PreparedOnly,
211    Published,
212    NoOp,
213}
214
215impl SqlDdlExecutionStatus {
216    /// Return the stable diagnostic label for this execution status.
217    #[must_use]
218    pub const fn as_str(self) -> &'static str {
219        match self {
220            Self::PreparedOnly => "prepared_only",
221            Self::Published => "published",
222            Self::NoOp => "no_op",
223        }
224    }
225}
226
227///
228/// BoundSqlDdlRequest
229///
230/// Accepted-catalog SQL DDL request after parser syntax has been resolved
231/// against one runtime schema snapshot.
232///
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub(in crate::db) struct BoundSqlDdlRequest {
235    statement: BoundSqlDdlStatement,
236    schema_version_contract: BoundSqlDdlSchemaVersionContract,
237}
238
239impl BoundSqlDdlRequest {
240    /// Borrow the bound statement payload.
241    #[must_use]
242    pub(in crate::db) const fn statement(&self) -> &BoundSqlDdlStatement {
243        &self.statement
244    }
245
246    /// Borrow the source-declared DDL schema-version contract.
247    #[must_use]
248    pub(in crate::db) const fn schema_version_contract(&self) -> BoundSqlDdlSchemaVersionContract {
249        self.schema_version_contract
250    }
251}
252
253///
254/// BoundSqlDdlSchemaVersionContract
255///
256/// Accepted-catalog DDL version intent after raw parser values have been
257/// checked for positive schema-version numbers.
258///
259#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
260pub(in crate::db) struct BoundSqlDdlSchemaVersionContract {
261    expected_schema_version: Option<SchemaVersion>,
262    next_schema_version: Option<SchemaVersion>,
263}
264
265impl BoundSqlDdlSchemaVersionContract {
266    /// Return the declared accepted-before schema version.
267    #[must_use]
268    pub(in crate::db) const fn expected_schema_version(self) -> Option<SchemaVersion> {
269        self.expected_schema_version
270    }
271
272    /// Return the declared accepted-after schema version.
273    #[must_use]
274    pub(in crate::db) const fn next_schema_version(self) -> Option<SchemaVersion> {
275        self.next_schema_version
276    }
277}
278
279///
280/// BoundSqlDdlStatement
281///
282/// Catalog-resolved DDL statement vocabulary.
283///
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub(in crate::db) enum BoundSqlDdlStatement {
286    AddColumn(BoundSqlAddColumnRequest),
287    AlterColumnDefault(BoundSqlAlterColumnDefaultRequest),
288    AlterColumnNullability(BoundSqlAlterColumnNullabilityRequest),
289    DropColumn(BoundSqlDropColumnRequest),
290    RenameColumn(BoundSqlRenameColumnRequest),
291    CreateIndex(BoundSqlCreateIndexRequest),
292    DropIndex(BoundSqlDropIndexRequest),
293    NoOp(BoundSqlDdlNoOpRequest),
294}
295
296///
297/// BoundSqlDdlNoOpRequest
298///
299/// Catalog-resolved idempotent DDL request that is already satisfied or absent.
300///
301#[derive(Clone, Debug, Eq, PartialEq)]
302pub(in crate::db) struct BoundSqlDdlNoOpRequest {
303    mutation_kind: SqlDdlMutationKind,
304    index_name: String,
305    entity_name: String,
306    target_store: String,
307    field_path: Vec<String>,
308}
309
310impl BoundSqlDdlNoOpRequest {
311    /// Return the user-facing mutation family this no-op belongs to.
312    #[must_use]
313    pub(in crate::db) const fn mutation_kind(&self) -> SqlDdlMutationKind {
314        self.mutation_kind
315    }
316
317    /// Borrow the requested index name.
318    #[must_use]
319    pub(in crate::db) const fn index_name(&self) -> &str {
320        self.index_name.as_str()
321    }
322
323    /// Borrow the accepted entity name that owns this request.
324    #[must_use]
325    #[cfg(test)]
326    pub(in crate::db) const fn entity_name(&self) -> &str {
327        self.entity_name.as_str()
328    }
329
330    /// Borrow the accepted index store path, or `-` when no target exists.
331    #[must_use]
332    pub(in crate::db) const fn target_store(&self) -> &str {
333        self.target_store.as_str()
334    }
335
336    /// Borrow the target field path, empty when no target exists.
337    #[must_use]
338    pub(in crate::db) const fn field_path(&self) -> &[String] {
339        self.field_path.as_slice()
340    }
341}
342
343///
344/// SqlDdlBindError
345///
346/// Typed fail-closed reasons for SQL DDL catalog binding.
347///
348#[derive(Debug, Eq, PartialEq, ThisError)]
349pub(in crate::db) enum SqlDdlBindError {
350    #[error("SQL DDL binder requires a DDL statement")]
351    NotDdl,
352
353    #[error("accepted schema does not expose an entity name")]
354    MissingEntityName,
355
356    #[error("SQL entity '{sql_entity}' does not match accepted entity '{expected_entity}'")]
357    EntityMismatch {
358        sql_entity: String,
359        expected_entity: String,
360    },
361
362    #[error("unknown field path '{field_path}' for accepted entity '{entity_name}'")]
363    UnknownFieldPath {
364        entity_name: String,
365        field_path: String,
366    },
367
368    #[error("field path '{field_path}' is not indexable")]
369    FieldPathNotIndexable { field_path: String },
370
371    #[error("field path '{field_path}' depends on generated-only metadata")]
372    FieldPathNotAcceptedCatalogBacked { field_path: String },
373
374    #[error("invalid filtered index predicate: {detail}")]
375    InvalidFilteredIndexPredicate { detail: String },
376
377    #[error("index name '{index_name}' already exists in the accepted schema")]
378    DuplicateIndexName { index_name: String },
379
380    #[error("accepted schema already has index '{existing_index}' for field path '{field_path}'")]
381    DuplicateFieldPathIndex {
382        field_path: String,
383        existing_index: String,
384    },
385
386    #[error("unknown index '{index_name}' for accepted entity '{entity_name}'")]
387    UnknownIndex {
388        entity_name: String,
389        index_name: String,
390    },
391
392    #[error(
393        "index '{index_name}' is generated by the entity model and cannot be dropped with SQL DDL; remove the index from the entity schema macro instead"
394    )]
395    GeneratedIndexDropRejected { index_name: String },
396
397    #[error(
398        "index '{index_name}' is not a supported DDL-droppable secondary index; SQL DDL can currently drop only indexes created through SQL DDL"
399    )]
400    UnsupportedDropIndex { index_name: String },
401
402    #[error(
403        "SQL DDL ALTER TABLE ADD COLUMN DEFAULT value is not encodable for accepted entity '{entity_name}' column '{column_name}': {detail}"
404    )]
405    InvalidAlterTableAddColumnDefault {
406        entity_name: String,
407        column_name: String,
408        detail: String,
409    },
410
411    #[error(
412        "SQL DDL ALTER TABLE ADD COLUMN NOT NULL is not executable yet for accepted entity '{entity_name}' column '{column_name}'"
413    )]
414    UnsupportedAlterTableAddColumnNotNull {
415        entity_name: String,
416        column_name: String,
417    },
418
419    #[error("field '{column_name}' already exists in accepted entity '{entity_name}'")]
420    DuplicateColumn {
421        entity_name: String,
422        column_name: String,
423    },
424
425    #[error(
426        "SQL DDL ALTER TABLE ADD COLUMN type '{column_type}' is not supported yet for accepted entity '{entity_name}' column '{column_name}'"
427    )]
428    UnsupportedAlterTableAddColumnType {
429        entity_name: String,
430        column_name: String,
431        column_type: String,
432    },
433
434    #[error("unknown column '{column_name}' for accepted entity '{entity_name}'")]
435    UnknownColumn {
436        entity_name: String,
437        column_name: String,
438    },
439
440    #[error(
441        "SQL DDL ALTER TABLE ALTER COLUMN SET DEFAULT value is not encodable for accepted entity '{entity_name}' column '{column_name}': {detail}"
442    )]
443    InvalidAlterTableAlterColumnDefault {
444        entity_name: String,
445        column_name: String,
446        detail: String,
447    },
448
449    #[error(
450        "SQL DDL ALTER TABLE ALTER COLUMN DROP DEFAULT is not executable yet for required accepted entity '{entity_name}' column '{column_name}'"
451    )]
452    UnsupportedAlterTableDropDefaultRequired {
453        entity_name: String,
454        column_name: String,
455    },
456
457    #[error(
458        "SQL DDL ALTER TABLE ALTER COLUMN DEFAULT cannot change generated accepted field '{column_name}' on entity '{entity_name}'; change the Rust schema default instead"
459    )]
460    GeneratedFieldDefaultChangeRejected {
461        entity_name: String,
462        column_name: String,
463    },
464
465    #[error(
466        "SQL DDL ALTER TABLE ALTER COLUMN NULLABILITY cannot change generated accepted field '{column_name}' on entity '{entity_name}'; change the Rust schema nullability instead"
467    )]
468    GeneratedFieldNullabilityChangeRejected {
469        entity_name: String,
470        column_name: String,
471    },
472
473    #[error(
474        "SQL DDL ALTER TABLE DROP COLUMN cannot drop primary-key field '{column_name}' on entity '{entity_name}'"
475    )]
476    PrimaryKeyFieldDropRejected {
477        entity_name: String,
478        column_name: String,
479    },
480
481    #[error(
482        "SQL DDL ALTER TABLE DROP COLUMN cannot change generated accepted field '{column_name}' on entity '{entity_name}'; remove the field from the Rust schema instead"
483    )]
484    GeneratedFieldDropRejected {
485        entity_name: String,
486        column_name: String,
487    },
488
489    #[error(
490        "SQL DDL ALTER TABLE DROP COLUMN cannot drop accepted field '{column_name}' on entity '{entity_name}' while index '{index_name}' depends on it; drop dependent DDL-owned indexes first"
491    )]
492    IndexedFieldDropRejected {
493        entity_name: String,
494        column_name: String,
495        index_name: String,
496    },
497
498    #[error(
499        "SQL DDL ALTER TABLE RENAME COLUMN cannot change generated accepted field '{column_name}' on entity '{entity_name}'; rename the field in the Rust schema instead"
500    )]
501    GeneratedFieldRenameRejected {
502        entity_name: String,
503        column_name: String,
504    },
505
506    #[error("SQL DDL {clause} must be a positive schema version")]
507    NonPositiveSchemaVersion { clause: &'static str },
508
509    #[error("mutating SQL DDL requires EXPECT SCHEMA VERSION")]
510    MissingExpectedSchemaVersion,
511
512    #[error("mutating SQL DDL requires SET SCHEMA VERSION")]
513    MissingNextSchemaVersion,
514
515    #[error(
516        "SQL DDL expected accepted schema version {expected}, but accepted schema version is {accepted}"
517    )]
518    StaleExpectedSchemaVersion { expected: u32, accepted: u32 },
519
520    #[error("SQL DDL no-op cannot SET SCHEMA VERSION {requested}")]
521    EmptySchemaVersionBump { requested: u32 },
522}
523
524///
525/// SqlDdlLoweringError
526///
527/// Typed fail-closed reasons while lowering bound DDL into schema mutation
528/// admission.
529///
530#[derive(Debug, Eq, PartialEq, ThisError)]
531pub(in crate::db) enum SqlDdlLoweringError {
532    #[error("SQL DDL lowering requires a supported DDL statement")]
533    UnsupportedStatement,
534
535    #[error("schema mutation admission rejected DDL candidate: {0}")]
536    MutationAdmission(SchemaDdlMutationAdmissionError),
537}
538
539///
540/// SqlDdlPrepareError
541///
542/// Typed fail-closed preparation errors for SQL DDL.
543///
544#[derive(Debug, Eq, PartialEq, ThisError)]
545pub(in crate::db) enum SqlDdlPrepareError {
546    #[error("{0}")]
547    Bind(#[from] SqlDdlBindError),
548
549    #[error("{0}")]
550    Lowering(#[from] SqlDdlLoweringError),
551}
552
553/// Prepare one parsed SQL DDL statement through every pre-execution proof.
554pub(in crate::db) fn prepare_sql_ddl_statement(
555    statement: &SqlStatement,
556    accepted_before: &AcceptedSchemaSnapshot,
557    schema: &SchemaInfo,
558    index_store_path: &'static str,
559) -> Result<PreparedSqlDdlCommand, SqlDdlPrepareError> {
560    let bound = bind_sql_ddl_statement(statement, accepted_before, schema, index_store_path)?;
561    validate_bound_sql_ddl_version_contract(&bound, accepted_before)?;
562    let derivation = if matches!(bound.statement(), BoundSqlDdlStatement::NoOp(_)) {
563        None
564    } else {
565        Some(derive_bound_sql_ddl_accepted_after(
566            accepted_before,
567            &bound,
568        )?)
569    };
570    let report = ddl_preparation_report(&bound);
571
572    Ok(PreparedSqlDdlCommand {
573        bound,
574        derivation,
575        report,
576    })
577}
578
579/// Bind one parsed SQL DDL statement against accepted catalog metadata.
580pub(in crate::db) fn bind_sql_ddl_statement(
581    statement: &SqlStatement,
582    accepted_before: &AcceptedSchemaSnapshot,
583    schema: &SchemaInfo,
584    index_store_path: &'static str,
585) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
586    let SqlStatement::Ddl(ddl) = statement else {
587        return Err(SqlDdlBindError::NotDdl);
588    };
589
590    let mut bound = match ddl {
591        SqlDdlStatement::CreateIndex(statement) => {
592            bind_create_index_statement(statement, schema, index_store_path)
593        }
594        SqlDdlStatement::DropIndex(statement) => {
595            bind_drop_index_statement(statement, accepted_before, schema)
596        }
597        SqlDdlStatement::AlterTableAddColumn(statement) => {
598            bind_alter_table_add_column_statement(statement, accepted_before, schema)
599        }
600        SqlDdlStatement::AlterTableAlterColumn(statement) => {
601            bind_alter_table_alter_column_statement(statement, accepted_before, schema)
602        }
603        SqlDdlStatement::AlterTableDropColumn(statement) => {
604            bind_alter_table_drop_column_statement(statement, accepted_before, schema)
605        }
606        SqlDdlStatement::AlterTableRenameColumn(statement) => {
607            bind_alter_table_rename_column_statement(statement, accepted_before, schema)
608        }
609    }?;
610    bound.schema_version_contract =
611        bind_sql_ddl_schema_version_contract(ddl_version_contract(ddl))?;
612
613    Ok(bound)
614}
615
616/// Lower one bound SQL DDL request through schema mutation admission.
617#[cfg(test)]
618pub(in crate::db) fn lower_bound_sql_ddl_to_schema_mutation_admission(
619    request: &BoundSqlDdlRequest,
620) -> Result<SchemaDdlMutationAdmission, SqlDdlLoweringError> {
621    match request.statement() {
622        BoundSqlDdlStatement::AddColumn(add) => {
623            Ok(admit_sql_ddl_field_addition_candidate(add.field()))
624        }
625        BoundSqlDdlStatement::AlterColumnDefault(alter) => {
626            Ok(admit_sql_ddl_field_default_candidate(alter.field()))
627        }
628        BoundSqlDdlStatement::AlterColumnNullability(alter) => {
629            Ok(admit_sql_ddl_field_nullability_candidate(alter.field()))
630        }
631        BoundSqlDdlStatement::DropColumn(drop) => {
632            Ok(admit_sql_ddl_field_drop_candidate(drop.field()))
633        }
634        BoundSqlDdlStatement::RenameColumn(rename) => {
635            let after = rename
636                .field()
637                .clone_with_name(rename.new_name().to_string());
638            Ok(admit_sql_ddl_field_rename_candidate(rename.field(), &after))
639        }
640        BoundSqlDdlStatement::CreateIndex(create) => {
641            if create.candidate_index().key().is_field_path_only() {
642                admit_sql_ddl_field_path_index_candidate(create.candidate_index())
643            } else {
644                admit_sql_ddl_expression_index_candidate(create.candidate_index())
645            }
646        }
647        BoundSqlDdlStatement::DropIndex(drop) => {
648            admit_sql_ddl_secondary_index_drop_candidate(drop.dropped_index())
649        }
650        BoundSqlDdlStatement::NoOp(_) => return Err(SqlDdlLoweringError::UnsupportedStatement),
651    }
652    .map_err(SqlDdlLoweringError::MutationAdmission)
653}
654
655/// Derive the accepted-after schema snapshot for one bound SQL DDL request.
656pub(in crate::db) fn derive_bound_sql_ddl_accepted_after(
657    accepted_before: &AcceptedSchemaSnapshot,
658    request: &BoundSqlDdlRequest,
659) -> Result<SchemaDdlAcceptedSnapshotDerivation, SqlDdlLoweringError> {
660    let next_schema_version = request
661        .schema_version_contract()
662        .next_schema_version()
663        .ok_or(SqlDdlLoweringError::UnsupportedStatement)?;
664    let derivation = match request.statement() {
665        BoundSqlDdlStatement::AddColumn(add) => {
666            derive_sql_ddl_field_addition_accepted_after(accepted_before, add.field().clone())
667        }
668        BoundSqlDdlStatement::AlterColumnDefault(alter) => {
669            derive_sql_ddl_field_default_accepted_after(
670                accepted_before,
671                alter.field_name(),
672                alter.default().clone(),
673            )
674        }
675        BoundSqlDdlStatement::AlterColumnNullability(alter) => {
676            derive_sql_ddl_field_nullability_accepted_after(
677                accepted_before,
678                alter.field_name(),
679                alter.nullable(),
680            )
681        }
682        BoundSqlDdlStatement::DropColumn(drop) => {
683            derive_sql_ddl_field_drop_accepted_after(accepted_before, drop.field_name())
684        }
685        BoundSqlDdlStatement::RenameColumn(rename) => derive_sql_ddl_field_rename_accepted_after(
686            accepted_before,
687            rename.old_name(),
688            rename.new_name(),
689        ),
690        BoundSqlDdlStatement::CreateIndex(create) => {
691            if create.candidate_index().key().is_field_path_only() {
692                derive_sql_ddl_field_path_index_accepted_after(
693                    accepted_before,
694                    create.candidate_index().clone(),
695                )
696            } else {
697                derive_sql_ddl_expression_index_accepted_after(
698                    accepted_before,
699                    create.candidate_index().clone(),
700                )
701            }
702        }
703        BoundSqlDdlStatement::DropIndex(drop) => {
704            derive_sql_ddl_secondary_index_drop_accepted_after(
705                accepted_before,
706                drop.dropped_index(),
707            )
708        }
709        BoundSqlDdlStatement::NoOp(_) => return Err(SqlDdlLoweringError::UnsupportedStatement),
710    }
711    .map_err(SqlDdlLoweringError::MutationAdmission)?;
712
713    derivation
714        .with_declared_schema_version(accepted_before, next_schema_version)
715        .map_err(SqlDdlLoweringError::MutationAdmission)
716}
717
718const fn ddl_version_contract(ddl: &SqlDdlStatement) -> SqlDdlSchemaVersionContract {
719    match ddl {
720        SqlDdlStatement::CreateIndex(statement) => statement.schema_version_contract,
721        SqlDdlStatement::DropIndex(statement) => statement.schema_version_contract,
722        SqlDdlStatement::AlterTableAddColumn(statement) => statement.schema_version_contract,
723        SqlDdlStatement::AlterTableAlterColumn(statement) => statement.schema_version_contract,
724        SqlDdlStatement::AlterTableDropColumn(statement) => statement.schema_version_contract,
725        SqlDdlStatement::AlterTableRenameColumn(statement) => statement.schema_version_contract,
726    }
727}
728
729fn bind_sql_ddl_schema_version_contract(
730    contract: SqlDdlSchemaVersionContract,
731) -> Result<BoundSqlDdlSchemaVersionContract, SqlDdlBindError> {
732    Ok(BoundSqlDdlSchemaVersionContract {
733        expected_schema_version: bind_sql_ddl_schema_version(
734            "EXPECT SCHEMA VERSION",
735            contract.expected_schema_version,
736        )?,
737        next_schema_version: bind_sql_ddl_schema_version(
738            "SET SCHEMA VERSION",
739            contract.next_schema_version,
740        )?,
741    })
742}
743
744fn bind_sql_ddl_schema_version(
745    clause: &'static str,
746    value: Option<u32>,
747) -> Result<Option<SchemaVersion>, SqlDdlBindError> {
748    value
749        .map(|raw| {
750            if raw == 0 {
751                Err(SqlDdlBindError::NonPositiveSchemaVersion { clause })
752            } else {
753                Ok(SchemaVersion::new(raw))
754            }
755        })
756        .transpose()
757}
758
759fn validate_bound_sql_ddl_version_contract(
760    bound: &BoundSqlDdlRequest,
761    accepted_before: &AcceptedSchemaSnapshot,
762) -> Result<(), SqlDdlBindError> {
763    let contract = bound.schema_version_contract();
764    let accepted_version = accepted_before.persisted_snapshot().version();
765    if let Some(expected) = contract.expected_schema_version()
766        && expected != accepted_version
767    {
768        return Err(SqlDdlBindError::StaleExpectedSchemaVersion {
769            expected: expected.get(),
770            accepted: accepted_version.get(),
771        });
772    }
773    if matches!(bound.statement(), BoundSqlDdlStatement::NoOp(_)) {
774        if contract.expected_schema_version().is_none() {
775            return Err(SqlDdlBindError::MissingExpectedSchemaVersion);
776        }
777        if let Some(requested) = contract.next_schema_version() {
778            return Err(SqlDdlBindError::EmptySchemaVersionBump {
779                requested: requested.get(),
780            });
781        }
782
783        return Ok(());
784    }
785    if contract.expected_schema_version().is_none() {
786        return Err(SqlDdlBindError::MissingExpectedSchemaVersion);
787    }
788    if contract.next_schema_version().is_none() {
789        return Err(SqlDdlBindError::MissingNextSchemaVersion);
790    }
791
792    Ok(())
793}
794
795fn ddl_preparation_report(bound: &BoundSqlDdlRequest) -> SqlDdlPreparationReport {
796    match bound.statement() {
797        BoundSqlDdlStatement::AddColumn(add) => SqlDdlPreparationReport {
798            mutation_kind: if add.field().default().is_none() {
799                SqlDdlMutationKind::AddNullableField
800            } else {
801                SqlDdlMutationKind::AddDefaultedField
802            },
803            target_index: add.field().name().to_string(),
804            target_store: add.entity_name().to_string(),
805            field_path: vec![add.field().name().to_string()],
806            execution_status: SqlDdlExecutionStatus::PreparedOnly,
807            rows_scanned: 0,
808            index_keys_written: 0,
809        },
810        BoundSqlDdlStatement::AlterColumnDefault(alter) => SqlDdlPreparationReport {
811            mutation_kind: alter.mutation_kind(),
812            target_index: alter.field_name().to_string(),
813            target_store: alter.entity_name().to_string(),
814            field_path: vec![alter.field_name().to_string()],
815            execution_status: SqlDdlExecutionStatus::PreparedOnly,
816            rows_scanned: 0,
817            index_keys_written: 0,
818        },
819        BoundSqlDdlStatement::AlterColumnNullability(alter) => SqlDdlPreparationReport {
820            mutation_kind: alter.mutation_kind(),
821            target_index: alter.field_name().to_string(),
822            target_store: alter.entity_name().to_string(),
823            field_path: vec![alter.field_name().to_string()],
824            execution_status: SqlDdlExecutionStatus::PreparedOnly,
825            rows_scanned: 0,
826            index_keys_written: 0,
827        },
828        BoundSqlDdlStatement::DropColumn(drop) => SqlDdlPreparationReport {
829            mutation_kind: SqlDdlMutationKind::DropField,
830            target_index: drop.field_name().to_string(),
831            target_store: drop.entity_name().to_string(),
832            field_path: vec![drop.field_name().to_string()],
833            execution_status: SqlDdlExecutionStatus::PreparedOnly,
834            rows_scanned: 0,
835            index_keys_written: 0,
836        },
837        BoundSqlDdlStatement::RenameColumn(rename) => SqlDdlPreparationReport {
838            mutation_kind: SqlDdlMutationKind::RenameField,
839            target_index: rename.new_name().to_string(),
840            target_store: rename.entity_name().to_string(),
841            field_path: vec![rename.old_name().to_string(), rename.new_name().to_string()],
842            execution_status: SqlDdlExecutionStatus::PreparedOnly,
843            rows_scanned: 0,
844            index_keys_written: 0,
845        },
846        BoundSqlDdlStatement::CreateIndex(create) => {
847            let target = create.candidate_index();
848
849            SqlDdlPreparationReport {
850                mutation_kind: if target.key().is_field_path_only() {
851                    SqlDdlMutationKind::AddFieldPathIndex
852                } else {
853                    SqlDdlMutationKind::AddExpressionIndex
854                },
855                target_index: target.name().to_string(),
856                target_store: target.store().to_string(),
857                field_path: ddl_key_item_report(create.key_items()),
858                execution_status: SqlDdlExecutionStatus::PreparedOnly,
859                rows_scanned: 0,
860                index_keys_written: 0,
861            }
862        }
863        BoundSqlDdlStatement::DropIndex(drop) => SqlDdlPreparationReport {
864            mutation_kind: SqlDdlMutationKind::DropSecondaryIndex,
865            target_index: drop.index_name().to_string(),
866            target_store: drop.dropped_index().store().to_string(),
867            field_path: drop.field_path().to_vec(),
868            execution_status: SqlDdlExecutionStatus::PreparedOnly,
869            rows_scanned: 0,
870            index_keys_written: 0,
871        },
872        BoundSqlDdlStatement::NoOp(no_op) => SqlDdlPreparationReport {
873            mutation_kind: no_op.mutation_kind(),
874            target_index: no_op.index_name().to_string(),
875            target_store: no_op.target_store().to_string(),
876            field_path: no_op.field_path().to_vec(),
877            execution_status: SqlDdlExecutionStatus::PreparedOnly,
878            rows_scanned: 0,
879            index_keys_written: 0,
880        },
881    }
882}