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; remove the index from the entity schema macro instead"
379    )]
380    GeneratedIndexDropRejected { index_name: String },
381
382    #[error(
383        "index '{index_name}' is not a supported DDL-droppable field-path index; SQL DDL can currently drop only non-unique field-path indexes created through SQL DDL"
384    )]
385    UnsupportedDropIndex { index_name: String },
386}
387
388///
389/// SqlDdlLoweringError
390///
391/// Typed fail-closed reasons while lowering bound DDL into schema mutation
392/// admission.
393///
394#[derive(Debug, Eq, PartialEq, ThisError)]
395pub(in crate::db) enum SqlDdlLoweringError {
396    #[error("SQL DDL lowering requires a supported DDL statement")]
397    UnsupportedStatement,
398
399    #[error("schema mutation admission rejected DDL candidate: {0:?}")]
400    MutationAdmission(SchemaDdlMutationAdmissionError),
401}
402
403///
404/// SqlDdlPrepareError
405///
406/// Typed fail-closed preparation errors for SQL DDL.
407///
408#[derive(Debug, Eq, PartialEq, ThisError)]
409pub(in crate::db) enum SqlDdlPrepareError {
410    #[error("{0}")]
411    Bind(#[from] SqlDdlBindError),
412
413    #[error("{0}")]
414    Lowering(#[from] SqlDdlLoweringError),
415}
416
417/// Prepare one parsed SQL DDL statement through every pre-execution proof.
418pub(in crate::db) fn prepare_sql_ddl_statement(
419    statement: &SqlStatement,
420    accepted_before: &AcceptedSchemaSnapshot,
421    schema: &SchemaInfo,
422    model: &EntityModel,
423    index_store_path: &'static str,
424) -> Result<PreparedSqlDdlCommand, SqlDdlPrepareError> {
425    let bound =
426        bind_sql_ddl_statement(statement, accepted_before, schema, model, index_store_path)?;
427    let derivation = derive_bound_sql_ddl_accepted_after(accepted_before, &bound)?;
428    let report = ddl_preparation_report(&bound, &derivation);
429
430    Ok(PreparedSqlDdlCommand {
431        bound,
432        derivation,
433        report,
434    })
435}
436
437/// Bind one parsed SQL DDL statement against accepted catalog metadata.
438pub(in crate::db) fn bind_sql_ddl_statement(
439    statement: &SqlStatement,
440    accepted_before: &AcceptedSchemaSnapshot,
441    schema: &SchemaInfo,
442    model: &EntityModel,
443    index_store_path: &'static str,
444) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
445    let SqlStatement::Ddl(ddl) = statement else {
446        return Err(SqlDdlBindError::NotDdl);
447    };
448
449    match ddl {
450        SqlDdlStatement::CreateIndex(statement) => {
451            bind_create_index_statement(statement, schema, index_store_path)
452        }
453        SqlDdlStatement::DropIndex(statement) => {
454            bind_drop_index_statement(statement, accepted_before, schema, model)
455        }
456    }
457}
458
459fn bind_create_index_statement(
460    statement: &SqlCreateIndexStatement,
461    schema: &SchemaInfo,
462    index_store_path: &'static str,
463) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
464    let entity_name = schema
465        .entity_name()
466        .ok_or(SqlDdlBindError::MissingEntityName)?;
467
468    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
469        return Err(SqlDdlBindError::EntityMismatch {
470            sql_entity: statement.entity.clone(),
471            expected_entity: entity_name.to_string(),
472        });
473    }
474
475    reject_duplicate_index_name(statement.name.as_str(), schema)?;
476    let field_path =
477        bind_create_index_field_path(statement.field_path.as_str(), entity_name, schema)?;
478    reject_duplicate_field_path_index(&field_path, schema)?;
479    let candidate_index = candidate_index_snapshot(
480        statement.name.as_str(),
481        &field_path,
482        schema,
483        index_store_path,
484    )?;
485
486    Ok(BoundSqlDdlRequest {
487        statement: BoundSqlDdlStatement::CreateIndex(BoundSqlCreateIndexRequest {
488            index_name: statement.name.clone(),
489            entity_name: entity_name.to_string(),
490            field_path,
491            candidate_index,
492        }),
493    })
494}
495
496fn bind_drop_index_statement(
497    statement: &SqlDropIndexStatement,
498    accepted_before: &AcceptedSchemaSnapshot,
499    schema: &SchemaInfo,
500    model: &EntityModel,
501) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
502    let entity_name = schema
503        .entity_name()
504        .ok_or(SqlDdlBindError::MissingEntityName)?;
505
506    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
507        return Err(SqlDdlBindError::EntityMismatch {
508            sql_entity: statement.entity.clone(),
509            expected_entity: entity_name.to_string(),
510        });
511    }
512    let (dropped_index, field_path) =
513        resolve_sql_ddl_secondary_index_drop_candidate(accepted_before, model, &statement.name)
514            .map_err(|error| match error {
515                SchemaDdlIndexDropCandidateError::Generated => {
516                    SqlDdlBindError::GeneratedIndexDropRejected {
517                        index_name: statement.name.clone(),
518                    }
519                }
520                SchemaDdlIndexDropCandidateError::Unknown => SqlDdlBindError::UnknownIndex {
521                    entity_name: entity_name.to_string(),
522                    index_name: statement.name.clone(),
523                },
524                SchemaDdlIndexDropCandidateError::Unsupported => {
525                    SqlDdlBindError::UnsupportedDropIndex {
526                        index_name: statement.name.clone(),
527                    }
528                }
529            })?;
530    Ok(BoundSqlDdlRequest {
531        statement: BoundSqlDdlStatement::DropIndex(BoundSqlDropIndexRequest {
532            index_name: statement.name.clone(),
533            entity_name: entity_name.to_string(),
534            dropped_index,
535            field_path,
536        }),
537    })
538}
539
540fn bind_create_index_field_path(
541    field_path: &str,
542    entity_name: &str,
543    schema: &SchemaInfo,
544) -> Result<BoundSqlDdlFieldPath, SqlDdlBindError> {
545    let mut path = field_path
546        .split('.')
547        .map(str::trim)
548        .filter(|segment| !segment.is_empty());
549    let Some(root) = path.next() else {
550        return Err(SqlDdlBindError::UnknownFieldPath {
551            entity_name: entity_name.to_string(),
552            field_path: field_path.to_string(),
553        });
554    };
555    let segments = path.map(str::to_string).collect::<Vec<_>>();
556
557    let capabilities = if segments.is_empty() {
558        schema.sql_capabilities(root)
559    } else {
560        schema.nested_sql_capabilities(root, segments.as_slice())
561    }
562    .ok_or_else(|| SqlDdlBindError::UnknownFieldPath {
563        entity_name: entity_name.to_string(),
564        field_path: field_path.to_string(),
565    })?;
566
567    if !capabilities.orderable() {
568        return Err(SqlDdlBindError::FieldPathNotIndexable {
569            field_path: field_path.to_string(),
570        });
571    }
572
573    let mut accepted_path = Vec::with_capacity(segments.len() + 1);
574    accepted_path.push(root.to_string());
575    accepted_path.extend(segments.iter().cloned());
576
577    Ok(BoundSqlDdlFieldPath {
578        root: root.to_string(),
579        segments,
580        accepted_path,
581    })
582}
583
584fn reject_duplicate_index_name(
585    index_name: &str,
586    schema: &SchemaInfo,
587) -> Result<(), SqlDdlBindError> {
588    if schema
589        .field_path_indexes()
590        .iter()
591        .any(|index| index.name() == index_name)
592        || schema
593            .expression_indexes()
594            .iter()
595            .any(|index| index.name() == index_name)
596    {
597        return Err(SqlDdlBindError::DuplicateIndexName {
598            index_name: index_name.to_string(),
599        });
600    }
601
602    Ok(())
603}
604
605fn reject_duplicate_field_path_index(
606    field_path: &BoundSqlDdlFieldPath,
607    schema: &SchemaInfo,
608) -> Result<(), SqlDdlBindError> {
609    let Some(existing_index) = schema.field_path_indexes().iter().find(|index| {
610        let fields = index.fields();
611        fields.len() == 1 && fields[0].path() == field_path.accepted_path()
612    }) else {
613        return Ok(());
614    };
615
616    Err(SqlDdlBindError::DuplicateFieldPathIndex {
617        field_path: field_path.accepted_path().join("."),
618        existing_index: existing_index.name().to_string(),
619    })
620}
621
622fn candidate_index_snapshot(
623    index_name: &str,
624    field_path: &BoundSqlDdlFieldPath,
625    schema: &SchemaInfo,
626    index_store_path: &'static str,
627) -> Result<PersistedIndexSnapshot, SqlDdlBindError> {
628    let key = schema
629        .accepted_index_field_path_snapshot(field_path.root(), field_path.segments())
630        .ok_or_else(|| SqlDdlBindError::FieldPathNotAcceptedCatalogBacked {
631            field_path: field_path.accepted_path().join("."),
632        })?;
633
634    Ok(PersistedIndexSnapshot::new(
635        schema.next_secondary_index_ordinal(),
636        index_name.to_string(),
637        index_store_path.to_string(),
638        false,
639        PersistedIndexKeySnapshot::FieldPath(vec![key]),
640        None,
641    ))
642}
643
644/// Lower one bound SQL DDL request through schema mutation admission.
645pub(in crate::db) fn lower_bound_sql_ddl_to_schema_mutation_admission(
646    request: &BoundSqlDdlRequest,
647) -> Result<SchemaDdlMutationAdmission, SqlDdlLoweringError> {
648    match request.statement() {
649        BoundSqlDdlStatement::CreateIndex(create) => {
650            admit_sql_ddl_field_path_index_candidate(create.candidate_index())
651        }
652        BoundSqlDdlStatement::DropIndex(drop) => {
653            admit_sql_ddl_secondary_index_drop_candidate(drop.dropped_index())
654        }
655    }
656    .map_err(SqlDdlLoweringError::MutationAdmission)
657}
658
659/// Derive the accepted-after schema snapshot for one bound SQL DDL request.
660pub(in crate::db) fn derive_bound_sql_ddl_accepted_after(
661    accepted_before: &AcceptedSchemaSnapshot,
662    request: &BoundSqlDdlRequest,
663) -> Result<SchemaDdlAcceptedSnapshotDerivation, SqlDdlLoweringError> {
664    match request.statement() {
665        BoundSqlDdlStatement::CreateIndex(create) => {
666            derive_sql_ddl_field_path_index_accepted_after(
667                accepted_before,
668                create.candidate_index().clone(),
669            )
670        }
671        BoundSqlDdlStatement::DropIndex(drop) => {
672            derive_sql_ddl_secondary_index_drop_accepted_after(
673                accepted_before,
674                drop.dropped_index(),
675            )
676        }
677    }
678    .map_err(SqlDdlLoweringError::MutationAdmission)
679}
680
681fn ddl_preparation_report(
682    bound: &BoundSqlDdlRequest,
683    derivation: &SchemaDdlAcceptedSnapshotDerivation,
684) -> SqlDdlPreparationReport {
685    match bound.statement() {
686        BoundSqlDdlStatement::CreateIndex(create) => {
687            let target = derivation.admission().target();
688
689            SqlDdlPreparationReport {
690                mutation_kind: SqlDdlMutationKind::AddNonUniqueFieldPathIndex,
691                target_index: target.name().to_string(),
692                target_store: target.store().to_string(),
693                field_path: create.field_path().accepted_path().to_vec(),
694                execution_status: SqlDdlExecutionStatus::PreparedOnly,
695                rows_scanned: 0,
696                index_keys_written: 0,
697            }
698        }
699        BoundSqlDdlStatement::DropIndex(drop) => SqlDdlPreparationReport {
700            mutation_kind: SqlDdlMutationKind::DropNonUniqueSecondaryIndex,
701            target_index: drop.index_name().to_string(),
702            target_store: drop.dropped_index().store().to_string(),
703            field_path: drop.field_path().to_vec(),
704            execution_status: SqlDdlExecutionStatus::PreparedOnly,
705            rows_scanned: 0,
706            index_keys_written: 0,
707        },
708    }
709}