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!(has_remove, "Should have RemoveConstraint for old composite PK");
1819
1820            let has_add = plan.actions.iter().any(|a| {
1821                matches!(
1822                    a,
1823                    MigrationAction::AddConstraint {
1824                        table,
1825                        constraint: TableConstraint::PrimaryKey { columns, .. }
1826                    } if table == "users" && columns == &vec!["id".to_string()]
1827                )
1828            });
1829            assert!(has_add, "Should have AddConstraint for new single-column PK");
1830        }
1831
1832        #[test]
1833        fn change_pk_columns_entirely() {
1834            // Primary key: [id] -> [uuid]
1835            let from = vec![table(
1836                "users",
1837                vec![
1838                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1839                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1840                ],
1841                vec![pk(vec!["id"])],
1842            )];
1843
1844            let to = vec![table(
1845                "users",
1846                vec![
1847                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1848                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1849                ],
1850                vec![pk(vec!["uuid"])],
1851            )];
1852
1853            let plan = diff_schemas(&from, &to).unwrap();
1854
1855            assert_eq!(plan.actions.len(), 2);
1856
1857            let has_remove = plan.actions.iter().any(|a| {
1858                matches!(
1859                    a,
1860                    MigrationAction::RemoveConstraint {
1861                        table,
1862                        constraint: TableConstraint::PrimaryKey { columns, .. }
1863                    } if table == "users" && columns == &vec!["id".to_string()]
1864                )
1865            });
1866            assert!(has_remove, "Should have RemoveConstraint for old PK");
1867
1868            let has_add = plan.actions.iter().any(|a| {
1869                matches!(
1870                    a,
1871                    MigrationAction::AddConstraint {
1872                        table,
1873                        constraint: TableConstraint::PrimaryKey { columns, .. }
1874                    } if table == "users" && columns == &vec!["uuid".to_string()]
1875                )
1876            });
1877            assert!(has_add, "Should have AddConstraint for new PK");
1878        }
1879
1880        #[test]
1881        fn add_multiple_columns_to_composite_pk() {
1882            // Primary key: [id] -> [id, tenant_id, region_id]
1883            let from = vec![table(
1884                "users",
1885                vec![
1886                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1887                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1888                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1889                ],
1890                vec![pk(vec!["id"])],
1891            )];
1892
1893            let to = vec![table(
1894                "users",
1895                vec![
1896                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1897                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1898                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1899                ],
1900                vec![pk(vec!["id", "tenant_id", "region_id"])],
1901            )];
1902
1903            let plan = diff_schemas(&from, &to).unwrap();
1904
1905            assert_eq!(plan.actions.len(), 2);
1906
1907            let has_remove = plan.actions.iter().any(|a| {
1908                matches!(
1909                    a,
1910                    MigrationAction::RemoveConstraint {
1911                        table,
1912                        constraint: TableConstraint::PrimaryKey { columns, .. }
1913                    } if table == "users" && columns == &vec!["id".to_string()]
1914                )
1915            });
1916            assert!(has_remove, "Should have RemoveConstraint for old single-column PK");
1917
1918            let has_add = plan.actions.iter().any(|a| {
1919                matches!(
1920                    a,
1921                    MigrationAction::AddConstraint {
1922                        table,
1923                        constraint: TableConstraint::PrimaryKey { columns, .. }
1924                    } if table == "users" && columns == &vec![
1925                        "id".to_string(),
1926                        "tenant_id".to_string(),
1927                        "region_id".to_string()
1928                    ]
1929                )
1930            });
1931            assert!(has_add, "Should have AddConstraint for new 3-column composite PK");
1932        }
1933
1934        #[test]
1935        fn remove_multiple_columns_from_composite_pk() {
1936            // Primary key: [id, tenant_id, region_id] -> [id]
1937            let from = vec![table(
1938                "users",
1939                vec![
1940                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1941                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1942                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1943                ],
1944                vec![pk(vec!["id", "tenant_id", "region_id"])],
1945            )];
1946
1947            let to = vec![table(
1948                "users",
1949                vec![
1950                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1951                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1952                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1953                ],
1954                vec![pk(vec!["id"])],
1955            )];
1956
1957            let plan = diff_schemas(&from, &to).unwrap();
1958
1959            assert_eq!(plan.actions.len(), 2);
1960
1961            let has_remove = plan.actions.iter().any(|a| {
1962                matches!(
1963                    a,
1964                    MigrationAction::RemoveConstraint {
1965                        table,
1966                        constraint: TableConstraint::PrimaryKey { columns, .. }
1967                    } if table == "users" && columns == &vec![
1968                        "id".to_string(),
1969                        "tenant_id".to_string(),
1970                        "region_id".to_string()
1971                    ]
1972                )
1973            });
1974            assert!(has_remove, "Should have RemoveConstraint for old 3-column composite PK");
1975
1976            let has_add = plan.actions.iter().any(|a| {
1977                matches!(
1978                    a,
1979                    MigrationAction::AddConstraint {
1980                        table,
1981                        constraint: TableConstraint::PrimaryKey { columns, .. }
1982                    } if table == "users" && columns == &vec!["id".to_string()]
1983                )
1984            });
1985            assert!(has_add, "Should have AddConstraint for new single-column PK");
1986        }
1987
1988        #[test]
1989        fn change_composite_pk_columns_partially() {
1990            // Primary key: [id, tenant_id] -> [id, region_id]
1991            // One column kept, one removed, one added
1992            let from = vec![table(
1993                "users",
1994                vec![
1995                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1996                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1997                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1998                ],
1999                vec![pk(vec!["id", "tenant_id"])],
2000            )];
2001
2002            let to = vec![table(
2003                "users",
2004                vec![
2005                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2006                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2007                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2008                ],
2009                vec![pk(vec!["id", "region_id"])],
2010            )];
2011
2012            let plan = diff_schemas(&from, &to).unwrap();
2013
2014            assert_eq!(plan.actions.len(), 2);
2015
2016            let has_remove = plan.actions.iter().any(|a| {
2017                matches!(
2018                    a,
2019                    MigrationAction::RemoveConstraint {
2020                        table,
2021                        constraint: TableConstraint::PrimaryKey { columns, .. }
2022                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2023                )
2024            });
2025            assert!(has_remove, "Should have RemoveConstraint for old PK with tenant_id");
2026
2027            let has_add = plan.actions.iter().any(|a| {
2028                matches!(
2029                    a,
2030                    MigrationAction::AddConstraint {
2031                        table,
2032                        constraint: TableConstraint::PrimaryKey { columns, .. }
2033                    } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2034                )
2035            });
2036            assert!(has_add, "Should have AddConstraint for new PK with region_id");
2037        }
2038    }
2039
2040    mod default_changes {
2041        use super::*;
2042
2043        fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2044            ColumnDef {
2045                name: name.to_string(),
2046                r#type: ty,
2047                nullable: true,
2048                default: default.map(|s| s.into()),
2049                comment: None,
2050                primary_key: None,
2051                unique: None,
2052                index: None,
2053                foreign_key: None,
2054            }
2055        }
2056
2057        #[test]
2058        fn add_default_value() {
2059            // Column: no default -> has default
2060            let from = vec![table(
2061                "users",
2062                vec![
2063                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2064                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2065                ],
2066                vec![],
2067            )];
2068
2069            let to = vec![table(
2070                "users",
2071                vec![
2072                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2073                    col_with_default(
2074                        "status",
2075                        ColumnType::Simple(SimpleColumnType::Text),
2076                        Some("'active'"),
2077                    ),
2078                ],
2079                vec![],
2080            )];
2081
2082            let plan = diff_schemas(&from, &to).unwrap();
2083
2084            assert_eq!(plan.actions.len(), 1);
2085            assert!(matches!(
2086                &plan.actions[0],
2087                MigrationAction::ModifyColumnDefault {
2088                    table,
2089                    column,
2090                    new_default: Some(default),
2091                } if table == "users" && column == "status" && default == "'active'"
2092            ));
2093        }
2094
2095        #[test]
2096        fn remove_default_value() {
2097            // Column: has default -> no default
2098            let from = vec![table(
2099                "users",
2100                vec![
2101                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2102                    col_with_default(
2103                        "status",
2104                        ColumnType::Simple(SimpleColumnType::Text),
2105                        Some("'active'"),
2106                    ),
2107                ],
2108                vec![],
2109            )];
2110
2111            let to = vec![table(
2112                "users",
2113                vec![
2114                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2115                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2116                ],
2117                vec![],
2118            )];
2119
2120            let plan = diff_schemas(&from, &to).unwrap();
2121
2122            assert_eq!(plan.actions.len(), 1);
2123            assert!(matches!(
2124                &plan.actions[0],
2125                MigrationAction::ModifyColumnDefault {
2126                    table,
2127                    column,
2128                    new_default: None,
2129                } if table == "users" && column == "status"
2130            ));
2131        }
2132
2133        #[test]
2134        fn change_default_value() {
2135            // Column: 'active' -> 'pending'
2136            let from = vec![table(
2137                "users",
2138                vec![
2139                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2140                    col_with_default(
2141                        "status",
2142                        ColumnType::Simple(SimpleColumnType::Text),
2143                        Some("'active'"),
2144                    ),
2145                ],
2146                vec![],
2147            )];
2148
2149            let to = vec![table(
2150                "users",
2151                vec![
2152                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2153                    col_with_default(
2154                        "status",
2155                        ColumnType::Simple(SimpleColumnType::Text),
2156                        Some("'pending'"),
2157                    ),
2158                ],
2159                vec![],
2160            )];
2161
2162            let plan = diff_schemas(&from, &to).unwrap();
2163
2164            assert_eq!(plan.actions.len(), 1);
2165            assert!(matches!(
2166                &plan.actions[0],
2167                MigrationAction::ModifyColumnDefault {
2168                    table,
2169                    column,
2170                    new_default: Some(default),
2171                } if table == "users" && column == "status" && default == "'pending'"
2172            ));
2173        }
2174
2175        #[test]
2176        fn no_change_same_default() {
2177            // Column: same default -> no action
2178            let from = vec![table(
2179                "users",
2180                vec![
2181                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2182                    col_with_default(
2183                        "status",
2184                        ColumnType::Simple(SimpleColumnType::Text),
2185                        Some("'active'"),
2186                    ),
2187                ],
2188                vec![],
2189            )];
2190
2191            let to = vec![table(
2192                "users",
2193                vec![
2194                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2195                    col_with_default(
2196                        "status",
2197                        ColumnType::Simple(SimpleColumnType::Text),
2198                        Some("'active'"),
2199                    ),
2200                ],
2201                vec![],
2202            )];
2203
2204            let plan = diff_schemas(&from, &to).unwrap();
2205
2206            assert!(plan.actions.is_empty());
2207        }
2208
2209        #[test]
2210        fn multiple_columns_default_changes() {
2211            // Multiple columns with default changes
2212            let from = vec![table(
2213                "users",
2214                vec![
2215                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2216                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2217                    col_with_default(
2218                        "role",
2219                        ColumnType::Simple(SimpleColumnType::Text),
2220                        Some("'user'"),
2221                    ),
2222                    col_with_default(
2223                        "active",
2224                        ColumnType::Simple(SimpleColumnType::Boolean),
2225                        Some("true"),
2226                    ),
2227                ],
2228                vec![],
2229            )];
2230
2231            let to = vec![table(
2232                "users",
2233                vec![
2234                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2235                    col_with_default(
2236                        "status",
2237                        ColumnType::Simple(SimpleColumnType::Text),
2238                        Some("'pending'"),
2239                    ), // None -> 'pending'
2240                    col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), // 'user' -> None
2241                    col_with_default(
2242                        "active",
2243                        ColumnType::Simple(SimpleColumnType::Boolean),
2244                        Some("true"),
2245                    ), // no change
2246                ],
2247                vec![],
2248            )];
2249
2250            let plan = diff_schemas(&from, &to).unwrap();
2251
2252            assert_eq!(plan.actions.len(), 2);
2253
2254            let has_status_change = plan.actions.iter().any(|a| {
2255                matches!(
2256                    a,
2257                    MigrationAction::ModifyColumnDefault {
2258                        table,
2259                        column,
2260                        new_default: Some(default),
2261                    } if table == "users" && column == "status" && default == "'pending'"
2262                )
2263            });
2264            assert!(has_status_change, "Should detect status default added");
2265
2266            let has_role_change = plan.actions.iter().any(|a| {
2267                matches!(
2268                    a,
2269                    MigrationAction::ModifyColumnDefault {
2270                        table,
2271                        column,
2272                        new_default: None,
2273                    } if table == "users" && column == "role"
2274                )
2275            });
2276            assert!(has_role_change, "Should detect role default removed");
2277        }
2278
2279        #[test]
2280        fn default_change_with_type_change() {
2281            // Column changing both type and default
2282            let from = vec![table(
2283                "users",
2284                vec![
2285                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2286                    col_with_default(
2287                        "count",
2288                        ColumnType::Simple(SimpleColumnType::Integer),
2289                        Some("0"),
2290                    ),
2291                ],
2292                vec![],
2293            )];
2294
2295            let to = vec![table(
2296                "users",
2297                vec![
2298                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2299                    col_with_default(
2300                        "count",
2301                        ColumnType::Simple(SimpleColumnType::Text),
2302                        Some("'0'"),
2303                    ),
2304                ],
2305                vec![],
2306            )];
2307
2308            let plan = diff_schemas(&from, &to).unwrap();
2309
2310            // Should generate both ModifyColumnType and ModifyColumnDefault
2311            assert_eq!(plan.actions.len(), 2);
2312
2313            let has_type_change = plan.actions.iter().any(|a| {
2314                matches!(
2315                    a,
2316                    MigrationAction::ModifyColumnType { table, column, .. }
2317                    if table == "users" && column == "count"
2318                )
2319            });
2320            assert!(has_type_change, "Should detect type change");
2321
2322            let has_default_change = plan.actions.iter().any(|a| {
2323                matches!(
2324                    a,
2325                    MigrationAction::ModifyColumnDefault {
2326                        table,
2327                        column,
2328                        new_default: Some(default),
2329                    } if table == "users" && column == "count" && default == "'0'"
2330                )
2331            });
2332            assert!(has_default_change, "Should detect default change");
2333        }
2334    }
2335
2336    mod comment_changes {
2337        use super::*;
2338
2339        fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2340            ColumnDef {
2341                name: name.to_string(),
2342                r#type: ty,
2343                nullable: true,
2344                default: None,
2345                comment: comment.map(|s| s.to_string()),
2346                primary_key: None,
2347                unique: None,
2348                index: None,
2349                foreign_key: None,
2350            }
2351        }
2352
2353        #[test]
2354        fn add_comment() {
2355            // Column: no comment -> has comment
2356            let from = vec![table(
2357                "users",
2358                vec![
2359                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2360                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2361                ],
2362                vec![],
2363            )];
2364
2365            let to = vec![table(
2366                "users",
2367                vec![
2368                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2369                    col_with_comment(
2370                        "email",
2371                        ColumnType::Simple(SimpleColumnType::Text),
2372                        Some("User's email address"),
2373                    ),
2374                ],
2375                vec![],
2376            )];
2377
2378            let plan = diff_schemas(&from, &to).unwrap();
2379
2380            assert_eq!(plan.actions.len(), 1);
2381            assert!(matches!(
2382                &plan.actions[0],
2383                MigrationAction::ModifyColumnComment {
2384                    table,
2385                    column,
2386                    new_comment: Some(comment),
2387                } if table == "users" && column == "email" && comment == "User's email address"
2388            ));
2389        }
2390
2391        #[test]
2392        fn remove_comment() {
2393            // Column: has comment -> no comment
2394            let from = vec![table(
2395                "users",
2396                vec![
2397                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2398                    col_with_comment(
2399                        "email",
2400                        ColumnType::Simple(SimpleColumnType::Text),
2401                        Some("User's email address"),
2402                    ),
2403                ],
2404                vec![],
2405            )];
2406
2407            let to = vec![table(
2408                "users",
2409                vec![
2410                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2411                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2412                ],
2413                vec![],
2414            )];
2415
2416            let plan = diff_schemas(&from, &to).unwrap();
2417
2418            assert_eq!(plan.actions.len(), 1);
2419            assert!(matches!(
2420                &plan.actions[0],
2421                MigrationAction::ModifyColumnComment {
2422                    table,
2423                    column,
2424                    new_comment: None,
2425                } if table == "users" && column == "email"
2426            ));
2427        }
2428
2429        #[test]
2430        fn change_comment() {
2431            // Column: 'old comment' -> 'new comment'
2432            let from = vec![table(
2433                "users",
2434                vec![
2435                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2436                    col_with_comment(
2437                        "email",
2438                        ColumnType::Simple(SimpleColumnType::Text),
2439                        Some("Old comment"),
2440                    ),
2441                ],
2442                vec![],
2443            )];
2444
2445            let to = vec![table(
2446                "users",
2447                vec![
2448                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2449                    col_with_comment(
2450                        "email",
2451                        ColumnType::Simple(SimpleColumnType::Text),
2452                        Some("New comment"),
2453                    ),
2454                ],
2455                vec![],
2456            )];
2457
2458            let plan = diff_schemas(&from, &to).unwrap();
2459
2460            assert_eq!(plan.actions.len(), 1);
2461            assert!(matches!(
2462                &plan.actions[0],
2463                MigrationAction::ModifyColumnComment {
2464                    table,
2465                    column,
2466                    new_comment: Some(comment),
2467                } if table == "users" && column == "email" && comment == "New comment"
2468            ));
2469        }
2470
2471        #[test]
2472        fn no_change_same_comment() {
2473            // Column: same comment -> no action
2474            let from = vec![table(
2475                "users",
2476                vec![
2477                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2478                    col_with_comment(
2479                        "email",
2480                        ColumnType::Simple(SimpleColumnType::Text),
2481                        Some("Same comment"),
2482                    ),
2483                ],
2484                vec![],
2485            )];
2486
2487            let to = vec![table(
2488                "users",
2489                vec![
2490                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2491                    col_with_comment(
2492                        "email",
2493                        ColumnType::Simple(SimpleColumnType::Text),
2494                        Some("Same comment"),
2495                    ),
2496                ],
2497                vec![],
2498            )];
2499
2500            let plan = diff_schemas(&from, &to).unwrap();
2501
2502            assert!(plan.actions.is_empty());
2503        }
2504
2505        #[test]
2506        fn multiple_columns_comment_changes() {
2507            // Multiple columns with comment changes
2508            let from = vec![table(
2509                "users",
2510                vec![
2511                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2512                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2513                    col_with_comment(
2514                        "name",
2515                        ColumnType::Simple(SimpleColumnType::Text),
2516                        Some("User name"),
2517                    ),
2518                    col_with_comment(
2519                        "phone",
2520                        ColumnType::Simple(SimpleColumnType::Text),
2521                        Some("Phone number"),
2522                    ),
2523                ],
2524                vec![],
2525            )];
2526
2527            let to = vec![table(
2528                "users",
2529                vec![
2530                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2531                    col_with_comment(
2532                        "email",
2533                        ColumnType::Simple(SimpleColumnType::Text),
2534                        Some("Email address"),
2535                    ), // None -> "Email address"
2536                    col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), // "User name" -> None
2537                    col_with_comment(
2538                        "phone",
2539                        ColumnType::Simple(SimpleColumnType::Text),
2540                        Some("Phone number"),
2541                    ), // no change
2542                ],
2543                vec![],
2544            )];
2545
2546            let plan = diff_schemas(&from, &to).unwrap();
2547
2548            assert_eq!(plan.actions.len(), 2);
2549
2550            let has_email_change = plan.actions.iter().any(|a| {
2551                matches!(
2552                    a,
2553                    MigrationAction::ModifyColumnComment {
2554                        table,
2555                        column,
2556                        new_comment: Some(comment),
2557                    } if table == "users" && column == "email" && comment == "Email address"
2558                )
2559            });
2560            assert!(has_email_change, "Should detect email comment added");
2561
2562            let has_name_change = plan.actions.iter().any(|a| {
2563                matches!(
2564                    a,
2565                    MigrationAction::ModifyColumnComment {
2566                        table,
2567                        column,
2568                        new_comment: None,
2569                    } if table == "users" && column == "name"
2570                )
2571            });
2572            assert!(has_name_change, "Should detect name comment removed");
2573        }
2574
2575        #[test]
2576        fn comment_change_with_nullable_change() {
2577            // Column changing both nullable and comment
2578            let from = vec![table(
2579                "users",
2580                vec![
2581                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2582                    {
2583                        let mut c =
2584                            col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
2585                        c.nullable = true;
2586                        c
2587                    },
2588                ],
2589                vec![],
2590            )];
2591
2592            let to = vec![table(
2593                "users",
2594                vec![
2595                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2596                    {
2597                        let mut c = col_with_comment(
2598                            "email",
2599                            ColumnType::Simple(SimpleColumnType::Text),
2600                            Some("Required email"),
2601                        );
2602                        c.nullable = false;
2603                        c
2604                    },
2605                ],
2606                vec![],
2607            )];
2608
2609            let plan = diff_schemas(&from, &to).unwrap();
2610
2611            // Should generate both ModifyColumnNullable and ModifyColumnComment
2612            assert_eq!(plan.actions.len(), 2);
2613
2614            let has_nullable_change = plan.actions.iter().any(|a| {
2615                matches!(
2616                    a,
2617                    MigrationAction::ModifyColumnNullable {
2618                        table,
2619                        column,
2620                        nullable: false,
2621                        ..
2622                    } if table == "users" && column == "email"
2623                )
2624            });
2625            assert!(has_nullable_change, "Should detect nullable change");
2626
2627            let has_comment_change = plan.actions.iter().any(|a| {
2628                matches!(
2629                    a,
2630                    MigrationAction::ModifyColumnComment {
2631                        table,
2632                        column,
2633                        new_comment: Some(comment),
2634                    } if table == "users" && column == "email" && comment == "Required email"
2635                )
2636            });
2637            assert!(has_comment_change, "Should detect comment change");
2638        }
2639    }
2640
2641    mod nullable_changes {
2642        use super::*;
2643
2644        fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
2645            ColumnDef {
2646                name: name.to_string(),
2647                r#type: ty,
2648                nullable,
2649                default: None,
2650                comment: None,
2651                primary_key: None,
2652                unique: None,
2653                index: None,
2654                foreign_key: None,
2655            }
2656        }
2657
2658        #[test]
2659        fn column_nullable_to_non_nullable() {
2660            // Column: nullable -> non-nullable
2661            let from = vec![table(
2662                "users",
2663                vec![
2664                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2665                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2666                ],
2667                vec![],
2668            )];
2669
2670            let to = vec![table(
2671                "users",
2672                vec![
2673                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2674                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2675                ],
2676                vec![],
2677            )];
2678
2679            let plan = diff_schemas(&from, &to).unwrap();
2680
2681            assert_eq!(plan.actions.len(), 1);
2682            assert!(matches!(
2683                &plan.actions[0],
2684                MigrationAction::ModifyColumnNullable {
2685                    table,
2686                    column,
2687                    nullable: false,
2688                    fill_with: None,
2689                } if table == "users" && column == "email"
2690            ));
2691        }
2692
2693        #[test]
2694        fn column_non_nullable_to_nullable() {
2695            // Column: non-nullable -> nullable
2696            let from = vec![table(
2697                "users",
2698                vec![
2699                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2700                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2701                ],
2702                vec![],
2703            )];
2704
2705            let to = vec![table(
2706                "users",
2707                vec![
2708                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2709                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2710                ],
2711                vec![],
2712            )];
2713
2714            let plan = diff_schemas(&from, &to).unwrap();
2715
2716            assert_eq!(plan.actions.len(), 1);
2717            assert!(matches!(
2718                &plan.actions[0],
2719                MigrationAction::ModifyColumnNullable {
2720                    table,
2721                    column,
2722                    nullable: true,
2723                    fill_with: None,
2724                } if table == "users" && column == "email"
2725            ));
2726        }
2727
2728        #[test]
2729        fn multiple_columns_nullable_changes() {
2730            // Multiple columns changing nullability at once
2731            let from = vec![table(
2732                "users",
2733                vec![
2734                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2735                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2736                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
2737                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
2738                ],
2739                vec![],
2740            )];
2741
2742            let to = vec![table(
2743                "users",
2744                vec![
2745                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2746                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), // nullable -> non-nullable
2747                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true),   // non-nullable -> nullable
2748                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),  // no change
2749                ],
2750                vec![],
2751            )];
2752
2753            let plan = diff_schemas(&from, &to).unwrap();
2754
2755            assert_eq!(plan.actions.len(), 2);
2756
2757            let has_email_change = plan.actions.iter().any(|a| {
2758                matches!(
2759                    a,
2760                    MigrationAction::ModifyColumnNullable {
2761                        table,
2762                        column,
2763                        nullable: false,
2764                        ..
2765                    } if table == "users" && column == "email"
2766                )
2767            });
2768            assert!(has_email_change, "Should detect email nullable -> non-nullable");
2769
2770            let has_name_change = plan.actions.iter().any(|a| {
2771                matches!(
2772                    a,
2773                    MigrationAction::ModifyColumnNullable {
2774                        table,
2775                        column,
2776                        nullable: true,
2777                        ..
2778                    } if table == "users" && column == "name"
2779                )
2780            });
2781            assert!(has_name_change, "Should detect name non-nullable -> nullable");
2782        }
2783
2784        #[test]
2785        fn nullable_change_with_type_change() {
2786            // Column changing both type and nullability
2787            let from = vec![table(
2788                "users",
2789                vec![
2790                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2791                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
2792                ],
2793                vec![],
2794            )];
2795
2796            let to = vec![table(
2797                "users",
2798                vec![
2799                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2800                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
2801                ],
2802                vec![],
2803            )];
2804
2805            let plan = diff_schemas(&from, &to).unwrap();
2806
2807            // Should generate both ModifyColumnType and ModifyColumnNullable
2808            assert_eq!(plan.actions.len(), 2);
2809
2810            let has_type_change = plan.actions.iter().any(|a| {
2811                matches!(
2812                    a,
2813                    MigrationAction::ModifyColumnType { table, column, .. }
2814                    if table == "users" && column == "age"
2815                )
2816            });
2817            assert!(has_type_change, "Should detect type change");
2818
2819            let has_nullable_change = plan.actions.iter().any(|a| {
2820                matches!(
2821                    a,
2822                    MigrationAction::ModifyColumnNullable {
2823                        table,
2824                        column,
2825                        nullable: false,
2826                        ..
2827                    } if table == "users" && column == "age"
2828                )
2829            });
2830            assert!(has_nullable_change, "Should detect nullable change");
2831        }
2832    }
2833
2834    mod diff_tables {
2835        use insta::assert_debug_snapshot;
2836
2837        use super::*;
2838
2839        #[test]
2840        fn create_table_with_inline_index() {
2841            let base = [table(
2842                "users",
2843                vec![
2844                    ColumnDef {
2845                        name: "id".to_string(),
2846                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2847                        nullable: false,
2848                        default: None,
2849                        comment: None,
2850                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2851                        unique: None,
2852                        index: Some(StrOrBoolOrArray::Bool(false)),
2853                        foreign_key: None,
2854                    },
2855                    ColumnDef {
2856                        name: "name".to_string(),
2857                        r#type: ColumnType::Simple(SimpleColumnType::Text),
2858                        nullable: true,
2859                        default: None,
2860                        comment: None,
2861                        primary_key: None,
2862                        unique: Some(StrOrBoolOrArray::Bool(true)),
2863                        index: Some(StrOrBoolOrArray::Bool(true)),
2864                        foreign_key: None,
2865                    },
2866                ],
2867                vec![],
2868            )];
2869            let plan = diff_schemas(&[], &base).unwrap();
2870
2871            assert_eq!(plan.actions.len(), 1);
2872            assert_debug_snapshot!(plan.actions);
2873
2874            let plan = diff_schemas(
2875                &base,
2876                &[table(
2877                    "users",
2878                    vec![
2879                        ColumnDef {
2880                            name: "id".to_string(),
2881                            r#type: ColumnType::Simple(SimpleColumnType::Integer),
2882                            nullable: false,
2883                            default: None,
2884                            comment: None,
2885                            primary_key: Some(PrimaryKeySyntax::Bool(true)),
2886                            unique: None,
2887                            index: Some(StrOrBoolOrArray::Bool(false)),
2888                            foreign_key: None,
2889                        },
2890                        ColumnDef {
2891                            name: "name".to_string(),
2892                            r#type: ColumnType::Simple(SimpleColumnType::Text),
2893                            nullable: true,
2894                            default: None,
2895                            comment: None,
2896                            primary_key: None,
2897                            unique: Some(StrOrBoolOrArray::Bool(true)),
2898                            index: Some(StrOrBoolOrArray::Bool(false)),
2899                            foreign_key: None,
2900                        },
2901                    ],
2902                    vec![],
2903                )],
2904            )
2905            .unwrap();
2906
2907            assert_eq!(plan.actions.len(), 1);
2908            assert_debug_snapshot!(plan.actions);
2909        }
2910
2911        #[rstest]
2912        #[case(
2913            "add_index",
2914            vec![table(
2915                "users",
2916                vec![
2917                    ColumnDef {
2918                        name: "id".to_string(),
2919                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2920                        nullable: false,
2921                        default: None,
2922                        comment: None,
2923                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2924                        unique: None,
2925                        index: None,
2926                        foreign_key: None,
2927                    },
2928                ],
2929                vec![],
2930            )],
2931            vec![table(
2932                "users",
2933                vec![
2934                    ColumnDef {
2935                        name: "id".to_string(),
2936                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2937                        nullable: false,
2938                        default: None,
2939                        comment: None,
2940                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2941                        unique: None,
2942                        index: Some(StrOrBoolOrArray::Bool(true)),
2943                        foreign_key: None,
2944                    },
2945                ],
2946                vec![],
2947            )],
2948        )]
2949        #[case(
2950            "remove_index",
2951            vec![table(
2952                "users",
2953                vec![
2954                    ColumnDef {
2955                        name: "id".to_string(),
2956                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2957                        nullable: false,
2958                        default: None,
2959                        comment: None,
2960                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2961                        unique: None,
2962                        index: Some(StrOrBoolOrArray::Bool(true)),
2963                        foreign_key: None,
2964                    },
2965                ],
2966                vec![],
2967            )],
2968            vec![table(
2969                "users",
2970                vec![
2971                    ColumnDef {
2972                        name: "id".to_string(),
2973                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2974                        nullable: false,
2975                        default: None,
2976                        comment: None,
2977                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2978                        unique: None,
2979                        index: Some(StrOrBoolOrArray::Bool(false)),
2980                        foreign_key: None,
2981                    },
2982                ],
2983                vec![],
2984            )],
2985        )]
2986        #[case(
2987            "add_named_index",
2988            vec![table(
2989                "users",
2990                vec![
2991                    ColumnDef {
2992                        name: "id".to_string(),
2993                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
2994                        nullable: false,
2995                        default: None,
2996                        comment: None,
2997                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
2998                        unique: None,
2999                        index: None,
3000                        foreign_key: None,
3001                    },
3002                ],
3003                vec![],
3004            )],
3005            vec![table(
3006                "users",
3007                vec![
3008                    ColumnDef {
3009                        name: "id".to_string(),
3010                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3011                        nullable: false,
3012                        default: None,
3013                        comment: None,
3014                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3015                        unique: None,
3016                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3017                        foreign_key: None,
3018                    },
3019                ],
3020                vec![],
3021            )],
3022        )]
3023        #[case(
3024            "remove_named_index",
3025            vec![table(
3026                "users",
3027                vec![
3028                    ColumnDef {
3029                        name: "id".to_string(),
3030                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3031                        nullable: false,
3032                        default: None,
3033                        comment: None,
3034                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3035                        unique: None,
3036                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3037                        foreign_key: None,
3038                    },
3039                ],
3040                vec![],
3041            )],
3042            vec![table(
3043                "users",
3044                vec![
3045                    ColumnDef {
3046                        name: "id".to_string(),
3047                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3048                        nullable: false,
3049                        default: None,
3050                        comment: None,
3051                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3052                        unique: None,
3053                        index: None,
3054                        foreign_key: None,
3055                    },
3056                ],
3057                vec![],
3058            )],
3059        )]
3060        fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3061            use insta::with_settings;
3062
3063            let plan = diff_schemas(&base, &to).unwrap();
3064            with_settings!({ snapshot_suffix => name }, {
3065                assert_debug_snapshot!(plan.actions);
3066            });
3067        }
3068    }
3069
3070    // Explicit coverage tests for lines that tarpaulin might miss in rstest
3071    mod coverage_explicit {
3072        use super::*;
3073
3074        #[test]
3075        fn delete_column_explicit() {
3076            // Covers lines 292-294: DeleteColumn action inside modified table loop
3077            let from = vec![table(
3078                "users",
3079                vec![
3080                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3081                    col("name", ColumnType::Simple(SimpleColumnType::Text)),
3082                ],
3083                vec![],
3084            )];
3085
3086            let to = vec![table(
3087                "users",
3088                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3089                vec![],
3090            )];
3091
3092            let plan = diff_schemas(&from, &to).unwrap();
3093            assert_eq!(plan.actions.len(), 1);
3094            assert!(matches!(
3095                &plan.actions[0],
3096                MigrationAction::DeleteColumn { table, column }
3097                if table == "users" && column == "name"
3098            ));
3099        }
3100
3101        #[test]
3102        fn add_column_explicit() {
3103            // Covers lines 359-362: AddColumn action inside modified table loop
3104            let from = vec![table(
3105                "users",
3106                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3107                vec![],
3108            )];
3109
3110            let to = vec![table(
3111                "users",
3112                vec![
3113                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3114                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3115                ],
3116                vec![],
3117            )];
3118
3119            let plan = diff_schemas(&from, &to).unwrap();
3120            assert_eq!(plan.actions.len(), 1);
3121            assert!(matches!(
3122                &plan.actions[0],
3123                MigrationAction::AddColumn { table, column, .. }
3124                if table == "users" && column.name == "email"
3125            ));
3126        }
3127
3128        #[test]
3129        fn remove_constraint_explicit() {
3130            // Covers lines 370-372: RemoveConstraint action inside modified table loop
3131            let from = vec![table(
3132                "users",
3133                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3134                vec![idx("idx_users_id", vec!["id"])],
3135            )];
3136
3137            let to = vec![table(
3138                "users",
3139                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
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::RemoveConstraint { table, constraint }
3148                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3149            ));
3150        }
3151
3152        #[test]
3153        fn add_constraint_explicit() {
3154            // Covers lines 378-380: AddConstraint action inside modified table loop
3155            let from = vec![table(
3156                "users",
3157                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3158                vec![],
3159            )];
3160
3161            let to = vec![table(
3162                "users",
3163                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3164                vec![idx("idx_users_id", vec!["id"])],
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::AddConstraint { table, constraint }
3172                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3173            ));
3174        }
3175    }
3176}