Skip to main content

gaman_core/drift/
mod.rs

1//! Registry-driven drift verification for inspectable database properties.
2//!
3//! Verification is dialect-owned: each dialect registers the entity properties
4//! it can compare accurately from live inspection. The generic engine matches
5//! replayed and reflected entities, runs the registered comparators, and returns
6//! structured findings plus repair operations.
7
8mod 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/// Result of comparing one verified property.
28#[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
113/// Returns the user-facing semantic drift contract for a dialect.
114pub 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
200/// Verifies replayed migration state against live inspected state.
201pub fn diff(replay: Schema, live: Schema, schema: &str, dialect: Dialect) -> VerificationReport {
202    diff_schemas(replay, live, &[schema], dialect)
203}
204
205/// Verifies replayed state across multiple inspected schemas.
206///
207/// Unqualified migration objects belong to the first schema. Objects explicitly
208/// qualified with later schemas retain their qualification during comparison.
209pub 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    /// Verifies PostgreSQL literal casts do not create default drift.
1355    #[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    /// Verifies real default drift includes the table, column, and property name.
1366    #[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    /// Verifies unquoted PostgreSQL default-expression case does not create drift.
1387    #[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    /// Verifies function body-only differences are outside live drift detection.
1398    #[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    /// Verifies additional explicitly qualified schemas participate in one drift comparison.
1414    #[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    /// Verifies a missing object in an additional schema is reported as drift.
1425    #[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    /// Verifies the drift contract documents registered comparator properties.
1442    #[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    /// Verifies inspected-schema normalization is owned by the dialect lifecycle.
1455    #[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}