1#![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#[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 #[must_use]
46 pub(in crate::db) const fn bound(&self) -> &BoundSqlDdlRequest {
47 &self.bound
48 }
49
50 #[must_use]
52 pub(in crate::db) const fn derivation(&self) -> &SchemaDdlAcceptedSnapshotDerivation {
53 &self.derivation
54 }
55
56 #[must_use]
58 pub(in crate::db) const fn report(&self) -> &SqlDdlPreparationReport {
59 &self.report
60 }
61}
62
63#[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 #[must_use]
83 pub const fn mutation_kind(&self) -> SqlDdlMutationKind {
84 self.mutation_kind
85 }
86
87 #[must_use]
89 pub const fn target_index(&self) -> &str {
90 self.target_index.as_str()
91 }
92
93 #[must_use]
95 pub const fn target_store(&self) -> &str {
96 self.target_store.as_str()
97 }
98
99 #[must_use]
101 pub const fn field_path(&self) -> &[String] {
102 self.field_path.as_slice()
103 }
104
105 #[must_use]
107 pub const fn execution_status(&self) -> SqlDdlExecutionStatus {
108 self.execution_status
109 }
110
111 #[must_use]
113 pub const fn rows_scanned(&self) -> usize {
114 self.rows_scanned
115 }
116
117 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum SqlDdlMutationKind {
149 AddNonUniqueFieldPathIndex,
150 DropNonUniqueSecondaryIndex,
151}
152
153impl SqlDdlMutationKind {
154 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub enum SqlDdlExecutionStatus {
171 PreparedOnly,
172 Published,
173}
174
175impl SqlDdlExecutionStatus {
176 #[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#[derive(Clone, Debug, Eq, PartialEq)]
193pub(in crate::db) struct BoundSqlDdlRequest {
194 statement: BoundSqlDdlStatement,
195}
196
197impl BoundSqlDdlRequest {
198 #[must_use]
200 pub(in crate::db) const fn statement(&self) -> &BoundSqlDdlStatement {
201 &self.statement
202 }
203}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
211pub(in crate::db) enum BoundSqlDdlStatement {
212 CreateIndex(BoundSqlCreateIndexRequest),
213 DropIndex(BoundSqlDropIndexRequest),
214}
215
216#[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 #[must_use]
233 pub(in crate::db) const fn index_name(&self) -> &str {
234 self.index_name.as_str()
235 }
236
237 #[must_use]
239 pub(in crate::db) const fn entity_name(&self) -> &str {
240 self.entity_name.as_str()
241 }
242
243 #[must_use]
245 pub(in crate::db) const fn field_path(&self) -> &BoundSqlDdlFieldPath {
246 &self.field_path
247 }
248
249 #[must_use]
251 pub(in crate::db) const fn candidate_index(&self) -> &PersistedIndexSnapshot {
252 &self.candidate_index
253 }
254}
255
256#[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 #[must_use]
273 pub(in crate::db) const fn index_name(&self) -> &str {
274 self.index_name.as_str()
275 }
276
277 #[must_use]
279 pub(in crate::db) const fn entity_name(&self) -> &str {
280 self.entity_name.as_str()
281 }
282
283 #[must_use]
285 pub(in crate::db) const fn dropped_index(&self) -> &PersistedIndexSnapshot {
286 &self.dropped_index
287 }
288
289 #[must_use]
291 pub(in crate::db) const fn field_path(&self) -> &[String] {
292 self.field_path.as_slice()
293 }
294}
295
296#[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 #[must_use]
311 pub(in crate::db) const fn root(&self) -> &str {
312 self.root.as_str()
313 }
314
315 #[must_use]
317 pub(in crate::db) const fn segments(&self) -> &[String] {
318 self.segments.as_slice()
319 }
320
321 #[must_use]
323 pub(in crate::db) const fn accepted_path(&self) -> &[String] {
324 self.accepted_path.as_slice()
325 }
326}
327
328#[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#[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#[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
415pub(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
435pub(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
642pub(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
657pub(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}