vespertide_planner/
diff.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef};
4
5use crate::error::PlannerError;
6
7/// Topologically sort tables based on foreign key dependencies.
8/// Returns tables in order where tables with no FK dependencies come first,
9/// and tables that reference other tables come after their referenced tables.
10fn topological_sort_tables<'a>(tables: &[&'a TableDef]) -> Result<Vec<&'a TableDef>, PlannerError> {
11    if tables.is_empty() {
12        return Ok(vec![]);
13    }
14
15    // Build a map of table names for quick lookup
16    let table_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect();
17
18    // Build adjacency list: for each table, list the tables it depends on (via FK)
19    // Use BTreeMap for consistent ordering
20    // Use BTreeSet to avoid duplicate dependencies (e.g., multiple FKs referencing the same table)
21    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
22    for table in tables {
23        let mut deps_set: BTreeSet<&str> = BTreeSet::new();
24        for constraint in &table.constraints {
25            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
26                // Only consider dependencies within the set of tables being created
27                if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
28                    deps_set.insert(ref_table.as_str());
29                }
30            }
31        }
32        dependencies.insert(table.name.as_str(), deps_set.into_iter().collect());
33    }
34
35    // Kahn's algorithm for topological sort
36    // Calculate in-degrees (number of tables that depend on each table)
37    // Use BTreeMap for consistent ordering
38    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
39    for table in tables {
40        in_degree.entry(table.name.as_str()).or_insert(0);
41    }
42
43    // For each dependency, increment the in-degree of the dependent table
44    for (table_name, deps) in &dependencies {
45        for _dep in deps {
46            // The table has dependencies, so those referenced tables must come first
47            // We actually want the reverse: tables with dependencies have higher in-degree
48        }
49        // Actually, we need to track: if A depends on B, then A has in-degree from B
50        // So A cannot be processed until B is processed
51        *in_degree.entry(table_name).or_insert(0) += deps.len();
52    }
53
54    // Start with tables that have no dependencies
55    // BTreeMap iteration is already sorted by key
56    let mut queue: VecDeque<&str> = in_degree
57        .iter()
58        .filter(|(_, deg)| **deg == 0)
59        .map(|(name, _)| *name)
60        .collect();
61
62    let mut result: Vec<&TableDef> = Vec::new();
63    let table_map: BTreeMap<&str, &TableDef> =
64        tables.iter().map(|t| (t.name.as_str(), *t)).collect();
65
66    while let Some(table_name) = queue.pop_front() {
67        if let Some(&table) = table_map.get(table_name) {
68            result.push(table);
69        }
70
71        // Collect tables that become ready (in-degree becomes 0)
72        // Use BTreeSet for consistent ordering
73        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
74        for (dependent, deps) in &dependencies {
75            if deps.contains(&table_name)
76                && let Some(degree) = in_degree.get_mut(dependent)
77            {
78                *degree -= 1;
79                if *degree == 0 {
80                    ready_tables.insert(dependent);
81                }
82            }
83        }
84        for t in ready_tables {
85            queue.push_back(t);
86        }
87    }
88
89    // Check for cycles
90    if result.len() != tables.len() {
91        let remaining: Vec<&str> = tables
92            .iter()
93            .map(|t| t.name.as_str())
94            .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
95            .collect();
96        return Err(PlannerError::TableValidation(format!(
97            "Circular foreign key dependency detected among tables: {:?}",
98            remaining
99        )));
100    }
101
102    Ok(result)
103}
104
105/// Sort DeleteTable actions so that tables with FK references are deleted first.
106/// This is the reverse of creation order - use topological sort then reverse.
107/// Helper function to extract table name from DeleteTable action
108/// Safety: should only be called on DeleteTable actions
109fn extract_delete_table_name(action: &MigrationAction) -> &str {
110    match action {
111        MigrationAction::DeleteTable { table } => table.as_str(),
112        _ => panic!("Expected DeleteTable action"),
113    }
114}
115
116fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
117    // Collect DeleteTable actions and their indices
118    let delete_indices: Vec<usize> = actions
119        .iter()
120        .enumerate()
121        .filter_map(|(i, a)| {
122            if matches!(a, MigrationAction::DeleteTable { .. }) {
123                Some(i)
124            } else {
125                None
126            }
127        })
128        .collect();
129
130    if delete_indices.len() <= 1 {
131        return;
132    }
133
134    // Extract table names being deleted
135    // Use BTreeSet for consistent ordering
136    let delete_table_names: BTreeSet<&str> = delete_indices
137        .iter()
138        .map(|&i| extract_delete_table_name(&actions[i]))
139        .collect();
140
141    // Build dependency graph for tables being deleted
142    // dependencies[A] = [B] means A has FK referencing B
143    // Use BTreeMap for consistent ordering
144    // Use BTreeSet to avoid duplicate dependencies (e.g., multiple FKs referencing the same table)
145    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
146    for &table_name in &delete_table_names {
147        let mut deps_set: BTreeSet<&str> = BTreeSet::new();
148        if let Some(table_def) = all_tables.get(table_name) {
149            for constraint in &table_def.constraints {
150                if let TableConstraint::ForeignKey { ref_table, .. } = constraint
151                    && delete_table_names.contains(ref_table.as_str())
152                    && ref_table != table_name
153                {
154                    deps_set.insert(ref_table.as_str());
155                }
156            }
157        }
158        dependencies.insert(table_name, deps_set.into_iter().collect());
159    }
160
161    // Use Kahn's algorithm for topological sort
162    // in_degree[A] = number of tables A depends on
163    // Use BTreeMap for consistent ordering
164    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
165    for &table_name in &delete_table_names {
166        in_degree.insert(
167            table_name,
168            dependencies.get(table_name).map_or(0, |d| d.len()),
169        );
170    }
171
172    // Start with tables that have no dependencies (can be deleted last in creation order)
173    // BTreeMap iteration is already sorted
174    let mut queue: VecDeque<&str> = in_degree
175        .iter()
176        .filter(|(_, deg)| **deg == 0)
177        .map(|(name, _)| *name)
178        .collect();
179
180    let mut sorted_tables: Vec<&str> = Vec::new();
181    while let Some(table_name) = queue.pop_front() {
182        sorted_tables.push(table_name);
183
184        // For each table that has this one as a dependency, decrement its in-degree
185        // Use BTreeSet for consistent ordering of newly ready tables
186        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
187        for (&dependent, deps) in &dependencies {
188            if deps.contains(&table_name)
189                && let Some(degree) = in_degree.get_mut(dependent)
190            {
191                *degree -= 1;
192                if *degree == 0 {
193                    ready_tables.insert(dependent);
194                }
195            }
196        }
197        for t in ready_tables {
198            queue.push_back(t);
199        }
200    }
201
202    // Reverse to get deletion order (tables with dependencies should be deleted first)
203    sorted_tables.reverse();
204
205    // Reorder the DeleteTable actions according to sorted order
206    let mut delete_actions: Vec<MigrationAction> =
207        delete_indices.iter().map(|&i| actions[i].clone()).collect();
208
209    delete_actions.sort_by(|a, b| {
210        let a_name = extract_delete_table_name(a);
211        let b_name = extract_delete_table_name(b);
212
213        let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
214        let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
215        a_pos.cmp(&b_pos)
216    });
217
218    // Put them back
219    for (i, idx) in delete_indices.iter().enumerate() {
220        actions[*idx] = delete_actions[i].clone();
221    }
222}
223
224/// Diff two schema snapshots into a migration plan.
225/// Schemas are normalized for comparison purposes, but the original (non-normalized)
226/// tables are used in migration actions to preserve inline constraint definitions.
227pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
228    let mut actions: Vec<MigrationAction> = Vec::new();
229
230    // Normalize both schemas for comparison (to ensure inline and table-level constraints are treated equally)
231    let from_normalized: Vec<TableDef> = from
232        .iter()
233        .map(|t| {
234            t.normalize().map_err(|e| {
235                PlannerError::TableValidation(format!(
236                    "Failed to normalize table '{}': {}",
237                    t.name, e
238                ))
239            })
240        })
241        .collect::<Result<Vec<_>, _>>()?;
242    let to_normalized: Vec<TableDef> = to
243        .iter()
244        .map(|t| {
245            t.normalize().map_err(|e| {
246                PlannerError::TableValidation(format!(
247                    "Failed to normalize table '{}': {}",
248                    t.name, e
249                ))
250            })
251        })
252        .collect::<Result<Vec<_>, _>>()?;
253
254    // Use BTreeMap for consistent ordering
255    // Normalized versions for comparison
256    let from_map: BTreeMap<_, _> = from_normalized
257        .iter()
258        .map(|t| (t.name.as_str(), t))
259        .collect();
260    let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
261
262    // Original (non-normalized) versions for migration storage
263    let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
264
265    // Drop tables that disappeared.
266    for name in from_map.keys() {
267        if !to_map.contains_key(name) {
268            actions.push(MigrationAction::DeleteTable {
269                table: name.to_string(),
270            });
271        }
272    }
273
274    // Update existing tables and their indexes/columns.
275    for (name, to_tbl) in &to_map {
276        if let Some(from_tbl) = from_map.get(name) {
277            // Columns - use BTreeMap for consistent ordering
278            let from_cols: BTreeMap<_, _> = from_tbl
279                .columns
280                .iter()
281                .map(|c| (c.name.as_str(), c))
282                .collect();
283            let to_cols: BTreeMap<_, _> = to_tbl
284                .columns
285                .iter()
286                .map(|c| (c.name.as_str(), c))
287                .collect();
288
289            // Deleted columns - collect the set of columns being deleted for this table
290            let deleted_columns: BTreeSet<&str> = from_cols
291                .keys()
292                .filter(|col| !to_cols.contains_key(*col))
293                .copied()
294                .collect();
295
296            for col in &deleted_columns {
297                actions.push(MigrationAction::DeleteColumn {
298                    table: name.to_string(),
299                    column: col.to_string(),
300                });
301            }
302
303            // Modified columns - type changes
304            for (col, to_def) in &to_cols {
305                if let Some(from_def) = from_cols.get(col)
306                    && from_def.r#type.requires_migration(&to_def.r#type)
307                {
308                    actions.push(MigrationAction::ModifyColumnType {
309                        table: name.to_string(),
310                        column: col.to_string(),
311                        new_type: to_def.r#type.clone(),
312                    });
313                }
314            }
315
316            // Modified columns - nullable changes
317            for (col, to_def) in &to_cols {
318                if let Some(from_def) = from_cols.get(col)
319                    && from_def.nullable != to_def.nullable
320                {
321                    actions.push(MigrationAction::ModifyColumnNullable {
322                        table: name.to_string(),
323                        column: col.to_string(),
324                        nullable: to_def.nullable,
325                        fill_with: None,
326                    });
327                }
328            }
329
330            // Modified columns - default value changes
331            for (col, to_def) in &to_cols {
332                if let Some(from_def) = from_cols.get(col) {
333                    let from_default = from_def.default.as_ref().map(|d| d.to_sql());
334                    let to_default = to_def.default.as_ref().map(|d| d.to_sql());
335                    if from_default != to_default {
336                        actions.push(MigrationAction::ModifyColumnDefault {
337                            table: name.to_string(),
338                            column: col.to_string(),
339                            new_default: to_default,
340                        });
341                    }
342                }
343            }
344
345            // Modified columns - comment changes
346            for (col, to_def) in &to_cols {
347                if let Some(from_def) = from_cols.get(col)
348                    && from_def.comment != to_def.comment
349                {
350                    actions.push(MigrationAction::ModifyColumnComment {
351                        table: name.to_string(),
352                        column: col.to_string(),
353                        new_comment: to_def.comment.clone(),
354                    });
355                }
356            }
357
358            // Added columns
359            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
360            // by normalize(), so they will be handled in the constraint diff below.
361            for (col, def) in &to_cols {
362                if !from_cols.contains_key(col) {
363                    actions.push(MigrationAction::AddColumn {
364                        table: name.to_string(),
365                        column: Box::new((*def).clone()),
366                        fill_with: None,
367                    });
368                }
369            }
370
371            // Constraints - compare and detect additions/removals (includes indexes)
372            // Skip RemoveConstraint for constraints where ALL columns are being deleted
373            // (the constraint will be automatically dropped when the column is dropped)
374            for from_constraint in &from_tbl.constraints {
375                if !to_tbl.constraints.contains(from_constraint) {
376                    // Get the columns referenced by this constraint
377                    let constraint_columns = from_constraint.columns();
378
379                    // Skip if ALL columns of the constraint are being deleted
380                    let all_columns_deleted = !constraint_columns.is_empty()
381                        && constraint_columns
382                            .iter()
383                            .all(|col| deleted_columns.contains(col.as_str()));
384
385                    if !all_columns_deleted {
386                        actions.push(MigrationAction::RemoveConstraint {
387                            table: name.to_string(),
388                            constraint: from_constraint.clone(),
389                        });
390                    }
391                }
392            }
393            for to_constraint in &to_tbl.constraints {
394                if !from_tbl.constraints.contains(to_constraint) {
395                    actions.push(MigrationAction::AddConstraint {
396                        table: name.to_string(),
397                        constraint: to_constraint.clone(),
398                    });
399                }
400            }
401        }
402    }
403
404    // Create new tables (and their indexes).
405    // Use original (non-normalized) tables to preserve inline constraint definitions.
406    // Collect new tables first, then topologically sort them by FK dependencies.
407    let new_tables: Vec<&TableDef> = to_map
408        .iter()
409        .filter(|(name, _)| !from_map.contains_key(*name))
410        .map(|(_, tbl)| *tbl)
411        .collect();
412
413    let sorted_new_tables = topological_sort_tables(&new_tables)?;
414
415    for tbl in sorted_new_tables {
416        // Get the original (non-normalized) table to preserve inline constraints
417        let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
418        actions.push(MigrationAction::CreateTable {
419            table: original_tbl.name.clone(),
420            columns: original_tbl.columns.clone(),
421            constraints: original_tbl.constraints.clone(),
422        });
423    }
424
425    // Sort DeleteTable actions so tables with FK dependencies are deleted first
426    sort_delete_tables(&mut actions, &from_map);
427
428    Ok(MigrationPlan {
429        comment: None,
430        created_at: None,
431        version: 0,
432        actions,
433    })
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use rstest::rstest;
440    use vespertide_core::{
441        ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
442        schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
443    };
444
445    fn col(name: &str, ty: ColumnType) -> ColumnDef {
446        ColumnDef {
447            name: name.to_string(),
448            r#type: ty,
449            nullable: true,
450            default: None,
451            comment: None,
452            primary_key: None,
453            unique: None,
454            index: None,
455            foreign_key: None,
456        }
457    }
458
459    fn table(
460        name: &str,
461        columns: Vec<ColumnDef>,
462        constraints: Vec<vespertide_core::TableConstraint>,
463    ) -> TableDef {
464        TableDef {
465            name: name.to_string(),
466            description: None,
467            columns,
468            constraints,
469        }
470    }
471
472    fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
473        TableConstraint::Index {
474            name: Some(name.to_string()),
475            columns: columns.into_iter().map(|s| s.to_string()).collect(),
476        }
477    }
478
479    #[rstest]
480    #[case::add_column_and_index(
481        vec![table(
482            "users",
483            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
484            vec![],
485        )],
486        vec![table(
487            "users",
488            vec![
489                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
490                col("name", ColumnType::Simple(SimpleColumnType::Text)),
491            ],
492            vec![idx("ix_users__name", vec!["name"])],
493        )],
494        vec![
495            MigrationAction::AddColumn {
496                table: "users".into(),
497                column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
498                fill_with: None,
499            },
500            MigrationAction::AddConstraint {
501                table: "users".into(),
502                constraint: idx("ix_users__name", vec!["name"]),
503            },
504        ]
505    )]
506    #[case::drop_table(
507        vec![table(
508            "users",
509            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
510            vec![],
511        )],
512        vec![],
513        vec![MigrationAction::DeleteTable {
514            table: "users".into()
515        }]
516    )]
517    #[case::add_table_with_index(
518        vec![],
519        vec![table(
520            "users",
521            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
522            vec![idx("idx_users_id", vec!["id"])],
523        )],
524        vec![
525            MigrationAction::CreateTable {
526                table: "users".into(),
527                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
528                constraints: vec![idx("idx_users_id", vec!["id"])],
529            },
530        ]
531    )]
532    #[case::delete_column(
533        vec![table(
534            "users",
535            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
536            vec![],
537        )],
538        vec![table(
539            "users",
540            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
541            vec![],
542        )],
543        vec![MigrationAction::DeleteColumn {
544            table: "users".into(),
545            column: "name".into(),
546        }]
547    )]
548    #[case::modify_column_type(
549        vec![table(
550            "users",
551            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
552            vec![],
553        )],
554        vec![table(
555            "users",
556            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
557            vec![],
558        )],
559        vec![MigrationAction::ModifyColumnType {
560            table: "users".into(),
561            column: "id".into(),
562            new_type: ColumnType::Simple(SimpleColumnType::Text),
563        }]
564    )]
565    #[case::remove_index(
566        vec![table(
567            "users",
568            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
569            vec![idx("idx_users_id", vec!["id"])],
570        )],
571        vec![table(
572            "users",
573            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
574            vec![],
575        )],
576        vec![MigrationAction::RemoveConstraint {
577            table: "users".into(),
578            constraint: idx("idx_users_id", vec!["id"]),
579        }]
580    )]
581    #[case::add_index_existing_table(
582        vec![table(
583            "users",
584            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
585            vec![],
586        )],
587        vec![table(
588            "users",
589            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
590            vec![idx("idx_users_id", vec!["id"])],
591        )],
592        vec![MigrationAction::AddConstraint {
593            table: "users".into(),
594            constraint: idx("idx_users_id", vec!["id"]),
595        }]
596    )]
597    fn diff_schemas_detects_additions(
598        #[case] from_schema: Vec<TableDef>,
599        #[case] to_schema: Vec<TableDef>,
600        #[case] expected_actions: Vec<MigrationAction>,
601    ) {
602        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
603        assert_eq!(plan.actions, expected_actions);
604    }
605
606    // Tests for integer enum handling
607    mod integer_enum {
608        use super::*;
609        use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
610
611        #[test]
612        fn integer_enum_values_changed_no_migration() {
613            // Integer enum values changed - should NOT generate ModifyColumnType
614            let from = vec![table(
615                "orders",
616                vec![col(
617                    "status",
618                    ColumnType::Complex(ComplexColumnType::Enum {
619                        name: "order_status".into(),
620                        values: EnumValues::Integer(vec![
621                            NumValue {
622                                name: "Pending".into(),
623                                value: 0,
624                            },
625                            NumValue {
626                                name: "Shipped".into(),
627                                value: 1,
628                            },
629                        ]),
630                    }),
631                )],
632                vec![],
633            )];
634
635            let to = vec![table(
636                "orders",
637                vec![col(
638                    "status",
639                    ColumnType::Complex(ComplexColumnType::Enum {
640                        name: "order_status".into(),
641                        values: EnumValues::Integer(vec![
642                            NumValue {
643                                name: "Pending".into(),
644                                value: 0,
645                            },
646                            NumValue {
647                                name: "Shipped".into(),
648                                value: 1,
649                            },
650                            NumValue {
651                                name: "Delivered".into(),
652                                value: 2,
653                            },
654                            NumValue {
655                                name: "Cancelled".into(),
656                                value: 100,
657                            },
658                        ]),
659                    }),
660                )],
661                vec![],
662            )];
663
664            let plan = diff_schemas(&from, &to).unwrap();
665            assert!(
666                plan.actions.is_empty(),
667                "Expected no actions, got: {:?}",
668                plan.actions
669            );
670        }
671
672        #[test]
673        fn string_enum_values_changed_requires_migration() {
674            // String enum values changed - SHOULD generate ModifyColumnType
675            let from = vec![table(
676                "orders",
677                vec![col(
678                    "status",
679                    ColumnType::Complex(ComplexColumnType::Enum {
680                        name: "order_status".into(),
681                        values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
682                    }),
683                )],
684                vec![],
685            )];
686
687            let to = vec![table(
688                "orders",
689                vec![col(
690                    "status",
691                    ColumnType::Complex(ComplexColumnType::Enum {
692                        name: "order_status".into(),
693                        values: EnumValues::String(vec![
694                            "pending".into(),
695                            "shipped".into(),
696                            "delivered".into(),
697                        ]),
698                    }),
699                )],
700                vec![],
701            )];
702
703            let plan = diff_schemas(&from, &to).unwrap();
704            assert_eq!(plan.actions.len(), 1);
705            assert!(matches!(
706                &plan.actions[0],
707                MigrationAction::ModifyColumnType { table, column, .. }
708                if table == "orders" && column == "status"
709            ));
710        }
711    }
712
713    // Tests for inline column constraints normalization
714    mod inline_constraints {
715        use super::*;
716        use vespertide_core::schema::foreign_key::ForeignKeyDef;
717        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
718        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
719        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
720
721        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
722            ColumnDef {
723                name: name.to_string(),
724                r#type: ty,
725                nullable: false,
726                default: None,
727                comment: None,
728                primary_key: Some(PrimaryKeySyntax::Bool(true)),
729                unique: None,
730                index: None,
731                foreign_key: None,
732            }
733        }
734
735        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
736            ColumnDef {
737                name: name.to_string(),
738                r#type: ty,
739                nullable: true,
740                default: None,
741                comment: None,
742                primary_key: None,
743                unique: Some(StrOrBoolOrArray::Bool(true)),
744                index: None,
745                foreign_key: None,
746            }
747        }
748
749        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
750            ColumnDef {
751                name: name.to_string(),
752                r#type: ty,
753                nullable: true,
754                default: None,
755                comment: None,
756                primary_key: None,
757                unique: None,
758                index: Some(StrOrBoolOrArray::Bool(true)),
759                foreign_key: None,
760            }
761        }
762
763        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
764            ColumnDef {
765                name: name.to_string(),
766                r#type: ty,
767                nullable: true,
768                default: None,
769                comment: None,
770                primary_key: None,
771                unique: None,
772                index: None,
773                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
774                    ref_table: ref_table.to_string(),
775                    ref_columns: vec![ref_col.to_string()],
776                    on_delete: None,
777                    on_update: None,
778                })),
779            }
780        }
781
782        #[test]
783        fn create_table_with_inline_pk() {
784            let plan = diff_schemas(
785                &[],
786                &[table(
787                    "users",
788                    vec![
789                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
790                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
791                    ],
792                    vec![],
793                )],
794            )
795            .unwrap();
796
797            // Inline PK should be preserved in column definition
798            assert_eq!(plan.actions.len(), 1);
799            if let MigrationAction::CreateTable {
800                columns,
801                constraints,
802                ..
803            } = &plan.actions[0]
804            {
805                // Constraints should be empty (inline PK not moved here)
806                assert_eq!(constraints.len(), 0);
807                // Check that the column has inline PK
808                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
809                assert!(id_col.primary_key.is_some());
810            } else {
811                panic!("Expected CreateTable action");
812            }
813        }
814
815        #[test]
816        fn create_table_with_inline_unique() {
817            let plan = diff_schemas(
818                &[],
819                &[table(
820                    "users",
821                    vec![
822                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
823                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
824                    ],
825                    vec![],
826                )],
827            )
828            .unwrap();
829
830            // Inline unique should be preserved in column definition
831            assert_eq!(plan.actions.len(), 1);
832            if let MigrationAction::CreateTable {
833                columns,
834                constraints,
835                ..
836            } = &plan.actions[0]
837            {
838                // Constraints should be empty (inline unique not moved here)
839                assert_eq!(constraints.len(), 0);
840                // Check that the column has inline unique
841                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
842                assert!(matches!(
843                    email_col.unique,
844                    Some(StrOrBoolOrArray::Bool(true))
845                ));
846            } else {
847                panic!("Expected CreateTable action");
848            }
849        }
850
851        #[test]
852        fn create_table_with_inline_index() {
853            let plan = diff_schemas(
854                &[],
855                &[table(
856                    "users",
857                    vec![
858                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
859                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
860                    ],
861                    vec![],
862                )],
863            )
864            .unwrap();
865
866            // Inline index should be preserved in column definition, not moved to constraints
867            assert_eq!(plan.actions.len(), 1);
868            if let MigrationAction::CreateTable {
869                columns,
870                constraints,
871                ..
872            } = &plan.actions[0]
873            {
874                // Constraints should be empty (inline index not moved here)
875                assert_eq!(constraints.len(), 0);
876                // Check that the column has inline index
877                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
878                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
879            } else {
880                panic!("Expected CreateTable action");
881            }
882        }
883
884        #[test]
885        fn create_table_with_inline_fk() {
886            let plan = diff_schemas(
887                &[],
888                &[table(
889                    "posts",
890                    vec![
891                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
892                        col_with_fk(
893                            "user_id",
894                            ColumnType::Simple(SimpleColumnType::Integer),
895                            "users",
896                            "id",
897                        ),
898                    ],
899                    vec![],
900                )],
901            )
902            .unwrap();
903
904            // Inline FK should be preserved in column definition
905            assert_eq!(plan.actions.len(), 1);
906            if let MigrationAction::CreateTable {
907                columns,
908                constraints,
909                ..
910            } = &plan.actions[0]
911            {
912                // Constraints should be empty (inline FK not moved here)
913                assert_eq!(constraints.len(), 0);
914                // Check that the column has inline FK
915                let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
916                assert!(user_id_col.foreign_key.is_some());
917            } else {
918                panic!("Expected CreateTable action");
919            }
920        }
921
922        #[test]
923        fn add_index_via_inline_constraint() {
924            // Existing table without index -> table with inline index
925            // Inline index (Bool(true)) is normalized to a named table-level constraint
926            let plan = diff_schemas(
927                &[table(
928                    "users",
929                    vec![
930                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
931                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
932                    ],
933                    vec![],
934                )],
935                &[table(
936                    "users",
937                    vec![
938                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
939                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
940                    ],
941                    vec![],
942                )],
943            )
944            .unwrap();
945
946            // Should generate AddConstraint with name: None (auto-generated indexes)
947            assert_eq!(plan.actions.len(), 1);
948            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
949                assert_eq!(table, "users");
950                if let TableConstraint::Index { name, columns } = constraint {
951                    assert_eq!(name, &None); // Auto-generated indexes use None
952                    assert_eq!(columns, &vec!["name".to_string()]);
953                } else {
954                    panic!("Expected Index constraint, got {:?}", constraint);
955                }
956            } else {
957                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
958            }
959        }
960
961        #[test]
962        fn create_table_with_all_inline_constraints() {
963            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
964            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
965            id_col.nullable = false;
966
967            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
968            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
969
970            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
971            name_col.index = Some(StrOrBoolOrArray::Bool(true));
972
973            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
974            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
975                ref_table: "orgs".into(),
976                ref_columns: vec!["id".into()],
977                on_delete: None,
978                on_update: None,
979            }));
980
981            let plan = diff_schemas(
982                &[],
983                &[table(
984                    "users",
985                    vec![id_col, email_col, name_col, org_id_col],
986                    vec![],
987                )],
988            )
989            .unwrap();
990
991            // All inline constraints should be preserved in column definitions
992            assert_eq!(plan.actions.len(), 1);
993
994            if let MigrationAction::CreateTable {
995                columns,
996                constraints,
997                ..
998            } = &plan.actions[0]
999            {
1000                // Constraints should be empty (all inline)
1001                assert_eq!(constraints.len(), 0);
1002
1003                // Check each column has its inline constraint
1004                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
1005                assert!(id_col.primary_key.is_some());
1006
1007                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
1008                assert!(matches!(
1009                    email_col.unique,
1010                    Some(StrOrBoolOrArray::Bool(true))
1011                ));
1012
1013                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
1014                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
1015
1016                let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
1017                assert!(org_id_col.foreign_key.is_some());
1018            } else {
1019                panic!("Expected CreateTable action");
1020            }
1021        }
1022
1023        #[test]
1024        fn add_constraint_to_existing_table() {
1025            // Add a unique constraint to an existing table
1026            let from_schema = vec![table(
1027                "users",
1028                vec![
1029                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1030                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1031                ],
1032                vec![],
1033            )];
1034
1035            let to_schema = vec![table(
1036                "users",
1037                vec![
1038                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1039                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1040                ],
1041                vec![vespertide_core::TableConstraint::Unique {
1042                    name: Some("uq_users_email".into()),
1043                    columns: vec!["email".into()],
1044                }],
1045            )];
1046
1047            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1048            assert_eq!(plan.actions.len(), 1);
1049            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1050                assert_eq!(table, "users");
1051                assert!(matches!(
1052                    constraint,
1053                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1054                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1055                ));
1056            } else {
1057                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1058            }
1059        }
1060
1061        #[test]
1062        fn remove_constraint_from_existing_table() {
1063            // Remove a unique constraint from an existing table
1064            let from_schema = vec![table(
1065                "users",
1066                vec![
1067                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1068                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1069                ],
1070                vec![vespertide_core::TableConstraint::Unique {
1071                    name: Some("uq_users_email".into()),
1072                    columns: vec!["email".into()],
1073                }],
1074            )];
1075
1076            let to_schema = vec![table(
1077                "users",
1078                vec![
1079                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1080                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1081                ],
1082                vec![],
1083            )];
1084
1085            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1086            assert_eq!(plan.actions.len(), 1);
1087            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1088                assert_eq!(table, "users");
1089                assert!(matches!(
1090                    constraint,
1091                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1092                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1093                ));
1094            } else {
1095                panic!(
1096                    "Expected RemoveConstraint action, got {:?}",
1097                    plan.actions[0]
1098                );
1099            }
1100        }
1101
1102        #[test]
1103        fn diff_schemas_with_normalize_error() {
1104            // Test that normalize errors are properly propagated
1105            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1106            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1107
1108            let table = TableDef {
1109                name: "test".into(),
1110                description: None,
1111                columns: vec![
1112                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1113                    col1.clone(),
1114                    {
1115                        // Same column with same index name - should error
1116                        let mut c = col1.clone();
1117                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1118                        c
1119                    },
1120                ],
1121                constraints: vec![],
1122            };
1123
1124            let result = diff_schemas(&[], &[table]);
1125            assert!(result.is_err());
1126            if let Err(PlannerError::TableValidation(msg)) = result {
1127                assert!(msg.contains("Failed to normalize table"));
1128                assert!(msg.contains("Duplicate index"));
1129            } else {
1130                panic!("Expected TableValidation error, got {:?}", result);
1131            }
1132        }
1133
1134        #[test]
1135        fn diff_schemas_with_normalize_error_in_from_schema() {
1136            // Test that normalize errors in 'from' schema are properly propagated
1137            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1138            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1139
1140            let table = TableDef {
1141                name: "test".into(),
1142                description: None,
1143                columns: vec![
1144                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1145                    col1.clone(),
1146                    {
1147                        // Same column with same index name - should error
1148                        let mut c = col1.clone();
1149                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1150                        c
1151                    },
1152                ],
1153                constraints: vec![],
1154            };
1155
1156            // 'from' schema has the invalid table
1157            let result = diff_schemas(&[table], &[]);
1158            assert!(result.is_err());
1159            if let Err(PlannerError::TableValidation(msg)) = result {
1160                assert!(msg.contains("Failed to normalize table"));
1161                assert!(msg.contains("Duplicate index"));
1162            } else {
1163                panic!("Expected TableValidation error, got {:?}", result);
1164            }
1165        }
1166    }
1167
1168    // Tests for foreign key dependency ordering
1169    mod fk_ordering {
1170        use super::*;
1171        use vespertide_core::TableConstraint;
1172
1173        fn table_with_fk(
1174            name: &str,
1175            ref_table: &str,
1176            fk_column: &str,
1177            ref_column: &str,
1178        ) -> TableDef {
1179            TableDef {
1180                name: name.to_string(),
1181                description: None,
1182                columns: vec![
1183                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1184                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1185                ],
1186                constraints: vec![TableConstraint::ForeignKey {
1187                    name: None,
1188                    columns: vec![fk_column.to_string()],
1189                    ref_table: ref_table.to_string(),
1190                    ref_columns: vec![ref_column.to_string()],
1191                    on_delete: None,
1192                    on_update: None,
1193                }],
1194            }
1195        }
1196
1197        fn simple_table(name: &str) -> TableDef {
1198            TableDef {
1199                name: name.to_string(),
1200                description: None,
1201                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1202                constraints: vec![],
1203            }
1204        }
1205
1206        #[test]
1207        fn create_tables_respects_fk_order() {
1208            // Create users and posts tables where posts references users
1209            // The order should be: users first, then posts
1210            let users = simple_table("users");
1211            let posts = table_with_fk("posts", "users", "user_id", "id");
1212
1213            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1214
1215            // Extract CreateTable actions in order
1216            let create_order: Vec<&str> = plan
1217                .actions
1218                .iter()
1219                .filter_map(|a| {
1220                    if let MigrationAction::CreateTable { table, .. } = a {
1221                        Some(table.as_str())
1222                    } else {
1223                        None
1224                    }
1225                })
1226                .collect();
1227
1228            assert_eq!(create_order, vec!["users", "posts"]);
1229        }
1230
1231        #[test]
1232        fn create_tables_chain_dependency() {
1233            // Chain: users <- media <- articles
1234            // users has no FK
1235            // media references users
1236            // articles references media
1237            let users = simple_table("users");
1238            let media = table_with_fk("media", "users", "owner_id", "id");
1239            let articles = table_with_fk("articles", "media", "media_id", "id");
1240
1241            // Pass in reverse order to ensure sorting works
1242            let plan =
1243                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1244
1245            let create_order: Vec<&str> = plan
1246                .actions
1247                .iter()
1248                .filter_map(|a| {
1249                    if let MigrationAction::CreateTable { table, .. } = a {
1250                        Some(table.as_str())
1251                    } else {
1252                        None
1253                    }
1254                })
1255                .collect();
1256
1257            assert_eq!(create_order, vec!["users", "media", "articles"]);
1258        }
1259
1260        #[test]
1261        fn create_tables_multiple_independent_branches() {
1262            // Two independent branches:
1263            // users <- posts
1264            // categories <- products
1265            let users = simple_table("users");
1266            let posts = table_with_fk("posts", "users", "user_id", "id");
1267            let categories = simple_table("categories");
1268            let products = table_with_fk("products", "categories", "category_id", "id");
1269
1270            let plan = diff_schemas(
1271                &[],
1272                &[
1273                    products.clone(),
1274                    posts.clone(),
1275                    categories.clone(),
1276                    users.clone(),
1277                ],
1278            )
1279            .unwrap();
1280
1281            let create_order: Vec<&str> = plan
1282                .actions
1283                .iter()
1284                .filter_map(|a| {
1285                    if let MigrationAction::CreateTable { table, .. } = a {
1286                        Some(table.as_str())
1287                    } else {
1288                        None
1289                    }
1290                })
1291                .collect();
1292
1293            // users must come before posts
1294            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1295            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1296            assert!(
1297                users_pos < posts_pos,
1298                "users should be created before posts"
1299            );
1300
1301            // categories must come before products
1302            let categories_pos = create_order
1303                .iter()
1304                .position(|&t| t == "categories")
1305                .unwrap();
1306            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1307            assert!(
1308                categories_pos < products_pos,
1309                "categories should be created before products"
1310            );
1311        }
1312
1313        #[test]
1314        fn delete_tables_respects_fk_order() {
1315            // When deleting users and posts where posts references users,
1316            // posts should be deleted first (reverse of creation order)
1317            let users = simple_table("users");
1318            let posts = table_with_fk("posts", "users", "user_id", "id");
1319
1320            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1321
1322            let delete_order: Vec<&str> = plan
1323                .actions
1324                .iter()
1325                .filter_map(|a| {
1326                    if let MigrationAction::DeleteTable { table } = a {
1327                        Some(table.as_str())
1328                    } else {
1329                        None
1330                    }
1331                })
1332                .collect();
1333
1334            assert_eq!(delete_order, vec!["posts", "users"]);
1335        }
1336
1337        #[test]
1338        fn delete_tables_chain_dependency() {
1339            // Chain: users <- media <- articles
1340            // Delete order should be: articles, media, users
1341            let users = simple_table("users");
1342            let media = table_with_fk("media", "users", "owner_id", "id");
1343            let articles = table_with_fk("articles", "media", "media_id", "id");
1344
1345            let plan =
1346                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1347
1348            let delete_order: Vec<&str> = plan
1349                .actions
1350                .iter()
1351                .filter_map(|a| {
1352                    if let MigrationAction::DeleteTable { table } = a {
1353                        Some(table.as_str())
1354                    } else {
1355                        None
1356                    }
1357                })
1358                .collect();
1359
1360            // articles must be deleted before media
1361            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1362            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1363            assert!(
1364                articles_pos < media_pos,
1365                "articles should be deleted before media"
1366            );
1367
1368            // media must be deleted before users
1369            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1370            assert!(
1371                media_pos < users_pos,
1372                "media should be deleted before users"
1373            );
1374        }
1375
1376        #[test]
1377        fn circular_fk_dependency_returns_error() {
1378            // Create circular dependency: A -> B -> A
1379            let table_a = TableDef {
1380                name: "table_a".to_string(),
1381                description: None,
1382                columns: vec![
1383                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1384                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1385                ],
1386                constraints: vec![TableConstraint::ForeignKey {
1387                    name: None,
1388                    columns: vec!["b_id".to_string()],
1389                    ref_table: "table_b".to_string(),
1390                    ref_columns: vec!["id".to_string()],
1391                    on_delete: None,
1392                    on_update: None,
1393                }],
1394            };
1395
1396            let table_b = TableDef {
1397                name: "table_b".to_string(),
1398                description: None,
1399                columns: vec![
1400                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1401                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1402                ],
1403                constraints: vec![TableConstraint::ForeignKey {
1404                    name: None,
1405                    columns: vec!["a_id".to_string()],
1406                    ref_table: "table_a".to_string(),
1407                    ref_columns: vec!["id".to_string()],
1408                    on_delete: None,
1409                    on_update: None,
1410                }],
1411            };
1412
1413            let result = diff_schemas(&[], &[table_a, table_b]);
1414            assert!(result.is_err());
1415            if let Err(PlannerError::TableValidation(msg)) = result {
1416                assert!(
1417                    msg.contains("Circular foreign key dependency"),
1418                    "Expected circular dependency error, got: {}",
1419                    msg
1420                );
1421            } else {
1422                panic!("Expected TableValidation error, got {:?}", result);
1423            }
1424        }
1425
1426        #[test]
1427        fn fk_to_external_table_is_ignored() {
1428            // FK referencing a table not in the migration should not affect ordering
1429            let posts = table_with_fk("posts", "users", "user_id", "id");
1430            let comments = table_with_fk("comments", "posts", "post_id", "id");
1431
1432            // users is NOT being created in this migration
1433            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1434
1435            let create_order: Vec<&str> = plan
1436                .actions
1437                .iter()
1438                .filter_map(|a| {
1439                    if let MigrationAction::CreateTable { table, .. } = a {
1440                        Some(table.as_str())
1441                    } else {
1442                        None
1443                    }
1444                })
1445                .collect();
1446
1447            // posts must come before comments (comments depends on posts)
1448            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1449            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1450            assert!(
1451                posts_pos < comments_pos,
1452                "posts should be created before comments"
1453            );
1454        }
1455
1456        #[test]
1457        fn delete_tables_mixed_with_other_actions() {
1458            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1459            // This tests lines 124, 193, 198 (the else branches)
1460            use crate::diff::diff_schemas;
1461
1462            let from_schema = vec![
1463                table(
1464                    "users",
1465                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1466                    vec![],
1467                ),
1468                table(
1469                    "posts",
1470                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1471                    vec![],
1472                ),
1473            ];
1474
1475            let to_schema = vec![
1476                // Drop posts table, but also add a new column to users
1477                table(
1478                    "users",
1479                    vec![
1480                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1481                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1482                    ],
1483                    vec![],
1484                ),
1485            ];
1486
1487            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1488
1489            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1490            assert!(
1491                plan.actions
1492                    .iter()
1493                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1494            );
1495            assert!(
1496                plan.actions
1497                    .iter()
1498                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1499            );
1500
1501            // The else branches in sort_delete_actions should handle AddColumn gracefully
1502            // (returning empty string for table name, which sorts it to position 0)
1503        }
1504
1505        #[test]
1506        #[should_panic(expected = "Expected DeleteTable action")]
1507        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1508            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1509            use super::extract_delete_table_name;
1510
1511            let action = MigrationAction::AddColumn {
1512                table: "users".into(),
1513                column: Box::new(ColumnDef {
1514                    name: "email".into(),
1515                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1516                    nullable: true,
1517                    default: None,
1518                    comment: None,
1519                    primary_key: None,
1520                    unique: None,
1521                    index: None,
1522                    foreign_key: None,
1523                }),
1524                fill_with: None,
1525            };
1526
1527            // This should panic
1528            extract_delete_table_name(&action);
1529        }
1530
1531        /// Test that inline FK across multiple tables works correctly with topological sort
1532        #[test]
1533        fn create_tables_with_inline_fk_chain() {
1534            use super::*;
1535            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1536            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1537
1538            fn col_pk(name: &str) -> ColumnDef {
1539                ColumnDef {
1540                    name: name.to_string(),
1541                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1542                    nullable: false,
1543                    default: None,
1544                    comment: None,
1545                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1546                    unique: None,
1547                    index: None,
1548                    foreign_key: None,
1549                }
1550            }
1551
1552            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1553                ColumnDef {
1554                    name: name.to_string(),
1555                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1556                    nullable: true,
1557                    default: None,
1558                    comment: None,
1559                    primary_key: None,
1560                    unique: None,
1561                    index: None,
1562                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1563                }
1564            }
1565
1566            // Reproduce the app example structure:
1567            // user -> (no deps)
1568            // product -> (no deps)
1569            // project -> user
1570            // code -> product, user, project
1571            // order -> user, project, product, code
1572            // payment -> order
1573
1574            let user = TableDef {
1575                name: "user".to_string(),
1576                description: None,
1577                columns: vec![col_pk("id")],
1578                constraints: vec![],
1579            };
1580
1581            let product = TableDef {
1582                name: "product".to_string(),
1583                description: None,
1584                columns: vec![col_pk("id")],
1585                constraints: vec![],
1586            };
1587
1588            let project = TableDef {
1589                name: "project".to_string(),
1590                description: None,
1591                columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1592                constraints: vec![],
1593            };
1594
1595            let code = TableDef {
1596                name: "code".to_string(),
1597                description: None,
1598                columns: vec![
1599                    col_pk("id"),
1600                    col_inline_fk("product_id", "product"),
1601                    col_inline_fk("creator_user_id", "user"),
1602                    col_inline_fk("project_id", "project"),
1603                ],
1604                constraints: vec![],
1605            };
1606
1607            let order = TableDef {
1608                name: "order".to_string(),
1609                description: None,
1610                columns: vec![
1611                    col_pk("id"),
1612                    col_inline_fk("user_id", "user"),
1613                    col_inline_fk("project_id", "project"),
1614                    col_inline_fk("product_id", "product"),
1615                    col_inline_fk("code_id", "code"),
1616                ],
1617                constraints: vec![],
1618            };
1619
1620            let payment = TableDef {
1621                name: "payment".to_string(),
1622                description: None,
1623                columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1624                constraints: vec![],
1625            };
1626
1627            // Pass in arbitrary order - should NOT return circular dependency error
1628            let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1629            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1630
1631            let plan = result.unwrap();
1632            let create_order: Vec<&str> = plan
1633                .actions
1634                .iter()
1635                .filter_map(|a| {
1636                    if let MigrationAction::CreateTable { table, .. } = a {
1637                        Some(table.as_str())
1638                    } else {
1639                        None
1640                    }
1641                })
1642                .collect();
1643
1644            // Verify order respects FK dependencies
1645            let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1646
1647            // user and product have no deps, can be in any order
1648            // project depends on user
1649            assert!(
1650                get_pos("user") < get_pos("project"),
1651                "user must come before project"
1652            );
1653            // code depends on product, user, project
1654            assert!(
1655                get_pos("product") < get_pos("code"),
1656                "product must come before code"
1657            );
1658            assert!(
1659                get_pos("user") < get_pos("code"),
1660                "user must come before code"
1661            );
1662            assert!(
1663                get_pos("project") < get_pos("code"),
1664                "project must come before code"
1665            );
1666            // order depends on user, project, product, code
1667            assert!(
1668                get_pos("code") < get_pos("order"),
1669                "code must come before order"
1670            );
1671            // payment depends on order
1672            assert!(
1673                get_pos("order") < get_pos("payment"),
1674                "order must come before payment"
1675            );
1676        }
1677
1678        /// Test that multiple FKs to the same table are deduplicated correctly
1679        #[test]
1680        fn create_tables_with_duplicate_fk_references() {
1681            use super::*;
1682            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1683            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1684
1685            fn col_pk(name: &str) -> ColumnDef {
1686                ColumnDef {
1687                    name: name.to_string(),
1688                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1689                    nullable: false,
1690                    default: None,
1691                    comment: None,
1692                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1693                    unique: None,
1694                    index: None,
1695                    foreign_key: None,
1696                }
1697            }
1698
1699            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1700                ColumnDef {
1701                    name: name.to_string(),
1702                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1703                    nullable: true,
1704                    default: None,
1705                    comment: None,
1706                    primary_key: None,
1707                    unique: None,
1708                    index: None,
1709                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1710                }
1711            }
1712
1713            // Table with multiple FKs referencing the same table (like code.creator_user_id and code.used_by_user_id)
1714            let user = TableDef {
1715                name: "user".to_string(),
1716                description: None,
1717                columns: vec![col_pk("id")],
1718                constraints: vec![],
1719            };
1720
1721            let code = TableDef {
1722                name: "code".to_string(),
1723                description: None,
1724                columns: vec![
1725                    col_pk("id"),
1726                    col_inline_fk("creator_user_id", "user"),
1727                    col_inline_fk("used_by_user_id", "user"), // Second FK to same table
1728                ],
1729                constraints: vec![],
1730            };
1731
1732            // This should NOT return circular dependency error even with duplicate FK refs
1733            let result = diff_schemas(&[], &[code, user]);
1734            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1735
1736            let plan = result.unwrap();
1737            let create_order: Vec<&str> = plan
1738                .actions
1739                .iter()
1740                .filter_map(|a| {
1741                    if let MigrationAction::CreateTable { table, .. } = a {
1742                        Some(table.as_str())
1743                    } else {
1744                        None
1745                    }
1746                })
1747                .collect();
1748
1749            // user must come before code
1750            let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1751            let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1752            assert!(user_pos < code_pos, "user must come before code");
1753        }
1754    }
1755
1756    mod primary_key_changes {
1757        use super::*;
1758
1759        fn pk(columns: Vec<&str>) -> TableConstraint {
1760            TableConstraint::PrimaryKey {
1761                auto_increment: false,
1762                columns: columns.into_iter().map(|s| s.to_string()).collect(),
1763            }
1764        }
1765
1766        #[test]
1767        fn add_column_to_composite_pk() {
1768            // Primary key: [id] -> [id, tenant_id]
1769            let from = vec![table(
1770                "users",
1771                vec![
1772                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1773                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1774                ],
1775                vec![pk(vec!["id"])],
1776            )];
1777
1778            let to = vec![table(
1779                "users",
1780                vec![
1781                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1782                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1783                ],
1784                vec![pk(vec!["id", "tenant_id"])],
1785            )];
1786
1787            let plan = diff_schemas(&from, &to).unwrap();
1788
1789            // Should remove old PK and add new composite PK
1790            assert_eq!(plan.actions.len(), 2);
1791
1792            let has_remove = plan.actions.iter().any(|a| {
1793                matches!(
1794                    a,
1795                    MigrationAction::RemoveConstraint {
1796                        table,
1797                        constraint: TableConstraint::PrimaryKey { columns, .. }
1798                    } if table == "users" && columns == &vec!["id".to_string()]
1799                )
1800            });
1801            assert!(has_remove, "Should have RemoveConstraint for old PK");
1802
1803            let has_add = plan.actions.iter().any(|a| {
1804                matches!(
1805                    a,
1806                    MigrationAction::AddConstraint {
1807                        table,
1808                        constraint: TableConstraint::PrimaryKey { columns, .. }
1809                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1810                )
1811            });
1812            assert!(has_add, "Should have AddConstraint for new composite PK");
1813        }
1814
1815        #[test]
1816        fn remove_column_from_composite_pk() {
1817            // Primary key: [id, tenant_id] -> [id]
1818            let from = vec![table(
1819                "users",
1820                vec![
1821                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1822                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1823                ],
1824                vec![pk(vec!["id", "tenant_id"])],
1825            )];
1826
1827            let to = vec![table(
1828                "users",
1829                vec![
1830                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1831                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1832                ],
1833                vec![pk(vec!["id"])],
1834            )];
1835
1836            let plan = diff_schemas(&from, &to).unwrap();
1837
1838            // Should remove old composite PK and add new single-column PK
1839            assert_eq!(plan.actions.len(), 2);
1840
1841            let has_remove = plan.actions.iter().any(|a| {
1842                matches!(
1843                    a,
1844                    MigrationAction::RemoveConstraint {
1845                        table,
1846                        constraint: TableConstraint::PrimaryKey { columns, .. }
1847                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1848                )
1849            });
1850            assert!(
1851                has_remove,
1852                "Should have RemoveConstraint for old composite PK"
1853            );
1854
1855            let has_add = plan.actions.iter().any(|a| {
1856                matches!(
1857                    a,
1858                    MigrationAction::AddConstraint {
1859                        table,
1860                        constraint: TableConstraint::PrimaryKey { columns, .. }
1861                    } if table == "users" && columns == &vec!["id".to_string()]
1862                )
1863            });
1864            assert!(
1865                has_add,
1866                "Should have AddConstraint for new single-column PK"
1867            );
1868        }
1869
1870        #[test]
1871        fn change_pk_columns_entirely() {
1872            // Primary key: [id] -> [uuid]
1873            let from = vec![table(
1874                "users",
1875                vec![
1876                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1877                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1878                ],
1879                vec![pk(vec!["id"])],
1880            )];
1881
1882            let to = vec![table(
1883                "users",
1884                vec![
1885                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1886                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1887                ],
1888                vec![pk(vec!["uuid"])],
1889            )];
1890
1891            let plan = diff_schemas(&from, &to).unwrap();
1892
1893            assert_eq!(plan.actions.len(), 2);
1894
1895            let has_remove = plan.actions.iter().any(|a| {
1896                matches!(
1897                    a,
1898                    MigrationAction::RemoveConstraint {
1899                        table,
1900                        constraint: TableConstraint::PrimaryKey { columns, .. }
1901                    } if table == "users" && columns == &vec!["id".to_string()]
1902                )
1903            });
1904            assert!(has_remove, "Should have RemoveConstraint for old PK");
1905
1906            let has_add = plan.actions.iter().any(|a| {
1907                matches!(
1908                    a,
1909                    MigrationAction::AddConstraint {
1910                        table,
1911                        constraint: TableConstraint::PrimaryKey { columns, .. }
1912                    } if table == "users" && columns == &vec!["uuid".to_string()]
1913                )
1914            });
1915            assert!(has_add, "Should have AddConstraint for new PK");
1916        }
1917
1918        #[test]
1919        fn add_multiple_columns_to_composite_pk() {
1920            // Primary key: [id] -> [id, tenant_id, region_id]
1921            let from = vec![table(
1922                "users",
1923                vec![
1924                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1925                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1926                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1927                ],
1928                vec![pk(vec!["id"])],
1929            )];
1930
1931            let to = vec![table(
1932                "users",
1933                vec![
1934                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1935                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1936                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1937                ],
1938                vec![pk(vec!["id", "tenant_id", "region_id"])],
1939            )];
1940
1941            let plan = diff_schemas(&from, &to).unwrap();
1942
1943            assert_eq!(plan.actions.len(), 2);
1944
1945            let has_remove = plan.actions.iter().any(|a| {
1946                matches!(
1947                    a,
1948                    MigrationAction::RemoveConstraint {
1949                        table,
1950                        constraint: TableConstraint::PrimaryKey { columns, .. }
1951                    } if table == "users" && columns == &vec!["id".to_string()]
1952                )
1953            });
1954            assert!(
1955                has_remove,
1956                "Should have RemoveConstraint for old single-column PK"
1957            );
1958
1959            let has_add = plan.actions.iter().any(|a| {
1960                matches!(
1961                    a,
1962                    MigrationAction::AddConstraint {
1963                        table,
1964                        constraint: TableConstraint::PrimaryKey { columns, .. }
1965                    } if table == "users" && columns == &vec![
1966                        "id".to_string(),
1967                        "tenant_id".to_string(),
1968                        "region_id".to_string()
1969                    ]
1970                )
1971            });
1972            assert!(
1973                has_add,
1974                "Should have AddConstraint for new 3-column composite PK"
1975            );
1976        }
1977
1978        #[test]
1979        fn remove_multiple_columns_from_composite_pk() {
1980            // Primary key: [id, tenant_id, region_id] -> [id]
1981            let from = vec![table(
1982                "users",
1983                vec![
1984                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1985                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1986                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1987                ],
1988                vec![pk(vec!["id", "tenant_id", "region_id"])],
1989            )];
1990
1991            let to = vec![table(
1992                "users",
1993                vec![
1994                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1995                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1996                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1997                ],
1998                vec![pk(vec!["id"])],
1999            )];
2000
2001            let plan = diff_schemas(&from, &to).unwrap();
2002
2003            assert_eq!(plan.actions.len(), 2);
2004
2005            let has_remove = plan.actions.iter().any(|a| {
2006                matches!(
2007                    a,
2008                    MigrationAction::RemoveConstraint {
2009                        table,
2010                        constraint: TableConstraint::PrimaryKey { columns, .. }
2011                    } if table == "users" && columns == &vec![
2012                        "id".to_string(),
2013                        "tenant_id".to_string(),
2014                        "region_id".to_string()
2015                    ]
2016                )
2017            });
2018            assert!(
2019                has_remove,
2020                "Should have RemoveConstraint for old 3-column composite PK"
2021            );
2022
2023            let has_add = plan.actions.iter().any(|a| {
2024                matches!(
2025                    a,
2026                    MigrationAction::AddConstraint {
2027                        table,
2028                        constraint: TableConstraint::PrimaryKey { columns, .. }
2029                    } if table == "users" && columns == &vec!["id".to_string()]
2030                )
2031            });
2032            assert!(
2033                has_add,
2034                "Should have AddConstraint for new single-column PK"
2035            );
2036        }
2037
2038        #[test]
2039        fn change_composite_pk_columns_partially() {
2040            // Primary key: [id, tenant_id] -> [id, region_id]
2041            // One column kept, one removed, one added
2042            let from = vec![table(
2043                "users",
2044                vec![
2045                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2046                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2047                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2048                ],
2049                vec![pk(vec!["id", "tenant_id"])],
2050            )];
2051
2052            let to = vec![table(
2053                "users",
2054                vec![
2055                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2056                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2057                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2058                ],
2059                vec![pk(vec!["id", "region_id"])],
2060            )];
2061
2062            let plan = diff_schemas(&from, &to).unwrap();
2063
2064            assert_eq!(plan.actions.len(), 2);
2065
2066            let has_remove = plan.actions.iter().any(|a| {
2067                matches!(
2068                    a,
2069                    MigrationAction::RemoveConstraint {
2070                        table,
2071                        constraint: TableConstraint::PrimaryKey { columns, .. }
2072                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2073                )
2074            });
2075            assert!(
2076                has_remove,
2077                "Should have RemoveConstraint for old PK with tenant_id"
2078            );
2079
2080            let has_add = plan.actions.iter().any(|a| {
2081                matches!(
2082                    a,
2083                    MigrationAction::AddConstraint {
2084                        table,
2085                        constraint: TableConstraint::PrimaryKey { columns, .. }
2086                    } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2087                )
2088            });
2089            assert!(
2090                has_add,
2091                "Should have AddConstraint for new PK with region_id"
2092            );
2093        }
2094    }
2095
2096    mod default_changes {
2097        use super::*;
2098
2099        fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2100            ColumnDef {
2101                name: name.to_string(),
2102                r#type: ty,
2103                nullable: true,
2104                default: default.map(|s| s.into()),
2105                comment: None,
2106                primary_key: None,
2107                unique: None,
2108                index: None,
2109                foreign_key: None,
2110            }
2111        }
2112
2113        #[test]
2114        fn add_default_value() {
2115            // Column: no default -> has default
2116            let from = vec![table(
2117                "users",
2118                vec![
2119                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2120                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2121                ],
2122                vec![],
2123            )];
2124
2125            let to = vec![table(
2126                "users",
2127                vec![
2128                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2129                    col_with_default(
2130                        "status",
2131                        ColumnType::Simple(SimpleColumnType::Text),
2132                        Some("'active'"),
2133                    ),
2134                ],
2135                vec![],
2136            )];
2137
2138            let plan = diff_schemas(&from, &to).unwrap();
2139
2140            assert_eq!(plan.actions.len(), 1);
2141            assert!(matches!(
2142                &plan.actions[0],
2143                MigrationAction::ModifyColumnDefault {
2144                    table,
2145                    column,
2146                    new_default: Some(default),
2147                } if table == "users" && column == "status" && default == "'active'"
2148            ));
2149        }
2150
2151        #[test]
2152        fn remove_default_value() {
2153            // Column: has default -> no default
2154            let from = vec![table(
2155                "users",
2156                vec![
2157                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2158                    col_with_default(
2159                        "status",
2160                        ColumnType::Simple(SimpleColumnType::Text),
2161                        Some("'active'"),
2162                    ),
2163                ],
2164                vec![],
2165            )];
2166
2167            let to = vec![table(
2168                "users",
2169                vec![
2170                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2171                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2172                ],
2173                vec![],
2174            )];
2175
2176            let plan = diff_schemas(&from, &to).unwrap();
2177
2178            assert_eq!(plan.actions.len(), 1);
2179            assert!(matches!(
2180                &plan.actions[0],
2181                MigrationAction::ModifyColumnDefault {
2182                    table,
2183                    column,
2184                    new_default: None,
2185                } if table == "users" && column == "status"
2186            ));
2187        }
2188
2189        #[test]
2190        fn change_default_value() {
2191            // Column: 'active' -> 'pending'
2192            let from = vec![table(
2193                "users",
2194                vec![
2195                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2196                    col_with_default(
2197                        "status",
2198                        ColumnType::Simple(SimpleColumnType::Text),
2199                        Some("'active'"),
2200                    ),
2201                ],
2202                vec![],
2203            )];
2204
2205            let to = vec![table(
2206                "users",
2207                vec![
2208                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2209                    col_with_default(
2210                        "status",
2211                        ColumnType::Simple(SimpleColumnType::Text),
2212                        Some("'pending'"),
2213                    ),
2214                ],
2215                vec![],
2216            )];
2217
2218            let plan = diff_schemas(&from, &to).unwrap();
2219
2220            assert_eq!(plan.actions.len(), 1);
2221            assert!(matches!(
2222                &plan.actions[0],
2223                MigrationAction::ModifyColumnDefault {
2224                    table,
2225                    column,
2226                    new_default: Some(default),
2227                } if table == "users" && column == "status" && default == "'pending'"
2228            ));
2229        }
2230
2231        #[test]
2232        fn no_change_same_default() {
2233            // Column: same default -> no action
2234            let from = vec![table(
2235                "users",
2236                vec![
2237                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2238                    col_with_default(
2239                        "status",
2240                        ColumnType::Simple(SimpleColumnType::Text),
2241                        Some("'active'"),
2242                    ),
2243                ],
2244                vec![],
2245            )];
2246
2247            let to = vec![table(
2248                "users",
2249                vec![
2250                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2251                    col_with_default(
2252                        "status",
2253                        ColumnType::Simple(SimpleColumnType::Text),
2254                        Some("'active'"),
2255                    ),
2256                ],
2257                vec![],
2258            )];
2259
2260            let plan = diff_schemas(&from, &to).unwrap();
2261
2262            assert!(plan.actions.is_empty());
2263        }
2264
2265        #[test]
2266        fn multiple_columns_default_changes() {
2267            // Multiple columns with default changes
2268            let from = vec![table(
2269                "users",
2270                vec![
2271                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2272                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2273                    col_with_default(
2274                        "role",
2275                        ColumnType::Simple(SimpleColumnType::Text),
2276                        Some("'user'"),
2277                    ),
2278                    col_with_default(
2279                        "active",
2280                        ColumnType::Simple(SimpleColumnType::Boolean),
2281                        Some("true"),
2282                    ),
2283                ],
2284                vec![],
2285            )];
2286
2287            let to = vec![table(
2288                "users",
2289                vec![
2290                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2291                    col_with_default(
2292                        "status",
2293                        ColumnType::Simple(SimpleColumnType::Text),
2294                        Some("'pending'"),
2295                    ), // None -> 'pending'
2296                    col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), // 'user' -> None
2297                    col_with_default(
2298                        "active",
2299                        ColumnType::Simple(SimpleColumnType::Boolean),
2300                        Some("true"),
2301                    ), // no change
2302                ],
2303                vec![],
2304            )];
2305
2306            let plan = diff_schemas(&from, &to).unwrap();
2307
2308            assert_eq!(plan.actions.len(), 2);
2309
2310            let has_status_change = plan.actions.iter().any(|a| {
2311                matches!(
2312                    a,
2313                    MigrationAction::ModifyColumnDefault {
2314                        table,
2315                        column,
2316                        new_default: Some(default),
2317                    } if table == "users" && column == "status" && default == "'pending'"
2318                )
2319            });
2320            assert!(has_status_change, "Should detect status default added");
2321
2322            let has_role_change = plan.actions.iter().any(|a| {
2323                matches!(
2324                    a,
2325                    MigrationAction::ModifyColumnDefault {
2326                        table,
2327                        column,
2328                        new_default: None,
2329                    } if table == "users" && column == "role"
2330                )
2331            });
2332            assert!(has_role_change, "Should detect role default removed");
2333        }
2334
2335        #[test]
2336        fn default_change_with_type_change() {
2337            // Column changing both type and default
2338            let from = vec![table(
2339                "users",
2340                vec![
2341                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2342                    col_with_default(
2343                        "count",
2344                        ColumnType::Simple(SimpleColumnType::Integer),
2345                        Some("0"),
2346                    ),
2347                ],
2348                vec![],
2349            )];
2350
2351            let to = vec![table(
2352                "users",
2353                vec![
2354                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2355                    col_with_default(
2356                        "count",
2357                        ColumnType::Simple(SimpleColumnType::Text),
2358                        Some("'0'"),
2359                    ),
2360                ],
2361                vec![],
2362            )];
2363
2364            let plan = diff_schemas(&from, &to).unwrap();
2365
2366            // Should generate both ModifyColumnType and ModifyColumnDefault
2367            assert_eq!(plan.actions.len(), 2);
2368
2369            let has_type_change = plan.actions.iter().any(|a| {
2370                matches!(
2371                    a,
2372                    MigrationAction::ModifyColumnType { table, column, .. }
2373                    if table == "users" && column == "count"
2374                )
2375            });
2376            assert!(has_type_change, "Should detect type change");
2377
2378            let has_default_change = plan.actions.iter().any(|a| {
2379                matches!(
2380                    a,
2381                    MigrationAction::ModifyColumnDefault {
2382                        table,
2383                        column,
2384                        new_default: Some(default),
2385                    } if table == "users" && column == "count" && default == "'0'"
2386                )
2387            });
2388            assert!(has_default_change, "Should detect default change");
2389        }
2390    }
2391
2392    mod comment_changes {
2393        use super::*;
2394
2395        fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2396            ColumnDef {
2397                name: name.to_string(),
2398                r#type: ty,
2399                nullable: true,
2400                default: None,
2401                comment: comment.map(|s| s.to_string()),
2402                primary_key: None,
2403                unique: None,
2404                index: None,
2405                foreign_key: None,
2406            }
2407        }
2408
2409        #[test]
2410        fn add_comment() {
2411            // Column: no comment -> has comment
2412            let from = vec![table(
2413                "users",
2414                vec![
2415                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2416                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2417                ],
2418                vec![],
2419            )];
2420
2421            let to = vec![table(
2422                "users",
2423                vec![
2424                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2425                    col_with_comment(
2426                        "email",
2427                        ColumnType::Simple(SimpleColumnType::Text),
2428                        Some("User's email address"),
2429                    ),
2430                ],
2431                vec![],
2432            )];
2433
2434            let plan = diff_schemas(&from, &to).unwrap();
2435
2436            assert_eq!(plan.actions.len(), 1);
2437            assert!(matches!(
2438                &plan.actions[0],
2439                MigrationAction::ModifyColumnComment {
2440                    table,
2441                    column,
2442                    new_comment: Some(comment),
2443                } if table == "users" && column == "email" && comment == "User's email address"
2444            ));
2445        }
2446
2447        #[test]
2448        fn remove_comment() {
2449            // Column: has comment -> no comment
2450            let from = vec![table(
2451                "users",
2452                vec![
2453                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2454                    col_with_comment(
2455                        "email",
2456                        ColumnType::Simple(SimpleColumnType::Text),
2457                        Some("User's email address"),
2458                    ),
2459                ],
2460                vec![],
2461            )];
2462
2463            let to = vec![table(
2464                "users",
2465                vec![
2466                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2467                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2468                ],
2469                vec![],
2470            )];
2471
2472            let plan = diff_schemas(&from, &to).unwrap();
2473
2474            assert_eq!(plan.actions.len(), 1);
2475            assert!(matches!(
2476                &plan.actions[0],
2477                MigrationAction::ModifyColumnComment {
2478                    table,
2479                    column,
2480                    new_comment: None,
2481                } if table == "users" && column == "email"
2482            ));
2483        }
2484
2485        #[test]
2486        fn change_comment() {
2487            // Column: 'old comment' -> 'new comment'
2488            let from = vec![table(
2489                "users",
2490                vec![
2491                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2492                    col_with_comment(
2493                        "email",
2494                        ColumnType::Simple(SimpleColumnType::Text),
2495                        Some("Old comment"),
2496                    ),
2497                ],
2498                vec![],
2499            )];
2500
2501            let to = vec![table(
2502                "users",
2503                vec![
2504                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2505                    col_with_comment(
2506                        "email",
2507                        ColumnType::Simple(SimpleColumnType::Text),
2508                        Some("New comment"),
2509                    ),
2510                ],
2511                vec![],
2512            )];
2513
2514            let plan = diff_schemas(&from, &to).unwrap();
2515
2516            assert_eq!(plan.actions.len(), 1);
2517            assert!(matches!(
2518                &plan.actions[0],
2519                MigrationAction::ModifyColumnComment {
2520                    table,
2521                    column,
2522                    new_comment: Some(comment),
2523                } if table == "users" && column == "email" && comment == "New comment"
2524            ));
2525        }
2526
2527        #[test]
2528        fn no_change_same_comment() {
2529            // Column: same comment -> no action
2530            let from = vec![table(
2531                "users",
2532                vec![
2533                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2534                    col_with_comment(
2535                        "email",
2536                        ColumnType::Simple(SimpleColumnType::Text),
2537                        Some("Same comment"),
2538                    ),
2539                ],
2540                vec![],
2541            )];
2542
2543            let to = vec![table(
2544                "users",
2545                vec![
2546                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2547                    col_with_comment(
2548                        "email",
2549                        ColumnType::Simple(SimpleColumnType::Text),
2550                        Some("Same comment"),
2551                    ),
2552                ],
2553                vec![],
2554            )];
2555
2556            let plan = diff_schemas(&from, &to).unwrap();
2557
2558            assert!(plan.actions.is_empty());
2559        }
2560
2561        #[test]
2562        fn multiple_columns_comment_changes() {
2563            // Multiple columns with comment changes
2564            let from = vec![table(
2565                "users",
2566                vec![
2567                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2568                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2569                    col_with_comment(
2570                        "name",
2571                        ColumnType::Simple(SimpleColumnType::Text),
2572                        Some("User name"),
2573                    ),
2574                    col_with_comment(
2575                        "phone",
2576                        ColumnType::Simple(SimpleColumnType::Text),
2577                        Some("Phone number"),
2578                    ),
2579                ],
2580                vec![],
2581            )];
2582
2583            let to = vec![table(
2584                "users",
2585                vec![
2586                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2587                    col_with_comment(
2588                        "email",
2589                        ColumnType::Simple(SimpleColumnType::Text),
2590                        Some("Email address"),
2591                    ), // None -> "Email address"
2592                    col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), // "User name" -> None
2593                    col_with_comment(
2594                        "phone",
2595                        ColumnType::Simple(SimpleColumnType::Text),
2596                        Some("Phone number"),
2597                    ), // no change
2598                ],
2599                vec![],
2600            )];
2601
2602            let plan = diff_schemas(&from, &to).unwrap();
2603
2604            assert_eq!(plan.actions.len(), 2);
2605
2606            let has_email_change = plan.actions.iter().any(|a| {
2607                matches!(
2608                    a,
2609                    MigrationAction::ModifyColumnComment {
2610                        table,
2611                        column,
2612                        new_comment: Some(comment),
2613                    } if table == "users" && column == "email" && comment == "Email address"
2614                )
2615            });
2616            assert!(has_email_change, "Should detect email comment added");
2617
2618            let has_name_change = plan.actions.iter().any(|a| {
2619                matches!(
2620                    a,
2621                    MigrationAction::ModifyColumnComment {
2622                        table,
2623                        column,
2624                        new_comment: None,
2625                    } if table == "users" && column == "name"
2626                )
2627            });
2628            assert!(has_name_change, "Should detect name comment removed");
2629        }
2630
2631        #[test]
2632        fn comment_change_with_nullable_change() {
2633            // Column changing both nullable and comment
2634            let from = vec![table(
2635                "users",
2636                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2637                    let mut c =
2638                        col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
2639                    c.nullable = true;
2640                    c
2641                }],
2642                vec![],
2643            )];
2644
2645            let to = vec![table(
2646                "users",
2647                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2648                    let mut c = col_with_comment(
2649                        "email",
2650                        ColumnType::Simple(SimpleColumnType::Text),
2651                        Some("Required email"),
2652                    );
2653                    c.nullable = false;
2654                    c
2655                }],
2656                vec![],
2657            )];
2658
2659            let plan = diff_schemas(&from, &to).unwrap();
2660
2661            // Should generate both ModifyColumnNullable and ModifyColumnComment
2662            assert_eq!(plan.actions.len(), 2);
2663
2664            let has_nullable_change = plan.actions.iter().any(|a| {
2665                matches!(
2666                    a,
2667                    MigrationAction::ModifyColumnNullable {
2668                        table,
2669                        column,
2670                        nullable: false,
2671                        ..
2672                    } if table == "users" && column == "email"
2673                )
2674            });
2675            assert!(has_nullable_change, "Should detect nullable change");
2676
2677            let has_comment_change = plan.actions.iter().any(|a| {
2678                matches!(
2679                    a,
2680                    MigrationAction::ModifyColumnComment {
2681                        table,
2682                        column,
2683                        new_comment: Some(comment),
2684                    } if table == "users" && column == "email" && comment == "Required email"
2685                )
2686            });
2687            assert!(has_comment_change, "Should detect comment change");
2688        }
2689    }
2690
2691    mod nullable_changes {
2692        use super::*;
2693
2694        fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
2695            ColumnDef {
2696                name: name.to_string(),
2697                r#type: ty,
2698                nullable,
2699                default: None,
2700                comment: None,
2701                primary_key: None,
2702                unique: None,
2703                index: None,
2704                foreign_key: None,
2705            }
2706        }
2707
2708        #[test]
2709        fn column_nullable_to_non_nullable() {
2710            // Column: nullable -> non-nullable
2711            let from = vec![table(
2712                "users",
2713                vec![
2714                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2715                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2716                ],
2717                vec![],
2718            )];
2719
2720            let to = vec![table(
2721                "users",
2722                vec![
2723                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2724                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2725                ],
2726                vec![],
2727            )];
2728
2729            let plan = diff_schemas(&from, &to).unwrap();
2730
2731            assert_eq!(plan.actions.len(), 1);
2732            assert!(matches!(
2733                &plan.actions[0],
2734                MigrationAction::ModifyColumnNullable {
2735                    table,
2736                    column,
2737                    nullable: false,
2738                    fill_with: None,
2739                } if table == "users" && column == "email"
2740            ));
2741        }
2742
2743        #[test]
2744        fn column_non_nullable_to_nullable() {
2745            // Column: non-nullable -> nullable
2746            let from = vec![table(
2747                "users",
2748                vec![
2749                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2750                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2751                ],
2752                vec![],
2753            )];
2754
2755            let to = vec![table(
2756                "users",
2757                vec![
2758                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2759                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2760                ],
2761                vec![],
2762            )];
2763
2764            let plan = diff_schemas(&from, &to).unwrap();
2765
2766            assert_eq!(plan.actions.len(), 1);
2767            assert!(matches!(
2768                &plan.actions[0],
2769                MigrationAction::ModifyColumnNullable {
2770                    table,
2771                    column,
2772                    nullable: true,
2773                    fill_with: None,
2774                } if table == "users" && column == "email"
2775            ));
2776        }
2777
2778        #[test]
2779        fn multiple_columns_nullable_changes() {
2780            // Multiple columns changing nullability at once
2781            let from = vec![table(
2782                "users",
2783                vec![
2784                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2785                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2786                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
2787                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
2788                ],
2789                vec![],
2790            )];
2791
2792            let to = vec![table(
2793                "users",
2794                vec![
2795                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2796                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), // nullable -> non-nullable
2797                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), // non-nullable -> nullable
2798                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), // no change
2799                ],
2800                vec![],
2801            )];
2802
2803            let plan = diff_schemas(&from, &to).unwrap();
2804
2805            assert_eq!(plan.actions.len(), 2);
2806
2807            let has_email_change = plan.actions.iter().any(|a| {
2808                matches!(
2809                    a,
2810                    MigrationAction::ModifyColumnNullable {
2811                        table,
2812                        column,
2813                        nullable: false,
2814                        ..
2815                    } if table == "users" && column == "email"
2816                )
2817            });
2818            assert!(
2819                has_email_change,
2820                "Should detect email nullable -> non-nullable"
2821            );
2822
2823            let has_name_change = plan.actions.iter().any(|a| {
2824                matches!(
2825                    a,
2826                    MigrationAction::ModifyColumnNullable {
2827                        table,
2828                        column,
2829                        nullable: true,
2830                        ..
2831                    } if table == "users" && column == "name"
2832                )
2833            });
2834            assert!(
2835                has_name_change,
2836                "Should detect name non-nullable -> nullable"
2837            );
2838        }
2839
2840        #[test]
2841        fn nullable_change_with_type_change() {
2842            // Column changing both type and nullability
2843            let from = vec![table(
2844                "users",
2845                vec![
2846                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2847                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
2848                ],
2849                vec![],
2850            )];
2851
2852            let to = vec![table(
2853                "users",
2854                vec![
2855                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2856                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
2857                ],
2858                vec![],
2859            )];
2860
2861            let plan = diff_schemas(&from, &to).unwrap();
2862
2863            // Should generate both ModifyColumnType and ModifyColumnNullable
2864            assert_eq!(plan.actions.len(), 2);
2865
2866            let has_type_change = plan.actions.iter().any(|a| {
2867                matches!(
2868                    a,
2869                    MigrationAction::ModifyColumnType { table, column, .. }
2870                    if table == "users" && column == "age"
2871                )
2872            });
2873            assert!(has_type_change, "Should detect type change");
2874
2875            let has_nullable_change = plan.actions.iter().any(|a| {
2876                matches!(
2877                    a,
2878                    MigrationAction::ModifyColumnNullable {
2879                        table,
2880                        column,
2881                        nullable: false,
2882                        ..
2883                    } if table == "users" && column == "age"
2884                )
2885            });
2886            assert!(has_nullable_change, "Should detect nullable change");
2887        }
2888    }
2889
2890    mod diff_tables {
2891        use insta::assert_debug_snapshot;
2892
2893        use super::*;
2894
2895        #[test]
2896        fn create_table_with_inline_index() {
2897            let base = [table(
2898                "users",
2899                vec![
2900                    ColumnDef {
2901                        name: "id".to_string(),
2902                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2903                        nullable: false,
2904                        default: None,
2905                        comment: None,
2906                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2907                        unique: None,
2908                        index: Some(StrOrBoolOrArray::Bool(false)),
2909                        foreign_key: None,
2910                    },
2911                    ColumnDef {
2912                        name: "name".to_string(),
2913                        r#type: ColumnType::Simple(SimpleColumnType::Text),
2914                        nullable: true,
2915                        default: None,
2916                        comment: None,
2917                        primary_key: None,
2918                        unique: Some(StrOrBoolOrArray::Bool(true)),
2919                        index: Some(StrOrBoolOrArray::Bool(true)),
2920                        foreign_key: None,
2921                    },
2922                ],
2923                vec![],
2924            )];
2925            let plan = diff_schemas(&[], &base).unwrap();
2926
2927            assert_eq!(plan.actions.len(), 1);
2928            assert_debug_snapshot!(plan.actions);
2929
2930            let plan = diff_schemas(
2931                &base,
2932                &[table(
2933                    "users",
2934                    vec![
2935                        ColumnDef {
2936                            name: "id".to_string(),
2937                            r#type: ColumnType::Simple(SimpleColumnType::Integer),
2938                            nullable: false,
2939                            default: None,
2940                            comment: None,
2941                            primary_key: Some(PrimaryKeySyntax::Bool(true)),
2942                            unique: None,
2943                            index: Some(StrOrBoolOrArray::Bool(false)),
2944                            foreign_key: None,
2945                        },
2946                        ColumnDef {
2947                            name: "name".to_string(),
2948                            r#type: ColumnType::Simple(SimpleColumnType::Text),
2949                            nullable: true,
2950                            default: None,
2951                            comment: None,
2952                            primary_key: None,
2953                            unique: Some(StrOrBoolOrArray::Bool(true)),
2954                            index: Some(StrOrBoolOrArray::Bool(false)),
2955                            foreign_key: None,
2956                        },
2957                    ],
2958                    vec![],
2959                )],
2960            )
2961            .unwrap();
2962
2963            assert_eq!(plan.actions.len(), 1);
2964            assert_debug_snapshot!(plan.actions);
2965        }
2966
2967        #[rstest]
2968        #[case(
2969            "add_index",
2970            vec![table(
2971                "users",
2972                vec![
2973                    ColumnDef {
2974                        name: "id".to_string(),
2975                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2976                        nullable: false,
2977                        default: None,
2978                        comment: None,
2979                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2980                        unique: None,
2981                        index: None,
2982                        foreign_key: None,
2983                    },
2984                ],
2985                vec![],
2986            )],
2987            vec![table(
2988                "users",
2989                vec![
2990                    ColumnDef {
2991                        name: "id".to_string(),
2992                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2993                        nullable: false,
2994                        default: None,
2995                        comment: None,
2996                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2997                        unique: None,
2998                        index: Some(StrOrBoolOrArray::Bool(true)),
2999                        foreign_key: None,
3000                    },
3001                ],
3002                vec![],
3003            )],
3004        )]
3005        #[case(
3006            "remove_index",
3007            vec![table(
3008                "users",
3009                vec![
3010                    ColumnDef {
3011                        name: "id".to_string(),
3012                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3013                        nullable: false,
3014                        default: None,
3015                        comment: None,
3016                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3017                        unique: None,
3018                        index: Some(StrOrBoolOrArray::Bool(true)),
3019                        foreign_key: None,
3020                    },
3021                ],
3022                vec![],
3023            )],
3024            vec![table(
3025                "users",
3026                vec![
3027                    ColumnDef {
3028                        name: "id".to_string(),
3029                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3030                        nullable: false,
3031                        default: None,
3032                        comment: None,
3033                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3034                        unique: None,
3035                        index: Some(StrOrBoolOrArray::Bool(false)),
3036                        foreign_key: None,
3037                    },
3038                ],
3039                vec![],
3040            )],
3041        )]
3042        #[case(
3043            "add_named_index",
3044            vec![table(
3045                "users",
3046                vec![
3047                    ColumnDef {
3048                        name: "id".to_string(),
3049                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3050                        nullable: false,
3051                        default: None,
3052                        comment: None,
3053                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3054                        unique: None,
3055                        index: None,
3056                        foreign_key: None,
3057                    },
3058                ],
3059                vec![],
3060            )],
3061            vec![table(
3062                "users",
3063                vec![
3064                    ColumnDef {
3065                        name: "id".to_string(),
3066                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3067                        nullable: false,
3068                        default: None,
3069                        comment: None,
3070                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3071                        unique: None,
3072                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3073                        foreign_key: None,
3074                    },
3075                ],
3076                vec![],
3077            )],
3078        )]
3079        #[case(
3080            "remove_named_index",
3081            vec![table(
3082                "users",
3083                vec![
3084                    ColumnDef {
3085                        name: "id".to_string(),
3086                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3087                        nullable: false,
3088                        default: None,
3089                        comment: None,
3090                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3091                        unique: None,
3092                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3093                        foreign_key: None,
3094                    },
3095                ],
3096                vec![],
3097            )],
3098            vec![table(
3099                "users",
3100                vec![
3101                    ColumnDef {
3102                        name: "id".to_string(),
3103                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3104                        nullable: false,
3105                        default: None,
3106                        comment: None,
3107                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3108                        unique: None,
3109                        index: None,
3110                        foreign_key: None,
3111                    },
3112                ],
3113                vec![],
3114            )],
3115        )]
3116        fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3117            use insta::with_settings;
3118
3119            let plan = diff_schemas(&base, &to).unwrap();
3120            with_settings!({ snapshot_suffix => name }, {
3121                assert_debug_snapshot!(plan.actions);
3122            });
3123        }
3124    }
3125
3126    // Explicit coverage tests for lines that tarpaulin might miss in rstest
3127    mod coverage_explicit {
3128        use super::*;
3129
3130        #[test]
3131        fn delete_column_explicit() {
3132            // Covers lines 292-294: DeleteColumn action inside modified table loop
3133            let from = vec![table(
3134                "users",
3135                vec![
3136                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3137                    col("name", ColumnType::Simple(SimpleColumnType::Text)),
3138                ],
3139                vec![],
3140            )];
3141
3142            let to = vec![table(
3143                "users",
3144                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3145                vec![],
3146            )];
3147
3148            let plan = diff_schemas(&from, &to).unwrap();
3149            assert_eq!(plan.actions.len(), 1);
3150            assert!(matches!(
3151                &plan.actions[0],
3152                MigrationAction::DeleteColumn { table, column }
3153                if table == "users" && column == "name"
3154            ));
3155        }
3156
3157        #[test]
3158        fn add_column_explicit() {
3159            // Covers lines 359-362: AddColumn action inside modified table loop
3160            let from = vec![table(
3161                "users",
3162                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3163                vec![],
3164            )];
3165
3166            let to = vec![table(
3167                "users",
3168                vec![
3169                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3170                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3171                ],
3172                vec![],
3173            )];
3174
3175            let plan = diff_schemas(&from, &to).unwrap();
3176            assert_eq!(plan.actions.len(), 1);
3177            assert!(matches!(
3178                &plan.actions[0],
3179                MigrationAction::AddColumn { table, column, .. }
3180                if table == "users" && column.name == "email"
3181            ));
3182        }
3183
3184        #[test]
3185        fn remove_constraint_explicit() {
3186            // Covers lines 370-372: RemoveConstraint action inside modified table loop
3187            let from = vec![table(
3188                "users",
3189                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3190                vec![idx("idx_users_id", vec!["id"])],
3191            )];
3192
3193            let to = vec![table(
3194                "users",
3195                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3196                vec![],
3197            )];
3198
3199            let plan = diff_schemas(&from, &to).unwrap();
3200            assert_eq!(plan.actions.len(), 1);
3201            assert!(matches!(
3202                &plan.actions[0],
3203                MigrationAction::RemoveConstraint { table, constraint }
3204                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3205            ));
3206        }
3207
3208        #[test]
3209        fn add_constraint_explicit() {
3210            // Covers lines 378-380: AddConstraint action inside modified table loop
3211            let from = vec![table(
3212                "users",
3213                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3214                vec![],
3215            )];
3216
3217            let to = vec![table(
3218                "users",
3219                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3220                vec![idx("idx_users_id", vec!["id"])],
3221            )];
3222
3223            let plan = diff_schemas(&from, &to).unwrap();
3224            assert_eq!(plan.actions.len(), 1);
3225            assert!(matches!(
3226                &plan.actions[0],
3227                MigrationAction::AddConstraint { table, constraint }
3228                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3229            ));
3230        }
3231    }
3232
3233    mod constraint_removal_on_deleted_columns {
3234        use super::*;
3235
3236        fn fk(columns: Vec<&str>, ref_table: &str, ref_columns: Vec<&str>) -> TableConstraint {
3237            TableConstraint::ForeignKey {
3238                name: None,
3239                columns: columns.into_iter().map(|s| s.to_string()).collect(),
3240                ref_table: ref_table.to_string(),
3241                ref_columns: ref_columns.into_iter().map(|s| s.to_string()).collect(),
3242                on_delete: None,
3243                on_update: None,
3244            }
3245        }
3246
3247        #[test]
3248        fn skip_remove_constraint_when_all_columns_deleted() {
3249            // When a column with FK and index is deleted, the constraints should NOT
3250            // generate separate RemoveConstraint actions (they are dropped with the column)
3251            let from = vec![table(
3252                "project",
3253                vec![
3254                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3255                    col("template_id", ColumnType::Simple(SimpleColumnType::Integer)),
3256                ],
3257                vec![
3258                    fk(vec!["template_id"], "book_template", vec!["id"]),
3259                    idx("ix_project__template_id", vec!["template_id"]),
3260                ],
3261            )];
3262
3263            let to = vec![table(
3264                "project",
3265                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3266                vec![],
3267            )];
3268
3269            let plan = diff_schemas(&from, &to).unwrap();
3270
3271            // Should only have DeleteColumn, NO RemoveConstraint actions
3272            assert_eq!(plan.actions.len(), 1);
3273            assert!(matches!(
3274                &plan.actions[0],
3275                MigrationAction::DeleteColumn { table, column }
3276                if table == "project" && column == "template_id"
3277            ));
3278
3279            // Explicitly verify no RemoveConstraint
3280            let has_remove_constraint = plan
3281                .actions
3282                .iter()
3283                .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3284            assert!(
3285                !has_remove_constraint,
3286                "Should NOT have RemoveConstraint when column is deleted"
3287            );
3288        }
3289
3290        #[test]
3291        fn keep_remove_constraint_when_only_some_columns_deleted() {
3292            // If a composite constraint has some columns remaining, RemoveConstraint is needed
3293            let from = vec![table(
3294                "orders",
3295                vec![
3296                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3297                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3298                    col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3299                ],
3300                vec![idx(
3301                    "ix_orders__user_product",
3302                    vec!["user_id", "product_id"],
3303                )],
3304            )];
3305
3306            let to = vec![table(
3307                "orders",
3308                vec![
3309                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3310                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3311                    // product_id is deleted, but user_id remains
3312                ],
3313                vec![],
3314            )];
3315
3316            let plan = diff_schemas(&from, &to).unwrap();
3317
3318            // Should have both DeleteColumn AND RemoveConstraint
3319            // (because user_id is still there, the composite index needs explicit removal)
3320            let has_delete_column = plan.actions.iter().any(|a| {
3321                matches!(
3322                    a,
3323                    MigrationAction::DeleteColumn { table, column }
3324                    if table == "orders" && column == "product_id"
3325                )
3326            });
3327            assert!(has_delete_column, "Should have DeleteColumn for product_id");
3328
3329            let has_remove_constraint = plan.actions.iter().any(|a| {
3330                matches!(
3331                    a,
3332                    MigrationAction::RemoveConstraint { table, .. }
3333                    if table == "orders"
3334                )
3335            });
3336            assert!(
3337                has_remove_constraint,
3338                "Should have RemoveConstraint for composite index when only some columns deleted"
3339            );
3340        }
3341
3342        #[test]
3343        fn skip_remove_constraint_when_all_composite_columns_deleted() {
3344            // If ALL columns of a composite constraint are deleted, skip RemoveConstraint
3345            let from = vec![table(
3346                "orders",
3347                vec![
3348                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3349                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3350                    col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3351                ],
3352                vec![idx(
3353                    "ix_orders__user_product",
3354                    vec!["user_id", "product_id"],
3355                )],
3356            )];
3357
3358            let to = vec![table(
3359                "orders",
3360                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3361                vec![],
3362            )];
3363
3364            let plan = diff_schemas(&from, &to).unwrap();
3365
3366            // Should only have DeleteColumn actions, no RemoveConstraint
3367            let delete_columns: Vec<_> = plan
3368                .actions
3369                .iter()
3370                .filter(|a| matches!(a, MigrationAction::DeleteColumn { .. }))
3371                .collect();
3372            assert_eq!(
3373                delete_columns.len(),
3374                2,
3375                "Should have 2 DeleteColumn actions"
3376            );
3377
3378            let has_remove_constraint = plan
3379                .actions
3380                .iter()
3381                .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3382            assert!(
3383                !has_remove_constraint,
3384                "Should NOT have RemoveConstraint when all composite columns deleted"
3385            );
3386        }
3387
3388        #[test]
3389        fn keep_remove_constraint_when_no_columns_deleted() {
3390            // Normal case: constraint removed but columns remain
3391            let from = vec![table(
3392                "users",
3393                vec![
3394                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3395                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3396                ],
3397                vec![idx("ix_users__email", vec!["email"])],
3398            )];
3399
3400            let to = vec![table(
3401                "users",
3402                vec![
3403                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3404                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3405                ],
3406                vec![], // Index removed but column remains
3407            )];
3408
3409            let plan = diff_schemas(&from, &to).unwrap();
3410
3411            assert_eq!(plan.actions.len(), 1);
3412            assert!(matches!(
3413                &plan.actions[0],
3414                MigrationAction::RemoveConstraint { table, .. }
3415                if table == "users"
3416            ));
3417        }
3418    }
3419}