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; 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#[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#[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
417pub(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
437pub(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
644pub(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
659pub(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}