Skip to main content

icydb_core/db/sql/
ddl.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
6#![allow(
7    dead_code,
8    reason = "DDL binding exposes prepare-only diagnostics and test-only inspection accessors"
9)]
10
11use crate::db::{
12    schema::{
13        AcceptedSchemaSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
14        SchemaDdlAcceptedSnapshotDerivation, SchemaDdlIndexDropCandidateError,
15        SchemaDdlMutationAdmission, SchemaDdlMutationAdmissionError, SchemaInfo,
16        admit_sql_ddl_field_path_index_candidate, admit_sql_ddl_secondary_index_drop_candidate,
17        derive_sql_ddl_field_path_index_accepted_after,
18        derive_sql_ddl_secondary_index_drop_accepted_after,
19        resolve_sql_ddl_secondary_index_drop_candidate,
20    },
21    sql::{
22        identifier::identifiers_tail_match,
23        parser::{SqlCreateIndexStatement, SqlDdlStatement, SqlDropIndexStatement, SqlStatement},
24    },
25};
26use crate::model::EntityModel;
27use thiserror::Error as ThisError;
28
29///
30/// PreparedSqlDdlCommand
31///
32/// Fully prepared SQL DDL command. This is intentionally not executable yet:
33/// it packages the accepted-catalog binding, accepted-after derivation, and
34/// schema mutation admission proof for the future execution boundary.
35///
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub(in crate::db) struct PreparedSqlDdlCommand {
38    bound: BoundSqlDdlRequest,
39    derivation: SchemaDdlAcceptedSnapshotDerivation,
40    report: SqlDdlPreparationReport,
41}
42
43impl PreparedSqlDdlCommand {
44    /// Borrow the accepted-catalog-bound DDL request.
45    #[must_use]
46    pub(in crate::db) const fn bound(&self) -> &BoundSqlDdlRequest {
47        &self.bound
48    }
49
50    /// Borrow the accepted-after derivation proof.
51    #[must_use]
52    pub(in crate::db) const fn derivation(&self) -> &SchemaDdlAcceptedSnapshotDerivation {
53        &self.derivation
54    }
55
56    /// Borrow the developer-facing preparation report.
57    #[must_use]
58    pub(in crate::db) const fn report(&self) -> &SqlDdlPreparationReport {
59        &self.report
60    }
61}
62
63///
64/// SqlDdlPreparationReport
65///
66/// Compact report for a DDL command that has passed all pre-execution
67/// frontend and schema-mutation checks.
68///
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct SqlDdlPreparationReport {
71    mutation_kind: SqlDdlMutationKind,
72    target_index: String,
73    target_store: String,
74    field_path: Vec<String>,
75    execution_status: SqlDdlExecutionStatus,
76    rows_scanned: usize,
77    index_keys_written: usize,
78}
79
80impl SqlDdlPreparationReport {
81    /// Return the prepared DDL mutation kind.
82    #[must_use]
83    pub const fn mutation_kind(&self) -> SqlDdlMutationKind {
84        self.mutation_kind
85    }
86
87    /// Borrow the target accepted index name.
88    #[must_use]
89    pub const fn target_index(&self) -> &str {
90        self.target_index.as_str()
91    }
92
93    /// Borrow the target accepted index store path.
94    #[must_use]
95    pub const fn target_store(&self) -> &str {
96        self.target_store.as_str()
97    }
98
99    /// Borrow the target field path.
100    #[must_use]
101    pub const fn field_path(&self) -> &[String] {
102        self.field_path.as_slice()
103    }
104
105    /// Return the execution status captured by this DDL report.
106    #[must_use]
107    pub const fn execution_status(&self) -> SqlDdlExecutionStatus {
108        self.execution_status
109    }
110
111    /// Return rows scanned by DDL execution.
112    #[must_use]
113    pub const fn rows_scanned(&self) -> usize {
114        self.rows_scanned
115    }
116
117    /// Return index keys written by DDL execution.
118    #[must_use]
119    pub const fn index_keys_written(&self) -> usize {
120        self.index_keys_written
121    }
122
123    pub(in crate::db) const fn with_execution_status(
124        mut self,
125        execution_status: SqlDdlExecutionStatus,
126    ) -> Self {
127        self.execution_status = execution_status;
128        self
129    }
130
131    pub(in crate::db) const fn with_execution_metrics(
132        mut self,
133        rows_scanned: usize,
134        index_keys_written: usize,
135    ) -> Self {
136        self.rows_scanned = rows_scanned;
137        self.index_keys_written = index_keys_written;
138        self
139    }
140}
141
142///
143/// SqlDdlMutationKind
144///
145/// Developer-facing SQL DDL mutation kind.
146///
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum SqlDdlMutationKind {
149    AddNonUniqueFieldPathIndex,
150    DropNonUniqueSecondaryIndex,
151}
152
153impl SqlDdlMutationKind {
154    /// Return the stable diagnostic label for this DDL mutation kind.
155    #[must_use]
156    pub const fn as_str(self) -> &'static str {
157        match self {
158            Self::AddNonUniqueFieldPathIndex => "add_non_unique_field_path_index",
159            Self::DropNonUniqueSecondaryIndex => "drop_non_unique_secondary_index",
160        }
161    }
162}
163
164///
165/// SqlDdlExecutionStatus
166///
167/// SQL DDL execution state at the current boundary.
168///
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub enum SqlDdlExecutionStatus {
171    PreparedOnly,
172    Published,
173}
174
175impl SqlDdlExecutionStatus {
176    /// Return the stable diagnostic label for this execution status.
177    #[must_use]
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::PreparedOnly => "prepared_only",
181            Self::Published => "published",
182        }
183    }
184}
185
186///
187/// BoundSqlDdlRequest
188///
189/// Accepted-catalog SQL DDL request after parser syntax has been resolved
190/// against one runtime schema snapshot.
191///
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub(in crate::db) struct BoundSqlDdlRequest {
194    statement: BoundSqlDdlStatement,
195}
196
197impl BoundSqlDdlRequest {
198    /// Borrow the bound statement payload.
199    #[must_use]
200    pub(in crate::db) const fn statement(&self) -> &BoundSqlDdlStatement {
201        &self.statement
202    }
203}
204
205///
206/// BoundSqlDdlStatement
207///
208/// Catalog-resolved DDL statement vocabulary.
209///
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub(in crate::db) enum BoundSqlDdlStatement {
212    CreateIndex(BoundSqlCreateIndexRequest),
213    DropIndex(BoundSqlDropIndexRequest),
214}
215
216///
217/// BoundSqlCreateIndexRequest
218///
219/// Catalog-resolved request for adding one non-unique field-path secondary
220/// index.
221///
222#[derive(Clone, Debug, Eq, PartialEq)]
223pub(in crate::db) struct BoundSqlCreateIndexRequest {
224    index_name: String,
225    entity_name: String,
226    field_path: BoundSqlDdlFieldPath,
227    candidate_index: PersistedIndexSnapshot,
228}
229
230impl BoundSqlCreateIndexRequest {
231    /// Borrow the requested index name.
232    #[must_use]
233    pub(in crate::db) const fn index_name(&self) -> &str {
234        self.index_name.as_str()
235    }
236
237    /// Borrow the accepted entity name that owns this request.
238    #[must_use]
239    pub(in crate::db) const fn entity_name(&self) -> &str {
240        self.entity_name.as_str()
241    }
242
243    /// Borrow the accepted field-path target.
244    #[must_use]
245    pub(in crate::db) const fn field_path(&self) -> &BoundSqlDdlFieldPath {
246        &self.field_path
247    }
248
249    /// Borrow the candidate accepted index snapshot for mutation admission.
250    #[must_use]
251    pub(in crate::db) const fn candidate_index(&self) -> &PersistedIndexSnapshot {
252        &self.candidate_index
253    }
254}
255
256///
257/// BoundSqlDropIndexRequest
258///
259/// Catalog-resolved request for dropping one DDL-published non-unique
260/// secondary index.
261///
262#[derive(Clone, Debug, Eq, PartialEq)]
263pub(in crate::db) struct BoundSqlDropIndexRequest {
264    index_name: String,
265    entity_name: String,
266    dropped_index: PersistedIndexSnapshot,
267    field_path: Vec<String>,
268}
269
270impl BoundSqlDropIndexRequest {
271    /// Borrow the requested index name.
272    #[must_use]
273    pub(in crate::db) const fn index_name(&self) -> &str {
274        self.index_name.as_str()
275    }
276
277    /// Borrow the accepted entity name that owns this request.
278    #[must_use]
279    pub(in crate::db) const fn entity_name(&self) -> &str {
280        self.entity_name.as_str()
281    }
282
283    /// Borrow the accepted index snapshot that will be removed.
284    #[must_use]
285    pub(in crate::db) const fn dropped_index(&self) -> &PersistedIndexSnapshot {
286        &self.dropped_index
287    }
288
289    /// Borrow the dropped field-path target.
290    #[must_use]
291    pub(in crate::db) const fn field_path(&self) -> &[String] {
292        self.field_path.as_slice()
293    }
294}
295
296///
297/// BoundSqlDdlFieldPath
298///
299/// Accepted field-path target for SQL DDL binding.
300///
301#[derive(Clone, Debug, Eq, PartialEq)]
302pub(in crate::db) struct BoundSqlDdlFieldPath {
303    root: String,
304    segments: Vec<String>,
305    accepted_path: Vec<String>,
306}
307
308impl BoundSqlDdlFieldPath {
309    /// Borrow the top-level field name.
310    #[must_use]
311    pub(in crate::db) const fn root(&self) -> &str {
312        self.root.as_str()
313    }
314
315    /// Borrow nested path segments below the top-level field.
316    #[must_use]
317    pub(in crate::db) const fn segments(&self) -> &[String] {
318        self.segments.as_slice()
319    }
320
321    /// Borrow the full accepted field path used by index metadata.
322    #[must_use]
323    pub(in crate::db) const fn accepted_path(&self) -> &[String] {
324        self.accepted_path.as_slice()
325    }
326}
327
328///
329/// SqlDdlBindError
330///
331/// Typed fail-closed reasons for SQL DDL catalog binding.
332///
333#[derive(Debug, Eq, PartialEq, ThisError)]
334pub(in crate::db) enum SqlDdlBindError {
335    #[error("SQL DDL binder requires a DDL statement")]
336    NotDdl,
337
338    #[error("accepted schema does not expose an entity name")]
339    MissingEntityName,
340
341    #[error("accepted schema does not expose an entity path")]
342    MissingEntityPath,
343
344    #[error("SQL entity '{sql_entity}' does not match accepted entity '{expected_entity}'")]
345    EntityMismatch {
346        sql_entity: String,
347        expected_entity: String,
348    },
349
350    #[error("unknown field path '{field_path}' for accepted entity '{entity_name}'")]
351    UnknownFieldPath {
352        entity_name: String,
353        field_path: String,
354    },
355
356    #[error("field path '{field_path}' is not indexable")]
357    FieldPathNotIndexable { field_path: String },
358
359    #[error("field path '{field_path}' depends on generated-only metadata")]
360    FieldPathNotAcceptedCatalogBacked { field_path: String },
361
362    #[error("index name '{index_name}' already exists in the accepted schema")]
363    DuplicateIndexName { index_name: String },
364
365    #[error("accepted schema already has index '{existing_index}' for field path '{field_path}'")]
366    DuplicateFieldPathIndex {
367        field_path: String,
368        existing_index: String,
369    },
370
371    #[error("unknown index '{index_name}' for accepted entity '{entity_name}'")]
372    UnknownIndex {
373        entity_name: String,
374        index_name: String,
375    },
376
377    #[error(
378        "index '{index_name}' is generated by the entity model and cannot be dropped with SQL DDL"
379    )]
380    GeneratedIndexDropRejected { index_name: String },
381
382    #[error("index '{index_name}' is not a supported DDL-droppable field-path index")]
383    UnsupportedDropIndex { index_name: String },
384}
385
386///
387/// SqlDdlLoweringError
388///
389/// Typed fail-closed reasons while lowering bound DDL into schema mutation
390/// admission.
391///
392#[derive(Debug, Eq, PartialEq, ThisError)]
393pub(in crate::db) enum SqlDdlLoweringError {
394    #[error("SQL DDL lowering requires a supported DDL statement")]
395    UnsupportedStatement,
396
397    #[error("schema mutation admission rejected DDL candidate: {0:?}")]
398    MutationAdmission(SchemaDdlMutationAdmissionError),
399}
400
401///
402/// SqlDdlPrepareError
403///
404/// Typed fail-closed preparation errors for SQL DDL.
405///
406#[derive(Debug, Eq, PartialEq, ThisError)]
407pub(in crate::db) enum SqlDdlPrepareError {
408    #[error("{0}")]
409    Bind(#[from] SqlDdlBindError),
410
411    #[error("{0}")]
412    Lowering(#[from] SqlDdlLoweringError),
413}
414
415/// Prepare one parsed SQL DDL statement through every pre-execution proof.
416pub(in crate::db) fn prepare_sql_ddl_statement(
417    statement: &SqlStatement,
418    accepted_before: &AcceptedSchemaSnapshot,
419    schema: &SchemaInfo,
420    model: &EntityModel,
421    index_store_path: &'static str,
422) -> Result<PreparedSqlDdlCommand, SqlDdlPrepareError> {
423    let bound =
424        bind_sql_ddl_statement(statement, accepted_before, schema, model, index_store_path)?;
425    let derivation = derive_bound_sql_ddl_accepted_after(accepted_before, &bound)?;
426    let report = ddl_preparation_report(&bound, &derivation);
427
428    Ok(PreparedSqlDdlCommand {
429        bound,
430        derivation,
431        report,
432    })
433}
434
435/// Bind one parsed SQL DDL statement against accepted catalog metadata.
436pub(in crate::db) fn bind_sql_ddl_statement(
437    statement: &SqlStatement,
438    accepted_before: &AcceptedSchemaSnapshot,
439    schema: &SchemaInfo,
440    model: &EntityModel,
441    index_store_path: &'static str,
442) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
443    let SqlStatement::Ddl(ddl) = statement else {
444        return Err(SqlDdlBindError::NotDdl);
445    };
446
447    match ddl {
448        SqlDdlStatement::CreateIndex(statement) => {
449            bind_create_index_statement(statement, schema, index_store_path)
450        }
451        SqlDdlStatement::DropIndex(statement) => {
452            bind_drop_index_statement(statement, accepted_before, schema, model)
453        }
454    }
455}
456
457fn bind_create_index_statement(
458    statement: &SqlCreateIndexStatement,
459    schema: &SchemaInfo,
460    index_store_path: &'static str,
461) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
462    let entity_name = schema
463        .entity_name()
464        .ok_or(SqlDdlBindError::MissingEntityName)?;
465
466    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
467        return Err(SqlDdlBindError::EntityMismatch {
468            sql_entity: statement.entity.clone(),
469            expected_entity: entity_name.to_string(),
470        });
471    }
472
473    reject_duplicate_index_name(statement.name.as_str(), schema)?;
474    let field_path =
475        bind_create_index_field_path(statement.field_path.as_str(), entity_name, schema)?;
476    reject_duplicate_field_path_index(&field_path, schema)?;
477    let candidate_index = candidate_index_snapshot(
478        statement.name.as_str(),
479        &field_path,
480        schema,
481        index_store_path,
482    )?;
483
484    Ok(BoundSqlDdlRequest {
485        statement: BoundSqlDdlStatement::CreateIndex(BoundSqlCreateIndexRequest {
486            index_name: statement.name.clone(),
487            entity_name: entity_name.to_string(),
488            field_path,
489            candidate_index,
490        }),
491    })
492}
493
494fn bind_drop_index_statement(
495    statement: &SqlDropIndexStatement,
496    accepted_before: &AcceptedSchemaSnapshot,
497    schema: &SchemaInfo,
498    model: &EntityModel,
499) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
500    let entity_name = schema
501        .entity_name()
502        .ok_or(SqlDdlBindError::MissingEntityName)?;
503
504    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
505        return Err(SqlDdlBindError::EntityMismatch {
506            sql_entity: statement.entity.clone(),
507            expected_entity: entity_name.to_string(),
508        });
509    }
510    let (dropped_index, field_path) =
511        resolve_sql_ddl_secondary_index_drop_candidate(accepted_before, model, &statement.name)
512            .map_err(|error| match error {
513                SchemaDdlIndexDropCandidateError::Generated => {
514                    SqlDdlBindError::GeneratedIndexDropRejected {
515                        index_name: statement.name.clone(),
516                    }
517                }
518                SchemaDdlIndexDropCandidateError::Unknown => SqlDdlBindError::UnknownIndex {
519                    entity_name: entity_name.to_string(),
520                    index_name: statement.name.clone(),
521                },
522                SchemaDdlIndexDropCandidateError::Unsupported => {
523                    SqlDdlBindError::UnsupportedDropIndex {
524                        index_name: statement.name.clone(),
525                    }
526                }
527            })?;
528    Ok(BoundSqlDdlRequest {
529        statement: BoundSqlDdlStatement::DropIndex(BoundSqlDropIndexRequest {
530            index_name: statement.name.clone(),
531            entity_name: entity_name.to_string(),
532            dropped_index,
533            field_path,
534        }),
535    })
536}
537
538fn bind_create_index_field_path(
539    field_path: &str,
540    entity_name: &str,
541    schema: &SchemaInfo,
542) -> Result<BoundSqlDdlFieldPath, SqlDdlBindError> {
543    let mut path = field_path
544        .split('.')
545        .map(str::trim)
546        .filter(|segment| !segment.is_empty());
547    let Some(root) = path.next() else {
548        return Err(SqlDdlBindError::UnknownFieldPath {
549            entity_name: entity_name.to_string(),
550            field_path: field_path.to_string(),
551        });
552    };
553    let segments = path.map(str::to_string).collect::<Vec<_>>();
554
555    let capabilities = if segments.is_empty() {
556        schema.sql_capabilities(root)
557    } else {
558        schema.nested_sql_capabilities(root, segments.as_slice())
559    }
560    .ok_or_else(|| SqlDdlBindError::UnknownFieldPath {
561        entity_name: entity_name.to_string(),
562        field_path: field_path.to_string(),
563    })?;
564
565    if !capabilities.orderable() {
566        return Err(SqlDdlBindError::FieldPathNotIndexable {
567            field_path: field_path.to_string(),
568        });
569    }
570
571    let mut accepted_path = Vec::with_capacity(segments.len() + 1);
572    accepted_path.push(root.to_string());
573    accepted_path.extend(segments.iter().cloned());
574
575    Ok(BoundSqlDdlFieldPath {
576        root: root.to_string(),
577        segments,
578        accepted_path,
579    })
580}
581
582fn reject_duplicate_index_name(
583    index_name: &str,
584    schema: &SchemaInfo,
585) -> Result<(), SqlDdlBindError> {
586    if schema
587        .field_path_indexes()
588        .iter()
589        .any(|index| index.name() == index_name)
590        || schema
591            .expression_indexes()
592            .iter()
593            .any(|index| index.name() == index_name)
594    {
595        return Err(SqlDdlBindError::DuplicateIndexName {
596            index_name: index_name.to_string(),
597        });
598    }
599
600    Ok(())
601}
602
603fn reject_duplicate_field_path_index(
604    field_path: &BoundSqlDdlFieldPath,
605    schema: &SchemaInfo,
606) -> Result<(), SqlDdlBindError> {
607    let Some(existing_index) = schema.field_path_indexes().iter().find(|index| {
608        let fields = index.fields();
609        fields.len() == 1 && fields[0].path() == field_path.accepted_path()
610    }) else {
611        return Ok(());
612    };
613
614    Err(SqlDdlBindError::DuplicateFieldPathIndex {
615        field_path: field_path.accepted_path().join("."),
616        existing_index: existing_index.name().to_string(),
617    })
618}
619
620fn candidate_index_snapshot(
621    index_name: &str,
622    field_path: &BoundSqlDdlFieldPath,
623    schema: &SchemaInfo,
624    index_store_path: &'static str,
625) -> Result<PersistedIndexSnapshot, SqlDdlBindError> {
626    let key = schema
627        .accepted_index_field_path_snapshot(field_path.root(), field_path.segments())
628        .ok_or_else(|| SqlDdlBindError::FieldPathNotAcceptedCatalogBacked {
629            field_path: field_path.accepted_path().join("."),
630        })?;
631
632    Ok(PersistedIndexSnapshot::new(
633        schema.next_secondary_index_ordinal(),
634        index_name.to_string(),
635        index_store_path.to_string(),
636        false,
637        PersistedIndexKeySnapshot::FieldPath(vec![key]),
638        None,
639    ))
640}
641
642/// Lower one bound SQL DDL request through schema mutation admission.
643pub(in crate::db) fn lower_bound_sql_ddl_to_schema_mutation_admission(
644    request: &BoundSqlDdlRequest,
645) -> Result<SchemaDdlMutationAdmission, SqlDdlLoweringError> {
646    match request.statement() {
647        BoundSqlDdlStatement::CreateIndex(create) => {
648            admit_sql_ddl_field_path_index_candidate(create.candidate_index())
649        }
650        BoundSqlDdlStatement::DropIndex(drop) => {
651            admit_sql_ddl_secondary_index_drop_candidate(drop.dropped_index())
652        }
653    }
654    .map_err(SqlDdlLoweringError::MutationAdmission)
655}
656
657/// Derive the accepted-after schema snapshot for one bound SQL DDL request.
658pub(in crate::db) fn derive_bound_sql_ddl_accepted_after(
659    accepted_before: &AcceptedSchemaSnapshot,
660    request: &BoundSqlDdlRequest,
661) -> Result<SchemaDdlAcceptedSnapshotDerivation, SqlDdlLoweringError> {
662    match request.statement() {
663        BoundSqlDdlStatement::CreateIndex(create) => {
664            derive_sql_ddl_field_path_index_accepted_after(
665                accepted_before,
666                create.candidate_index().clone(),
667            )
668        }
669        BoundSqlDdlStatement::DropIndex(drop) => {
670            derive_sql_ddl_secondary_index_drop_accepted_after(
671                accepted_before,
672                drop.dropped_index(),
673            )
674        }
675    }
676    .map_err(SqlDdlLoweringError::MutationAdmission)
677}
678
679fn ddl_preparation_report(
680    bound: &BoundSqlDdlRequest,
681    derivation: &SchemaDdlAcceptedSnapshotDerivation,
682) -> SqlDdlPreparationReport {
683    match bound.statement() {
684        BoundSqlDdlStatement::CreateIndex(create) => {
685            let target = derivation.admission().target();
686
687            SqlDdlPreparationReport {
688                mutation_kind: SqlDdlMutationKind::AddNonUniqueFieldPathIndex,
689                target_index: target.name().to_string(),
690                target_store: target.store().to_string(),
691                field_path: create.field_path().accepted_path().to_vec(),
692                execution_status: SqlDdlExecutionStatus::PreparedOnly,
693                rows_scanned: 0,
694                index_keys_written: 0,
695            }
696        }
697        BoundSqlDdlStatement::DropIndex(drop) => SqlDdlPreparationReport {
698            mutation_kind: SqlDdlMutationKind::DropNonUniqueSecondaryIndex,
699            target_index: drop.index_name().to_string(),
700            target_store: drop.dropped_index().store().to_string(),
701            field_path: drop.field_path().to_vec(),
702            execution_status: SqlDdlExecutionStatus::PreparedOnly,
703            rows_scanned: 0,
704            index_keys_written: 0,
705        },
706    }
707}