Skip to main content

sqlmodel_schema/
diff.rs

1//! Schema diff engine for comparing database schemas.
2//!
3//! This module provides utilities to compare a current database schema
4//! against an expected schema and generate operations to bring them
5//! into alignment.
6
7use crate::introspect::{
8    ColumnInfo, DatabaseSchema, Dialect, ForeignKeyInfo, IndexInfo, ParsedSqlType, TableInfo,
9    UniqueConstraintInfo,
10};
11use std::collections::{HashMap, HashSet};
12
13fn fk_effective_name(table: &str, fk: &ForeignKeyInfo) -> String {
14    fk.name
15        .clone()
16        .unwrap_or_else(|| format!("fk_{}_{}", table, fk.column))
17}
18
19fn unique_effective_name(table: &str, constraint: &UniqueConstraintInfo) -> String {
20    constraint
21        .name
22        .clone()
23        .unwrap_or_else(|| format!("uk_{}_{}", table, constraint.columns.join("_")))
24}
25
26// ============================================================================
27// Schema Operations
28// ============================================================================
29
30/// A single schema modification operation.
31#[derive(Debug, Clone)]
32pub enum SchemaOperation {
33    // Tables
34    /// Create a new table.
35    CreateTable(TableInfo),
36    /// Drop an existing table.
37    DropTable(String),
38    /// Rename a table.
39    RenameTable { from: String, to: String },
40
41    // Columns
42    /// Add a column to a table.
43    AddColumn { table: String, column: ColumnInfo },
44    /// Drop a column from a table.
45    ///
46    /// For SQLite, safely dropping a column on versions < 3.35 requires table
47    /// recreation. When `table_info` is present, the SQLite DDL generator can
48    /// emit a correct recreate-copy-drop sequence (preserving indexes we track).
49    DropColumn {
50        table: String,
51        column: String,
52        table_info: Option<TableInfo>,
53    },
54    /// Change a column's type.
55    AlterColumnType {
56        table: String,
57        column: String,
58        from_type: String,
59        to_type: String,
60        table_info: Option<TableInfo>,
61    },
62    /// Change a column's nullability.
63    AlterColumnNullable {
64        table: String,
65        column: ColumnInfo,
66        from_nullable: bool,
67        to_nullable: bool,
68        table_info: Option<TableInfo>,
69    },
70    /// Change a column's default value.
71    AlterColumnDefault {
72        table: String,
73        column: String,
74        from_default: Option<String>,
75        to_default: Option<String>,
76        table_info: Option<TableInfo>,
77    },
78    /// Rename a column.
79    RenameColumn {
80        table: String,
81        from: String,
82        to: String,
83    },
84
85    // Primary Keys
86    /// Add a primary key constraint.
87    AddPrimaryKey {
88        table: String,
89        columns: Vec<String>,
90        table_info: Option<TableInfo>,
91    },
92    /// Drop a primary key constraint.
93    DropPrimaryKey {
94        table: String,
95        table_info: Option<TableInfo>,
96    },
97
98    // Foreign Keys
99    /// Add a foreign key constraint.
100    AddForeignKey {
101        table: String,
102        fk: ForeignKeyInfo,
103        table_info: Option<TableInfo>,
104    },
105    /// Drop a foreign key constraint.
106    DropForeignKey {
107        table: String,
108        name: String,
109        table_info: Option<TableInfo>,
110    },
111
112    // Unique Constraints
113    /// Add a unique constraint.
114    AddUnique {
115        table: String,
116        constraint: UniqueConstraintInfo,
117        table_info: Option<TableInfo>,
118    },
119    /// Drop a unique constraint.
120    DropUnique {
121        table: String,
122        name: String,
123        table_info: Option<TableInfo>,
124    },
125
126    // Indexes
127    /// Create an index.
128    CreateIndex { table: String, index: IndexInfo },
129    /// Drop an index.
130    DropIndex { table: String, name: String },
131}
132
133impl SchemaOperation {
134    /// Check if this operation potentially loses data.
135    pub fn is_destructive(&self) -> bool {
136        matches!(
137            self,
138            SchemaOperation::DropTable(_)
139                | SchemaOperation::DropColumn { .. }
140                | SchemaOperation::AlterColumnType { .. }
141        )
142    }
143
144    /// Get the inverse operation for rollback, if possible.
145    ///
146    /// Some operations are not reversible (e.g., dropping a table/column) because the
147    /// original schema and data cannot be reconstructed from the operation alone.
148    pub fn inverse(&self) -> Option<Self> {
149        match self {
150            SchemaOperation::CreateTable(table) => {
151                Some(SchemaOperation::DropTable(table.name.clone()))
152            }
153            SchemaOperation::DropTable(_) => None,
154            SchemaOperation::RenameTable { from, to } => Some(SchemaOperation::RenameTable {
155                from: to.clone(),
156                to: from.clone(),
157            }),
158            SchemaOperation::AddColumn { table, column } => Some(SchemaOperation::DropColumn {
159                table: table.clone(),
160                column: column.name.clone(),
161                table_info: None,
162            }),
163            SchemaOperation::DropColumn { .. } => None,
164            SchemaOperation::AlterColumnType {
165                table,
166                column,
167                from_type,
168                to_type,
169                ..
170            } => Some(SchemaOperation::AlterColumnType {
171                table: table.clone(),
172                column: column.clone(),
173                from_type: to_type.clone(),
174                to_type: from_type.clone(),
175                table_info: None,
176            }),
177            SchemaOperation::AlterColumnNullable {
178                table,
179                column,
180                from_nullable,
181                to_nullable,
182                ..
183            } => Some(SchemaOperation::AlterColumnNullable {
184                table: table.clone(),
185                column: {
186                    let mut col = column.clone();
187                    col.nullable = *from_nullable;
188                    col
189                },
190                from_nullable: *to_nullable,
191                to_nullable: *from_nullable,
192                table_info: None,
193            }),
194            SchemaOperation::AlterColumnDefault {
195                table,
196                column,
197                from_default,
198                to_default,
199                ..
200            } => Some(SchemaOperation::AlterColumnDefault {
201                table: table.clone(),
202                column: column.clone(),
203                from_default: to_default.clone(),
204                to_default: from_default.clone(),
205                table_info: None,
206            }),
207            SchemaOperation::RenameColumn { table, from, to } => {
208                Some(SchemaOperation::RenameColumn {
209                    table: table.clone(),
210                    from: to.clone(),
211                    to: from.clone(),
212                })
213            }
214            SchemaOperation::AddPrimaryKey { table, .. } => Some(SchemaOperation::DropPrimaryKey {
215                table: table.clone(),
216                table_info: None,
217            }),
218            SchemaOperation::DropPrimaryKey { .. } => None,
219            SchemaOperation::AddForeignKey { table, fk, .. } => {
220                Some(SchemaOperation::DropForeignKey {
221                    table: table.clone(),
222                    name: fk_effective_name(table, fk),
223                    table_info: None,
224                })
225            }
226            SchemaOperation::DropForeignKey { .. } => None,
227            SchemaOperation::AddUnique {
228                table, constraint, ..
229            } => Some(SchemaOperation::DropUnique {
230                table: table.clone(),
231                name: unique_effective_name(table, constraint),
232                table_info: None,
233            }),
234            SchemaOperation::DropUnique { .. } => None,
235            SchemaOperation::CreateIndex { table, index } => Some(SchemaOperation::DropIndex {
236                table: table.clone(),
237                name: index.name.clone(),
238            }),
239            SchemaOperation::DropIndex { .. } => None,
240        }
241    }
242
243    /// Get the table this operation affects.
244    pub fn table(&self) -> Option<&str> {
245        match self {
246            SchemaOperation::CreateTable(t) => Some(&t.name),
247            SchemaOperation::DropTable(name) => Some(name),
248            SchemaOperation::RenameTable { from, .. } => Some(from),
249            SchemaOperation::AddColumn { table, .. }
250            | SchemaOperation::DropColumn { table, .. }
251            | SchemaOperation::AlterColumnType { table, .. }
252            | SchemaOperation::AlterColumnNullable { table, .. }
253            | SchemaOperation::AlterColumnDefault { table, .. }
254            | SchemaOperation::RenameColumn { table, .. }
255            | SchemaOperation::AddPrimaryKey { table, .. }
256            | SchemaOperation::DropPrimaryKey { table, .. }
257            | SchemaOperation::AddForeignKey { table, .. }
258            | SchemaOperation::DropForeignKey { table, .. }
259            | SchemaOperation::AddUnique { table, .. }
260            | SchemaOperation::DropUnique { table, .. }
261            | SchemaOperation::CreateIndex { table, .. }
262            | SchemaOperation::DropIndex { table, .. } => Some(table),
263        }
264    }
265
266    /// Get a priority value for ordering operations.
267    fn priority(&self) -> u8 {
268        // Order:
269        // 1. Drop foreign keys (remove constraints before modifying)
270        // 2. Drop indexes
271        // 3. Drop unique constraints
272        // 4. Drop primary keys
273        // 5. Drop columns
274        // 6. Alter columns
275        // 7. Add columns
276        // 8. Create tables (in FK order)
277        // 9. Add primary keys
278        // 10. Add unique constraints
279        // 11. Add indexes
280        // 12. Add foreign keys
281        // 13. Drop tables (last, after FK removal)
282        match self {
283            SchemaOperation::DropForeignKey { .. } => 1,
284            SchemaOperation::DropIndex { .. } => 2,
285            SchemaOperation::DropUnique { .. } => 3,
286            SchemaOperation::DropPrimaryKey { .. } => 4,
287            SchemaOperation::DropColumn { .. } => 5,
288            SchemaOperation::AlterColumnType { .. } => 6,
289            SchemaOperation::AlterColumnNullable { .. } => 7,
290            SchemaOperation::AlterColumnDefault { .. } => 8,
291            SchemaOperation::AddColumn { .. } => 9,
292            SchemaOperation::CreateTable(_) => 10,
293            SchemaOperation::RenameTable { .. } => 11,
294            SchemaOperation::RenameColumn { .. } => 12,
295            SchemaOperation::AddPrimaryKey { .. } => 13,
296            SchemaOperation::AddUnique { .. } => 14,
297            SchemaOperation::CreateIndex { .. } => 15,
298            SchemaOperation::AddForeignKey { .. } => 16,
299            SchemaOperation::DropTable(_) => 17,
300        }
301    }
302}
303
304// ============================================================================
305// Diff Result
306// ============================================================================
307
308/// Warning severity levels.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum WarningSeverity {
311    /// Informational message.
312    Info,
313    /// Warning that should be reviewed.
314    Warning,
315    /// Potential data loss.
316    DataLoss,
317}
318
319/// A warning about a schema operation.
320#[derive(Debug, Clone)]
321pub struct DiffWarning {
322    /// Severity of the warning.
323    pub severity: WarningSeverity,
324    /// Warning message.
325    pub message: String,
326    /// Index into operations that caused this warning.
327    pub operation_index: Option<usize>,
328}
329
330/// How to handle destructive schema operations (drops, type changes).
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
332pub enum DestructivePolicy {
333    /// Skip destructive operations entirely.
334    Skip,
335    /// Include destructive operations, but require explicit confirmation.
336    #[default]
337    Warn,
338    /// Include destructive operations without additional confirmation gating.
339    Allow,
340}
341
342/// The result of comparing two schemas.
343#[derive(Debug)]
344pub struct SchemaDiff {
345    /// Policy used when generating this diff.
346    pub destructive_policy: DestructivePolicy,
347    /// Operations to transform current schema to expected schema.
348    pub operations: Vec<SchemaOperation>,
349    /// Warnings about potential issues.
350    pub warnings: Vec<DiffWarning>,
351}
352
353impl SchemaDiff {
354    /// Create an empty diff with the provided destructive policy.
355    pub fn new(destructive_policy: DestructivePolicy) -> Self {
356        Self {
357            destructive_policy,
358            operations: Vec::new(),
359            warnings: Vec::new(),
360        }
361    }
362
363    /// Check if there are any changes.
364    pub fn is_empty(&self) -> bool {
365        self.operations.is_empty()
366    }
367
368    /// Count of all operations.
369    pub fn len(&self) -> usize {
370        self.operations.len()
371    }
372
373    /// Check if there are any destructive operations.
374    pub fn has_destructive(&self) -> bool {
375        self.operations.iter().any(|op| op.is_destructive())
376    }
377
378    /// Get only destructive operations.
379    pub fn destructive_operations(&self) -> Vec<&SchemaOperation> {
380        self.operations
381            .iter()
382            .filter(|op| op.is_destructive())
383            .collect()
384    }
385
386    /// Whether this diff requires explicit confirmation before applying.
387    pub fn requires_confirmation(&self) -> bool {
388        self.destructive_policy == DestructivePolicy::Warn && self.has_destructive()
389    }
390
391    /// Reorder operations for safe execution.
392    pub fn order_operations(&mut self) {
393        self.operations.sort_by_key(|op| op.priority());
394    }
395
396    /// SQLite-only: refresh `table_info` snapshots for operations that require table recreation.
397    ///
398    /// The diff can contain multiple SQLite operations for the same table that each require
399    /// recreation (DROP COLUMN / ALTER COLUMN / ADD/DROP PK/FK/UNIQUE). If each op carries the
400    /// original `TableInfo`, later ops become stale once the first recreation is applied.
401    ///
402    /// This pass simulates the operations against an in-memory `TableInfo` per table and updates
403    /// each op's `table_info` to the schema state immediately before that op executes.
404    fn sqlite_refresh_table_infos(&mut self, current: &DatabaseSchema) {
405        let mut state: HashMap<String, TableInfo> = current
406            .tables
407            .iter()
408            .map(|(name, t)| (name.clone(), t.clone()))
409            .collect();
410
411        for op in &mut self.operations {
412            match op {
413                SchemaOperation::CreateTable(t) => {
414                    state.insert(t.name.clone(), t.clone());
415                    continue;
416                }
417                SchemaOperation::DropTable(name) => {
418                    state.remove(name);
419                    continue;
420                }
421                SchemaOperation::RenameTable { from, to } => {
422                    if let Some(mut t) = state.remove(from) {
423                        t.name.clone_from(to);
424                        state.insert(to.clone(), t);
425                    }
426                    continue;
427                }
428                _ => {}
429            }
430
431            let Some(table) = op.table().map(str::to_string) else {
432                continue;
433            };
434
435            let before = state.get(&table).cloned();
436
437            match op {
438                SchemaOperation::DropColumn { table_info, .. }
439                | SchemaOperation::AlterColumnType { table_info, .. }
440                | SchemaOperation::AlterColumnNullable { table_info, .. }
441                | SchemaOperation::AlterColumnDefault { table_info, .. }
442                | SchemaOperation::AddPrimaryKey { table_info, .. }
443                | SchemaOperation::DropPrimaryKey { table_info, .. }
444                | SchemaOperation::AddForeignKey { table_info, .. }
445                | SchemaOperation::DropForeignKey { table_info, .. }
446                | SchemaOperation::AddUnique { table_info, .. }
447                | SchemaOperation::DropUnique { table_info, .. } => {
448                    table_info.clone_from(&before);
449                }
450                _ => {}
451            }
452
453            if let Some(table_state) = state.get_mut(&table) {
454                sqlite_apply_op_to_table_info(table_state, op);
455            }
456        }
457    }
458
459    /// Add an operation.
460    fn add_op(&mut self, op: SchemaOperation) -> usize {
461        let index = self.operations.len();
462        self.operations.push(op);
463        index
464    }
465
466    /// Add a warning.
467    fn warn(
468        &mut self,
469        severity: WarningSeverity,
470        message: impl Into<String>,
471        operation_index: Option<usize>,
472    ) {
473        self.warnings.push(DiffWarning {
474            severity,
475            message: message.into(),
476            operation_index,
477        });
478    }
479
480    fn add_destructive_op(
481        &mut self,
482        op: SchemaOperation,
483        warn_severity: WarningSeverity,
484        warn_message: impl Into<String>,
485    ) {
486        let warn_message = warn_message.into();
487        match self.destructive_policy {
488            DestructivePolicy::Skip => {
489                self.warn(
490                    WarningSeverity::Warning,
491                    format!("Skipped destructive operation: {}", warn_message),
492                    None,
493                );
494            }
495            DestructivePolicy::Warn => {
496                let op_index = self.add_op(op);
497                self.warn(warn_severity, warn_message, Some(op_index));
498            }
499            DestructivePolicy::Allow => {
500                self.add_op(op);
501            }
502        }
503    }
504}
505
506impl Default for SchemaDiff {
507    fn default() -> Self {
508        Self::new(DestructivePolicy::Warn)
509    }
510}
511
512// ============================================================================
513// Diff Algorithm
514// ============================================================================
515
516/// Compare two schemas and generate operations to transform current to expected.
517///
518/// # Example
519///
520/// ```ignore
521/// use sqlmodel_schema::{expected_schema, Dialect};
522/// use sqlmodel_schema::diff::schema_diff;
523///
524/// let current = introspector.introspect_all(&cx, &conn).await?;
525/// let expected = expected_schema::<(Hero, Team)>(Dialect::Sqlite);
526///
527/// let diff = schema_diff(&current, &expected);
528/// for op in &diff.operations {
529///     println!("  {:?}", op);
530/// }
531/// ```
532pub fn schema_diff(current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
533    schema_diff_with_policy(current, expected, DestructivePolicy::Warn)
534}
535
536/// Compare two schemas and generate operations to transform current to expected.
537pub fn schema_diff_with_policy(
538    current: &DatabaseSchema,
539    expected: &DatabaseSchema,
540    destructive_policy: DestructivePolicy,
541) -> SchemaDiff {
542    SchemaDiffer::new(destructive_policy).diff(current, expected)
543}
544
545/// Schema diff engine.
546#[derive(Debug, Clone, Copy)]
547pub struct SchemaDiffer {
548    destructive_policy: DestructivePolicy,
549}
550
551impl SchemaDiffer {
552    pub const fn new(destructive_policy: DestructivePolicy) -> Self {
553        Self { destructive_policy }
554    }
555
556    pub fn diff(&self, current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
557        let mut diff = SchemaDiff::new(self.destructive_policy);
558
559        // Detect table renames (identical structure, different name).
560        let renames = detect_table_renames(current, expected, expected.dialect);
561        let mut renamed_from: HashSet<&str> = HashSet::new();
562        let mut renamed_to: HashSet<&str> = HashSet::new();
563        for (from, to) in &renames {
564            renamed_from.insert(from.as_str());
565            renamed_to.insert(to.as_str());
566            diff.add_op(SchemaOperation::RenameTable {
567                from: from.clone(),
568                to: to.clone(),
569            });
570        }
571
572        // Find new tables (in expected but not current)
573        for (name, table) in &expected.tables {
574            if renamed_to.contains(name.as_str()) {
575                continue;
576            }
577            if !current.tables.contains_key(name) {
578                diff.add_op(SchemaOperation::CreateTable(table.clone()));
579            }
580        }
581
582        // Find dropped tables (in current but not expected)
583        for name in current.tables.keys() {
584            if renamed_from.contains(name.as_str()) {
585                continue;
586            }
587            if !expected.tables.contains_key(name) {
588                diff.add_destructive_op(
589                    SchemaOperation::DropTable(name.clone()),
590                    WarningSeverity::DataLoss,
591                    format!("Dropping table '{}' will delete all data", name),
592                );
593            }
594        }
595
596        // Compare existing tables
597        for (name, expected_table) in &expected.tables {
598            if let Some(current_table) = current.tables.get(name) {
599                diff_table(current_table, expected_table, expected.dialect, &mut diff);
600            }
601        }
602
603        // Order operations for safe execution
604        diff.order_operations();
605
606        if expected.dialect == Dialect::Sqlite {
607            diff.sqlite_refresh_table_infos(current);
608        }
609
610        diff
611    }
612}
613
614fn sqlite_apply_op_to_table_info(table: &mut TableInfo, op: &SchemaOperation) {
615    match op {
616        SchemaOperation::AddColumn { column, .. } => {
617            table.columns.push(column.clone());
618        }
619        SchemaOperation::DropColumn { column, .. } => {
620            table.columns.retain(|c| c.name != *column);
621            table.primary_key.retain(|c| c != column);
622            table.foreign_keys.retain(|fk| fk.column != *column);
623            table
624                .unique_constraints
625                .retain(|uc| !uc.columns.iter().any(|c| c == column));
626            table
627                .indexes
628                .retain(|idx| !idx.columns.iter().any(|c| c == column));
629        }
630        SchemaOperation::AlterColumnType {
631            column, to_type, ..
632        } => {
633            if let Some(col) = table.columns.iter_mut().find(|c| c.name == *column) {
634                col.sql_type.clone_from(to_type);
635                col.parsed_type = ParsedSqlType::parse(to_type);
636            }
637        }
638        SchemaOperation::AlterColumnNullable {
639            column,
640            to_nullable,
641            ..
642        } => {
643            if let Some(col) = table.columns.iter_mut().find(|c| c.name == column.name) {
644                col.nullable = *to_nullable;
645            }
646        }
647        SchemaOperation::AlterColumnDefault {
648            column, to_default, ..
649        } => {
650            if let Some(col) = table.columns.iter_mut().find(|c| c.name == *column) {
651                col.default.clone_from(to_default);
652            }
653        }
654        SchemaOperation::RenameColumn { from, to, .. } => {
655            if let Some(col) = table.columns.iter_mut().find(|c| c.name == *from) {
656                col.name.clone_from(to);
657            }
658            for pk in &mut table.primary_key {
659                if pk == from {
660                    pk.clone_from(to);
661                }
662            }
663            for fk in &mut table.foreign_keys {
664                if fk.column == *from {
665                    fk.column.clone_from(to);
666                }
667            }
668            for uc in &mut table.unique_constraints {
669                for c in &mut uc.columns {
670                    if c == from {
671                        c.clone_from(to);
672                    }
673                }
674            }
675            for idx in &mut table.indexes {
676                for c in &mut idx.columns {
677                    if c == from {
678                        c.clone_from(to);
679                    }
680                }
681            }
682        }
683        SchemaOperation::AddPrimaryKey { columns, .. } => {
684            table.primary_key.clone_from(columns);
685            for col in &mut table.columns {
686                col.primary_key = table.primary_key.iter().any(|c| c == &col.name);
687            }
688        }
689        SchemaOperation::DropPrimaryKey { .. } => {
690            table.primary_key.clear();
691            for col in &mut table.columns {
692                col.primary_key = false;
693            }
694        }
695        SchemaOperation::AddForeignKey { fk, .. } => {
696            let name = fk_effective_name(&table.name, fk);
697            table
698                .foreign_keys
699                .retain(|existing| fk_effective_name(&table.name, existing) != name);
700            table.foreign_keys.push(fk.clone());
701        }
702        SchemaOperation::DropForeignKey { name, .. } => {
703            table
704                .foreign_keys
705                .retain(|fk| fk_effective_name(&table.name, fk) != *name);
706        }
707        SchemaOperation::AddUnique { constraint, .. } => {
708            let name = unique_effective_name(&table.name, constraint);
709            table
710                .unique_constraints
711                .retain(|existing| unique_effective_name(&table.name, existing) != name);
712            table.unique_constraints.push(constraint.clone());
713        }
714        SchemaOperation::DropUnique { name, .. } => {
715            table
716                .unique_constraints
717                .retain(|uc| unique_effective_name(&table.name, uc) != *name);
718        }
719        SchemaOperation::CreateIndex { index, .. } => {
720            table.indexes.retain(|i| i.name != index.name);
721            table.indexes.push(index.clone());
722        }
723        SchemaOperation::DropIndex { name, .. } => {
724            table.indexes.retain(|i| i.name != *name);
725        }
726        SchemaOperation::CreateTable(_)
727        | SchemaOperation::DropTable(_)
728        | SchemaOperation::RenameTable { .. } => {}
729    }
730}
731
732/// Compare two tables.
733fn diff_table(current: &TableInfo, expected: &TableInfo, dialect: Dialect, diff: &mut SchemaDiff) {
734    let table = &current.name;
735
736    // Diff columns
737    diff_columns(current, expected, dialect, diff);
738
739    // Diff primary key
740    diff_primary_key(current, &expected.primary_key, diff);
741
742    // Diff foreign keys
743    diff_foreign_keys(current, &expected.foreign_keys, diff);
744
745    // Diff unique constraints
746    diff_unique_constraints(current, &expected.unique_constraints, diff);
747
748    // Diff indexes
749    diff_indexes(table, &current.indexes, &expected.indexes, diff);
750}
751
752/// Compare columns between tables.
753fn diff_columns(
754    current_table: &TableInfo,
755    expected_table: &TableInfo,
756    dialect: Dialect,
757    diff: &mut SchemaDiff,
758) {
759    let table = current_table.name.as_str();
760    let current = current_table.columns.as_slice();
761    let expected = expected_table.columns.as_slice();
762    let current_map: HashMap<&str, &ColumnInfo> =
763        current.iter().map(|c| (c.name.as_str(), c)).collect();
764    let expected_map: HashMap<&str, &ColumnInfo> =
765        expected.iter().map(|c| (c.name.as_str(), c)).collect();
766
767    // Detect column renames (identical definition, different name) within the table.
768    let removed: Vec<&ColumnInfo> = current
769        .iter()
770        .filter(|c| !expected_map.contains_key(c.name.as_str()))
771        .collect();
772    let added: Vec<&ColumnInfo> = expected
773        .iter()
774        .filter(|c| !current_map.contains_key(c.name.as_str()))
775        .collect();
776
777    let col_renames = detect_column_renames(&removed, &added, dialect);
778    let mut renamed_from: HashSet<&str> = HashSet::new();
779    let mut renamed_to: HashSet<&str> = HashSet::new();
780    for (from, to) in &col_renames {
781        renamed_from.insert(from.as_str());
782        renamed_to.insert(to.as_str());
783        diff.add_op(SchemaOperation::RenameColumn {
784            table: table.to_string(),
785            from: from.clone(),
786            to: to.clone(),
787        });
788    }
789
790    // New columns
791    for (name, col) in &expected_map {
792        if renamed_to.contains(*name) {
793            continue;
794        }
795        if !current_map.contains_key(name) {
796            diff.add_op(SchemaOperation::AddColumn {
797                table: table.to_string(),
798                column: (*col).clone(),
799            });
800        }
801    }
802
803    // Dropped columns
804    for name in current_map.keys() {
805        if renamed_from.contains(*name) {
806            continue;
807        }
808        if !expected_map.contains_key(name) {
809            diff.add_destructive_op(
810                SchemaOperation::DropColumn {
811                    table: table.to_string(),
812                    column: (*name).to_string(),
813                    table_info: Some(current_table.clone()),
814                },
815                WarningSeverity::DataLoss,
816                format!("Dropping column '{}.{}' will delete data", table, name),
817            );
818        }
819    }
820
821    // Changed columns
822    for (name, expected_col) in &expected_map {
823        if let Some(current_col) = current_map.get(name) {
824            diff_column_details(current_table, current_col, expected_col, dialect, diff);
825        }
826    }
827}
828
829/// Compare column details.
830fn diff_column_details(
831    current_table: &TableInfo,
832    current: &ColumnInfo,
833    expected: &ColumnInfo,
834    dialect: Dialect,
835    diff: &mut SchemaDiff,
836) {
837    let table = current_table.name.as_str();
838    let col = &current.name;
839
840    // Type change (normalize for comparison)
841    let current_type = normalize_type(&current.sql_type, dialect);
842    let expected_type = normalize_type(&expected.sql_type, dialect);
843
844    if current_type != expected_type {
845        diff.add_destructive_op(
846            SchemaOperation::AlterColumnType {
847                table: table.to_string(),
848                column: col.clone(),
849                from_type: current.sql_type.clone(),
850                to_type: expected.sql_type.clone(),
851                table_info: Some(current_table.clone()),
852            },
853            WarningSeverity::Warning,
854            format!(
855                "Changing type of '{}.{}' from {} to {} may cause data conversion issues",
856                table, col, current.sql_type, expected.sql_type
857            ),
858        );
859    }
860
861    // Nullable change
862    if current.nullable != expected.nullable {
863        let op_index = diff.add_op(SchemaOperation::AlterColumnNullable {
864            table: table.to_string(),
865            column: (*expected).clone(),
866            from_nullable: current.nullable,
867            to_nullable: expected.nullable,
868            table_info: Some(current_table.clone()),
869        });
870
871        if !expected.nullable {
872            diff.warn(
873                WarningSeverity::Warning,
874                format!(
875                    "Making '{}.{}' NOT NULL may fail if column contains NULL values",
876                    table, col
877                ),
878                Some(op_index),
879            );
880        }
881    }
882
883    // Default change
884    if current.default != expected.default {
885        diff.add_op(SchemaOperation::AlterColumnDefault {
886            table: table.to_string(),
887            column: col.clone(),
888            from_default: current.default.clone(),
889            to_default: expected.default.clone(),
890            table_info: Some(current_table.clone()),
891        });
892    }
893}
894
895/// Compare primary keys.
896fn diff_primary_key(current_table: &TableInfo, expected_pk: &[String], diff: &mut SchemaDiff) {
897    let table = current_table.name.as_str();
898    let current = current_table.primary_key.as_slice();
899    let expected = expected_pk;
900    let current_set: HashSet<&str> = current.iter().map(|s| s.as_str()).collect();
901    let expected_set: HashSet<&str> = expected.iter().map(|s| s.as_str()).collect();
902
903    if current_set != expected_set {
904        // If current has a PK, drop it first
905        if !current.is_empty() {
906            diff.add_op(SchemaOperation::DropPrimaryKey {
907                table: table.to_string(),
908                table_info: Some(current_table.clone()),
909            });
910        }
911
912        // Add the new PK if expected has one
913        if !expected.is_empty() {
914            diff.add_op(SchemaOperation::AddPrimaryKey {
915                table: table.to_string(),
916                columns: expected.to_vec(),
917                table_info: Some(current_table.clone()),
918            });
919        }
920    }
921}
922
923/// Compare foreign keys.
924fn diff_foreign_keys(
925    current_table: &TableInfo,
926    expected: &[ForeignKeyInfo],
927    diff: &mut SchemaDiff,
928) {
929    let table = current_table.name.as_str();
930    let current = current_table.foreign_keys.as_slice();
931    // Build maps by column (since names may differ or be auto-generated)
932    let current_map: HashMap<&str, &ForeignKeyInfo> =
933        current.iter().map(|fk| (fk.column.as_str(), fk)).collect();
934    let expected_map: HashMap<&str, &ForeignKeyInfo> =
935        expected.iter().map(|fk| (fk.column.as_str(), fk)).collect();
936
937    // New foreign keys
938    for (col, fk) in &expected_map {
939        if !current_map.contains_key(col) {
940            diff.add_op(SchemaOperation::AddForeignKey {
941                table: table.to_string(),
942                fk: (*fk).clone(),
943                table_info: Some(current_table.clone()),
944            });
945        }
946    }
947
948    // Dropped foreign keys
949    for (col, fk) in &current_map {
950        if !expected_map.contains_key(col) {
951            let name = fk_effective_name(table, fk);
952            diff.add_op(SchemaOperation::DropForeignKey {
953                table: table.to_string(),
954                name,
955                table_info: Some(current_table.clone()),
956            });
957        }
958    }
959
960    // Changed foreign keys (compare references)
961    for (col, expected_fk) in &expected_map {
962        if let Some(current_fk) = current_map.get(col) {
963            if !fk_matches(current_fk, expected_fk) {
964                // Drop and recreate
965                let name = fk_effective_name(table, current_fk);
966                diff.add_op(SchemaOperation::DropForeignKey {
967                    table: table.to_string(),
968                    name,
969                    table_info: Some(current_table.clone()),
970                });
971                diff.add_op(SchemaOperation::AddForeignKey {
972                    table: table.to_string(),
973                    fk: (*expected_fk).clone(),
974                    table_info: Some(current_table.clone()),
975                });
976            }
977        }
978    }
979}
980
981/// Check if two foreign keys match.
982fn fk_matches(current: &ForeignKeyInfo, expected: &ForeignKeyInfo) -> bool {
983    current.foreign_table == expected.foreign_table
984        && current.foreign_column == expected.foreign_column
985        && current.on_delete == expected.on_delete
986        && current.on_update == expected.on_update
987}
988
989/// Compare unique constraints.
990fn diff_unique_constraints(
991    current_table: &TableInfo,
992    expected: &[UniqueConstraintInfo],
993    diff: &mut SchemaDiff,
994) {
995    let table = current_table.name.as_str();
996    let current = current_table.unique_constraints.as_slice();
997    // Build sets of column combinations
998    let current_set: HashSet<Vec<&str>> = current
999        .iter()
1000        .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
1001        .collect();
1002    let expected_set: HashSet<Vec<&str>> = expected
1003        .iter()
1004        .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
1005        .collect();
1006
1007    // Find constraints to add
1008    for constraint in expected {
1009        let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
1010        if !current_set.contains(&cols) {
1011            diff.add_op(SchemaOperation::AddUnique {
1012                table: table.to_string(),
1013                constraint: constraint.clone(),
1014                table_info: Some(current_table.clone()),
1015            });
1016        }
1017    }
1018
1019    // Find constraints to drop
1020    for constraint in current {
1021        let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
1022        if !expected_set.contains(&cols) {
1023            let name = unique_effective_name(table, constraint);
1024            diff.add_op(SchemaOperation::DropUnique {
1025                table: table.to_string(),
1026                name,
1027                table_info: Some(current_table.clone()),
1028            });
1029        }
1030    }
1031}
1032
1033/// Compare indexes.
1034fn diff_indexes(table: &str, current: &[IndexInfo], expected: &[IndexInfo], diff: &mut SchemaDiff) {
1035    // Skip primary key indexes as they're handled separately
1036    let current_filtered: Vec<_> = current.iter().filter(|i| !i.primary).collect();
1037    let expected_filtered: Vec<_> = expected.iter().filter(|i| !i.primary).collect();
1038
1039    // Build maps by name
1040    let current_map: HashMap<&str, &&IndexInfo> = current_filtered
1041        .iter()
1042        .map(|i| (i.name.as_str(), i))
1043        .collect();
1044    let expected_map: HashMap<&str, &&IndexInfo> = expected_filtered
1045        .iter()
1046        .map(|i| (i.name.as_str(), i))
1047        .collect();
1048
1049    // New indexes
1050    for (name, index) in &expected_map {
1051        if !current_map.contains_key(name) {
1052            diff.add_op(SchemaOperation::CreateIndex {
1053                table: table.to_string(),
1054                index: (**index).clone(),
1055            });
1056        }
1057    }
1058
1059    // Dropped indexes
1060    for name in current_map.keys() {
1061        if !expected_map.contains_key(name) {
1062            diff.add_op(SchemaOperation::DropIndex {
1063                table: table.to_string(),
1064                name: (*name).to_string(),
1065            });
1066        }
1067    }
1068
1069    // Changed indexes (check columns and unique flag)
1070    for (name, expected_idx) in &expected_map {
1071        if let Some(current_idx) = current_map.get(name) {
1072            if current_idx.columns != expected_idx.columns
1073                || current_idx.unique != expected_idx.unique
1074            {
1075                // Drop and recreate
1076                diff.add_op(SchemaOperation::DropIndex {
1077                    table: table.to_string(),
1078                    name: (*name).to_string(),
1079                });
1080                diff.add_op(SchemaOperation::CreateIndex {
1081                    table: table.to_string(),
1082                    index: (**expected_idx).clone(),
1083                });
1084            }
1085        }
1086    }
1087}
1088
1089// ============================================================================
1090// Rename Detection (Best-Effort)
1091// ============================================================================
1092
1093fn column_signature(col: &ColumnInfo, dialect: Dialect) -> String {
1094    let ty = normalize_type(&col.sql_type, dialect);
1095    let default = col.default.as_deref().unwrap_or("");
1096    format!(
1097        "type={};nullable={};default={};pk={};ai={}",
1098        ty, col.nullable, default, col.primary_key, col.auto_increment
1099    )
1100}
1101
1102fn detect_column_renames(
1103    removed: &[&ColumnInfo],
1104    added: &[&ColumnInfo],
1105    dialect: Dialect,
1106) -> Vec<(String, String)> {
1107    let mut removed_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1108    let mut added_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1109
1110    for col in removed {
1111        removed_by_sig
1112            .entry(column_signature(col, dialect))
1113            .or_default()
1114            .push(*col);
1115    }
1116    for col in added {
1117        added_by_sig
1118            .entry(column_signature(col, dialect))
1119            .or_default()
1120            .push(*col);
1121    }
1122
1123    let mut renames = Vec::new();
1124    for (sig, removed_cols) in removed_by_sig {
1125        if removed_cols.len() != 1 {
1126            continue;
1127        }
1128        let Some(added_cols) = added_by_sig.get(&sig) else {
1129            continue;
1130        };
1131        if added_cols.len() != 1 {
1132            continue;
1133        }
1134        renames.push((removed_cols[0].name.clone(), added_cols[0].name.clone()));
1135    }
1136
1137    renames.sort_by(|a, b| a.0.cmp(&b.0));
1138    renames
1139}
1140
1141fn table_signature(table: &TableInfo, dialect: Dialect) -> String {
1142    let mut parts = Vec::new();
1143
1144    let mut cols: Vec<String> = table
1145        .columns
1146        .iter()
1147        .map(|c| {
1148            let ty = normalize_type(&c.sql_type, dialect);
1149            let default = c.default.as_deref().unwrap_or("");
1150            format!(
1151                "{}:{}:{}:{}:{}:{}",
1152                c.name, ty, c.nullable, default, c.primary_key, c.auto_increment
1153            )
1154        })
1155        .collect();
1156    cols.sort();
1157    parts.push(format!("cols={}", cols.join(",")));
1158
1159    let mut pk = table.primary_key.clone();
1160    pk.sort();
1161    parts.push(format!("pk={}", pk.join(",")));
1162
1163    let mut fks: Vec<String> = table
1164        .foreign_keys
1165        .iter()
1166        .map(|fk| {
1167            let on_delete = fk.on_delete.as_deref().unwrap_or("");
1168            let on_update = fk.on_update.as_deref().unwrap_or("");
1169            format!(
1170                "{}->{}.{}:{}:{}",
1171                fk.column, fk.foreign_table, fk.foreign_column, on_delete, on_update
1172            )
1173        })
1174        .collect();
1175    fks.sort();
1176    parts.push(format!("fks={}", fks.join("|")));
1177
1178    let mut uniques: Vec<String> = table
1179        .unique_constraints
1180        .iter()
1181        .map(|u| {
1182            let mut cols = u.columns.clone();
1183            cols.sort();
1184            cols.join(",")
1185        })
1186        .collect();
1187    uniques.sort();
1188    parts.push(format!("uniques={}", uniques.join("|")));
1189
1190    let mut checks: Vec<String> = table
1191        .check_constraints
1192        .iter()
1193        .map(|c| c.expression.trim().to_string())
1194        .collect();
1195    checks.sort();
1196    parts.push(format!("checks={}", checks.join("|")));
1197
1198    let mut indexes: Vec<String> = table
1199        .indexes
1200        .iter()
1201        .map(|i| {
1202            let ty = i.index_type.as_deref().unwrap_or("");
1203            format!("{}:{}:{}:{}", i.columns.join(","), i.unique, i.primary, ty)
1204        })
1205        .collect();
1206    indexes.sort();
1207    parts.push(format!("indexes={}", indexes.join("|")));
1208
1209    parts.join(";")
1210}
1211
1212fn detect_table_renames(
1213    current: &DatabaseSchema,
1214    expected: &DatabaseSchema,
1215    dialect: Dialect,
1216) -> Vec<(String, String)> {
1217    let current_only: Vec<&TableInfo> = current
1218        .tables
1219        .values()
1220        .filter(|t| !expected.tables.contains_key(&t.name))
1221        .collect();
1222    let expected_only: Vec<&TableInfo> = expected
1223        .tables
1224        .values()
1225        .filter(|t| !current.tables.contains_key(&t.name))
1226        .collect();
1227
1228    let mut current_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1229    let mut expected_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1230
1231    for table in current_only {
1232        current_by_sig
1233            .entry(table_signature(table, dialect))
1234            .or_default()
1235            .push(table);
1236    }
1237    for table in expected_only {
1238        expected_by_sig
1239            .entry(table_signature(table, dialect))
1240            .or_default()
1241            .push(table);
1242    }
1243
1244    let mut renames = Vec::new();
1245    for (sig, current_tables) in current_by_sig {
1246        if current_tables.len() != 1 {
1247            continue;
1248        }
1249        let Some(expected_tables) = expected_by_sig.get(&sig) else {
1250            continue;
1251        };
1252        if expected_tables.len() != 1 {
1253            continue;
1254        }
1255
1256        renames.push((
1257            current_tables[0].name.clone(),
1258            expected_tables[0].name.clone(),
1259        ));
1260    }
1261
1262    renames.sort_by(|a, b| a.0.cmp(&b.0));
1263    renames
1264}
1265
1266// ============================================================================
1267// Type Normalization
1268// ============================================================================
1269
1270/// Normalize a SQL type for comparison.
1271fn normalize_type(sql_type: &str, dialect: Dialect) -> String {
1272    let upper = sql_type.to_uppercase();
1273
1274    match dialect {
1275        Dialect::Sqlite => {
1276            // SQLite type affinity
1277            if upper.contains("INT") {
1278                "INTEGER".to_string()
1279            } else if upper.contains("CHAR") || upper.contains("TEXT") || upper.contains("CLOB") {
1280                "TEXT".to_string()
1281            } else if upper.contains("REAL") || upper.contains("FLOAT") || upper.contains("DOUB") {
1282                "REAL".to_string()
1283            } else if upper.contains("BLOB") || upper.is_empty() {
1284                "BLOB".to_string()
1285            } else {
1286                upper
1287            }
1288        }
1289        Dialect::Postgres => match upper.as_str() {
1290            "INT" | "INT4" => "INTEGER".to_string(),
1291            "INT8" => "BIGINT".to_string(),
1292            "INT2" => "SMALLINT".to_string(),
1293            "FLOAT4" => "REAL".to_string(),
1294            "FLOAT8" => "DOUBLE PRECISION".to_string(),
1295            "BOOL" => "BOOLEAN".to_string(),
1296            "SERIAL" => "INTEGER".to_string(),
1297            "BIGSERIAL" => "BIGINT".to_string(),
1298            "SMALLSERIAL" => "SMALLINT".to_string(),
1299            _ => upper,
1300        },
1301        Dialect::Mysql => match upper.as_str() {
1302            "INTEGER" => "INT".to_string(),
1303            "BOOL" | "BOOLEAN" => "TINYINT".to_string(),
1304            _ => upper,
1305        },
1306    }
1307}
1308
1309// ============================================================================
1310// Unit Tests
1311// ============================================================================
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::*;
1316    use crate::introspect::ParsedSqlType;
1317
1318    fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
1319        ColumnInfo {
1320            name: name.to_string(),
1321            sql_type: sql_type.to_string(),
1322            parsed_type: ParsedSqlType::parse(sql_type),
1323            nullable,
1324            default: None,
1325            primary_key: false,
1326            auto_increment: false,
1327            comment: None,
1328        }
1329    }
1330
1331    fn make_table(name: &str, columns: Vec<ColumnInfo>) -> TableInfo {
1332        TableInfo {
1333            name: name.to_string(),
1334            columns,
1335            primary_key: Vec::new(),
1336            foreign_keys: Vec::new(),
1337            unique_constraints: Vec::new(),
1338            check_constraints: Vec::new(),
1339            indexes: Vec::new(),
1340            comment: None,
1341        }
1342    }
1343
1344    #[test]
1345    fn test_schema_diff_new_table() {
1346        let current = DatabaseSchema::new(Dialect::Sqlite);
1347        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1348        expected.tables.insert(
1349            "heroes".to_string(),
1350            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1351        );
1352
1353        let diff = schema_diff(&current, &expected);
1354        assert_eq!(diff.len(), 1);
1355        assert!(
1356            matches!(&diff.operations[0], SchemaOperation::CreateTable(t) if t.name == "heroes")
1357        );
1358    }
1359
1360    #[test]
1361    fn test_schema_diff_rename_table() {
1362        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1363        current.tables.insert(
1364            "heroes_old".to_string(),
1365            make_table("heroes_old", vec![make_column("id", "INTEGER", false)]),
1366        );
1367
1368        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1369        expected.tables.insert(
1370            "heroes".to_string(),
1371            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1372        );
1373
1374        let diff = schema_diff(&current, &expected);
1375        assert!(diff.operations.iter().any(|op| {
1376            matches!(op, SchemaOperation::RenameTable { from, to } if from == "heroes_old" && to == "heroes")
1377        }));
1378        assert!(!diff.operations.iter().any(|op| matches!(
1379            op,
1380            SchemaOperation::CreateTable(_) | SchemaOperation::DropTable(_)
1381        )));
1382    }
1383
1384    #[test]
1385    fn test_schema_diff_drop_table() {
1386        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1387        current.tables.insert(
1388            "heroes".to_string(),
1389            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1390        );
1391        let expected = DatabaseSchema::new(Dialect::Sqlite);
1392
1393        let diff = schema_diff(&current, &expected);
1394        assert_eq!(diff.len(), 1);
1395        assert!(
1396            matches!(&diff.operations[0], SchemaOperation::DropTable(name) if name == "heroes")
1397        );
1398        assert!(diff.has_destructive());
1399        assert!(diff.requires_confirmation());
1400        assert_eq!(diff.warnings.len(), 1);
1401        assert_eq!(diff.warnings[0].severity, WarningSeverity::DataLoss);
1402    }
1403
1404    #[test]
1405    fn test_schema_diff_drop_table_allow_policy() {
1406        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1407        current.tables.insert(
1408            "heroes".to_string(),
1409            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1410        );
1411        let expected = DatabaseSchema::new(Dialect::Sqlite);
1412
1413        let diff = schema_diff_with_policy(&current, &expected, DestructivePolicy::Allow);
1414        assert_eq!(diff.len(), 1);
1415        assert!(diff.has_destructive());
1416        assert!(!diff.requires_confirmation());
1417        assert!(diff.warnings.is_empty());
1418    }
1419
1420    #[test]
1421    fn test_schema_diff_drop_table_skip_policy() {
1422        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1423        current.tables.insert(
1424            "heroes".to_string(),
1425            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1426        );
1427        let expected = DatabaseSchema::new(Dialect::Sqlite);
1428
1429        let diff = schema_diff_with_policy(&current, &expected, DestructivePolicy::Skip);
1430        assert!(diff.operations.is_empty());
1431        assert!(!diff.has_destructive());
1432        assert!(!diff.requires_confirmation());
1433        assert!(
1434            diff.warnings
1435                .iter()
1436                .any(|w| w.message.contains("Skipped destructive operation"))
1437        );
1438    }
1439
1440    #[test]
1441    fn test_schema_diff_add_column() {
1442        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1443        current.tables.insert(
1444            "heroes".to_string(),
1445            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1446        );
1447
1448        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1449        expected.tables.insert(
1450            "heroes".to_string(),
1451            make_table(
1452                "heroes",
1453                vec![
1454                    make_column("id", "INTEGER", false),
1455                    make_column("name", "TEXT", false),
1456                ],
1457            ),
1458        );
1459
1460        let diff = schema_diff(&current, &expected);
1461        assert!(diff
1462            .operations
1463            .iter()
1464            .any(|op| matches!(op, SchemaOperation::AddColumn { table, column } if table == "heroes" && column.name == "name")));
1465    }
1466
1467    #[test]
1468    fn test_schema_diff_drop_column() {
1469        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1470        current.tables.insert(
1471            "heroes".to_string(),
1472            make_table(
1473                "heroes",
1474                vec![
1475                    make_column("id", "INTEGER", false),
1476                    make_column("old_field", "TEXT", true),
1477                ],
1478            ),
1479        );
1480
1481        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1482        expected.tables.insert(
1483            "heroes".to_string(),
1484            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1485        );
1486
1487        let diff = schema_diff(&current, &expected);
1488        assert!(diff.has_destructive());
1489        assert!(diff.operations.iter().any(
1490            |op| matches!(op, SchemaOperation::DropColumn { table, column, table_info: Some(_), .. } if table == "heroes" && column == "old_field")
1491        ));
1492    }
1493
1494    #[test]
1495    fn test_sqlite_refreshes_table_info_for_multiple_recreate_ops_on_same_table() {
1496        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1497        current.tables.insert(
1498            "heroes".to_string(),
1499            make_table(
1500                "heroes",
1501                vec![
1502                    make_column("id", "INTEGER", false),
1503                    make_column("old_field", "TEXT", true),
1504                    make_column("name", "TEXT", false),
1505                ],
1506            ),
1507        );
1508
1509        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1510        let mut name = make_column("name", "TEXT", false);
1511        name.default = Some("'anon'".to_string());
1512        expected.tables.insert(
1513            "heroes".to_string(),
1514            make_table("heroes", vec![make_column("id", "INTEGER", false), name]),
1515        );
1516
1517        let diff = schema_diff(&current, &expected);
1518
1519        // We should have both a DROP COLUMN and an ALTER DEFAULT (both require SQLite recreate).
1520        assert!(
1521            diff.operations.iter().any(|op| matches!(
1522                op,
1523                SchemaOperation::DropColumn { table, column, .. } if table == "heroes" && column == "old_field"
1524            )),
1525            "Expected DropColumn(old_field) op"
1526        );
1527
1528        let alter_default_table_info = diff.operations.iter().find_map(|op| match op {
1529            SchemaOperation::AlterColumnDefault {
1530                table,
1531                column,
1532                to_default,
1533                table_info,
1534                ..
1535            } if table == "heroes"
1536                && column == "name"
1537                && to_default.as_deref() == Some("'anon'") =>
1538            {
1539                table_info.as_ref()
1540            }
1541            _ => None,
1542        });
1543        let table_info =
1544            alter_default_table_info.expect("Expected AlterColumnDefault(name) op with table_info");
1545
1546        // The ALTER op's table_info should reflect the DROP op having already removed old_field.
1547        assert!(
1548            table_info.column("old_field").is_none(),
1549            "Expected stale column to be absent from refreshed table_info"
1550        );
1551    }
1552
1553    #[test]
1554    fn test_schema_diff_rename_column() {
1555        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1556        current.tables.insert(
1557            "heroes".to_string(),
1558            make_table("heroes", vec![make_column("old_name", "TEXT", false)]),
1559        );
1560
1561        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1562        expected.tables.insert(
1563            "heroes".to_string(),
1564            make_table("heroes", vec![make_column("name", "TEXT", false)]),
1565        );
1566
1567        let diff = schema_diff(&current, &expected);
1568        assert!(diff.operations.iter().any(|op| {
1569            matches!(op, SchemaOperation::RenameColumn { table, from, to } if table == "heroes" && from == "old_name" && to == "name")
1570        }));
1571        assert!(!diff.operations.iter().any(|op| matches!(
1572            op,
1573            SchemaOperation::AddColumn { .. } | SchemaOperation::DropColumn { .. }
1574        )));
1575        assert!(!diff.has_destructive());
1576    }
1577
1578    #[test]
1579    fn test_schema_diff_alter_column_type() {
1580        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1581        current.tables.insert(
1582            "heroes".to_string(),
1583            make_table("heroes", vec![make_column("age", "INTEGER", false)]),
1584        );
1585
1586        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1587        expected.tables.insert(
1588            "heroes".to_string(),
1589            make_table("heroes", vec![make_column("age", "REAL", false)]),
1590        );
1591
1592        let diff = schema_diff(&current, &expected);
1593        assert!(diff.operations.iter().any(
1594            |op| matches!(op, SchemaOperation::AlterColumnType { table, column, .. } if table == "heroes" && column == "age")
1595        ));
1596    }
1597
1598    #[test]
1599    fn test_schema_diff_alter_nullable() {
1600        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1601        current.tables.insert(
1602            "heroes".to_string(),
1603            make_table("heroes", vec![make_column("name", "TEXT", true)]),
1604        );
1605
1606        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1607        expected.tables.insert(
1608            "heroes".to_string(),
1609            make_table("heroes", vec![make_column("name", "TEXT", false)]),
1610        );
1611
1612        let diff = schema_diff(&current, &expected);
1613        assert!(diff.operations.iter().any(
1614            |op| matches!(op, SchemaOperation::AlterColumnNullable { table, column, to_nullable: false, .. } if table == "heroes" && column.name == "name")
1615        ));
1616    }
1617
1618    #[test]
1619    fn test_schema_diff_empty() {
1620        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1621        current.tables.insert(
1622            "heroes".to_string(),
1623            make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1624        );
1625
1626        let expected = current.clone();
1627
1628        let diff = schema_diff(&current, &expected);
1629        assert!(diff.is_empty());
1630    }
1631
1632    #[test]
1633    fn test_schema_diff_foreign_key_add() {
1634        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1635        current.tables.insert(
1636            "heroes".to_string(),
1637            make_table("heroes", vec![make_column("team_id", "INTEGER", true)]),
1638        );
1639
1640        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1641        let mut heroes = make_table("heroes", vec![make_column("team_id", "INTEGER", true)]);
1642        heroes.foreign_keys.push(ForeignKeyInfo {
1643            name: Some("fk_heroes_team".to_string()),
1644            column: "team_id".to_string(),
1645            foreign_table: "teams".to_string(),
1646            foreign_column: "id".to_string(),
1647            on_delete: Some("CASCADE".to_string()),
1648            on_update: None,
1649        });
1650        expected.tables.insert("heroes".to_string(), heroes);
1651
1652        let diff = schema_diff(&current, &expected);
1653        let op = diff.operations.iter().find_map(|op| match op {
1654            SchemaOperation::AddForeignKey {
1655                table,
1656                fk,
1657                table_info,
1658            } if table == "heroes" && fk.column == "team_id" => Some(table_info),
1659            _ => None,
1660        });
1661        assert!(op.is_some(), "Expected AddForeignKey op for heroes.team_id");
1662        assert!(
1663            op.unwrap().is_some(),
1664            "Expected table_info on AddForeignKey op"
1665        );
1666    }
1667
1668    #[test]
1669    fn test_schema_diff_primary_key_add_attaches_table_info() {
1670        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1671        let mut current_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1672        current_table.primary_key.clear();
1673        current.tables.insert("heroes".to_string(), current_table);
1674
1675        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1676        let mut expected_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1677        expected_table.primary_key = vec!["id".to_string()];
1678        expected.tables.insert("heroes".to_string(), expected_table);
1679
1680        let diff = schema_diff(&current, &expected);
1681        let op = diff.operations.iter().find_map(|op| match op {
1682            SchemaOperation::AddPrimaryKey {
1683                table,
1684                columns,
1685                table_info,
1686            } if table == "heroes" && columns == &vec!["id".to_string()] => Some(table_info),
1687            _ => None,
1688        });
1689        assert!(op.is_some(), "Expected AddPrimaryKey op for heroes(id)");
1690        assert!(
1691            op.unwrap().is_some(),
1692            "Expected table_info on AddPrimaryKey op"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_schema_diff_unique_add_attaches_table_info() {
1698        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1699        current.tables.insert(
1700            "heroes".to_string(),
1701            make_table("heroes", vec![make_column("name", "TEXT", false)]),
1702        );
1703
1704        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1705        let mut expected_table = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1706        expected_table
1707            .unique_constraints
1708            .push(UniqueConstraintInfo {
1709                name: Some("uk_heroes_name".to_string()),
1710                columns: vec!["name".to_string()],
1711            });
1712        expected.tables.insert("heroes".to_string(), expected_table);
1713
1714        let diff = schema_diff(&current, &expected);
1715        let op = diff.operations.iter().find_map(|op| match op {
1716            SchemaOperation::AddUnique {
1717                table,
1718                constraint,
1719                table_info,
1720            } if table == "heroes" && constraint.columns == vec!["name".to_string()] => {
1721                Some(table_info)
1722            }
1723            _ => None,
1724        });
1725        assert!(op.is_some(), "Expected AddUnique op for heroes(name)");
1726        assert!(op.unwrap().is_some(), "Expected table_info on AddUnique op");
1727    }
1728
1729    #[test]
1730    fn test_schema_diff_index_add() {
1731        let mut current = DatabaseSchema::new(Dialect::Sqlite);
1732        current.tables.insert(
1733            "heroes".to_string(),
1734            make_table("heroes", vec![make_column("name", "TEXT", false)]),
1735        );
1736
1737        let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1738        let mut heroes = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1739        heroes.indexes.push(IndexInfo {
1740            name: "idx_heroes_name".to_string(),
1741            columns: vec!["name".to_string()],
1742            unique: false,
1743            index_type: None,
1744            primary: false,
1745        });
1746        expected.tables.insert("heroes".to_string(), heroes);
1747
1748        let diff = schema_diff(&current, &expected);
1749        assert!(diff.operations.iter().any(
1750            |op| matches!(op, SchemaOperation::CreateIndex { table, index } if table == "heroes" && index.name == "idx_heroes_name")
1751        ));
1752    }
1753
1754    #[test]
1755    fn test_operation_ordering() {
1756        let mut diff = SchemaDiff::new(DestructivePolicy::Warn);
1757
1758        // Add in wrong order
1759        diff.add_op(SchemaOperation::AddForeignKey {
1760            table: "heroes".to_string(),
1761            fk: ForeignKeyInfo {
1762                name: None,
1763                column: "team_id".to_string(),
1764                foreign_table: "teams".to_string(),
1765                foreign_column: "id".to_string(),
1766                on_delete: None,
1767                on_update: None,
1768            },
1769            table_info: None,
1770        });
1771        diff.add_op(SchemaOperation::DropForeignKey {
1772            table: "old".to_string(),
1773            name: "fk_old".to_string(),
1774            table_info: None,
1775        });
1776        diff.add_op(SchemaOperation::AddColumn {
1777            table: "heroes".to_string(),
1778            column: make_column("age", "INTEGER", true),
1779        });
1780
1781        diff.order_operations();
1782
1783        // DropForeignKey should come first
1784        assert!(matches!(
1785            &diff.operations[0],
1786            SchemaOperation::DropForeignKey { .. }
1787        ));
1788        // AddColumn should come before AddForeignKey
1789        assert!(matches!(
1790            &diff.operations[1],
1791            SchemaOperation::AddColumn { .. }
1792        ));
1793        assert!(matches!(
1794            &diff.operations[2],
1795            SchemaOperation::AddForeignKey { .. }
1796        ));
1797    }
1798
1799    #[test]
1800    fn test_type_normalization_sqlite() {
1801        assert_eq!(normalize_type("INT", Dialect::Sqlite), "INTEGER");
1802        assert_eq!(normalize_type("BIGINT", Dialect::Sqlite), "INTEGER");
1803        assert_eq!(normalize_type("VARCHAR(100)", Dialect::Sqlite), "TEXT");
1804        assert_eq!(normalize_type("FLOAT", Dialect::Sqlite), "REAL");
1805    }
1806
1807    #[test]
1808    fn test_type_normalization_postgres() {
1809        assert_eq!(normalize_type("INT", Dialect::Postgres), "INTEGER");
1810        assert_eq!(normalize_type("INT4", Dialect::Postgres), "INTEGER");
1811        assert_eq!(normalize_type("INT8", Dialect::Postgres), "BIGINT");
1812        assert_eq!(normalize_type("SERIAL", Dialect::Postgres), "INTEGER");
1813    }
1814
1815    #[test]
1816    fn test_type_normalization_mysql() {
1817        assert_eq!(normalize_type("INTEGER", Dialect::Mysql), "INT");
1818        assert_eq!(normalize_type("BOOLEAN", Dialect::Mysql), "TINYINT");
1819    }
1820
1821    #[test]
1822    fn test_schema_operation_is_destructive() {
1823        assert!(SchemaOperation::DropTable("heroes".to_string()).is_destructive());
1824        assert!(
1825            SchemaOperation::DropColumn {
1826                table: "heroes".to_string(),
1827                column: "age".to_string(),
1828                table_info: None,
1829            }
1830            .is_destructive()
1831        );
1832        assert!(
1833            SchemaOperation::AlterColumnType {
1834                table: "heroes".to_string(),
1835                column: "age".to_string(),
1836                from_type: "TEXT".to_string(),
1837                to_type: "INTEGER".to_string(),
1838                table_info: None,
1839            }
1840            .is_destructive()
1841        );
1842        assert!(
1843            !SchemaOperation::AddColumn {
1844                table: "heroes".to_string(),
1845                column: make_column("name", "TEXT", false),
1846            }
1847            .is_destructive()
1848        );
1849    }
1850
1851    #[test]
1852    fn test_schema_operation_inverse() {
1853        let table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1854        let op = SchemaOperation::CreateTable(table);
1855        assert!(matches!(op.inverse(), Some(SchemaOperation::DropTable(name)) if name == "heroes"));
1856
1857        let op = SchemaOperation::AlterColumnType {
1858            table: "heroes".to_string(),
1859            column: "age".to_string(),
1860            from_type: "TEXT".to_string(),
1861            to_type: "INTEGER".to_string(),
1862            table_info: None,
1863        };
1864        assert!(
1865            matches!(op.inverse(), Some(SchemaOperation::AlterColumnType { from_type, to_type, .. }) if from_type == "INTEGER" && to_type == "TEXT")
1866        );
1867    }
1868}