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 = "0.155 stages accepted-catalog DDL binding before execution is enabled"
9)]
10
11use crate::db::{
12    schema::{
13        AcceptedSchemaSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
14        SchemaDdlAcceptedSnapshotDerivation, SchemaDdlMutationAdmission,
15        SchemaDdlMutationAdmissionError, SchemaInfo, admit_sql_ddl_field_path_index_candidate,
16        derive_sql_ddl_field_path_index_accepted_after,
17    },
18    sql::{
19        identifier::identifiers_tail_match,
20        parser::{SqlCreateIndexStatement, SqlDdlStatement, SqlStatement},
21    },
22};
23use thiserror::Error as ThisError;
24
25///
26/// PreparedSqlDdlCommand
27///
28/// Fully prepared SQL DDL command. This is intentionally not executable yet:
29/// it packages the accepted-catalog binding, accepted-after derivation, and
30/// schema mutation admission proof for the future execution boundary.
31///
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub(in crate::db) struct PreparedSqlDdlCommand {
34    bound: BoundSqlDdlRequest,
35    derivation: SchemaDdlAcceptedSnapshotDerivation,
36    report: SqlDdlPreparationReport,
37}
38
39impl PreparedSqlDdlCommand {
40    /// Borrow the accepted-catalog-bound DDL request.
41    #[must_use]
42    pub(in crate::db) const fn bound(&self) -> &BoundSqlDdlRequest {
43        &self.bound
44    }
45
46    /// Borrow the accepted-after derivation proof.
47    #[must_use]
48    pub(in crate::db) const fn derivation(&self) -> &SchemaDdlAcceptedSnapshotDerivation {
49        &self.derivation
50    }
51
52    /// Borrow the developer-facing preparation report.
53    #[must_use]
54    pub(in crate::db) const fn report(&self) -> &SqlDdlPreparationReport {
55        &self.report
56    }
57}
58
59///
60/// SqlDdlPreparationReport
61///
62/// Compact report for a DDL command that has passed all pre-execution
63/// frontend and schema-mutation checks.
64///
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct SqlDdlPreparationReport {
67    mutation_kind: SqlDdlMutationKind,
68    target_index: String,
69    target_store: String,
70    field_path: Vec<String>,
71    execution_status: SqlDdlExecutionStatus,
72}
73
74impl SqlDdlPreparationReport {
75    /// Return the prepared DDL mutation kind.
76    #[must_use]
77    pub const fn mutation_kind(&self) -> SqlDdlMutationKind {
78        self.mutation_kind
79    }
80
81    /// Borrow the target accepted index name.
82    #[must_use]
83    pub const fn target_index(&self) -> &str {
84        self.target_index.as_str()
85    }
86
87    /// Borrow the target accepted index store path.
88    #[must_use]
89    pub const fn target_store(&self) -> &str {
90        self.target_store.as_str()
91    }
92
93    /// Borrow the target field path.
94    #[must_use]
95    pub const fn field_path(&self) -> &[String] {
96        self.field_path.as_slice()
97    }
98
99    /// Return the execution status captured by this DDL report.
100    #[must_use]
101    pub const fn execution_status(&self) -> SqlDdlExecutionStatus {
102        self.execution_status
103    }
104
105    pub(in crate::db) const fn with_execution_status(
106        mut self,
107        execution_status: SqlDdlExecutionStatus,
108    ) -> Self {
109        self.execution_status = execution_status;
110        self
111    }
112}
113
114///
115/// SqlDdlMutationKind
116///
117/// Developer-facing SQL DDL mutation kind.
118///
119#[derive(Clone, Copy, Debug, Eq, PartialEq)]
120pub enum SqlDdlMutationKind {
121    AddNonUniqueFieldPathIndex,
122}
123
124impl SqlDdlMutationKind {
125    /// Return the stable diagnostic label for this DDL mutation kind.
126    #[must_use]
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::AddNonUniqueFieldPathIndex => "add_non_unique_field_path_index",
130        }
131    }
132}
133
134///
135/// SqlDdlExecutionStatus
136///
137/// SQL DDL execution state at the current boundary.
138///
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub enum SqlDdlExecutionStatus {
141    PreparedOnly,
142    Published,
143}
144
145impl SqlDdlExecutionStatus {
146    /// Return the stable diagnostic label for this execution status.
147    #[must_use]
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::PreparedOnly => "prepared_only",
151            Self::Published => "published",
152        }
153    }
154}
155
156///
157/// BoundSqlDdlRequest
158///
159/// Accepted-catalog SQL DDL request after parser syntax has been resolved
160/// against one runtime schema snapshot.
161///
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub(in crate::db) struct BoundSqlDdlRequest {
164    statement: BoundSqlDdlStatement,
165}
166
167impl BoundSqlDdlRequest {
168    /// Borrow the bound statement payload.
169    #[must_use]
170    pub(in crate::db) const fn statement(&self) -> &BoundSqlDdlStatement {
171        &self.statement
172    }
173}
174
175///
176/// BoundSqlDdlStatement
177///
178/// Catalog-resolved DDL statement vocabulary.
179///
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub(in crate::db) enum BoundSqlDdlStatement {
182    CreateIndex(BoundSqlCreateIndexRequest),
183}
184
185///
186/// BoundSqlCreateIndexRequest
187///
188/// Catalog-resolved request for the only 0.155 DDL shape: one non-unique
189/// field-path secondary index.
190///
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub(in crate::db) struct BoundSqlCreateIndexRequest {
193    index_name: String,
194    entity_name: String,
195    field_path: BoundSqlDdlFieldPath,
196    candidate_index: PersistedIndexSnapshot,
197}
198
199impl BoundSqlCreateIndexRequest {
200    /// Borrow the requested index name.
201    #[must_use]
202    pub(in crate::db) const fn index_name(&self) -> &str {
203        self.index_name.as_str()
204    }
205
206    /// Borrow the accepted entity name that owns this request.
207    #[must_use]
208    pub(in crate::db) const fn entity_name(&self) -> &str {
209        self.entity_name.as_str()
210    }
211
212    /// Borrow the accepted field-path target.
213    #[must_use]
214    pub(in crate::db) const fn field_path(&self) -> &BoundSqlDdlFieldPath {
215        &self.field_path
216    }
217
218    /// Borrow the candidate accepted index snapshot for mutation admission.
219    #[must_use]
220    pub(in crate::db) const fn candidate_index(&self) -> &PersistedIndexSnapshot {
221        &self.candidate_index
222    }
223}
224
225///
226/// BoundSqlDdlFieldPath
227///
228/// Accepted field-path target for SQL DDL binding.
229///
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub(in crate::db) struct BoundSqlDdlFieldPath {
232    root: String,
233    segments: Vec<String>,
234    accepted_path: Vec<String>,
235}
236
237impl BoundSqlDdlFieldPath {
238    /// Borrow the top-level field name.
239    #[must_use]
240    pub(in crate::db) const fn root(&self) -> &str {
241        self.root.as_str()
242    }
243
244    /// Borrow nested path segments below the top-level field.
245    #[must_use]
246    pub(in crate::db) const fn segments(&self) -> &[String] {
247        self.segments.as_slice()
248    }
249
250    /// Borrow the full accepted field path used by index metadata.
251    #[must_use]
252    pub(in crate::db) const fn accepted_path(&self) -> &[String] {
253        self.accepted_path.as_slice()
254    }
255}
256
257///
258/// SqlDdlBindError
259///
260/// Typed fail-closed reasons for SQL DDL catalog binding.
261///
262#[derive(Debug, Eq, PartialEq, ThisError)]
263pub(in crate::db) enum SqlDdlBindError {
264    #[error("SQL DDL binder requires a DDL statement")]
265    NotDdl,
266
267    #[error("accepted schema does not expose an entity name")]
268    MissingEntityName,
269
270    #[error("accepted schema does not expose an entity path")]
271    MissingEntityPath,
272
273    #[error("SQL entity '{sql_entity}' does not match accepted entity '{expected_entity}'")]
274    EntityMismatch {
275        sql_entity: String,
276        expected_entity: String,
277    },
278
279    #[error("unknown field path '{field_path}' for accepted entity '{entity_name}'")]
280    UnknownFieldPath {
281        entity_name: String,
282        field_path: String,
283    },
284
285    #[error("field path '{field_path}' is not indexable")]
286    FieldPathNotIndexable { field_path: String },
287
288    #[error("field path '{field_path}' depends on generated-only metadata")]
289    FieldPathNotAcceptedCatalogBacked { field_path: String },
290
291    #[error("index name '{index_name}' already exists in the accepted schema")]
292    DuplicateIndexName { index_name: String },
293
294    #[error("accepted schema already has index '{existing_index}' for field path '{field_path}'")]
295    DuplicateFieldPathIndex {
296        field_path: String,
297        existing_index: String,
298    },
299}
300
301///
302/// SqlDdlLoweringError
303///
304/// Typed fail-closed reasons while lowering bound DDL into schema mutation
305/// admission.
306///
307#[derive(Debug, Eq, PartialEq, ThisError)]
308pub(in crate::db) enum SqlDdlLoweringError {
309    #[error("SQL DDL lowering requires a CREATE INDEX statement")]
310    UnsupportedStatement,
311
312    #[error("schema mutation admission rejected DDL candidate: {0:?}")]
313    MutationAdmission(SchemaDdlMutationAdmissionError),
314}
315
316///
317/// SqlDdlPrepareError
318///
319/// Typed fail-closed preparation errors for SQL DDL.
320///
321#[derive(Debug, Eq, PartialEq, ThisError)]
322pub(in crate::db) enum SqlDdlPrepareError {
323    #[error("{0}")]
324    Bind(#[from] SqlDdlBindError),
325
326    #[error("{0}")]
327    Lowering(#[from] SqlDdlLoweringError),
328}
329
330/// Prepare one parsed SQL DDL statement through every pre-execution proof.
331pub(in crate::db) fn prepare_sql_ddl_statement(
332    statement: &SqlStatement,
333    accepted_before: &AcceptedSchemaSnapshot,
334    schema: &SchemaInfo,
335) -> Result<PreparedSqlDdlCommand, SqlDdlPrepareError> {
336    let bound = bind_sql_ddl_statement(statement, schema)?;
337    let derivation = derive_bound_sql_ddl_accepted_after(accepted_before, &bound)?;
338    let report = ddl_preparation_report(&bound, &derivation);
339
340    Ok(PreparedSqlDdlCommand {
341        bound,
342        derivation,
343        report,
344    })
345}
346
347/// Bind one parsed SQL DDL statement against accepted catalog metadata.
348pub(in crate::db) fn bind_sql_ddl_statement(
349    statement: &SqlStatement,
350    schema: &SchemaInfo,
351) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
352    let SqlStatement::Ddl(ddl) = statement else {
353        return Err(SqlDdlBindError::NotDdl);
354    };
355
356    match ddl {
357        SqlDdlStatement::CreateIndex(statement) => bind_create_index_statement(statement, schema),
358    }
359}
360
361fn bind_create_index_statement(
362    statement: &SqlCreateIndexStatement,
363    schema: &SchemaInfo,
364) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
365    let entity_name = schema
366        .entity_name()
367        .ok_or(SqlDdlBindError::MissingEntityName)?;
368
369    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
370        return Err(SqlDdlBindError::EntityMismatch {
371            sql_entity: statement.entity.clone(),
372            expected_entity: entity_name.to_string(),
373        });
374    }
375
376    reject_duplicate_index_name(statement.name.as_str(), schema)?;
377    let field_path =
378        bind_create_index_field_path(statement.field_path.as_str(), entity_name, schema)?;
379    reject_duplicate_field_path_index(&field_path, schema)?;
380    let candidate_index = candidate_index_snapshot(statement.name.as_str(), &field_path, schema)?;
381
382    Ok(BoundSqlDdlRequest {
383        statement: BoundSqlDdlStatement::CreateIndex(BoundSqlCreateIndexRequest {
384            index_name: statement.name.clone(),
385            entity_name: entity_name.to_string(),
386            field_path,
387            candidate_index,
388        }),
389    })
390}
391
392fn bind_create_index_field_path(
393    field_path: &str,
394    entity_name: &str,
395    schema: &SchemaInfo,
396) -> Result<BoundSqlDdlFieldPath, SqlDdlBindError> {
397    let mut path = field_path
398        .split('.')
399        .map(str::trim)
400        .filter(|segment| !segment.is_empty());
401    let Some(root) = path.next() else {
402        return Err(SqlDdlBindError::UnknownFieldPath {
403            entity_name: entity_name.to_string(),
404            field_path: field_path.to_string(),
405        });
406    };
407    let segments = path.map(str::to_string).collect::<Vec<_>>();
408
409    let capabilities = if segments.is_empty() {
410        schema.sql_capabilities(root)
411    } else {
412        schema.nested_sql_capabilities(root, segments.as_slice())
413    }
414    .ok_or_else(|| SqlDdlBindError::UnknownFieldPath {
415        entity_name: entity_name.to_string(),
416        field_path: field_path.to_string(),
417    })?;
418
419    if !capabilities.orderable() {
420        return Err(SqlDdlBindError::FieldPathNotIndexable {
421            field_path: field_path.to_string(),
422        });
423    }
424
425    let mut accepted_path = Vec::with_capacity(segments.len() + 1);
426    accepted_path.push(root.to_string());
427    accepted_path.extend(segments.iter().cloned());
428
429    Ok(BoundSqlDdlFieldPath {
430        root: root.to_string(),
431        segments,
432        accepted_path,
433    })
434}
435
436fn reject_duplicate_index_name(
437    index_name: &str,
438    schema: &SchemaInfo,
439) -> Result<(), SqlDdlBindError> {
440    if schema
441        .field_path_indexes()
442        .iter()
443        .any(|index| index.name() == index_name)
444        || schema
445            .expression_indexes()
446            .iter()
447            .any(|index| index.name() == index_name)
448    {
449        return Err(SqlDdlBindError::DuplicateIndexName {
450            index_name: index_name.to_string(),
451        });
452    }
453
454    Ok(())
455}
456
457fn reject_duplicate_field_path_index(
458    field_path: &BoundSqlDdlFieldPath,
459    schema: &SchemaInfo,
460) -> Result<(), SqlDdlBindError> {
461    let Some(existing_index) = schema.field_path_indexes().iter().find(|index| {
462        let fields = index.fields();
463        fields.len() == 1 && fields[0].path() == field_path.accepted_path()
464    }) else {
465        return Ok(());
466    };
467
468    Err(SqlDdlBindError::DuplicateFieldPathIndex {
469        field_path: field_path.accepted_path().join("."),
470        existing_index: existing_index.name().to_string(),
471    })
472}
473
474fn candidate_index_snapshot(
475    index_name: &str,
476    field_path: &BoundSqlDdlFieldPath,
477    schema: &SchemaInfo,
478) -> Result<PersistedIndexSnapshot, SqlDdlBindError> {
479    let key = schema
480        .accepted_index_field_path_snapshot(field_path.root(), field_path.segments())
481        .ok_or_else(|| SqlDdlBindError::FieldPathNotAcceptedCatalogBacked {
482            field_path: field_path.accepted_path().join("."),
483        })?;
484    let store = schema
485        .ddl_index_store_path(index_name)
486        .ok_or(SqlDdlBindError::MissingEntityPath)?;
487
488    Ok(PersistedIndexSnapshot::new(
489        schema.next_secondary_index_ordinal(),
490        index_name.to_string(),
491        store,
492        false,
493        PersistedIndexKeySnapshot::FieldPath(vec![key]),
494        None,
495    ))
496}
497
498/// Lower one bound SQL DDL request through schema mutation admission.
499pub(in crate::db) fn lower_bound_sql_ddl_to_schema_mutation_admission(
500    request: &BoundSqlDdlRequest,
501) -> Result<SchemaDdlMutationAdmission, SqlDdlLoweringError> {
502    let BoundSqlDdlStatement::CreateIndex(create) = request.statement();
503
504    admit_sql_ddl_field_path_index_candidate(create.candidate_index())
505        .map_err(SqlDdlLoweringError::MutationAdmission)
506}
507
508/// Derive the accepted-after schema snapshot for one bound SQL DDL request.
509pub(in crate::db) fn derive_bound_sql_ddl_accepted_after(
510    accepted_before: &AcceptedSchemaSnapshot,
511    request: &BoundSqlDdlRequest,
512) -> Result<SchemaDdlAcceptedSnapshotDerivation, SqlDdlLoweringError> {
513    let BoundSqlDdlStatement::CreateIndex(create) = request.statement();
514
515    derive_sql_ddl_field_path_index_accepted_after(
516        accepted_before,
517        create.candidate_index().clone(),
518    )
519    .map_err(SqlDdlLoweringError::MutationAdmission)
520}
521
522fn ddl_preparation_report(
523    bound: &BoundSqlDdlRequest,
524    derivation: &SchemaDdlAcceptedSnapshotDerivation,
525) -> SqlDdlPreparationReport {
526    let BoundSqlDdlStatement::CreateIndex(create) = bound.statement();
527    let target = derivation.admission().target();
528
529    SqlDdlPreparationReport {
530        mutation_kind: SqlDdlMutationKind::AddNonUniqueFieldPathIndex,
531        target_index: target.name().to_string(),
532        target_store: target.store().to_string(),
533        field_path: create.field_path().accepted_path().to_vec(),
534        execution_status: SqlDdlExecutionStatus::PreparedOnly,
535    }
536}