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