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