Skip to main content

oxide_sql_core/migrations/
diff.rs

1//! Schema diff engine for auto-migration generation.
2//!
3//! Compares an "old" (current DB) and "new" (desired from code)
4//! [`SchemaSnapshot`] and produces a `Vec<Operation>` representing
5//! the DDL changes needed to migrate from old to new.
6
7use std::collections::BTreeSet;
8
9use crate::schema::{RustTypeMapping, TableSchema};
10
11use super::column_builder::ColumnDefinition;
12use super::dialect::MigrationDialect;
13use super::operation::{
14    AddColumnOp, AddForeignKeyOp, AlterColumnChange, AlterColumnOp, CreateIndexOp, CreateTableOp,
15    DropColumnOp, DropForeignKeyOp, DropIndexOp, DropTableOp, Operation,
16};
17use super::snapshot::{
18    ColumnSnapshot, ForeignKeySnapshot, IndexSnapshot, SchemaSnapshot, TableSnapshot,
19};
20
21/// Minimum normalized similarity score (0.0–1.0) for a
22/// (dropped, added) column pair to be flagged as a possible rename.
23const RENAME_SIMILARITY_THRESHOLD: f64 = 0.4;
24
25// ================================================================
26// String similarity helpers
27// ================================================================
28
29/// Computes the Levenshtein edit distance between two strings.
30fn levenshtein(a: &str, b: &str) -> usize {
31    let a: Vec<char> = a.chars().collect();
32    let b: Vec<char> = b.chars().collect();
33    let m = a.len();
34    let n = b.len();
35    let mut prev = (0..=n).collect::<Vec<_>>();
36    let mut curr = vec![0; n + 1];
37    for i in 1..=m {
38        curr[0] = i;
39        for j in 1..=n {
40            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
41            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
42        }
43        std::mem::swap(&mut prev, &mut curr);
44    }
45    prev[n]
46}
47
48/// Returns a normalized similarity score in `[0.0, 1.0]`.
49/// 1.0 means identical, 0.0 means completely different.
50fn similarity(a: &str, b: &str) -> f64 {
51    let max_len = a.len().max(b.len());
52    if max_len == 0 {
53        return 1.0;
54    }
55    1.0 - (levenshtein(a, b) as f64 / max_len as f64)
56}
57
58// ================================================================
59// Public types
60// ================================================================
61
62/// A change that cannot be automatically resolved and requires
63/// user intervention.
64#[derive(Debug, Clone, PartialEq)]
65pub enum AmbiguousChange {
66    /// A dropped and added column with the same type and similar
67    /// names, suggesting a possible rename.
68    PossibleRename {
69        /// Table containing the columns.
70        table: String,
71        /// The column that was dropped.
72        old_column: String,
73        /// The column that was added.
74        new_column: String,
75        /// Name similarity score (0.0–1.0).
76        similarity: f64,
77    },
78    /// A dropped and added table with the same column structure,
79    /// suggesting a possible rename.
80    PossibleTableRename {
81        /// The table that was dropped.
82        old_table: String,
83        /// The table that was added.
84        new_table: String,
85        /// Name similarity score (0.0–1.0).
86        similarity: f64,
87    },
88}
89
90/// Informational warnings about changes that the diff engine
91/// detected but cannot (or should not) translate into DDL
92/// operations automatically.
93#[derive(Debug, Clone, PartialEq)]
94pub enum DiffWarning {
95    /// A column's primary key status changed. This typically
96    /// requires table recreation on most databases.
97    PrimaryKeyChange {
98        /// Table name.
99        table: String,
100        /// Column name.
101        column: String,
102        /// New value of the primary_key flag.
103        new_value: bool,
104    },
105    /// A column's autoincrement status changed. Most databases
106    /// cannot alter this without recreating the table.
107    AutoincrementChange {
108        /// Table name.
109        table: String,
110        /// Column name.
111        column: String,
112        /// New value of the autoincrement flag.
113        new_value: bool,
114    },
115    /// The relative ordering of columns changed. Most databases
116    /// cannot reorder columns without recreating the table.
117    ColumnOrderChanged {
118        /// Table name.
119        table: String,
120        /// Column names in the old order.
121        old_order: Vec<String>,
122        /// Column names in the new order.
123        new_order: Vec<String>,
124    },
125}
126
127/// Result of comparing two schema snapshots.
128#[derive(Debug, Clone, PartialEq)]
129pub struct SchemaDiff {
130    /// The migration operations to apply.
131    pub operations: Vec<Operation>,
132    /// Changes that may be renames and need user confirmation.
133    pub ambiguous: Vec<AmbiguousChange>,
134    /// Informational warnings (destructive changes that require
135    /// manual intervention, column order changes, etc.).
136    pub warnings: Vec<DiffWarning>,
137}
138
139impl SchemaDiff {
140    /// Returns `true` if there are no changes at all (no ops,
141    /// no ambiguous changes, and no warnings).
142    #[must_use]
143    pub fn is_empty(&self) -> bool {
144        self.operations.is_empty() && self.ambiguous.is_empty() && self.warnings.is_empty()
145    }
146
147    /// Convenience: generates SQL for every operation using the
148    /// given dialect.
149    #[must_use]
150    pub fn to_sql(&self, dialect: &impl MigrationDialect) -> Vec<String> {
151        self.operations
152            .iter()
153            .map(|op| dialect.generate_sql(op))
154            .collect()
155    }
156
157    /// Attempts to reverse the entire diff. Returns `None` if any
158    /// operation is non-reversible.
159    #[must_use]
160    pub fn reverse(&self) -> Option<Self> {
161        let mut reversed = Vec::new();
162        for op in self.operations.iter().rev() {
163            reversed.push(op.reverse()?);
164        }
165        Some(Self {
166            operations: reversed,
167            ambiguous: vec![],
168            warnings: vec![],
169        })
170    }
171
172    /// Returns `true` if every operation is reversible.
173    #[must_use]
174    pub fn is_reversible(&self) -> bool {
175        self.operations.iter().all(Operation::is_reversible)
176    }
177
178    /// Returns references to the non-reversible operations.
179    #[must_use]
180    pub fn non_reversible_operations(&self) -> Vec<&Operation> {
181        self.operations
182            .iter()
183            .filter(|op| !op.is_reversible())
184            .collect()
185    }
186}
187
188// ================================================================
189// Table-level diff
190// ================================================================
191
192/// Compares a single table's current and desired snapshots,
193/// producing the operations needed to migrate.
194fn diff_table(table_name: &str, old: &TableSnapshot, new: &TableSnapshot) -> SchemaDiff {
195    let old_names: BTreeSet<&str> = old.columns.iter().map(|c| c.name.as_str()).collect();
196    let new_names: BTreeSet<&str> = new.columns.iter().map(|c| c.name.as_str()).collect();
197
198    let dropped: Vec<&str> = old_names.difference(&new_names).copied().collect();
199    let added: Vec<&str> = new_names.difference(&old_names).copied().collect();
200    let common: BTreeSet<&str> = old_names.intersection(&new_names).copied().collect();
201
202    let mut operations = Vec::new();
203    let mut ambiguous = Vec::new();
204    let mut warnings = Vec::new();
205
206    // ---- N:M rename detection with similarity scoring ----------
207    let mut rename_dropped: BTreeSet<&str> = BTreeSet::new();
208    let mut rename_added: BTreeSet<&str> = BTreeSet::new();
209
210    // Build candidate pairs: (dropped, added, similarity)
211    let mut candidates: Vec<(&str, &str, f64)> = Vec::new();
212    for &d in &dropped {
213        let old_col = old.column(d).unwrap();
214        for &a in &added {
215            let new_col = new.column(a).unwrap();
216            if old_col.data_type == new_col.data_type {
217                let sim = similarity(d, a);
218                if sim >= RENAME_SIMILARITY_THRESHOLD {
219                    candidates.push((d, a, sim));
220                }
221            }
222        }
223    }
224    // Greedy matching: highest similarity first.
225    candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
226    for (d, a, sim) in &candidates {
227        if rename_dropped.contains(d) || rename_added.contains(a) {
228            continue;
229        }
230        ambiguous.push(AmbiguousChange::PossibleRename {
231            table: table_name.to_string(),
232            old_column: d.to_string(),
233            new_column: a.to_string(),
234            similarity: *sim,
235        });
236        rename_dropped.insert(d);
237        rename_added.insert(a);
238    }
239
240    // ---- AddColumn for truly new columns -----------------------
241    for &name in &added {
242        if rename_added.contains(name) {
243            continue;
244        }
245        let col = new.column(name).unwrap();
246        operations.push(Operation::AddColumn(AddColumnOp {
247            table: table_name.to_string(),
248            column: snapshot_to_column_def(col),
249        }));
250    }
251
252    // ---- AlterColumn for changed properties on common cols -----
253    for &name in &common {
254        let old_col = old.column(name).unwrap();
255        let new_col = new.column(name).unwrap();
256
257        if old_col.data_type != new_col.data_type {
258            operations.push(Operation::AlterColumn(AlterColumnOp {
259                table: table_name.to_string(),
260                column: name.to_string(),
261                change: AlterColumnChange::SetDataType(new_col.data_type.clone()),
262            }));
263        }
264
265        if old_col.nullable != new_col.nullable {
266            operations.push(Operation::AlterColumn(AlterColumnOp {
267                table: table_name.to_string(),
268                column: name.to_string(),
269                change: AlterColumnChange::SetNullable(new_col.nullable),
270            }));
271        }
272
273        if old_col.unique != new_col.unique {
274            operations.push(Operation::AlterColumn(AlterColumnOp {
275                table: table_name.to_string(),
276                column: name.to_string(),
277                change: AlterColumnChange::SetUnique(new_col.unique),
278            }));
279        }
280
281        if old_col.primary_key != new_col.primary_key {
282            warnings.push(DiffWarning::PrimaryKeyChange {
283                table: table_name.to_string(),
284                column: name.to_string(),
285                new_value: new_col.primary_key,
286            });
287        }
288
289        if old_col.autoincrement != new_col.autoincrement {
290            warnings.push(DiffWarning::AutoincrementChange {
291                table: table_name.to_string(),
292                column: name.to_string(),
293                new_value: new_col.autoincrement,
294            });
295        }
296
297        match (&old_col.default, &new_col.default) {
298            (None, Some(new_default)) => {
299                operations.push(Operation::AlterColumn(AlterColumnOp {
300                    table: table_name.to_string(),
301                    column: name.to_string(),
302                    change: AlterColumnChange::SetDefault(new_default.clone()),
303                }));
304            }
305            (Some(_), None) => {
306                operations.push(Operation::AlterColumn(AlterColumnOp {
307                    table: table_name.to_string(),
308                    column: name.to_string(),
309                    change: AlterColumnChange::DropDefault,
310                }));
311            }
312            (Some(old_def), Some(new_def)) if old_def != new_def => {
313                operations.push(Operation::AlterColumn(AlterColumnOp {
314                    table: table_name.to_string(),
315                    column: name.to_string(),
316                    change: AlterColumnChange::SetDefault(new_def.clone()),
317                }));
318            }
319            _ => {}
320        }
321    }
322
323    // ---- DropColumn for truly removed columns ------------------
324    for &name in &dropped {
325        if rename_dropped.contains(name) {
326            continue;
327        }
328        operations.push(Operation::DropColumn(DropColumnOp {
329            table: table_name.to_string(),
330            column: name.to_string(),
331        }));
332    }
333
334    // ---- Index diff --------------------------------------------
335    diff_indexes(table_name, old, new, &mut operations);
336
337    // ---- Foreign key diff --------------------------------------
338    diff_foreign_keys(table_name, old, new, &mut operations);
339
340    // ---- Column ordering detection -----------------------------
341    detect_column_order_change(table_name, old, new, &common, &mut warnings);
342
343    SchemaDiff {
344        operations,
345        ambiguous,
346        warnings,
347    }
348}
349
350// ================================================================
351// Index / FK diffing helpers
352// ================================================================
353
354/// Two indexes are considered equivalent if they cover the same
355/// columns, uniqueness, and type. Names are ignored because they
356/// may differ between environments.
357fn indexes_equivalent(a: &IndexSnapshot, b: &IndexSnapshot) -> bool {
358    a.columns == b.columns
359        && a.unique == b.unique
360        && a.index_type == b.index_type
361        && a.condition == b.condition
362}
363
364/// Diffs indexes between old and new table snapshots.
365fn diff_indexes(
366    table_name: &str,
367    old: &TableSnapshot,
368    new: &TableSnapshot,
369    operations: &mut Vec<Operation>,
370) {
371    // Indexes present in old but not in new → DropIndex.
372    for old_idx in &old.indexes {
373        let still_exists = new.indexes.iter().any(|n| indexes_equivalent(old_idx, n));
374        if !still_exists {
375            operations.push(Operation::DropIndex(DropIndexOp {
376                name: old_idx.name.clone(),
377                table: Some(table_name.to_string()),
378                if_exists: false,
379            }));
380        }
381    }
382    // Indexes present in new but not in old → CreateIndex.
383    for new_idx in &new.indexes {
384        let already_exists = old.indexes.iter().any(|o| indexes_equivalent(o, new_idx));
385        if !already_exists {
386            operations.push(Operation::CreateIndex(CreateIndexOp {
387                name: new_idx.name.clone(),
388                table: table_name.to_string(),
389                columns: new_idx.columns.clone(),
390                unique: new_idx.unique,
391                index_type: new_idx.index_type,
392                if_not_exists: false,
393                condition: new_idx.condition.clone(),
394            }));
395        }
396    }
397}
398
399/// Two foreign keys are equivalent if they reference the same
400/// columns, target table, target columns, and actions.
401fn fks_equivalent(a: &ForeignKeySnapshot, b: &ForeignKeySnapshot) -> bool {
402    a.columns == b.columns
403        && a.references_table == b.references_table
404        && a.references_columns == b.references_columns
405        && a.on_delete == b.on_delete
406        && a.on_update == b.on_update
407}
408
409/// Diffs foreign keys between old and new table snapshots.
410fn diff_foreign_keys(
411    table_name: &str,
412    old: &TableSnapshot,
413    new: &TableSnapshot,
414    operations: &mut Vec<Operation>,
415) {
416    // FKs present in old but not in new → DropForeignKey.
417    for old_fk in &old.foreign_keys {
418        let still_exists = new.foreign_keys.iter().any(|n| fks_equivalent(old_fk, n));
419        if !still_exists {
420            if let Some(ref name) = old_fk.name {
421                operations.push(Operation::DropForeignKey(DropForeignKeyOp {
422                    table: table_name.to_string(),
423                    name: name.clone(),
424                }));
425            }
426        }
427    }
428    // FKs present in new but not in old → AddForeignKey.
429    for new_fk in &new.foreign_keys {
430        let already_exists = old.foreign_keys.iter().any(|o| fks_equivalent(o, new_fk));
431        if !already_exists {
432            operations.push(Operation::AddForeignKey(AddForeignKeyOp {
433                table: table_name.to_string(),
434                name: new_fk.name.clone(),
435                columns: new_fk.columns.clone(),
436                references_table: new_fk.references_table.clone(),
437                references_columns: new_fk.references_columns.clone(),
438                on_delete: new_fk.on_delete,
439                on_update: new_fk.on_update,
440            }));
441        }
442    }
443}
444
445// ================================================================
446// Column ordering
447// ================================================================
448
449/// Detects whether the relative order of common columns changed
450/// between old and new snapshots.
451fn detect_column_order_change(
452    table_name: &str,
453    old: &TableSnapshot,
454    new: &TableSnapshot,
455    common: &BTreeSet<&str>,
456    warnings: &mut Vec<DiffWarning>,
457) {
458    let old_order: Vec<String> = old
459        .columns
460        .iter()
461        .filter(|c| common.contains(c.name.as_str()))
462        .map(|c| c.name.clone())
463        .collect();
464    let new_order: Vec<String> = new
465        .columns
466        .iter()
467        .filter(|c| common.contains(c.name.as_str()))
468        .map(|c| c.name.clone())
469        .collect();
470
471    if old_order != new_order {
472        warnings.push(DiffWarning::ColumnOrderChanged {
473            table: table_name.to_string(),
474            old_order,
475            new_order,
476        });
477    }
478}
479
480// ================================================================
481// Helpers
482// ================================================================
483
484/// Converts a `ColumnSnapshot` into a `ColumnDefinition` for use
485/// in `AddColumnOp`.
486fn snapshot_to_column_def(col: &ColumnSnapshot) -> ColumnDefinition {
487    ColumnDefinition {
488        name: col.name.clone(),
489        data_type: col.data_type.clone(),
490        nullable: col.nullable,
491        default: col.default.clone(),
492        primary_key: col.primary_key,
493        unique: col.unique,
494        autoincrement: col.autoincrement,
495        references: None,
496        check: None,
497        collation: None,
498    }
499}
500
501// ================================================================
502// Schema-level diff
503// ================================================================
504
505/// Compares two full schema snapshots and produces the operations
506/// needed to migrate from `current` to `desired`.
507///
508/// Operation ordering: CreateTable > AddColumn > AlterColumn >
509/// DropColumn > DropTable (avoids FK constraint violations).
510pub fn auto_diff_schema(current: &SchemaSnapshot, desired: &SchemaSnapshot) -> SchemaDiff {
511    let current_tables: BTreeSet<&str> = current.tables.keys().map(String::as_str).collect();
512    let desired_tables: BTreeSet<&str> = desired.tables.keys().map(String::as_str).collect();
513
514    let dropped_tables: Vec<&str> = current_tables
515        .difference(&desired_tables)
516        .copied()
517        .collect();
518    let added_tables: Vec<&str> = desired_tables
519        .difference(&current_tables)
520        .copied()
521        .collect();
522    let common_tables: Vec<&str> = current_tables
523        .intersection(&desired_tables)
524        .copied()
525        .collect();
526
527    let mut create_ops = Vec::new();
528    let mut add_ops = Vec::new();
529    let mut alter_ops = Vec::new();
530    let mut drop_col_ops = Vec::new();
531    let mut drop_table_ops = Vec::new();
532    let mut ambiguous = Vec::new();
533    let mut warnings = Vec::new();
534
535    // ---- N:M table rename detection ----------------------------
536    let mut rename_dropped: BTreeSet<&str> = BTreeSet::new();
537    let mut rename_added: BTreeSet<&str> = BTreeSet::new();
538
539    // Build candidate pairs: (dropped, added, similarity)
540    let mut candidates: Vec<(&str, &str, f64)> = Vec::new();
541    for &d in &dropped_tables {
542        let old_table = &current.tables[d];
543        for &a in &added_tables {
544            let new_table = &desired.tables[a];
545            if tables_have_same_columns(old_table, new_table) {
546                let sim = similarity(d, a);
547                candidates.push((d, a, sim));
548            }
549        }
550    }
551    candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
552    for (d, a, sim) in &candidates {
553        if rename_dropped.contains(d) || rename_added.contains(a) {
554            continue;
555        }
556        ambiguous.push(AmbiguousChange::PossibleTableRename {
557            old_table: d.to_string(),
558            new_table: a.to_string(),
559            similarity: *sim,
560        });
561        rename_dropped.insert(d);
562        rename_added.insert(a);
563    }
564
565    // ---- New tables -> CreateTable -----------------------------
566    for &name in &added_tables {
567        if rename_added.contains(name) {
568            continue;
569        }
570        let table = &desired.tables[name];
571        let columns = table.columns.iter().map(snapshot_to_column_def).collect();
572        create_ops.push(Operation::CreateTable(CreateTableOp {
573            name: name.to_string(),
574            columns,
575            constraints: vec![],
576            if_not_exists: false,
577        }));
578    }
579
580    // ---- Existing tables -> diff columns -----------------------
581    for &name in &common_tables {
582        let old_table = &current.tables[name];
583        let new_table = &desired.tables[name];
584        let table_diff = diff_table(name, old_table, new_table);
585
586        for op in table_diff.operations {
587            match &op {
588                Operation::AddColumn(_) => add_ops.push(op),
589                Operation::AlterColumn(_) => {
590                    alter_ops.push(op);
591                }
592                Operation::DropColumn(_) => {
593                    drop_col_ops.push(op);
594                }
595                _ => add_ops.push(op),
596            }
597        }
598        ambiguous.extend(table_diff.ambiguous);
599        warnings.extend(table_diff.warnings);
600    }
601
602    // ---- Dropped tables -> DropTable ---------------------------
603    for &name in &dropped_tables {
604        if rename_dropped.contains(name) {
605            continue;
606        }
607        drop_table_ops.push(Operation::DropTable(DropTableOp {
608            name: name.to_string(),
609            if_exists: false,
610            cascade: false,
611        }));
612    }
613
614    // Assemble in safe order.
615    let mut operations = Vec::new();
616    operations.extend(create_ops);
617    operations.extend(add_ops);
618    operations.extend(alter_ops);
619    operations.extend(drop_col_ops);
620    operations.extend(drop_table_ops);
621
622    SchemaDiff {
623        operations,
624        ambiguous,
625        warnings,
626    }
627}
628
629/// Compares a single table's current snapshot against the desired
630/// schema derived from a `#[derive(Table)]` struct.
631pub fn auto_diff_table<T: TableSchema>(
632    current: &TableSnapshot,
633    dialect: &impl RustTypeMapping,
634) -> SchemaDiff {
635    let desired = TableSnapshot::from_table_schema::<T>(dialect);
636    diff_table(&desired.name, current, &desired)
637}
638
639/// Returns `true` if two table snapshots have identical column
640/// structure (names, types, nullable, etc.).
641fn tables_have_same_columns(a: &TableSnapshot, b: &TableSnapshot) -> bool {
642    if a.columns.len() != b.columns.len() {
643        return false;
644    }
645    a.columns.iter().zip(b.columns.iter()).all(|(ac, bc)| {
646        ac.name == bc.name
647            && ac.data_type == bc.data_type
648            && ac.nullable == bc.nullable
649            && ac.primary_key == bc.primary_key
650            && ac.unique == bc.unique
651            && ac.autoincrement == bc.autoincrement
652            && ac.default == bc.default
653    })
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::ast::DataType;
660    use crate::migrations::column_builder::{DefaultValue, ForeignKeyAction};
661    use crate::migrations::operation::IndexType;
662
663    // ============================================================
664    // Helpers
665    // ============================================================
666
667    fn col(name: &str, data_type: DataType, nullable: bool) -> ColumnSnapshot {
668        ColumnSnapshot {
669            name: name.to_string(),
670            data_type,
671            nullable,
672            primary_key: false,
673            unique: false,
674            autoincrement: false,
675            default: None,
676        }
677    }
678
679    fn pk_col(name: &str, data_type: DataType) -> ColumnSnapshot {
680        ColumnSnapshot {
681            name: name.to_string(),
682            data_type,
683            nullable: false,
684            primary_key: true,
685            unique: false,
686            autoincrement: true,
687            default: None,
688        }
689    }
690
691    fn table(name: &str, columns: Vec<ColumnSnapshot>) -> TableSnapshot {
692        TableSnapshot {
693            name: name.to_string(),
694            columns,
695            indexes: vec![],
696            foreign_keys: vec![],
697        }
698    }
699
700    fn schema(tables: Vec<TableSnapshot>) -> SchemaSnapshot {
701        let mut s = SchemaSnapshot::new();
702        for t in tables {
703            s.add_table(t);
704        }
705        s
706    }
707
708    // ============================================================
709    // Levenshtein / similarity unit tests
710    // ============================================================
711
712    #[test]
713    fn levenshtein_basic() {
714        assert_eq!(levenshtein("", ""), 0);
715        assert_eq!(levenshtein("abc", "abc"), 0);
716        assert_eq!(levenshtein("abc", ""), 3);
717        assert_eq!(levenshtein("", "abc"), 3);
718        assert_eq!(levenshtein("kitten", "sitting"), 3);
719    }
720
721    #[test]
722    fn similarity_basic() {
723        assert!((similarity("abc", "abc") - 1.0).abs() < f64::EPSILON);
724        assert!((similarity("", "") - 1.0).abs() < f64::EPSILON);
725        // "name" vs "full_name": dist 5, max 9, sim ~0.44
726        let s = similarity("name", "full_name");
727        assert!(s > 0.4 && s < 0.5, "sim={s}");
728    }
729
730    // ============================================================
731    // Original diff tests (updated for new SchemaDiff fields)
732    // ============================================================
733
734    #[test]
735    fn no_changes_produces_empty_diff() {
736        let t = table(
737            "users",
738            vec![
739                pk_col("id", DataType::Bigint),
740                col("name", DataType::Text, false),
741            ],
742        );
743        let diff = diff_table("users", &t, &t);
744        assert!(diff.is_empty());
745    }
746
747    #[test]
748    fn new_table_detected() {
749        let current = schema(vec![]);
750        let desired = schema(vec![table("users", vec![pk_col("id", DataType::Bigint)])]);
751        let diff = auto_diff_schema(&current, &desired);
752        assert_eq!(diff.operations.len(), 1);
753        assert!(matches!(
754            &diff.operations[0],
755            Operation::CreateTable(op) if op.name == "users"
756        ));
757    }
758
759    #[test]
760    fn dropped_table_detected() {
761        let current = schema(vec![table("users", vec![pk_col("id", DataType::Bigint)])]);
762        let desired = schema(vec![]);
763        let diff = auto_diff_schema(&current, &desired);
764        assert_eq!(diff.operations.len(), 1);
765        assert!(matches!(
766            &diff.operations[0],
767            Operation::DropTable(op) if op.name == "users"
768        ));
769    }
770
771    #[test]
772    fn added_column_detected() {
773        let old = table("users", vec![pk_col("id", DataType::Bigint)]);
774        let new = table(
775            "users",
776            vec![
777                pk_col("id", DataType::Bigint),
778                col("email", DataType::Text, true),
779            ],
780        );
781        let diff = diff_table("users", &old, &new);
782        assert_eq!(diff.operations.len(), 1);
783        assert!(matches!(
784            &diff.operations[0],
785            Operation::AddColumn(op)
786                if op.table == "users"
787                    && op.column.name == "email"
788        ));
789    }
790
791    #[test]
792    fn dropped_column_detected() {
793        let old = table(
794            "users",
795            vec![
796                pk_col("id", DataType::Bigint),
797                col("email", DataType::Text, true),
798            ],
799        );
800        let new = table("users", vec![pk_col("id", DataType::Bigint)]);
801        let diff = diff_table("users", &old, &new);
802        assert_eq!(diff.operations.len(), 1);
803        assert!(matches!(
804            &diff.operations[0],
805            Operation::DropColumn(op)
806                if op.table == "users" && op.column == "email"
807        ));
808    }
809
810    #[test]
811    fn type_change_detected() {
812        let old = table(
813            "users",
814            vec![
815                pk_col("id", DataType::Bigint),
816                col("score", DataType::Integer, false),
817            ],
818        );
819        let new = table(
820            "users",
821            vec![
822                pk_col("id", DataType::Bigint),
823                col("score", DataType::Bigint, false),
824            ],
825        );
826        let diff = diff_table("users", &old, &new);
827        assert_eq!(diff.operations.len(), 1);
828        assert!(matches!(
829            &diff.operations[0],
830            Operation::AlterColumn(op)
831                if op.column == "score"
832                    && op.change
833                        == AlterColumnChange::SetDataType(
834                            DataType::Bigint
835                        )
836        ));
837    }
838
839    #[test]
840    fn nullable_change_detected() {
841        let old = table(
842            "users",
843            vec![
844                pk_col("id", DataType::Bigint),
845                col("email", DataType::Text, false),
846            ],
847        );
848        let new = table(
849            "users",
850            vec![
851                pk_col("id", DataType::Bigint),
852                col("email", DataType::Text, true),
853            ],
854        );
855        let diff = diff_table("users", &old, &new);
856        assert_eq!(diff.operations.len(), 1);
857        assert!(matches!(
858            &diff.operations[0],
859            Operation::AlterColumn(op)
860                if op.column == "email"
861                    && op.change
862                        == AlterColumnChange::SetNullable(true)
863        ));
864    }
865
866    #[test]
867    fn default_added() {
868        let old = table("t", vec![col("active", DataType::Boolean, false)]);
869        let mut new_col = col("active", DataType::Boolean, false);
870        new_col.default = Some(DefaultValue::Expression("TRUE".into()));
871        let new = table("t", vec![new_col]);
872        let diff = diff_table("t", &old, &new);
873        assert_eq!(diff.operations.len(), 1);
874        assert!(matches!(
875            &diff.operations[0],
876            Operation::AlterColumn(op)
877                if matches!(
878                    &op.change,
879                    AlterColumnChange::SetDefault(
880                        DefaultValue::Expression(s)
881                    ) if s == "TRUE"
882                )
883        ));
884    }
885
886    #[test]
887    fn default_changed() {
888        let mut old_col = col("count", DataType::Integer, false);
889        old_col.default = Some(DefaultValue::Integer(0));
890        let old = table("t", vec![old_col]);
891
892        let mut new_col = col("count", DataType::Integer, false);
893        new_col.default = Some(DefaultValue::Integer(1));
894        let new = table("t", vec![new_col]);
895
896        let diff = diff_table("t", &old, &new);
897        assert_eq!(diff.operations.len(), 1);
898        assert!(matches!(
899            &diff.operations[0],
900            Operation::AlterColumn(op)
901                if op.change
902                    == AlterColumnChange::SetDefault(
903                        DefaultValue::Integer(1)
904                    )
905        ));
906    }
907
908    #[test]
909    fn default_removed() {
910        let mut old_col = col("active", DataType::Boolean, false);
911        old_col.default = Some(DefaultValue::Expression("TRUE".into()));
912        let old = table("t", vec![old_col]);
913        let new = table("t", vec![col("active", DataType::Boolean, false)]);
914        let diff = diff_table("t", &old, &new);
915        assert_eq!(diff.operations.len(), 1);
916        assert!(matches!(
917            &diff.operations[0],
918            Operation::AlterColumn(op)
919                if op.change == AlterColumnChange::DropDefault
920        ));
921    }
922
923    // ============================================================
924    // Rename detection (N:M with similarity)
925    // ============================================================
926
927    #[test]
928    fn ambiguous_rename_detected() {
929        // "name" -> "full_name" (sim ~0.44, above threshold 0.4)
930        let old = table(
931            "users",
932            vec![
933                pk_col("id", DataType::Bigint),
934                col("name", DataType::Text, false),
935            ],
936        );
937        let new = table(
938            "users",
939            vec![
940                pk_col("id", DataType::Bigint),
941                col("full_name", DataType::Text, false),
942            ],
943        );
944        let diff = diff_table("users", &old, &new);
945
946        assert!(diff.operations.is_empty());
947        assert_eq!(diff.ambiguous.len(), 1);
948        match &diff.ambiguous[0] {
949            AmbiguousChange::PossibleRename {
950                table,
951                old_column,
952                new_column,
953                ..
954            } => {
955                assert_eq!(table, "users");
956                assert_eq!(old_column, "name");
957                assert_eq!(new_column, "full_name");
958            }
959            other => {
960                panic!("Expected PossibleRename, got {other:?}")
961            }
962        }
963    }
964
965    #[test]
966    fn ambiguous_rename_not_triggered_different_types() {
967        let old = table(
968            "users",
969            vec![
970                pk_col("id", DataType::Bigint),
971                col("name", DataType::Text, false),
972            ],
973        );
974        let new = table(
975            "users",
976            vec![
977                pk_col("id", DataType::Bigint),
978                col("full_name", DataType::Integer, false),
979            ],
980        );
981        let diff = diff_table("users", &old, &new);
982        assert!(diff.ambiguous.is_empty());
983        assert_eq!(diff.operations.len(), 2);
984    }
985
986    #[test]
987    fn low_similarity_produces_add_drop_not_rename() {
988        // "body" vs "summary" — similarity ~0.14, below threshold.
989        let old = table(
990            "t",
991            vec![
992                pk_col("id", DataType::Bigint),
993                col("body", DataType::Text, false),
994            ],
995        );
996        let new = table(
997            "t",
998            vec![
999                pk_col("id", DataType::Bigint),
1000                col("summary", DataType::Text, false),
1001            ],
1002        );
1003        let diff = diff_table("t", &old, &new);
1004
1005        // Below threshold → no rename, just drop + add.
1006        assert!(diff.ambiguous.is_empty());
1007        assert_eq!(diff.operations.len(), 2);
1008    }
1009
1010    #[test]
1011    fn n_m_rename_detection() {
1012        // Two drops + two adds with matching types.
1013        // "user_name" → "username" (high sim), "addr" → "address"
1014        // (high sim).
1015        let old = table(
1016            "t",
1017            vec![
1018                pk_col("id", DataType::Bigint),
1019                col("user_name", DataType::Text, false),
1020                col("addr", DataType::Text, false),
1021            ],
1022        );
1023        let new = table(
1024            "t",
1025            vec![
1026                pk_col("id", DataType::Bigint),
1027                col("username", DataType::Text, false),
1028                col("address", DataType::Text, false),
1029            ],
1030        );
1031        let diff = diff_table("t", &old, &new);
1032
1033        assert!(diff.operations.is_empty());
1034        assert_eq!(diff.ambiguous.len(), 2);
1035    }
1036
1037    #[test]
1038    fn multiple_changes_combined() {
1039        let old = table(
1040            "users",
1041            vec![
1042                pk_col("id", DataType::Bigint),
1043                col("name", DataType::Text, false),
1044                col("old_field", DataType::Integer, false),
1045            ],
1046        );
1047        let new = table(
1048            "users",
1049            vec![
1050                pk_col("id", DataType::Bigint),
1051                col("name", DataType::Varchar(Some(255)), true),
1052                col("new_field", DataType::Boolean, false),
1053            ],
1054        );
1055        let diff = diff_table("users", &old, &new);
1056
1057        // name: type + nullable = 2 alters
1058        // old_field→new_field: different types → no rename →
1059        // add + drop = 2
1060        assert!(diff.ambiguous.is_empty());
1061        assert_eq!(diff.operations.len(), 4);
1062    }
1063
1064    #[test]
1065    fn operation_ordering_in_schema_diff() {
1066        let current = schema(vec![
1067            table(
1068                "to_drop",
1069                vec![
1070                    pk_col("id", DataType::Bigint),
1071                    col("legacy", DataType::Text, false),
1072                ],
1073            ),
1074            table(
1075                "to_alter",
1076                vec![
1077                    pk_col("id", DataType::Bigint),
1078                    col("alpha", DataType::Text, false),
1079                    col("beta", DataType::Integer, false),
1080                ],
1081            ),
1082        ]);
1083        let desired = schema(vec![
1084            table("to_create", vec![pk_col("id", DataType::Bigint)]),
1085            table(
1086                "to_alter",
1087                vec![
1088                    pk_col("id", DataType::Bigint),
1089                    col("xxx", DataType::Boolean, false),
1090                    col("yyy", DataType::Real, false),
1091                ],
1092            ),
1093        ]);
1094        let diff = auto_diff_schema(&current, &desired);
1095
1096        let mut saw_create = false;
1097        let mut saw_add = false;
1098        let mut saw_drop_col = false;
1099        let mut saw_drop_table = false;
1100
1101        for op in &diff.operations {
1102            match op {
1103                Operation::CreateTable(_) => {
1104                    assert!(!saw_add && !saw_drop_col && !saw_drop_table);
1105                    saw_create = true;
1106                }
1107                Operation::AddColumn(_) => {
1108                    assert!(
1109                        !saw_drop_col && !saw_drop_table,
1110                        "AddColumn before DropColumn/DropTable"
1111                    );
1112                    saw_add = true;
1113                }
1114                Operation::DropColumn(_) => {
1115                    assert!(!saw_drop_table, "DropColumn before DropTable");
1116                    saw_drop_col = true;
1117                }
1118                Operation::DropTable(_) => {
1119                    saw_drop_table = true;
1120                }
1121                _ => {}
1122            }
1123        }
1124
1125        assert!(saw_create);
1126        assert!(saw_add);
1127        assert!(saw_drop_col);
1128        assert!(saw_drop_table);
1129    }
1130
1131    #[test]
1132    fn possible_table_rename_detected() {
1133        let current = schema(vec![table(
1134            "users",
1135            vec![
1136                pk_col("id", DataType::Bigint),
1137                col("name", DataType::Text, false),
1138            ],
1139        )]);
1140        let desired = schema(vec![table(
1141            "accounts",
1142            vec![
1143                pk_col("id", DataType::Bigint),
1144                col("name", DataType::Text, false),
1145            ],
1146        )]);
1147        let diff = auto_diff_schema(&current, &desired);
1148
1149        assert!(diff.operations.is_empty());
1150        assert_eq!(diff.ambiguous.len(), 1);
1151        match &diff.ambiguous[0] {
1152            AmbiguousChange::PossibleTableRename {
1153                old_table,
1154                new_table,
1155                ..
1156            } => {
1157                assert_eq!(old_table, "users");
1158                assert_eq!(new_table, "accounts");
1159            }
1160            other => {
1161                panic!("Expected PossibleTableRename, got {other:?}")
1162            }
1163        }
1164    }
1165
1166    #[test]
1167    fn auto_diff_table_works() {
1168        use crate::migrations::SqliteDialect;
1169        use crate::schema::{ColumnSchema, Table};
1170
1171        struct MyTable;
1172        struct MyRow;
1173
1174        impl Table for MyTable {
1175            type Row = MyRow;
1176            const NAME: &'static str = "items";
1177            const COLUMNS: &'static [&'static str] = &["id", "title"];
1178            const PRIMARY_KEY: Option<&'static str> = Some("id");
1179        }
1180
1181        impl TableSchema for MyTable {
1182            const SCHEMA: &'static [ColumnSchema] = &[
1183                ColumnSchema {
1184                    name: "id",
1185                    rust_type: "i64",
1186                    nullable: false,
1187                    primary_key: true,
1188                    unique: false,
1189                    autoincrement: true,
1190                    default_expr: None,
1191                },
1192                ColumnSchema {
1193                    name: "title",
1194                    rust_type: "String",
1195                    nullable: false,
1196                    primary_key: false,
1197                    unique: false,
1198                    autoincrement: false,
1199                    default_expr: None,
1200                },
1201            ];
1202        }
1203
1204        let dialect = SqliteDialect::new();
1205        let current = table("items", vec![pk_col("id", DataType::Bigint)]);
1206        let diff = auto_diff_table::<MyTable>(&current, &dialect);
1207
1208        assert_eq!(diff.operations.len(), 1);
1209        assert!(matches!(
1210            &diff.operations[0],
1211            Operation::AddColumn(op)
1212                if op.column.name == "title"
1213                    && op.column.data_type == DataType::Text
1214        ));
1215    }
1216
1217    // ============================================================
1218    // Unique change detection
1219    // ============================================================
1220
1221    #[test]
1222    fn unique_change_detected() {
1223        let mut old_col = col("email", DataType::Text, false);
1224        old_col.unique = false;
1225        let old = table("users", vec![old_col]);
1226
1227        let mut new_col = col("email", DataType::Text, false);
1228        new_col.unique = true;
1229        let new = table("users", vec![new_col]);
1230
1231        let diff = diff_table("users", &old, &new);
1232        assert!(diff.operations.iter().any(|op| matches!(
1233            op,
1234            Operation::AlterColumn(a)
1235                if a.column == "email"
1236                    && a.change
1237                        == AlterColumnChange::SetUnique(true)
1238        )));
1239    }
1240
1241    // ============================================================
1242    // Warning detection
1243    // ============================================================
1244
1245    #[test]
1246    fn primary_key_change_emits_warning() {
1247        let mut old_col = col("email", DataType::Text, false);
1248        old_col.primary_key = false;
1249        let old = table("t", vec![old_col]);
1250
1251        let mut new_col = col("email", DataType::Text, false);
1252        new_col.primary_key = true;
1253        let new = table("t", vec![new_col]);
1254
1255        let diff = diff_table("t", &old, &new);
1256        assert!(diff.warnings.iter().any(|w| matches!(
1257            w,
1258            DiffWarning::PrimaryKeyChange {
1259                column,
1260                new_value: true,
1261                ..
1262            } if column == "email"
1263        )));
1264    }
1265
1266    #[test]
1267    fn autoincrement_change_emits_warning() {
1268        let mut old_col = col("id", DataType::Bigint, false);
1269        old_col.autoincrement = false;
1270        let old = table("t", vec![old_col]);
1271
1272        let mut new_col = col("id", DataType::Bigint, false);
1273        new_col.autoincrement = true;
1274        let new = table("t", vec![new_col]);
1275
1276        let diff = diff_table("t", &old, &new);
1277        assert!(diff.warnings.iter().any(|w| matches!(
1278            w,
1279            DiffWarning::AutoincrementChange {
1280                column,
1281                new_value: true,
1282                ..
1283            } if column == "id"
1284        )));
1285    }
1286
1287    #[test]
1288    fn column_order_change_emits_warning() {
1289        let old = table(
1290            "t",
1291            vec![
1292                col("a", DataType::Text, false),
1293                col("b", DataType::Text, false),
1294            ],
1295        );
1296        let new = table(
1297            "t",
1298            vec![
1299                col("b", DataType::Text, false),
1300                col("a", DataType::Text, false),
1301            ],
1302        );
1303        let diff = diff_table("t", &old, &new);
1304        assert!(
1305            diff.warnings
1306                .iter()
1307                .any(|w| matches!(w, DiffWarning::ColumnOrderChanged { .. }))
1308        );
1309    }
1310
1311    // ============================================================
1312    // Index diff
1313    // ============================================================
1314
1315    #[test]
1316    fn index_added_detected() {
1317        let old = table("t", vec![col("a", DataType::Text, false)]);
1318        let mut new = table("t", vec![col("a", DataType::Text, false)]);
1319        new.indexes.push(IndexSnapshot {
1320            name: "idx_a".into(),
1321            columns: vec!["a".into()],
1322            unique: false,
1323            index_type: IndexType::BTree,
1324            condition: None,
1325        });
1326        let diff = diff_table("t", &old, &new);
1327        assert!(
1328            diff.operations
1329                .iter()
1330                .any(|op| matches!(op, Operation::CreateIndex(ci) if ci.name == "idx_a"))
1331        );
1332    }
1333
1334    #[test]
1335    fn index_dropped_detected() {
1336        let mut old = table("t", vec![col("a", DataType::Text, false)]);
1337        old.indexes.push(IndexSnapshot {
1338            name: "idx_a".into(),
1339            columns: vec!["a".into()],
1340            unique: false,
1341            index_type: IndexType::BTree,
1342            condition: None,
1343        });
1344        let new = table("t", vec![col("a", DataType::Text, false)]);
1345        let diff = diff_table("t", &old, &new);
1346        assert!(
1347            diff.operations
1348                .iter()
1349                .any(|op| matches!(op, Operation::DropIndex(di) if di.name == "idx_a"))
1350        );
1351    }
1352
1353    // ============================================================
1354    // Foreign key diff
1355    // ============================================================
1356
1357    #[test]
1358    fn fk_added_detected() {
1359        let old = table("t", vec![col("a", DataType::Bigint, false)]);
1360        let mut new = table("t", vec![col("a", DataType::Bigint, false)]);
1361        new.foreign_keys.push(ForeignKeySnapshot {
1362            name: Some("fk_a".into()),
1363            columns: vec!["a".into()],
1364            references_table: "other".into(),
1365            references_columns: vec!["id".into()],
1366            on_delete: Some(ForeignKeyAction::Cascade),
1367            on_update: None,
1368        });
1369        let diff = diff_table("t", &old, &new);
1370        assert!(diff.operations.iter().any(
1371            |op| matches!(op, Operation::AddForeignKey(fk) if fk.name == Some("fk_a".into()))
1372        ));
1373    }
1374
1375    #[test]
1376    fn fk_dropped_detected() {
1377        let mut old = table("t", vec![col("a", DataType::Bigint, false)]);
1378        old.foreign_keys.push(ForeignKeySnapshot {
1379            name: Some("fk_a".into()),
1380            columns: vec!["a".into()],
1381            references_table: "other".into(),
1382            references_columns: vec!["id".into()],
1383            on_delete: None,
1384            on_update: None,
1385        });
1386        let new = table("t", vec![col("a", DataType::Bigint, false)]);
1387        let diff = diff_table("t", &old, &new);
1388        assert!(
1389            diff.operations
1390                .iter()
1391                .any(|op| matches!(op, Operation::DropForeignKey(fk) if fk.name == "fk_a"))
1392        );
1393    }
1394
1395    // ============================================================
1396    // Reversibility
1397    // ============================================================
1398
1399    #[test]
1400    fn reversible_diff() {
1401        let old = table("t", vec![pk_col("id", DataType::Bigint)]);
1402        let new = table(
1403            "t",
1404            vec![
1405                pk_col("id", DataType::Bigint),
1406                col("email", DataType::Text, true),
1407            ],
1408        );
1409        let diff = diff_table("t", &old, &new);
1410        assert!(diff.is_reversible());
1411
1412        let reversed = diff.reverse().unwrap();
1413        assert_eq!(reversed.operations.len(), 1);
1414        assert!(matches!(
1415            &reversed.operations[0],
1416            Operation::DropColumn(dc)
1417                if dc.column == "email"
1418        ));
1419    }
1420
1421    #[test]
1422    fn non_reversible_diff() {
1423        let old = table(
1424            "t",
1425            vec![
1426                pk_col("id", DataType::Bigint),
1427                col("email", DataType::Text, true),
1428            ],
1429        );
1430        let new = table("t", vec![pk_col("id", DataType::Bigint)]);
1431        let diff = diff_table("t", &old, &new);
1432
1433        // DropColumn is not reversible (no column definition).
1434        assert!(!diff.is_reversible());
1435        assert_eq!(diff.non_reversible_operations().len(), 1);
1436        assert!(diff.reverse().is_none());
1437    }
1438
1439    // ============================================================
1440    // to_sql convenience
1441    // ============================================================
1442
1443    #[test]
1444    fn to_sql_produces_output() {
1445        use crate::migrations::SqliteDialect;
1446
1447        let old = table("t", vec![pk_col("id", DataType::Bigint)]);
1448        let new = table(
1449            "t",
1450            vec![
1451                pk_col("id", DataType::Bigint),
1452                col("name", DataType::Text, false),
1453            ],
1454        );
1455        let diff = diff_table("t", &old, &new);
1456        let sqls = diff.to_sql(&SqliteDialect::new());
1457        assert_eq!(sqls.len(), 1);
1458        assert!(sqls[0].contains("ADD COLUMN"));
1459    }
1460}