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