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    index_store_path: &'static str,
336) -> Result<PreparedSqlDdlCommand, SqlDdlPrepareError> {
337    let bound = bind_sql_ddl_statement(statement, schema, index_store_path)?;
338    let derivation = derive_bound_sql_ddl_accepted_after(accepted_before, &bound)?;
339    let report = ddl_preparation_report(&bound, &derivation);
340
341    Ok(PreparedSqlDdlCommand {
342        bound,
343        derivation,
344        report,
345    })
346}
347
348/// Bind one parsed SQL DDL statement against accepted catalog metadata.
349pub(in crate::db) fn bind_sql_ddl_statement(
350    statement: &SqlStatement,
351    schema: &SchemaInfo,
352    index_store_path: &'static str,
353) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
354    let SqlStatement::Ddl(ddl) = statement else {
355        return Err(SqlDdlBindError::NotDdl);
356    };
357
358    match ddl {
359        SqlDdlStatement::CreateIndex(statement) => {
360            bind_create_index_statement(statement, schema, index_store_path)
361        }
362    }
363}
364
365fn bind_create_index_statement(
366    statement: &SqlCreateIndexStatement,
367    schema: &SchemaInfo,
368    index_store_path: &'static str,
369) -> Result<BoundSqlDdlRequest, SqlDdlBindError> {
370    let entity_name = schema
371        .entity_name()
372        .ok_or(SqlDdlBindError::MissingEntityName)?;
373
374    if !identifiers_tail_match(statement.entity.as_str(), entity_name) {
375        return Err(SqlDdlBindError::EntityMismatch {
376            sql_entity: statement.entity.clone(),
377            expected_entity: entity_name.to_string(),
378        });
379    }
380
381    reject_duplicate_index_name(statement.name.as_str(), schema)?;
382    let field_path =
383        bind_create_index_field_path(statement.field_path.as_str(), entity_name, schema)?;
384    reject_duplicate_field_path_index(&field_path, schema)?;
385    let candidate_index = candidate_index_snapshot(
386        statement.name.as_str(),
387        &field_path,
388        schema,
389        index_store_path,
390    )?;
391
392    Ok(BoundSqlDdlRequest {
393        statement: BoundSqlDdlStatement::CreateIndex(BoundSqlCreateIndexRequest {
394            index_name: statement.name.clone(),
395            entity_name: entity_name.to_string(),
396            field_path,
397            candidate_index,
398        }),
399    })
400}
401
402fn bind_create_index_field_path(
403    field_path: &str,
404    entity_name: &str,
405    schema: &SchemaInfo,
406) -> Result<BoundSqlDdlFieldPath, SqlDdlBindError> {
407    let mut path = field_path
408        .split('.')
409        .map(str::trim)
410        .filter(|segment| !segment.is_empty());
411    let Some(root) = path.next() else {
412        return Err(SqlDdlBindError::UnknownFieldPath {
413            entity_name: entity_name.to_string(),
414            field_path: field_path.to_string(),
415        });
416    };
417    let segments = path.map(str::to_string).collect::<Vec<_>>();
418
419    let capabilities = if segments.is_empty() {
420        schema.sql_capabilities(root)
421    } else {
422        schema.nested_sql_capabilities(root, segments.as_slice())
423    }
424    .ok_or_else(|| SqlDdlBindError::UnknownFieldPath {
425        entity_name: entity_name.to_string(),
426        field_path: field_path.to_string(),
427    })?;
428
429    if !capabilities.orderable() {
430        return Err(SqlDdlBindError::FieldPathNotIndexable {
431            field_path: field_path.to_string(),
432        });
433    }
434
435    let mut accepted_path = Vec::with_capacity(segments.len() + 1);
436    accepted_path.push(root.to_string());
437    accepted_path.extend(segments.iter().cloned());
438
439    Ok(BoundSqlDdlFieldPath {
440        root: root.to_string(),
441        segments,
442        accepted_path,
443    })
444}
445
446fn reject_duplicate_index_name(
447    index_name: &str,
448    schema: &SchemaInfo,
449) -> Result<(), SqlDdlBindError> {
450    if schema
451        .field_path_indexes()
452        .iter()
453        .any(|index| index.name() == index_name)
454        || schema
455            .expression_indexes()
456            .iter()
457            .any(|index| index.name() == index_name)
458    {
459        return Err(SqlDdlBindError::DuplicateIndexName {
460            index_name: index_name.to_string(),
461        });
462    }
463
464    Ok(())
465}
466
467fn reject_duplicate_field_path_index(
468    field_path: &BoundSqlDdlFieldPath,
469    schema: &SchemaInfo,
470) -> Result<(), SqlDdlBindError> {
471    let Some(existing_index) = schema.field_path_indexes().iter().find(|index| {
472        let fields = index.fields();
473        fields.len() == 1 && fields[0].path() == field_path.accepted_path()
474    }) else {
475        return Ok(());
476    };
477
478    Err(SqlDdlBindError::DuplicateFieldPathIndex {
479        field_path: field_path.accepted_path().join("."),
480        existing_index: existing_index.name().to_string(),
481    })
482}
483
484fn candidate_index_snapshot(
485    index_name: &str,
486    field_path: &BoundSqlDdlFieldPath,
487    schema: &SchemaInfo,
488    index_store_path: &'static str,
489) -> Result<PersistedIndexSnapshot, SqlDdlBindError> {
490    let key = schema
491        .accepted_index_field_path_snapshot(field_path.root(), field_path.segments())
492        .ok_or_else(|| SqlDdlBindError::FieldPathNotAcceptedCatalogBacked {
493            field_path: field_path.accepted_path().join("."),
494        })?;
495
496    Ok(PersistedIndexSnapshot::new(
497        schema.next_secondary_index_ordinal(),
498        index_name.to_string(),
499        index_store_path.to_string(),
500        false,
501        PersistedIndexKeySnapshot::FieldPath(vec![key]),
502        None,
503    ))
504}
505
506/// Lower one bound SQL DDL request through schema mutation admission.
507pub(in crate::db) fn lower_bound_sql_ddl_to_schema_mutation_admission(
508    request: &BoundSqlDdlRequest,
509) -> Result<SchemaDdlMutationAdmission, SqlDdlLoweringError> {
510    let BoundSqlDdlStatement::CreateIndex(create) = request.statement();
511
512    admit_sql_ddl_field_path_index_candidate(create.candidate_index())
513        .map_err(SqlDdlLoweringError::MutationAdmission)
514}
515
516/// Derive the accepted-after schema snapshot for one bound SQL DDL request.
517pub(in crate::db) fn derive_bound_sql_ddl_accepted_after(
518    accepted_before: &AcceptedSchemaSnapshot,
519    request: &BoundSqlDdlRequest,
520) -> Result<SchemaDdlAcceptedSnapshotDerivation, SqlDdlLoweringError> {
521    let BoundSqlDdlStatement::CreateIndex(create) = request.statement();
522
523    derive_sql_ddl_field_path_index_accepted_after(
524        accepted_before,
525        create.candidate_index().clone(),
526    )
527    .map_err(SqlDdlLoweringError::MutationAdmission)
528}
529
530fn ddl_preparation_report(
531    bound: &BoundSqlDdlRequest,
532    derivation: &SchemaDdlAcceptedSnapshotDerivation,
533) -> SqlDdlPreparationReport {
534    let BoundSqlDdlStatement::CreateIndex(create) = bound.statement();
535    let target = derivation.admission().target();
536
537    SqlDdlPreparationReport {
538        mutation_kind: SqlDdlMutationKind::AddNonUniqueFieldPathIndex,
539        target_index: target.name().to_string(),
540        target_store: target.store().to_string(),
541        field_path: create.field_path().accepted_path().to_vec(),
542        execution_status: SqlDdlExecutionStatus::PreparedOnly,
543    }
544}