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