1mod contract;
9pub(crate) mod mysql;
10pub(crate) mod postgres;
11mod report;
12pub(crate) mod sqlite;
13
14use std::sync::OnceLock;
15
16use crate::dialects::Dialect;
17use crate::operations::Operation;
18use crate::states::types::EntityKind;
19use crate::states::{
20 Column, Constraint, EnumDef, ExtensionDef, ForeignKey, FunctionDef, Index, PrimaryKey, Schema,
21 Table, TriggerDef, ViewDef,
22};
23
24pub use contract::DriftPropertyDoc;
25pub use report::{DriftFinding, VerificationReport};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub(crate) enum PropertyMatch {
30 Match,
31 Drift {
32 expected: String,
33 observed: String,
34 note: Option<String>,
35 },
36}
37
38#[derive(Clone, Copy)]
39pub(crate) struct DriftContext<'a> {
40 pub dialect: Dialect,
41 pub table_name: Option<&'a str>,
42}
43
44pub(crate) struct TableProperty {
45 pub name: &'static str,
46 pub compare: fn(&Table, &Table, DriftContext<'_>) -> PropertyMatch,
47}
48
49pub(crate) struct ColumnProperty {
50 pub name: &'static str,
51 pub compare: fn(&Column, &Column, DriftContext<'_>) -> PropertyMatch,
52}
53
54pub(crate) struct PrimaryKeyProperty {
55 pub name: &'static str,
56 pub compare: fn(&PrimaryKey, &PrimaryKey, DriftContext<'_>) -> PropertyMatch,
57}
58
59pub(crate) struct ForeignKeyProperty {
60 pub name: &'static str,
61 pub compare: fn(&ForeignKey, &ForeignKey, DriftContext<'_>) -> PropertyMatch,
62}
63
64pub(crate) struct IndexProperty {
65 pub name: &'static str,
66 pub compare: fn(&Index, &Index, DriftContext<'_>) -> PropertyMatch,
67}
68
69pub(crate) struct ConstraintProperty {
70 pub name: &'static str,
71 pub compare: fn(&Constraint, &Constraint, DriftContext<'_>) -> PropertyMatch,
72}
73
74pub(crate) struct TriggerProperty {
75 pub name: &'static str,
76 pub compare: fn(&TriggerDef, &TriggerDef, DriftContext<'_>) -> PropertyMatch,
77}
78
79pub(crate) struct FunctionProperty {
80 pub name: &'static str,
81 pub compare: fn(&FunctionDef, &FunctionDef, DriftContext<'_>) -> PropertyMatch,
82}
83
84pub(crate) struct ViewProperty {
85 pub name: &'static str,
86 pub compare: fn(&ViewDef, &ViewDef, DriftContext<'_>) -> PropertyMatch,
87}
88
89pub(crate) struct EnumProperty {
90 pub name: &'static str,
91 pub compare: fn(&EnumDef, &EnumDef, DriftContext<'_>) -> PropertyMatch,
92}
93
94pub(crate) struct ExtensionProperty {
95 pub name: &'static str,
96 pub compare: fn(&ExtensionDef, &ExtensionDef, DriftContext<'_>) -> PropertyMatch,
97}
98
99pub(crate) struct DriftRegistry {
100 pub tables: &'static [TableProperty],
101 pub columns: &'static [ColumnProperty],
102 pub primary_keys: &'static [PrimaryKeyProperty],
103 pub foreign_keys: &'static [ForeignKeyProperty],
104 pub indexes: &'static [IndexProperty],
105 pub constraints: &'static [ConstraintProperty],
106 pub triggers: &'static [TriggerProperty],
107 pub functions: &'static [FunctionProperty],
108 pub views: &'static [ViewProperty],
109 pub enums: &'static [EnumProperty],
110 pub extensions: &'static [ExtensionProperty],
111}
112
113pub fn contract_for(dialect: Dialect) -> &'static [DriftPropertyDoc] {
115 static POSTGRES: OnceLock<Vec<DriftPropertyDoc>> = OnceLock::new();
116 static SQLITE: OnceLock<Vec<DriftPropertyDoc>> = OnceLock::new();
117 static MYSQL: OnceLock<Vec<DriftPropertyDoc>> = OnceLock::new();
118
119 let contract = match dialect {
120 Dialect::Postgres => &POSTGRES,
121 Dialect::Sqlite => &SQLITE,
122 Dialect::Mysql => &MYSQL,
123 };
124 contract.get_or_init(|| registry_for(dialect).contract())
125}
126
127impl DriftRegistry {
128 pub(crate) fn contract(&self) -> Vec<DriftPropertyDoc> {
129 let mut docs = Vec::new();
130 push_docs(
131 &mut docs,
132 EntityKind::Table,
133 self.tables.iter().map(|p| p.name),
134 );
135 push_docs(
136 &mut docs,
137 EntityKind::Column,
138 self.columns.iter().map(|p| p.name),
139 );
140 push_docs(
141 &mut docs,
142 EntityKind::Table,
143 self.primary_keys.iter().map(|p| p.name),
144 );
145 push_docs(
146 &mut docs,
147 EntityKind::ForeignKey,
148 self.foreign_keys.iter().map(|p| p.name),
149 );
150 push_docs(
151 &mut docs,
152 EntityKind::Index,
153 self.indexes.iter().map(|p| p.name),
154 );
155 push_docs(
156 &mut docs,
157 EntityKind::Constraint,
158 self.constraints.iter().map(|p| p.name),
159 );
160 push_docs(
161 &mut docs,
162 EntityKind::Trigger,
163 self.triggers.iter().map(|p| p.name),
164 );
165 push_docs(
166 &mut docs,
167 EntityKind::Function,
168 self.functions.iter().map(|p| p.name),
169 );
170 push_docs(
171 &mut docs,
172 EntityKind::View,
173 self.views.iter().map(|p| p.name),
174 );
175 push_docs(
176 &mut docs,
177 EntityKind::Enum,
178 self.enums.iter().map(|p| p.name),
179 );
180 push_docs(
181 &mut docs,
182 EntityKind::Extension,
183 self.extensions.iter().map(|p| p.name),
184 );
185 docs
186 }
187}
188
189fn push_docs<I>(docs: &mut Vec<DriftPropertyDoc>, entity_kind: EntityKind, properties: I)
190where
191 I: IntoIterator<Item = &'static str>,
192{
193 docs.extend(
194 properties
195 .into_iter()
196 .map(|property| contract::property_doc(entity_kind, property)),
197 );
198}
199
200pub fn diff(replay: Schema, live: Schema, schema: &str, dialect: Dialect) -> VerificationReport {
202 diff_schemas(replay, live, &[schema], dialect)
203}
204
205pub fn diff_schemas(
210 mut replay: Schema,
211 mut live: Schema,
212 schemas: &[&str],
213 dialect: Dialect,
214) -> VerificationReport {
215 scope_schemas(&mut replay, schemas, dialect);
216 scope_schemas(&mut live, schemas, dialect);
217
218 let registry = registry_for(dialect);
219 let mut report = VerificationReport::default();
220 let context = DriftContext {
221 dialect,
222 table_name: None,
223 };
224
225 verify_top_level_objects(&replay, &live, registry, context, &mut report);
226 verify_tables(&replay, &live, registry, context, &mut report);
227 report
228}
229
230pub(crate) fn registry_for(dialect: Dialect) -> &'static DriftRegistry {
231 dialect.drift_registry()
232}
233
234fn verify_tables(
235 replay: &Schema,
236 live: &Schema,
237 registry: &DriftRegistry,
238 context: DriftContext<'_>,
239 report: &mut VerificationReport,
240) {
241 for (name, expected) in &replay.tables {
242 let Some(observed) = live.tables.get(name) else {
243 report.operations.push(Operation::CreateTable {
244 table: expected.clone(),
245 });
246 report.findings.push(missing_finding(
247 "create_table",
248 EntityKind::Table,
249 name,
250 "presence",
251 ));
252 continue;
253 };
254
255 compare_table_properties(expected, observed, registry, context, report);
256 verify_table_children(expected, observed, registry, context, report);
257 }
258}
259
260fn verify_table_children(
261 expected: &Table,
262 observed: &Table,
263 registry: &DriftRegistry,
264 context: DriftContext<'_>,
265 report: &mut VerificationReport,
266) {
267 let table_name = expected.qualified_name();
268 let context = DriftContext {
269 table_name: Some(&table_name),
270 ..context
271 };
272 verify_columns(expected, observed, registry, context, report);
273 verify_primary_key(expected, observed, registry, context, report);
274 verify_named_items(
275 &expected.foreign_keys,
276 &observed.foreign_keys,
277 |fk| fk.name.as_str(),
278 |fk| Operation::AddForeignKey {
279 table_name: table_name.clone(),
280 foreign_key: fk.clone(),
281 },
282 |old, new| {
283 vec![
284 Operation::DropForeignKey {
285 table_name: table_name.clone(),
286 foreign_key: old.clone(),
287 cascade: false,
288 },
289 Operation::AddForeignKey {
290 table_name: table_name.clone(),
291 foreign_key: new.clone(),
292 },
293 ]
294 },
295 EntityKind::ForeignKey,
296 "add_foreign_key",
297 "drop_add_foreign_key",
298 registry.foreign_keys,
299 compare_foreign_key_properties,
300 context,
301 report,
302 );
303 verify_named_items(
304 &expected.indexes,
305 &observed.indexes,
306 |index| index.name.as_str(),
307 |index| Operation::AddIndex {
308 table_name: table_name.clone(),
309 index: index.clone(),
310 concurrent: false,
311 },
312 |old, new| {
313 vec![
314 Operation::DropIndex {
315 table_name: table_name.clone(),
316 index: old.clone(),
317 concurrent: false,
318 },
319 Operation::AddIndex {
320 table_name: table_name.clone(),
321 index: new.clone(),
322 concurrent: false,
323 },
324 ]
325 },
326 EntityKind::Index,
327 "add_index",
328 "drop_add_index",
329 registry.indexes,
330 compare_index_properties,
331 context,
332 report,
333 );
334 verify_named_items(
335 &expected.constraints,
336 &observed.constraints,
337 Constraint::name,
338 |constraint| Operation::AddConstraint {
339 table_name: table_name.clone(),
340 constraint: constraint.clone(),
341 },
342 |old, new| {
343 vec![
344 Operation::DropConstraint {
345 table_name: table_name.clone(),
346 constraint: old.clone(),
347 },
348 Operation::AddConstraint {
349 table_name: table_name.clone(),
350 constraint: new.clone(),
351 },
352 ]
353 },
354 EntityKind::Constraint,
355 "add_constraint",
356 "drop_add_constraint",
357 registry.constraints,
358 compare_constraint_properties,
359 context,
360 report,
361 );
362 verify_named_items(
363 &expected.triggers,
364 &observed.triggers,
365 |trigger| trigger.name.as_deref().unwrap_or(""),
366 |trigger| Operation::CreateTrigger {
367 table_name: table_name.clone(),
368 trigger: sanitized_trigger(trigger),
369 },
370 |old, new| {
371 vec![Operation::AlterTrigger {
372 table_name: table_name.clone(),
373 old: sanitized_trigger(old),
374 new: sanitized_trigger(new),
375 }]
376 },
377 EntityKind::Trigger,
378 "create_trigger",
379 "alter_trigger",
380 registry.triggers,
381 compare_trigger_properties,
382 context,
383 report,
384 );
385}
386
387fn verify_columns(
388 expected: &Table,
389 observed: &Table,
390 registry: &DriftRegistry,
391 context: DriftContext<'_>,
392 report: &mut VerificationReport,
393) {
394 for column in &expected.columns {
395 let Some(actual_column) = observed
396 .columns
397 .iter()
398 .find(|item| item.name == column.name)
399 else {
400 report.operations.push(Operation::AddColumn {
401 table_name: expected.qualified_name(),
402 column: column.clone(),
403 });
404 report.findings.push(missing_finding(
405 "add_column",
406 EntityKind::Column,
407 &format!("{}.{}", expected.qualified_name(), column.name),
408 "presence",
409 ));
410 continue;
411 };
412
413 let findings = compare_column_properties(column, actual_column, registry, context);
414 if !findings.is_empty() {
415 report.operations.push(Operation::AlterColumn {
416 table_name: expected.qualified_name(),
417 old: column_for_operation(actual_column, context.dialect),
418 new: column_for_operation(column, context.dialect),
419 cast_expr: None,
420 });
421 report.findings.extend(findings);
422 }
423 }
424}
425
426fn verify_primary_key(
427 expected: &Table,
428 observed: &Table,
429 registry: &DriftRegistry,
430 context: DriftContext<'_>,
431 report: &mut VerificationReport,
432) {
433 let Some(expected_pk) = &expected.primary_key else {
434 return;
435 };
436 let entity_name = format!("{}.{}", expected.qualified_name(), expected_pk.name);
437 let Some(actual_pk) = &observed.primary_key else {
438 report.findings.push(missing_finding(
439 "alter_primary_key",
440 EntityKind::Constraint,
441 &entity_name,
442 "presence",
443 ));
444 return;
445 };
446 let findings = compare_primary_key_properties(
447 expected_pk,
448 actual_pk,
449 registry.primary_keys,
450 context,
451 &entity_name,
452 );
453 report.findings.extend(findings);
454}
455
456fn verify_top_level_objects(
457 replay: &Schema,
458 live: &Schema,
459 registry: &DriftRegistry,
460 context: DriftContext<'_>,
461 report: &mut VerificationReport,
462) {
463 verify_top_map(
464 &replay.functions,
465 &live.functions,
466 function_verify_key,
467 |function| Operation::CreateFunction {
468 function: sanitized_function(function),
469 },
470 |old, new| {
471 vec![Operation::AlterFunction {
472 old: sanitized_function(old),
473 new: sanitized_function(new),
474 }]
475 },
476 EntityKind::Function,
477 "create_function",
478 "alter_function",
479 registry.functions,
480 compare_function_properties,
481 context,
482 report,
483 );
484 verify_top_map(
485 &replay.views,
486 &live.views,
487 ViewDef::qualified_name,
488 |view| Operation::CreateView {
489 view: sanitized_view(view),
490 },
491 |old, new| {
492 vec![Operation::ReplaceView {
493 old: sanitized_view(old),
494 new: sanitized_view(new),
495 }]
496 },
497 EntityKind::View,
498 "create_view",
499 "replace_view",
500 registry.views,
501 compare_view_properties,
502 context,
503 report,
504 );
505 verify_top_map(
506 &replay.enums,
507 &live.enums,
508 EnumDef::qualified_name,
509 |enum_def| Operation::CreateEnum {
510 enum_def: enum_def.clone(),
511 },
512 enum_repair_operations,
513 EntityKind::Enum,
514 "create_enum",
515 "alter_enum",
516 registry.enums,
517 compare_enum_properties,
518 context,
519 report,
520 );
521 verify_top_map(
522 &replay.extensions,
523 &live.extensions,
524 ExtensionDef::qualified_name,
525 |extension| Operation::CreateExtension {
526 extension: extension.clone(),
527 },
528 |old, new| {
529 vec![
530 Operation::DropExtension {
531 extension: old.clone(),
532 },
533 Operation::CreateExtension {
534 extension: new.clone(),
535 },
536 ]
537 },
538 EntityKind::Extension,
539 "create_extension",
540 "alter_extension",
541 registry.extensions,
542 compare_extension_properties,
543 context,
544 report,
545 );
546}
547
548#[allow(clippy::too_many_arguments)]
549fn verify_top_map<T, P>(
550 expected: &std::collections::BTreeMap<String, T>,
551 observed: &std::collections::BTreeMap<String, T>,
552 key: fn(&T) -> String,
553 create: impl Fn(&T) -> Operation,
554 alter: impl Fn(&T, &T) -> Vec<Operation>,
555 kind: EntityKind,
556 create_op: &'static str,
557 alter_op: &'static str,
558 properties: &'static [P],
559 compare: fn(&T, &T, &'static [P], DriftContext<'_>, &str) -> Vec<DriftFinding>,
560 context: DriftContext<'_>,
561 report: &mut VerificationReport,
562) where
563 P: RegisteredProperty<T> + 'static,
564 T: DriftOpaque,
565{
566 for expected_value in expected.values() {
567 let expected_key = key(expected_value);
568 let Some(actual_value) = observed.values().find(|item| key(item) == expected_key) else {
569 report.operations.push(create(expected_value));
570 report
571 .findings
572 .push(missing_finding(create_op, kind, &expected_key, "presence"));
573 continue;
574 };
575 if expected_value.is_drift_opaque() {
576 continue;
577 }
578 let findings = compare(
579 expected_value,
580 actual_value,
581 properties,
582 context,
583 &expected_key,
584 );
585 if !findings.is_empty() {
586 report
587 .operations
588 .extend(alter(actual_value, expected_value));
589 report
590 .findings
591 .extend(findings.into_iter().map(|finding| DriftFinding {
592 operation: alter_op,
593 ..finding
594 }));
595 }
596 }
597}
598
599fn enum_repair_operations(old: &EnumDef, new: &EnumDef) -> Vec<Operation> {
600 let old_set: std::collections::HashSet<&str> = old.values.iter().map(String::as_str).collect();
601 let new_set: std::collections::HashSet<&str> = new.values.iter().map(String::as_str).collect();
602 if old_set.is_subset(&new_set) && values_are_subsequence(&old.values, &new.values) {
603 vec![Operation::AlterEnum {
604 old: old.clone(),
605 new: new.clone(),
606 }]
607 } else {
608 vec![
609 Operation::DropEnum {
610 enum_def: old.clone(),
611 },
612 Operation::CreateEnum {
613 enum_def: new.clone(),
614 },
615 ]
616 }
617}
618
619fn values_are_subsequence(old: &[String], new: &[String]) -> bool {
620 let mut new_iter = new.iter();
621 old.iter()
622 .all(|old_value| new_iter.by_ref().any(|new_value| new_value == old_value))
623}
624
625fn sanitized_function(function: &FunctionDef) -> FunctionDef {
626 let mut function = function.clone();
627 function.body.clear();
628 function
629}
630
631fn sanitized_view(view: &ViewDef) -> ViewDef {
632 let mut view = view.clone();
633 view.definition.clear();
634 view
635}
636
637fn sanitized_trigger(trigger: &TriggerDef) -> TriggerDef {
638 let mut trigger = trigger.clone();
639 trigger.query = None;
640 trigger.when = None;
641 trigger.language = None;
642 trigger
643}
644
645fn column_for_operation(column: &Column, dialect: Dialect) -> Column {
646 let mut column = column.clone();
647 if dialect == Dialect::Postgres {
648 column.default = postgres::canonical_column_default(column.default.as_deref());
649 }
650 column
651}
652
653#[allow(clippy::too_many_arguments, clippy::type_complexity)]
654fn verify_named_items<T, P>(
655 expected: &[T],
656 observed: &[T],
657 name: fn(&T) -> &str,
658 create: impl Fn(&T) -> Operation,
659 alter: impl Fn(&T, &T) -> Vec<Operation>,
660 kind: EntityKind,
661 create_op: &'static str,
662 alter_op: &'static str,
663 properties: &'static [P],
664 compare: fn(
665 &T,
666 &T,
667 &'static [P],
668 DriftContext<'_>,
669 &str,
670 &'static str,
671 EntityKind,
672 ) -> Vec<DriftFinding>,
673 context: DriftContext<'_>,
674 report: &mut VerificationReport,
675) where
676 T: DriftOpaque,
677{
678 for expected_value in expected {
679 let expected_name = name(expected_value);
680 let entity_name = scoped_child_name(context.table_name, expected_name);
681 let Some(actual_value) = observed.iter().find(|item| name(item) == expected_name) else {
682 report.operations.push(create(expected_value));
683 report
684 .findings
685 .push(missing_finding(create_op, kind, &entity_name, "presence"));
686 continue;
687 };
688 if expected_value.is_drift_opaque() {
689 continue;
690 }
691 let findings = compare(
692 expected_value,
693 actual_value,
694 properties,
695 context,
696 &entity_name,
697 alter_op,
698 kind,
699 );
700 if !findings.is_empty() {
701 report
702 .operations
703 .extend(alter(actual_value, expected_value));
704 report.findings.extend(findings);
705 }
706 }
707}
708
709trait DriftOpaque {
710 fn is_drift_opaque(&self) -> bool {
711 false
712 }
713}
714
715impl DriftOpaque for ForeignKey {}
716impl DriftOpaque for Constraint {
717 fn is_drift_opaque(&self) -> bool {
718 self.is_opaque()
719 }
720}
721impl DriftOpaque for EnumDef {}
722
723impl DriftOpaque for Index {
724 fn is_drift_opaque(&self) -> bool {
725 self.is_opaque()
726 }
727}
728
729impl DriftOpaque for TriggerDef {
730 fn is_drift_opaque(&self) -> bool {
731 self.is_opaque()
732 }
733}
734
735impl DriftOpaque for FunctionDef {
736 fn is_drift_opaque(&self) -> bool {
737 self.is_opaque()
738 }
739}
740
741impl DriftOpaque for ViewDef {
742 fn is_drift_opaque(&self) -> bool {
743 self.is_opaque()
744 }
745}
746
747impl DriftOpaque for ExtensionDef {
748 fn is_drift_opaque(&self) -> bool {
749 self.is_opaque()
750 }
751}
752
753fn compare_table_properties(
754 expected: &Table,
755 observed: &Table,
756 registry: &DriftRegistry,
757 context: DriftContext<'_>,
758 report: &mut VerificationReport,
759) {
760 for property in registry.tables {
761 if let PropertyMatch::Drift {
762 expected: expected_value,
763 observed: actual_value,
764 note,
765 } = (property.compare)(expected, observed, context)
766 {
767 report.findings.push(finding(
768 "alter_table",
769 EntityKind::Table,
770 &expected.qualified_name(),
771 property.name,
772 expected_value,
773 actual_value,
774 note,
775 ));
776 }
777 }
778}
779
780fn compare_column_properties(
781 expected: &Column,
782 observed: &Column,
783 registry: &DriftRegistry,
784 context: DriftContext<'_>,
785) -> Vec<DriftFinding> {
786 let entity_name = scoped_child_name(context.table_name, &observed.name);
787 registry
788 .columns
789 .iter()
790 .filter_map(
791 |property| match (property.compare)(expected, observed, context) {
792 PropertyMatch::Match => None,
793 PropertyMatch::Drift {
794 expected,
795 observed,
796 note,
797 } => Some(finding(
798 "alter_column",
799 EntityKind::Column,
800 &entity_name,
801 property.name,
802 expected,
803 observed,
804 note,
805 )),
806 },
807 )
808 .collect()
809}
810
811fn compare_primary_key_properties(
812 expected: &PrimaryKey,
813 observed: &PrimaryKey,
814 properties: &'static [PrimaryKeyProperty],
815 context: DriftContext<'_>,
816 entity_name: &str,
817) -> Vec<DriftFinding> {
818 properties
819 .iter()
820 .filter_map(
821 |property| match (property.compare)(expected, observed, context) {
822 PropertyMatch::Match => None,
823 PropertyMatch::Drift {
824 expected,
825 observed,
826 note,
827 } => Some(finding(
828 "alter_primary_key",
829 EntityKind::Constraint,
830 entity_name,
831 property.name,
832 expected,
833 observed,
834 note,
835 )),
836 },
837 )
838 .collect()
839}
840
841fn compare_foreign_key_properties(
842 expected: &ForeignKey,
843 observed: &ForeignKey,
844 properties: &'static [ForeignKeyProperty],
845 context: DriftContext<'_>,
846 entity_name: &str,
847 operation: &'static str,
848 kind: EntityKind,
849) -> Vec<DriftFinding> {
850 compare_properties(
851 expected,
852 observed,
853 properties,
854 context,
855 entity_name,
856 operation,
857 kind,
858 )
859}
860
861fn compare_index_properties(
862 expected: &Index,
863 observed: &Index,
864 properties: &'static [IndexProperty],
865 context: DriftContext<'_>,
866 entity_name: &str,
867 operation: &'static str,
868 kind: EntityKind,
869) -> Vec<DriftFinding> {
870 compare_properties(
871 expected,
872 observed,
873 properties,
874 context,
875 entity_name,
876 operation,
877 kind,
878 )
879}
880
881fn compare_constraint_properties(
882 expected: &Constraint,
883 observed: &Constraint,
884 properties: &'static [ConstraintProperty],
885 context: DriftContext<'_>,
886 entity_name: &str,
887 operation: &'static str,
888 kind: EntityKind,
889) -> Vec<DriftFinding> {
890 compare_properties(
891 expected,
892 observed,
893 properties,
894 context,
895 entity_name,
896 operation,
897 kind,
898 )
899}
900
901fn compare_trigger_properties(
902 expected: &TriggerDef,
903 observed: &TriggerDef,
904 properties: &'static [TriggerProperty],
905 context: DriftContext<'_>,
906 entity_name: &str,
907 operation: &'static str,
908 kind: EntityKind,
909) -> Vec<DriftFinding> {
910 compare_properties(
911 expected,
912 observed,
913 properties,
914 context,
915 entity_name,
916 operation,
917 kind,
918 )
919}
920
921fn compare_function_properties(
922 expected: &FunctionDef,
923 observed: &FunctionDef,
924 properties: &'static [FunctionProperty],
925 context: DriftContext<'_>,
926 entity_name: &str,
927) -> Vec<DriftFinding> {
928 compare_properties(
929 expected,
930 observed,
931 properties,
932 context,
933 entity_name,
934 "alter_function",
935 EntityKind::Function,
936 )
937}
938
939fn compare_view_properties(
940 expected: &ViewDef,
941 observed: &ViewDef,
942 properties: &'static [ViewProperty],
943 context: DriftContext<'_>,
944 entity_name: &str,
945) -> Vec<DriftFinding> {
946 compare_properties(
947 expected,
948 observed,
949 properties,
950 context,
951 entity_name,
952 "replace_view",
953 EntityKind::View,
954 )
955}
956
957fn compare_enum_properties(
958 expected: &EnumDef,
959 observed: &EnumDef,
960 properties: &'static [EnumProperty],
961 context: DriftContext<'_>,
962 entity_name: &str,
963) -> Vec<DriftFinding> {
964 compare_properties(
965 expected,
966 observed,
967 properties,
968 context,
969 entity_name,
970 "alter_enum",
971 EntityKind::Enum,
972 )
973}
974
975fn compare_extension_properties(
976 expected: &ExtensionDef,
977 observed: &ExtensionDef,
978 properties: &'static [ExtensionProperty],
979 context: DriftContext<'_>,
980 entity_name: &str,
981) -> Vec<DriftFinding> {
982 compare_properties(
983 expected,
984 observed,
985 properties,
986 context,
987 entity_name,
988 "alter_extension",
989 EntityKind::Extension,
990 )
991}
992
993trait RegisteredProperty<T> {
994 fn name(&self) -> &'static str;
995 fn compare(&self) -> fn(&T, &T, DriftContext<'_>) -> PropertyMatch;
996}
997
998macro_rules! impl_registered_property {
999 ($ty:ty, $target:ty) => {
1000 impl RegisteredProperty<$target> for $ty {
1001 fn name(&self) -> &'static str {
1002 self.name
1003 }
1004 fn compare(&self) -> fn(&$target, &$target, DriftContext<'_>) -> PropertyMatch {
1005 self.compare
1006 }
1007 }
1008 };
1009}
1010
1011impl_registered_property!(ForeignKeyProperty, ForeignKey);
1012impl_registered_property!(IndexProperty, Index);
1013impl_registered_property!(ConstraintProperty, Constraint);
1014impl_registered_property!(TriggerProperty, TriggerDef);
1015impl_registered_property!(FunctionProperty, FunctionDef);
1016impl_registered_property!(ViewProperty, ViewDef);
1017impl_registered_property!(EnumProperty, EnumDef);
1018impl_registered_property!(ExtensionProperty, ExtensionDef);
1019
1020fn compare_properties<T, P>(
1021 expected: &T,
1022 observed: &T,
1023 properties: &'static [P],
1024 context: DriftContext<'_>,
1025 entity_name: &str,
1026 operation: &'static str,
1027 kind: EntityKind,
1028) -> Vec<DriftFinding>
1029where
1030 P: RegisteredProperty<T> + 'static,
1031{
1032 properties
1033 .iter()
1034 .filter_map(
1035 |property| match (property.compare())(expected, observed, context) {
1036 PropertyMatch::Match => None,
1037 PropertyMatch::Drift {
1038 expected,
1039 observed,
1040 note,
1041 } => Some(finding(
1042 operation,
1043 kind,
1044 entity_name,
1045 property.name(),
1046 expected,
1047 observed,
1048 note,
1049 )),
1050 },
1051 )
1052 .collect()
1053}
1054
1055fn scope_schemas(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1056 scope_tables(state, schemas, dialect);
1057 scope_views(state, schemas, dialect);
1058 scope_functions(state, schemas, dialect);
1059 scope_extensions(state, schemas, dialect);
1060 scope_enums(state, schemas, dialect);
1061}
1062
1063fn scope_tables(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1064 let tables = std::mem::take(&mut state.tables);
1065 state.tables = tables
1066 .into_values()
1067 .filter_map(|mut table| {
1068 table.schema =
1069 scoped_schema(dialect, EntityKind::Table, table.schema.as_deref(), schemas)?;
1070 if let Some(default_schema) = schemas.first() {
1071 scope_table_references(&mut table, default_schema);
1072 }
1073 Some(table)
1074 })
1075 .map(|table| (table.qualified_name(), table))
1076 .collect();
1077}
1078
1079fn scope_views(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1080 let views = std::mem::take(&mut state.views);
1081 state.views = views
1082 .into_values()
1083 .filter_map(|mut view| {
1084 view.schema =
1085 scoped_schema(dialect, EntityKind::View, view.schema.as_deref(), schemas)?;
1086 Some(view)
1087 })
1088 .map(|view| (view.qualified_name(), view))
1089 .collect();
1090}
1091
1092fn scope_functions(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1093 let functions = std::mem::take(&mut state.functions);
1094 state.functions = functions
1095 .into_values()
1096 .filter_map(|mut function| {
1097 function.schema = scoped_schema(
1098 dialect,
1099 EntityKind::Function,
1100 function.schema.as_deref(),
1101 schemas,
1102 )?;
1103 Some(function)
1104 })
1105 .map(|function| {
1106 let key = function_verify_key(&function);
1107 (key, function)
1108 })
1109 .collect();
1110}
1111
1112fn scope_extensions(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1113 let extensions = std::mem::take(&mut state.extensions);
1114 state.extensions = extensions
1115 .into_values()
1116 .filter_map(|mut extension| {
1117 extension.schema = scoped_schema(
1118 dialect,
1119 EntityKind::Extension,
1120 extension.schema.as_deref(),
1121 schemas,
1122 )?;
1123 Some(extension)
1124 })
1125 .map(|extension| (extension.qualified_name(), extension))
1126 .collect();
1127}
1128
1129fn scope_enums(state: &mut Schema, schemas: &[&str], dialect: Dialect) {
1130 let enums = std::mem::take(&mut state.enums);
1131 state.enums = enums
1132 .into_values()
1133 .filter_map(|mut enum_def| {
1134 enum_def.schema = scoped_schema(
1135 dialect,
1136 EntityKind::Enum,
1137 enum_def.schema.as_deref(),
1138 schemas,
1139 )?;
1140 Some(enum_def)
1141 })
1142 .map(|enum_def| (enum_def.qualified_name(), enum_def))
1143 .collect();
1144}
1145
1146fn scoped_schema(
1147 dialect: Dialect,
1148 kind: EntityKind,
1149 current: Option<&str>,
1150 schemas: &[&str],
1151) -> Option<Option<String>> {
1152 let Some(current) = current else {
1153 return Some(None);
1154 };
1155 if schemas.is_empty() {
1156 return Some(dialect.canonicalize_schema_name(kind, Some(current)));
1157 }
1158
1159 let current = dialect.canonicalize_schema_name(kind, Some(current));
1160 schemas.iter().enumerate().find_map(|(index, requested)| {
1161 let requested = dialect.canonicalize_schema_name(kind, Some(requested));
1162 if current == requested {
1163 Some(if index == 0 { None } else { current.clone() })
1164 } else {
1165 None
1166 }
1167 })
1168}
1169
1170fn scope_table_references(table: &mut Table, schema: &str) {
1171 let prefix = format!("{schema}.");
1172 for fk in &mut table.foreign_keys {
1173 if let Some(local) = fk.to_table.strip_prefix(&prefix) {
1174 fk.to_table = local.to_string();
1175 }
1176 }
1177 for trigger in &mut table.triggers {
1178 if let Some(function_name) = &mut trigger.function_name
1179 && let Some(local) = function_name.strip_prefix(&prefix)
1180 {
1181 *function_name = local.to_string();
1182 }
1183 }
1184}
1185
1186fn function_verify_key(function: &FunctionDef) -> String {
1187 if function.arguments.is_empty() {
1188 function.qualified_name()
1189 } else {
1190 format!("{}({})", function.qualified_name(), function.arguments)
1191 }
1192}
1193
1194pub fn format_report(report: &VerificationReport) -> Vec<String> {
1195 use std::collections::BTreeMap;
1196
1197 let pending = report
1198 .pending_migrations
1199 .iter()
1200 .map(|id| format!(" pending migration: {id}"));
1201 let mut grouped: BTreeMap<(&'static str, String), Vec<&DriftFinding>> = BTreeMap::new();
1202 for finding in &report.findings {
1203 grouped
1204 .entry((finding.operation, finding.entity_name.clone()))
1205 .or_default()
1206 .push(finding);
1207 }
1208
1209 pending
1210 .chain(
1211 grouped
1212 .into_iter()
1213 .flat_map(|((operation, entity), findings)| {
1214 let mut lines = vec![format!(" drift: {operation} {entity}")];
1215 lines.extend(findings.into_iter().map(|finding| {
1216 let mut line = format!(
1217 " {}: expected {}, observed {}",
1218 finding.property, finding.expected, finding.observed
1219 );
1220 if let Some(note) = &finding.note {
1221 line.push_str(&format!(" ({note})"));
1222 }
1223 line
1224 }));
1225 lines
1226 }),
1227 )
1228 .collect()
1229}
1230
1231fn finding(
1232 operation: &'static str,
1233 entity_kind: EntityKind,
1234 entity_name: &str,
1235 property: &'static str,
1236 expected: String,
1237 observed: String,
1238 note: Option<String>,
1239) -> DriftFinding {
1240 DriftFinding {
1241 operation,
1242 entity_kind,
1243 entity_name: entity_name.to_string(),
1244 property,
1245 expected,
1246 observed,
1247 note,
1248 }
1249}
1250
1251fn missing_finding(
1252 operation: &'static str,
1253 entity_kind: EntityKind,
1254 entity_name: &str,
1255 property: &'static str,
1256) -> DriftFinding {
1257 finding(
1258 operation,
1259 entity_kind,
1260 entity_name,
1261 property,
1262 "present".to_string(),
1263 "<missing>".to_string(),
1264 None,
1265 )
1266}
1267
1268fn scoped_child_name(table_name: Option<&str>, child_name: &str) -> String {
1269 match table_name {
1270 Some(table) if !child_name.is_empty() => format!("{table}.{child_name}"),
1271 Some(table) => table.to_string(),
1272 None => child_name.to_string(),
1273 }
1274}
1275
1276pub(crate) fn exact_string(expected: &str, observed: &str) -> PropertyMatch {
1277 if expected == observed {
1278 PropertyMatch::Match
1279 } else {
1280 PropertyMatch::Drift {
1281 expected: display_str(expected),
1282 observed: display_str(observed),
1283 note: None,
1284 }
1285 }
1286}
1287
1288pub(crate) fn exact_bool(expected: bool, observed: bool) -> PropertyMatch {
1289 if expected == observed {
1290 PropertyMatch::Match
1291 } else {
1292 PropertyMatch::Drift {
1293 expected: expected.to_string(),
1294 observed: observed.to_string(),
1295 note: None,
1296 }
1297 }
1298}
1299
1300pub(crate) fn exact_option(expected: &Option<String>, observed: &Option<String>) -> PropertyMatch {
1301 if expected == observed {
1302 PropertyMatch::Match
1303 } else {
1304 PropertyMatch::Drift {
1305 expected: display_option(expected),
1306 observed: display_option(observed),
1307 note: None,
1308 }
1309 }
1310}
1311
1312pub(crate) fn exact_vec(expected: &[String], observed: &[String]) -> PropertyMatch {
1313 if expected == observed {
1314 PropertyMatch::Match
1315 } else {
1316 PropertyMatch::Drift {
1317 expected: display_vec(expected),
1318 observed: display_vec(observed),
1319 note: None,
1320 }
1321 }
1322}
1323
1324pub(crate) fn display_option(value: &Option<String>) -> String {
1325 value
1326 .as_deref()
1327 .map(display_str)
1328 .unwrap_or_else(|| "<none>".to_string())
1329}
1330
1331pub(crate) fn display_str(value: &str) -> String {
1332 if value.is_empty() {
1333 "<empty>".to_string()
1334 } else {
1335 value.to_string()
1336 }
1337}
1338
1339pub(crate) fn display_vec(values: &[String]) -> String {
1340 if values.is_empty() {
1341 "[]".to_string()
1342 } else {
1343 format!("[{}]", values.join(", "))
1344 }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use crate::dialects::Dialect;
1350 use crate::states::{Column, FunctionDef, Schema, Table, Volatility};
1351
1352 use super::{diff, diff_schemas, format_report};
1353
1354 #[test]
1356 fn postgres_literal_text_cast_default_matches() {
1357 let replay = schema_with_column(defaulted_column("theme", Some("'light'")));
1358 let live = schema_with_column(defaulted_column("theme", Some("'light'::text")));
1359
1360 let report = diff(replay, live, "public", Dialect::Postgres);
1361
1362 assert!(report.findings.is_empty(), "unexpected drift: {:?}", report);
1363 }
1364
1365 #[test]
1367 fn column_default_drift_reports_entity_property_and_values() {
1368 let replay = schema_with_column(defaulted_column("posts_per_page", None));
1369 let live = schema_with_column(defaulted_column("posts_per_page", Some("10")));
1370
1371 let report = diff(replay, live, "public", Dialect::Postgres);
1372 let lines = format_report(&report);
1373
1374 assert!(
1375 lines
1376 .iter()
1377 .any(|line| { line.contains("drift: alter_column preferences.posts_per_page") })
1378 );
1379 assert!(
1380 lines
1381 .iter()
1382 .any(|line| { line.contains("default: expected <none>, observed 10") })
1383 );
1384 }
1385
1386 #[test]
1388 fn postgres_default_function_case_is_not_drift() {
1389 let replay = schema_with_column(defaulted_column("uploaded_at", Some("NOW()")));
1390 let live = schema_with_column(defaulted_column("uploaded_at", Some("now()")));
1391
1392 let report = diff(replay, live, "public", Dialect::Postgres);
1393
1394 assert!(report.findings.is_empty(), "unexpected drift: {report:?}");
1395 }
1396
1397 #[test]
1399 fn function_body_only_drift_is_not_reported() {
1400 let mut replay = Schema::default();
1401 replay
1402 .functions
1403 .insert("audit_users".to_string(), function("SELECT 1"));
1404 let mut live = Schema::default();
1405 live.functions
1406 .insert("audit_users".to_string(), function("SELECT 2"));
1407
1408 let report = diff(replay, live, "public", Dialect::Postgres);
1409
1410 assert!(report.findings.is_empty(), "unexpected drift: {:?}", report);
1411 }
1412
1413 #[test]
1415 fn multiple_schema_diff_compares_qualified_objects() {
1416 let replay = schema_with_namespaced_tables();
1417 let live = schema_with_namespaced_tables();
1418
1419 let report = diff_schemas(replay, live, &["public", "billing"], Dialect::Postgres);
1420
1421 assert!(report.findings.is_empty(), "unexpected drift: {report:?}");
1422 }
1423
1424 #[test]
1426 fn multiple_schema_diff_reports_missing_qualified_object() {
1427 let replay = schema_with_namespaced_tables();
1428 let mut live = schema_with_namespaced_tables();
1429 live.tables.remove("billing.invoices");
1430
1431 let report = diff_schemas(replay, live, &["public", "billing"], Dialect::Postgres);
1432
1433 assert!(
1434 report
1435 .findings
1436 .iter()
1437 .any(|finding| finding.entity_name == "billing.invoices")
1438 );
1439 }
1440
1441 #[test]
1443 fn drift_contract_lists_registered_column_default() {
1444 let docs = Dialect::Postgres.drift_contract();
1445
1446 assert!(docs.iter().any(|doc| {
1447 doc.entity_kind == crate::states::types::EntityKind::Column
1448 && doc.property == "default"
1449 && !doc.compared.is_empty()
1450 && !doc.ignored.is_empty()
1451 }));
1452 }
1453
1454 #[test]
1456 fn normalize_inspected_schema_prepares_reflected_schema() {
1457 let inspected = schema_with_column(defaulted_column("theme", Some("'light'::text")));
1458
1459 let normalized = Dialect::Postgres
1460 .normalize_inspected_schema(inspected)
1461 .expect("inspected schema should normalize");
1462
1463 assert_eq!(
1464 normalized.tables["preferences"].columns[0]
1465 .default
1466 .as_deref(),
1467 Some("'light'::text")
1468 );
1469 }
1470
1471 fn schema_with_column(column: Column) -> Schema {
1472 let mut schema = Schema::default();
1473 schema.tables.insert(
1474 "preferences".to_string(),
1475 Table {
1476 name: "preferences".to_string(),
1477 schema: None,
1478 primary_key: None,
1479 columns: vec![column],
1480 foreign_keys: Vec::new(),
1481 indexes: Vec::new(),
1482 constraints: Vec::new(),
1483 triggers: Vec::new(),
1484 options: Default::default(),
1485 },
1486 );
1487 schema
1488 }
1489
1490 fn schema_with_namespaced_tables() -> Schema {
1491 let mut schema = Schema::default();
1492 let public = Table {
1493 name: "users".to_string(),
1494 schema: None,
1495 primary_key: None,
1496 columns: Vec::new(),
1497 foreign_keys: Vec::new(),
1498 indexes: Vec::new(),
1499 constraints: Vec::new(),
1500 triggers: Vec::new(),
1501 options: Default::default(),
1502 };
1503 let billing = Table {
1504 name: "invoices".to_string(),
1505 schema: Some("billing".to_string()),
1506 primary_key: None,
1507 columns: Vec::new(),
1508 foreign_keys: Vec::new(),
1509 indexes: Vec::new(),
1510 constraints: Vec::new(),
1511 triggers: Vec::new(),
1512 options: Default::default(),
1513 };
1514 schema.tables.insert("users".to_string(), public);
1515 schema
1516 .tables
1517 .insert("billing.invoices".to_string(), billing);
1518 schema
1519 }
1520
1521 fn defaulted_column(name: &str, default: Option<&str>) -> Column {
1522 Column {
1523 name: name.to_string(),
1524 col_type: "text".to_string(),
1525 nullable: false,
1526 default: default.map(ToString::to_string),
1527 primary_key: false,
1528 references: None,
1529 check: None,
1530 generated: None,
1531 }
1532 }
1533
1534 fn function(body: &str) -> FunctionDef {
1535 FunctionDef {
1536 name: "audit_users".to_string(),
1537 schema: None,
1538 arguments: String::new(),
1539 returns: "trigger".to_string(),
1540 language: "plpgsql".to_string(),
1541 body: body.to_string(),
1542 volatility: Volatility::Volatile,
1543 security_definer: false,
1544 opaque: Default::default(),
1545 }
1546 }
1547}