Skip to main content

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/// Compare two migration actions for sorting.
225/// Returns ordering where CreateTable comes first, then non-FK-ref actions, then FK-ref actions.
226fn compare_actions_for_create_order(
227    a: &MigrationAction,
228    b: &MigrationAction,
229    created_tables: &BTreeSet<String>,
230) -> std::cmp::Ordering {
231    let a_is_create = matches!(a, MigrationAction::CreateTable { .. });
232    let b_is_create = matches!(b, MigrationAction::CreateTable { .. });
233
234    // Check if action is AddConstraint with FK referencing a created table
235    let a_refs_created = if let MigrationAction::AddConstraint {
236        constraint: TableConstraint::ForeignKey { ref_table, .. },
237        ..
238    } = a
239    {
240        created_tables.contains(ref_table)
241    } else {
242        false
243    };
244    let b_refs_created = if let MigrationAction::AddConstraint {
245        constraint: TableConstraint::ForeignKey { ref_table, .. },
246        ..
247    } = b
248    {
249        created_tables.contains(ref_table)
250    } else {
251        false
252    };
253
254    // Order: CreateTable first, then non-referencing actions, then referencing AddConstraint last
255    match (a_is_create, b_is_create, a_refs_created, b_refs_created) {
256        // Both CreateTable - maintain original order
257        (true, true, _, _) => std::cmp::Ordering::Equal,
258        // a is CreateTable, b is not - a comes first
259        (true, false, _, _) => std::cmp::Ordering::Less,
260        // a is not CreateTable, b is - b comes first
261        (false, true, _, _) => std::cmp::Ordering::Greater,
262        // Neither is CreateTable
263        // If a refs created table and b doesn't, a comes after
264        (false, false, true, false) => std::cmp::Ordering::Greater,
265        // If b refs created table and a doesn't, b comes after
266        (false, false, false, true) => std::cmp::Ordering::Less,
267        // Both ref or both don't ref - maintain original order
268        (false, false, _, _) => std::cmp::Ordering::Equal,
269    }
270}
271
272/// Sort actions so that CreateTable actions come before AddConstraint actions
273/// that reference those newly created tables via foreign keys.
274fn sort_create_before_add_constraint(actions: &mut [MigrationAction]) {
275    // Collect names of tables being created
276    let created_tables: BTreeSet<String> = actions
277        .iter()
278        .filter_map(|a| {
279            if let MigrationAction::CreateTable { table, .. } = a {
280                Some(table.clone())
281            } else {
282                None
283            }
284        })
285        .collect();
286
287    if created_tables.is_empty() {
288        return;
289    }
290
291    actions.sort_by(|a, b| compare_actions_for_create_order(a, b, &created_tables));
292}
293
294/// Diff two schema snapshots into a migration plan.
295/// Schemas are normalized for comparison purposes, but the original (non-normalized)
296/// tables are used in migration actions to preserve inline constraint definitions.
297pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
298    let mut actions: Vec<MigrationAction> = Vec::new();
299
300    // Normalize both schemas for comparison (to ensure inline and table-level constraints are treated equally)
301    let from_normalized: Vec<TableDef> = from
302        .iter()
303        .map(|t| {
304            t.normalize().map_err(|e| {
305                PlannerError::TableValidation(format!(
306                    "Failed to normalize table '{}': {}",
307                    t.name, e
308                ))
309            })
310        })
311        .collect::<Result<Vec<_>, _>>()?;
312    let to_normalized: Vec<TableDef> = to
313        .iter()
314        .map(|t| {
315            t.normalize().map_err(|e| {
316                PlannerError::TableValidation(format!(
317                    "Failed to normalize table '{}': {}",
318                    t.name, e
319                ))
320            })
321        })
322        .collect::<Result<Vec<_>, _>>()?;
323
324    // Use BTreeMap for consistent ordering
325    // Normalized versions for comparison
326    let from_map: BTreeMap<_, _> = from_normalized
327        .iter()
328        .map(|t| (t.name.as_str(), t))
329        .collect();
330    let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
331
332    // Original (non-normalized) versions for migration storage
333    let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
334
335    // Drop tables that disappeared.
336    for name in from_map.keys() {
337        if !to_map.contains_key(name) {
338            actions.push(MigrationAction::DeleteTable {
339                table: name.to_string(),
340            });
341        }
342    }
343
344    // Update existing tables and their indexes/columns.
345    for (name, to_tbl) in &to_map {
346        if let Some(from_tbl) = from_map.get(name) {
347            // Columns - use BTreeMap for consistent ordering
348            let from_cols: BTreeMap<_, _> = from_tbl
349                .columns
350                .iter()
351                .map(|c| (c.name.as_str(), c))
352                .collect();
353            let to_cols: BTreeMap<_, _> = to_tbl
354                .columns
355                .iter()
356                .map(|c| (c.name.as_str(), c))
357                .collect();
358
359            // Deleted columns - collect the set of columns being deleted for this table
360            let deleted_columns: BTreeSet<&str> = from_cols
361                .keys()
362                .filter(|col| !to_cols.contains_key(*col))
363                .copied()
364                .collect();
365
366            for col in &deleted_columns {
367                actions.push(MigrationAction::DeleteColumn {
368                    table: name.to_string(),
369                    column: col.to_string(),
370                });
371            }
372
373            // Modified columns - type changes
374            for (col, to_def) in &to_cols {
375                if let Some(from_def) = from_cols.get(col)
376                    && from_def.r#type.requires_migration(&to_def.r#type)
377                {
378                    actions.push(MigrationAction::ModifyColumnType {
379                        table: name.to_string(),
380                        column: col.to_string(),
381                        new_type: to_def.r#type.clone(),
382                    });
383                }
384            }
385
386            // Modified columns - nullable changes
387            for (col, to_def) in &to_cols {
388                if let Some(from_def) = from_cols.get(col)
389                    && from_def.nullable != to_def.nullable
390                {
391                    actions.push(MigrationAction::ModifyColumnNullable {
392                        table: name.to_string(),
393                        column: col.to_string(),
394                        nullable: to_def.nullable,
395                        fill_with: None,
396                    });
397                }
398            }
399
400            // Modified columns - default value changes
401            for (col, to_def) in &to_cols {
402                if let Some(from_def) = from_cols.get(col) {
403                    let from_default = from_def.default.as_ref().map(|d| d.to_sql());
404                    let to_default = to_def.default.as_ref().map(|d| d.to_sql());
405                    if from_default != to_default {
406                        actions.push(MigrationAction::ModifyColumnDefault {
407                            table: name.to_string(),
408                            column: col.to_string(),
409                            new_default: to_default,
410                        });
411                    }
412                }
413            }
414
415            // Modified columns - comment changes
416            for (col, to_def) in &to_cols {
417                if let Some(from_def) = from_cols.get(col)
418                    && from_def.comment != to_def.comment
419                {
420                    actions.push(MigrationAction::ModifyColumnComment {
421                        table: name.to_string(),
422                        column: col.to_string(),
423                        new_comment: to_def.comment.clone(),
424                    });
425                }
426            }
427
428            // Added columns
429            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
430            // by normalize(), so they will be handled in the constraint diff below.
431            for (col, def) in &to_cols {
432                if !from_cols.contains_key(col) {
433                    actions.push(MigrationAction::AddColumn {
434                        table: name.to_string(),
435                        column: Box::new((*def).clone()),
436                        fill_with: None,
437                    });
438                }
439            }
440
441            // Constraints - compare and detect additions/removals (includes indexes)
442            // Skip RemoveConstraint for constraints where ALL columns are being deleted
443            // (the constraint will be automatically dropped when the column is dropped)
444            for from_constraint in &from_tbl.constraints {
445                if !to_tbl.constraints.contains(from_constraint) {
446                    // Get the columns referenced by this constraint
447                    let constraint_columns = from_constraint.columns();
448
449                    // Skip if ALL columns of the constraint are being deleted
450                    let all_columns_deleted = !constraint_columns.is_empty()
451                        && constraint_columns
452                            .iter()
453                            .all(|col| deleted_columns.contains(col.as_str()));
454
455                    if !all_columns_deleted {
456                        actions.push(MigrationAction::RemoveConstraint {
457                            table: name.to_string(),
458                            constraint: from_constraint.clone(),
459                        });
460                    }
461                }
462            }
463            for to_constraint in &to_tbl.constraints {
464                if !from_tbl.constraints.contains(to_constraint) {
465                    actions.push(MigrationAction::AddConstraint {
466                        table: name.to_string(),
467                        constraint: to_constraint.clone(),
468                    });
469                }
470            }
471        }
472    }
473
474    // Create new tables (and their indexes).
475    // Use original (non-normalized) tables to preserve inline constraint definitions.
476    // Collect new tables first, then topologically sort them by FK dependencies.
477    let new_tables: Vec<&TableDef> = to_map
478        .iter()
479        .filter(|(name, _)| !from_map.contains_key(*name))
480        .map(|(_, tbl)| *tbl)
481        .collect();
482
483    let sorted_new_tables = topological_sort_tables(&new_tables)?;
484
485    for tbl in sorted_new_tables {
486        // Get the original (non-normalized) table to preserve inline constraints
487        let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
488        actions.push(MigrationAction::CreateTable {
489            table: original_tbl.name.clone(),
490            columns: original_tbl.columns.clone(),
491            constraints: original_tbl.constraints.clone(),
492        });
493    }
494
495    // Sort DeleteTable actions so tables with FK dependencies are deleted first
496    sort_delete_tables(&mut actions, &from_map);
497
498    // Sort so CreateTable comes before AddConstraint that references the new table
499    sort_create_before_add_constraint(&mut actions);
500
501    Ok(MigrationPlan {
502        comment: None,
503        created_at: None,
504        version: 0,
505        actions,
506    })
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use rstest::rstest;
513    use vespertide_core::{
514        ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
515        schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
516    };
517
518    fn col(name: &str, ty: ColumnType) -> ColumnDef {
519        ColumnDef {
520            name: name.to_string(),
521            r#type: ty,
522            nullable: true,
523            default: None,
524            comment: None,
525            primary_key: None,
526            unique: None,
527            index: None,
528            foreign_key: None,
529        }
530    }
531
532    fn table(
533        name: &str,
534        columns: Vec<ColumnDef>,
535        constraints: Vec<vespertide_core::TableConstraint>,
536    ) -> TableDef {
537        TableDef {
538            name: name.to_string(),
539            description: None,
540            columns,
541            constraints,
542        }
543    }
544
545    fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
546        TableConstraint::Index {
547            name: Some(name.to_string()),
548            columns: columns.into_iter().map(|s| s.to_string()).collect(),
549        }
550    }
551
552    #[rstest]
553    #[case::add_column_and_index(
554        vec![table(
555            "users",
556            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
557            vec![],
558        )],
559        vec![table(
560            "users",
561            vec![
562                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
563                col("name", ColumnType::Simple(SimpleColumnType::Text)),
564            ],
565            vec![idx("ix_users__name", vec!["name"])],
566        )],
567        vec![
568            MigrationAction::AddColumn {
569                table: "users".into(),
570                column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
571                fill_with: None,
572            },
573            MigrationAction::AddConstraint {
574                table: "users".into(),
575                constraint: idx("ix_users__name", vec!["name"]),
576            },
577        ]
578    )]
579    #[case::drop_table(
580        vec![table(
581            "users",
582            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
583            vec![],
584        )],
585        vec![],
586        vec![MigrationAction::DeleteTable {
587            table: "users".into()
588        }]
589    )]
590    #[case::add_table_with_index(
591        vec![],
592        vec![table(
593            "users",
594            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
595            vec![idx("idx_users_id", vec!["id"])],
596        )],
597        vec![
598            MigrationAction::CreateTable {
599                table: "users".into(),
600                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
601                constraints: vec![idx("idx_users_id", vec!["id"])],
602            },
603        ]
604    )]
605    #[case::delete_column(
606        vec![table(
607            "users",
608            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
609            vec![],
610        )],
611        vec![table(
612            "users",
613            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
614            vec![],
615        )],
616        vec![MigrationAction::DeleteColumn {
617            table: "users".into(),
618            column: "name".into(),
619        }]
620    )]
621    #[case::modify_column_type(
622        vec![table(
623            "users",
624            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
625            vec![],
626        )],
627        vec![table(
628            "users",
629            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
630            vec![],
631        )],
632        vec![MigrationAction::ModifyColumnType {
633            table: "users".into(),
634            column: "id".into(),
635            new_type: ColumnType::Simple(SimpleColumnType::Text),
636        }]
637    )]
638    #[case::remove_index(
639        vec![table(
640            "users",
641            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
642            vec![idx("idx_users_id", vec!["id"])],
643        )],
644        vec![table(
645            "users",
646            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
647            vec![],
648        )],
649        vec![MigrationAction::RemoveConstraint {
650            table: "users".into(),
651            constraint: idx("idx_users_id", vec!["id"]),
652        }]
653    )]
654    #[case::add_index_existing_table(
655        vec![table(
656            "users",
657            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
658            vec![],
659        )],
660        vec![table(
661            "users",
662            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
663            vec![idx("idx_users_id", vec!["id"])],
664        )],
665        vec![MigrationAction::AddConstraint {
666            table: "users".into(),
667            constraint: idx("idx_users_id", vec!["id"]),
668        }]
669    )]
670    fn diff_schemas_detects_additions(
671        #[case] from_schema: Vec<TableDef>,
672        #[case] to_schema: Vec<TableDef>,
673        #[case] expected_actions: Vec<MigrationAction>,
674    ) {
675        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
676        assert_eq!(plan.actions, expected_actions);
677    }
678
679    // Tests for integer enum handling
680    mod integer_enum {
681        use super::*;
682        use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
683
684        #[test]
685        fn integer_enum_values_changed_no_migration() {
686            // Integer enum values changed - should NOT generate ModifyColumnType
687            let from = vec![table(
688                "orders",
689                vec![col(
690                    "status",
691                    ColumnType::Complex(ComplexColumnType::Enum {
692                        name: "order_status".into(),
693                        values: EnumValues::Integer(vec![
694                            NumValue {
695                                name: "Pending".into(),
696                                value: 0,
697                            },
698                            NumValue {
699                                name: "Shipped".into(),
700                                value: 1,
701                            },
702                        ]),
703                    }),
704                )],
705                vec![],
706            )];
707
708            let to = vec![table(
709                "orders",
710                vec![col(
711                    "status",
712                    ColumnType::Complex(ComplexColumnType::Enum {
713                        name: "order_status".into(),
714                        values: EnumValues::Integer(vec![
715                            NumValue {
716                                name: "Pending".into(),
717                                value: 0,
718                            },
719                            NumValue {
720                                name: "Shipped".into(),
721                                value: 1,
722                            },
723                            NumValue {
724                                name: "Delivered".into(),
725                                value: 2,
726                            },
727                            NumValue {
728                                name: "Cancelled".into(),
729                                value: 100,
730                            },
731                        ]),
732                    }),
733                )],
734                vec![],
735            )];
736
737            let plan = diff_schemas(&from, &to).unwrap();
738            assert!(
739                plan.actions.is_empty(),
740                "Expected no actions, got: {:?}",
741                plan.actions
742            );
743        }
744
745        #[test]
746        fn string_enum_values_changed_requires_migration() {
747            // String enum values changed - SHOULD generate ModifyColumnType
748            let from = vec![table(
749                "orders",
750                vec![col(
751                    "status",
752                    ColumnType::Complex(ComplexColumnType::Enum {
753                        name: "order_status".into(),
754                        values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
755                    }),
756                )],
757                vec![],
758            )];
759
760            let to = vec![table(
761                "orders",
762                vec![col(
763                    "status",
764                    ColumnType::Complex(ComplexColumnType::Enum {
765                        name: "order_status".into(),
766                        values: EnumValues::String(vec![
767                            "pending".into(),
768                            "shipped".into(),
769                            "delivered".into(),
770                        ]),
771                    }),
772                )],
773                vec![],
774            )];
775
776            let plan = diff_schemas(&from, &to).unwrap();
777            assert_eq!(plan.actions.len(), 1);
778            assert!(matches!(
779                &plan.actions[0],
780                MigrationAction::ModifyColumnType { table, column, .. }
781                if table == "orders" && column == "status"
782            ));
783        }
784    }
785
786    // Tests for inline column constraints normalization
787    mod inline_constraints {
788        use super::*;
789        use vespertide_core::schema::foreign_key::ForeignKeyDef;
790        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
791        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
792        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
793
794        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
795            ColumnDef {
796                name: name.to_string(),
797                r#type: ty,
798                nullable: false,
799                default: None,
800                comment: None,
801                primary_key: Some(PrimaryKeySyntax::Bool(true)),
802                unique: None,
803                index: None,
804                foreign_key: None,
805            }
806        }
807
808        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
809            ColumnDef {
810                name: name.to_string(),
811                r#type: ty,
812                nullable: true,
813                default: None,
814                comment: None,
815                primary_key: None,
816                unique: Some(StrOrBoolOrArray::Bool(true)),
817                index: None,
818                foreign_key: None,
819            }
820        }
821
822        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
823            ColumnDef {
824                name: name.to_string(),
825                r#type: ty,
826                nullable: true,
827                default: None,
828                comment: None,
829                primary_key: None,
830                unique: None,
831                index: Some(StrOrBoolOrArray::Bool(true)),
832                foreign_key: None,
833            }
834        }
835
836        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
837            ColumnDef {
838                name: name.to_string(),
839                r#type: ty,
840                nullable: true,
841                default: None,
842                comment: None,
843                primary_key: None,
844                unique: None,
845                index: None,
846                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
847                    ref_table: ref_table.to_string(),
848                    ref_columns: vec![ref_col.to_string()],
849                    on_delete: None,
850                    on_update: None,
851                })),
852            }
853        }
854
855        #[test]
856        fn create_table_with_inline_pk() {
857            let plan = diff_schemas(
858                &[],
859                &[table(
860                    "users",
861                    vec![
862                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
863                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
864                    ],
865                    vec![],
866                )],
867            )
868            .unwrap();
869
870            // Inline PK should be preserved in column definition
871            assert_eq!(plan.actions.len(), 1);
872            if let MigrationAction::CreateTable {
873                columns,
874                constraints,
875                ..
876            } = &plan.actions[0]
877            {
878                // Constraints should be empty (inline PK not moved here)
879                assert_eq!(constraints.len(), 0);
880                // Check that the column has inline PK
881                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
882                assert!(id_col.primary_key.is_some());
883            } else {
884                panic!("Expected CreateTable action");
885            }
886        }
887
888        #[test]
889        fn create_table_with_inline_unique() {
890            let plan = diff_schemas(
891                &[],
892                &[table(
893                    "users",
894                    vec![
895                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
896                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
897                    ],
898                    vec![],
899                )],
900            )
901            .unwrap();
902
903            // Inline unique should be preserved in column definition
904            assert_eq!(plan.actions.len(), 1);
905            if let MigrationAction::CreateTable {
906                columns,
907                constraints,
908                ..
909            } = &plan.actions[0]
910            {
911                // Constraints should be empty (inline unique not moved here)
912                assert_eq!(constraints.len(), 0);
913                // Check that the column has inline unique
914                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
915                assert!(matches!(
916                    email_col.unique,
917                    Some(StrOrBoolOrArray::Bool(true))
918                ));
919            } else {
920                panic!("Expected CreateTable action");
921            }
922        }
923
924        #[test]
925        fn create_table_with_inline_index() {
926            let plan = diff_schemas(
927                &[],
928                &[table(
929                    "users",
930                    vec![
931                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
932                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
933                    ],
934                    vec![],
935                )],
936            )
937            .unwrap();
938
939            // Inline index should be preserved in column definition, not moved to constraints
940            assert_eq!(plan.actions.len(), 1);
941            if let MigrationAction::CreateTable {
942                columns,
943                constraints,
944                ..
945            } = &plan.actions[0]
946            {
947                // Constraints should be empty (inline index not moved here)
948                assert_eq!(constraints.len(), 0);
949                // Check that the column has inline index
950                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
951                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
952            } else {
953                panic!("Expected CreateTable action");
954            }
955        }
956
957        #[test]
958        fn create_table_with_inline_fk() {
959            let plan = diff_schemas(
960                &[],
961                &[table(
962                    "posts",
963                    vec![
964                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
965                        col_with_fk(
966                            "user_id",
967                            ColumnType::Simple(SimpleColumnType::Integer),
968                            "users",
969                            "id",
970                        ),
971                    ],
972                    vec![],
973                )],
974            )
975            .unwrap();
976
977            // Inline FK should be preserved in column definition
978            assert_eq!(plan.actions.len(), 1);
979            if let MigrationAction::CreateTable {
980                columns,
981                constraints,
982                ..
983            } = &plan.actions[0]
984            {
985                // Constraints should be empty (inline FK not moved here)
986                assert_eq!(constraints.len(), 0);
987                // Check that the column has inline FK
988                let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
989                assert!(user_id_col.foreign_key.is_some());
990            } else {
991                panic!("Expected CreateTable action");
992            }
993        }
994
995        #[test]
996        fn add_index_via_inline_constraint() {
997            // Existing table without index -> table with inline index
998            // Inline index (Bool(true)) is normalized to a named table-level constraint
999            let plan = diff_schemas(
1000                &[table(
1001                    "users",
1002                    vec![
1003                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1004                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1005                    ],
1006                    vec![],
1007                )],
1008                &[table(
1009                    "users",
1010                    vec![
1011                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1012                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
1013                    ],
1014                    vec![],
1015                )],
1016            )
1017            .unwrap();
1018
1019            // Should generate AddConstraint with name: None (auto-generated indexes)
1020            assert_eq!(plan.actions.len(), 1);
1021            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1022                assert_eq!(table, "users");
1023                if let TableConstraint::Index { name, columns } = constraint {
1024                    assert_eq!(name, &None); // Auto-generated indexes use None
1025                    assert_eq!(columns, &vec!["name".to_string()]);
1026                } else {
1027                    panic!("Expected Index constraint, got {:?}", constraint);
1028                }
1029            } else {
1030                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1031            }
1032        }
1033
1034        #[test]
1035        fn create_table_with_all_inline_constraints() {
1036            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
1037            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
1038            id_col.nullable = false;
1039
1040            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
1041            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
1042
1043            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
1044            name_col.index = Some(StrOrBoolOrArray::Bool(true));
1045
1046            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
1047            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
1048                ref_table: "orgs".into(),
1049                ref_columns: vec!["id".into()],
1050                on_delete: None,
1051                on_update: None,
1052            }));
1053
1054            let plan = diff_schemas(
1055                &[],
1056                &[table(
1057                    "users",
1058                    vec![id_col, email_col, name_col, org_id_col],
1059                    vec![],
1060                )],
1061            )
1062            .unwrap();
1063
1064            // All inline constraints should be preserved in column definitions
1065            assert_eq!(plan.actions.len(), 1);
1066
1067            if let MigrationAction::CreateTable {
1068                columns,
1069                constraints,
1070                ..
1071            } = &plan.actions[0]
1072            {
1073                // Constraints should be empty (all inline)
1074                assert_eq!(constraints.len(), 0);
1075
1076                // Check each column has its inline constraint
1077                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
1078                assert!(id_col.primary_key.is_some());
1079
1080                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
1081                assert!(matches!(
1082                    email_col.unique,
1083                    Some(StrOrBoolOrArray::Bool(true))
1084                ));
1085
1086                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
1087                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
1088
1089                let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
1090                assert!(org_id_col.foreign_key.is_some());
1091            } else {
1092                panic!("Expected CreateTable action");
1093            }
1094        }
1095
1096        #[test]
1097        fn add_constraint_to_existing_table() {
1098            // Add a unique constraint to an existing table
1099            let from_schema = vec![table(
1100                "users",
1101                vec![
1102                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1103                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1104                ],
1105                vec![],
1106            )];
1107
1108            let to_schema = vec![table(
1109                "users",
1110                vec![
1111                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1112                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1113                ],
1114                vec![vespertide_core::TableConstraint::Unique {
1115                    name: Some("uq_users_email".into()),
1116                    columns: vec!["email".into()],
1117                }],
1118            )];
1119
1120            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1121            assert_eq!(plan.actions.len(), 1);
1122            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1123                assert_eq!(table, "users");
1124                assert!(matches!(
1125                    constraint,
1126                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1127                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1128                ));
1129            } else {
1130                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1131            }
1132        }
1133
1134        #[test]
1135        fn remove_constraint_from_existing_table() {
1136            // Remove a unique constraint from an existing table
1137            let from_schema = vec![table(
1138                "users",
1139                vec![
1140                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1141                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1142                ],
1143                vec![vespertide_core::TableConstraint::Unique {
1144                    name: Some("uq_users_email".into()),
1145                    columns: vec!["email".into()],
1146                }],
1147            )];
1148
1149            let to_schema = vec![table(
1150                "users",
1151                vec![
1152                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1153                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1154                ],
1155                vec![],
1156            )];
1157
1158            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1159            assert_eq!(plan.actions.len(), 1);
1160            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1161                assert_eq!(table, "users");
1162                assert!(matches!(
1163                    constraint,
1164                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1165                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1166                ));
1167            } else {
1168                panic!(
1169                    "Expected RemoveConstraint action, got {:?}",
1170                    plan.actions[0]
1171                );
1172            }
1173        }
1174
1175        #[test]
1176        fn diff_schemas_with_normalize_error() {
1177            // Test that normalize errors are properly propagated
1178            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1179            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1180
1181            let table = TableDef {
1182                name: "test".into(),
1183                description: None,
1184                columns: vec![
1185                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1186                    col1.clone(),
1187                    {
1188                        // Same column with same index name - should error
1189                        let mut c = col1.clone();
1190                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1191                        c
1192                    },
1193                ],
1194                constraints: vec![],
1195            };
1196
1197            let result = diff_schemas(&[], &[table]);
1198            assert!(result.is_err());
1199            if let Err(PlannerError::TableValidation(msg)) = result {
1200                assert!(msg.contains("Failed to normalize table"));
1201                assert!(msg.contains("Duplicate index"));
1202            } else {
1203                panic!("Expected TableValidation error, got {:?}", result);
1204            }
1205        }
1206
1207        #[test]
1208        fn diff_schemas_with_normalize_error_in_from_schema() {
1209            // Test that normalize errors in 'from' schema are properly propagated
1210            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1211            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1212
1213            let table = TableDef {
1214                name: "test".into(),
1215                description: None,
1216                columns: vec![
1217                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1218                    col1.clone(),
1219                    {
1220                        // Same column with same index name - should error
1221                        let mut c = col1.clone();
1222                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1223                        c
1224                    },
1225                ],
1226                constraints: vec![],
1227            };
1228
1229            // 'from' schema has the invalid table
1230            let result = diff_schemas(&[table], &[]);
1231            assert!(result.is_err());
1232            if let Err(PlannerError::TableValidation(msg)) = result {
1233                assert!(msg.contains("Failed to normalize table"));
1234                assert!(msg.contains("Duplicate index"));
1235            } else {
1236                panic!("Expected TableValidation error, got {:?}", result);
1237            }
1238        }
1239    }
1240
1241    // Direct unit tests for sort_create_before_add_constraint and compare_actions_for_create_order
1242    mod sort_create_before_add_constraint_tests {
1243        use super::*;
1244        use crate::diff::{compare_actions_for_create_order, sort_create_before_add_constraint};
1245        use std::cmp::Ordering;
1246
1247        fn make_add_column(table: &str, col: &str) -> MigrationAction {
1248            MigrationAction::AddColumn {
1249                table: table.into(),
1250                column: Box::new(ColumnDef {
1251                    name: col.into(),
1252                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1253                    nullable: true,
1254                    default: None,
1255                    comment: None,
1256                    primary_key: None,
1257                    unique: None,
1258                    index: None,
1259                    foreign_key: None,
1260                }),
1261                fill_with: None,
1262            }
1263        }
1264
1265        fn make_create_table(name: &str) -> MigrationAction {
1266            MigrationAction::CreateTable {
1267                table: name.into(),
1268                columns: vec![],
1269                constraints: vec![],
1270            }
1271        }
1272
1273        fn make_add_fk(table: &str, ref_table: &str) -> MigrationAction {
1274            MigrationAction::AddConstraint {
1275                table: table.into(),
1276                constraint: TableConstraint::ForeignKey {
1277                    name: None,
1278                    columns: vec!["fk_col".into()],
1279                    ref_table: ref_table.into(),
1280                    ref_columns: vec!["id".into()],
1281                    on_delete: None,
1282                    on_update: None,
1283                },
1284            }
1285        }
1286
1287        /// Test line 218: (false, true, _, _) - a is NOT CreateTable, b IS CreateTable
1288        /// Direct test of comparison function
1289        #[test]
1290        fn test_compare_non_create_vs_create() {
1291            let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1292
1293            let add_col = make_add_column("users", "name");
1294            let create_table = make_create_table("roles");
1295
1296            // a=AddColumn (non-create), b=CreateTable (create) -> Greater (b comes first)
1297            let result = compare_actions_for_create_order(&add_col, &create_table, &created_tables);
1298            assert_eq!(
1299                result,
1300                Ordering::Greater,
1301                "Non-CreateTable vs CreateTable should return Greater"
1302            );
1303        }
1304
1305        /// Test line 216: (true, false, _, _) - a IS CreateTable, b is NOT CreateTable
1306        #[test]
1307        fn test_compare_create_vs_non_create() {
1308            let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1309
1310            let create_table = make_create_table("roles");
1311            let add_col = make_add_column("users", "name");
1312
1313            // a=CreateTable (create), b=AddColumn (non-create) -> Less (a comes first)
1314            let result = compare_actions_for_create_order(&create_table, &add_col, &created_tables);
1315            assert_eq!(
1316                result,
1317                Ordering::Less,
1318                "CreateTable vs Non-CreateTable should return Less"
1319            );
1320        }
1321
1322        /// Test line 214: (true, true, _, _) - both CreateTable
1323        #[test]
1324        fn test_compare_create_vs_create() {
1325            let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1326                .into_iter()
1327                .collect();
1328
1329            let create1 = make_create_table("roles");
1330            let create2 = make_create_table("categories");
1331
1332            // Both CreateTable -> Equal (maintain original order)
1333            let result = compare_actions_for_create_order(&create1, &create2, &created_tables);
1334            assert_eq!(
1335                result,
1336                Ordering::Equal,
1337                "CreateTable vs CreateTable should return Equal"
1338            );
1339        }
1340
1341        /// Test line 221: (false, false, true, false) - neither CreateTable, a refs created, b doesn't
1342        #[test]
1343        fn test_compare_refs_vs_non_refs() {
1344            let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1345
1346            let add_fk = make_add_fk("users", "roles"); // refs created
1347            let add_col = make_add_column("posts", "title"); // doesn't ref
1348
1349            // a refs created, b doesn't -> Greater (a comes after)
1350            let result = compare_actions_for_create_order(&add_fk, &add_col, &created_tables);
1351            assert_eq!(
1352                result,
1353                Ordering::Greater,
1354                "FK-ref vs non-ref should return Greater"
1355            );
1356        }
1357
1358        /// Test line 223: (false, false, false, true) - neither CreateTable, a doesn't ref, b refs
1359        #[test]
1360        fn test_compare_non_refs_vs_refs() {
1361            let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1362
1363            let add_col = make_add_column("posts", "title"); // doesn't ref
1364            let add_fk = make_add_fk("users", "roles"); // refs created
1365
1366            // a doesn't ref, b refs -> Less (b comes after, a comes first)
1367            let result = compare_actions_for_create_order(&add_col, &add_fk, &created_tables);
1368            assert_eq!(
1369                result,
1370                Ordering::Less,
1371                "Non-ref vs FK-ref should return Less"
1372            );
1373        }
1374
1375        /// Test line 225: (false, false, _, _) - neither CreateTable, both don't ref
1376        #[test]
1377        fn test_compare_non_refs_vs_non_refs() {
1378            let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1379
1380            let add_col1 = make_add_column("users", "name");
1381            let add_col2 = make_add_column("posts", "title");
1382
1383            // Both don't ref -> Equal
1384            let result = compare_actions_for_create_order(&add_col1, &add_col2, &created_tables);
1385            assert_eq!(
1386                result,
1387                Ordering::Equal,
1388                "Non-ref vs non-ref should return Equal"
1389            );
1390        }
1391
1392        /// Test line 225: (false, false, _, _) - neither CreateTable, both ref created
1393        #[test]
1394        fn test_compare_refs_vs_refs() {
1395            let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1396                .into_iter()
1397                .collect();
1398
1399            let add_fk1 = make_add_fk("users", "roles");
1400            let add_fk2 = make_add_fk("posts", "categories");
1401
1402            // Both ref -> Equal
1403            let result = compare_actions_for_create_order(&add_fk1, &add_fk2, &created_tables);
1404            assert_eq!(
1405                result,
1406                Ordering::Equal,
1407                "FK-ref vs FK-ref should return Equal"
1408            );
1409        }
1410
1411        /// Integration test: sort function works correctly
1412        #[test]
1413        fn test_sort_integration() {
1414            let mut actions = vec![
1415                make_add_column("t1", "c1"),
1416                make_add_fk("users", "roles"),
1417                make_create_table("roles"),
1418            ];
1419
1420            sort_create_before_add_constraint(&mut actions);
1421
1422            // CreateTable first, AddColumn second, AddConstraint FK last
1423            assert!(matches!(&actions[0], MigrationAction::CreateTable { .. }));
1424            assert!(matches!(&actions[1], MigrationAction::AddColumn { .. }));
1425            assert!(matches!(&actions[2], MigrationAction::AddConstraint { .. }));
1426        }
1427    }
1428
1429    // Tests for foreign key dependency ordering
1430    mod fk_ordering {
1431        use super::*;
1432        use vespertide_core::TableConstraint;
1433
1434        fn table_with_fk(
1435            name: &str,
1436            ref_table: &str,
1437            fk_column: &str,
1438            ref_column: &str,
1439        ) -> TableDef {
1440            TableDef {
1441                name: name.to_string(),
1442                description: None,
1443                columns: vec![
1444                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1445                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1446                ],
1447                constraints: vec![TableConstraint::ForeignKey {
1448                    name: None,
1449                    columns: vec![fk_column.to_string()],
1450                    ref_table: ref_table.to_string(),
1451                    ref_columns: vec![ref_column.to_string()],
1452                    on_delete: None,
1453                    on_update: None,
1454                }],
1455            }
1456        }
1457
1458        fn simple_table(name: &str) -> TableDef {
1459            TableDef {
1460                name: name.to_string(),
1461                description: None,
1462                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1463                constraints: vec![],
1464            }
1465        }
1466
1467        #[test]
1468        fn create_tables_respects_fk_order() {
1469            // Create users and posts tables where posts references users
1470            // The order should be: users first, then posts
1471            let users = simple_table("users");
1472            let posts = table_with_fk("posts", "users", "user_id", "id");
1473
1474            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1475
1476            // Extract CreateTable actions in order
1477            let create_order: Vec<&str> = plan
1478                .actions
1479                .iter()
1480                .filter_map(|a| {
1481                    if let MigrationAction::CreateTable { table, .. } = a {
1482                        Some(table.as_str())
1483                    } else {
1484                        None
1485                    }
1486                })
1487                .collect();
1488
1489            assert_eq!(create_order, vec!["users", "posts"]);
1490        }
1491
1492        #[test]
1493        fn create_tables_chain_dependency() {
1494            // Chain: users <- media <- articles
1495            // users has no FK
1496            // media references users
1497            // articles references media
1498            let users = simple_table("users");
1499            let media = table_with_fk("media", "users", "owner_id", "id");
1500            let articles = table_with_fk("articles", "media", "media_id", "id");
1501
1502            // Pass in reverse order to ensure sorting works
1503            let plan =
1504                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1505
1506            let create_order: Vec<&str> = plan
1507                .actions
1508                .iter()
1509                .filter_map(|a| {
1510                    if let MigrationAction::CreateTable { table, .. } = a {
1511                        Some(table.as_str())
1512                    } else {
1513                        None
1514                    }
1515                })
1516                .collect();
1517
1518            assert_eq!(create_order, vec!["users", "media", "articles"]);
1519        }
1520
1521        #[test]
1522        fn create_tables_multiple_independent_branches() {
1523            // Two independent branches:
1524            // users <- posts
1525            // categories <- products
1526            let users = simple_table("users");
1527            let posts = table_with_fk("posts", "users", "user_id", "id");
1528            let categories = simple_table("categories");
1529            let products = table_with_fk("products", "categories", "category_id", "id");
1530
1531            let plan = diff_schemas(
1532                &[],
1533                &[
1534                    products.clone(),
1535                    posts.clone(),
1536                    categories.clone(),
1537                    users.clone(),
1538                ],
1539            )
1540            .unwrap();
1541
1542            let create_order: Vec<&str> = plan
1543                .actions
1544                .iter()
1545                .filter_map(|a| {
1546                    if let MigrationAction::CreateTable { table, .. } = a {
1547                        Some(table.as_str())
1548                    } else {
1549                        None
1550                    }
1551                })
1552                .collect();
1553
1554            // users must come before posts
1555            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1556            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1557            assert!(
1558                users_pos < posts_pos,
1559                "users should be created before posts"
1560            );
1561
1562            // categories must come before products
1563            let categories_pos = create_order
1564                .iter()
1565                .position(|&t| t == "categories")
1566                .unwrap();
1567            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1568            assert!(
1569                categories_pos < products_pos,
1570                "categories should be created before products"
1571            );
1572        }
1573
1574        #[test]
1575        fn delete_tables_respects_fk_order() {
1576            // When deleting users and posts where posts references users,
1577            // posts should be deleted first (reverse of creation order)
1578            let users = simple_table("users");
1579            let posts = table_with_fk("posts", "users", "user_id", "id");
1580
1581            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1582
1583            let delete_order: Vec<&str> = plan
1584                .actions
1585                .iter()
1586                .filter_map(|a| {
1587                    if let MigrationAction::DeleteTable { table } = a {
1588                        Some(table.as_str())
1589                    } else {
1590                        None
1591                    }
1592                })
1593                .collect();
1594
1595            assert_eq!(delete_order, vec!["posts", "users"]);
1596        }
1597
1598        #[test]
1599        fn delete_tables_chain_dependency() {
1600            // Chain: users <- media <- articles
1601            // Delete order should be: articles, media, users
1602            let users = simple_table("users");
1603            let media = table_with_fk("media", "users", "owner_id", "id");
1604            let articles = table_with_fk("articles", "media", "media_id", "id");
1605
1606            let plan =
1607                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1608
1609            let delete_order: Vec<&str> = plan
1610                .actions
1611                .iter()
1612                .filter_map(|a| {
1613                    if let MigrationAction::DeleteTable { table } = a {
1614                        Some(table.as_str())
1615                    } else {
1616                        None
1617                    }
1618                })
1619                .collect();
1620
1621            // articles must be deleted before media
1622            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1623            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1624            assert!(
1625                articles_pos < media_pos,
1626                "articles should be deleted before media"
1627            );
1628
1629            // media must be deleted before users
1630            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1631            assert!(
1632                media_pos < users_pos,
1633                "media should be deleted before users"
1634            );
1635        }
1636
1637        #[test]
1638        fn circular_fk_dependency_returns_error() {
1639            // Create circular dependency: A -> B -> A
1640            let table_a = TableDef {
1641                name: "table_a".to_string(),
1642                description: None,
1643                columns: vec![
1644                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1645                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1646                ],
1647                constraints: vec![TableConstraint::ForeignKey {
1648                    name: None,
1649                    columns: vec!["b_id".to_string()],
1650                    ref_table: "table_b".to_string(),
1651                    ref_columns: vec!["id".to_string()],
1652                    on_delete: None,
1653                    on_update: None,
1654                }],
1655            };
1656
1657            let table_b = TableDef {
1658                name: "table_b".to_string(),
1659                description: None,
1660                columns: vec![
1661                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1662                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1663                ],
1664                constraints: vec![TableConstraint::ForeignKey {
1665                    name: None,
1666                    columns: vec!["a_id".to_string()],
1667                    ref_table: "table_a".to_string(),
1668                    ref_columns: vec!["id".to_string()],
1669                    on_delete: None,
1670                    on_update: None,
1671                }],
1672            };
1673
1674            let result = diff_schemas(&[], &[table_a, table_b]);
1675            assert!(result.is_err());
1676            if let Err(PlannerError::TableValidation(msg)) = result {
1677                assert!(
1678                    msg.contains("Circular foreign key dependency"),
1679                    "Expected circular dependency error, got: {}",
1680                    msg
1681                );
1682            } else {
1683                panic!("Expected TableValidation error, got {:?}", result);
1684            }
1685        }
1686
1687        #[test]
1688        fn fk_to_external_table_is_ignored() {
1689            // FK referencing a table not in the migration should not affect ordering
1690            let posts = table_with_fk("posts", "users", "user_id", "id");
1691            let comments = table_with_fk("comments", "posts", "post_id", "id");
1692
1693            // users is NOT being created in this migration
1694            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1695
1696            let create_order: Vec<&str> = plan
1697                .actions
1698                .iter()
1699                .filter_map(|a| {
1700                    if let MigrationAction::CreateTable { table, .. } = a {
1701                        Some(table.as_str())
1702                    } else {
1703                        None
1704                    }
1705                })
1706                .collect();
1707
1708            // posts must come before comments (comments depends on posts)
1709            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1710            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1711            assert!(
1712                posts_pos < comments_pos,
1713                "posts should be created before comments"
1714            );
1715        }
1716
1717        #[test]
1718        fn delete_tables_mixed_with_other_actions() {
1719            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1720            // This tests lines 124, 193, 198 (the else branches)
1721            use crate::diff::diff_schemas;
1722
1723            let from_schema = vec![
1724                table(
1725                    "users",
1726                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1727                    vec![],
1728                ),
1729                table(
1730                    "posts",
1731                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1732                    vec![],
1733                ),
1734            ];
1735
1736            let to_schema = vec![
1737                // Drop posts table, but also add a new column to users
1738                table(
1739                    "users",
1740                    vec![
1741                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1742                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1743                    ],
1744                    vec![],
1745                ),
1746            ];
1747
1748            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1749
1750            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1751            assert!(
1752                plan.actions
1753                    .iter()
1754                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1755            );
1756            assert!(
1757                plan.actions
1758                    .iter()
1759                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1760            );
1761
1762            // The else branches in sort_delete_actions should handle AddColumn gracefully
1763            // (returning empty string for table name, which sorts it to position 0)
1764        }
1765
1766        #[test]
1767        #[should_panic(expected = "Expected DeleteTable action")]
1768        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1769            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1770            use super::extract_delete_table_name;
1771
1772            let action = MigrationAction::AddColumn {
1773                table: "users".into(),
1774                column: Box::new(ColumnDef {
1775                    name: "email".into(),
1776                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1777                    nullable: true,
1778                    default: None,
1779                    comment: None,
1780                    primary_key: None,
1781                    unique: None,
1782                    index: None,
1783                    foreign_key: None,
1784                }),
1785                fill_with: None,
1786            };
1787
1788            // This should panic
1789            extract_delete_table_name(&action);
1790        }
1791
1792        /// Test that inline FK across multiple tables works correctly with topological sort
1793        #[test]
1794        fn create_tables_with_inline_fk_chain() {
1795            use super::*;
1796            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1797            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1798
1799            fn col_pk(name: &str) -> ColumnDef {
1800                ColumnDef {
1801                    name: name.to_string(),
1802                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1803                    nullable: false,
1804                    default: None,
1805                    comment: None,
1806                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1807                    unique: None,
1808                    index: None,
1809                    foreign_key: None,
1810                }
1811            }
1812
1813            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1814                ColumnDef {
1815                    name: name.to_string(),
1816                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1817                    nullable: true,
1818                    default: None,
1819                    comment: None,
1820                    primary_key: None,
1821                    unique: None,
1822                    index: None,
1823                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1824                }
1825            }
1826
1827            // Reproduce the app example structure:
1828            // user -> (no deps)
1829            // product -> (no deps)
1830            // project -> user
1831            // code -> product, user, project
1832            // order -> user, project, product, code
1833            // payment -> order
1834
1835            let user = TableDef {
1836                name: "user".to_string(),
1837                description: None,
1838                columns: vec![col_pk("id")],
1839                constraints: vec![],
1840            };
1841
1842            let product = TableDef {
1843                name: "product".to_string(),
1844                description: None,
1845                columns: vec![col_pk("id")],
1846                constraints: vec![],
1847            };
1848
1849            let project = TableDef {
1850                name: "project".to_string(),
1851                description: None,
1852                columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1853                constraints: vec![],
1854            };
1855
1856            let code = TableDef {
1857                name: "code".to_string(),
1858                description: None,
1859                columns: vec![
1860                    col_pk("id"),
1861                    col_inline_fk("product_id", "product"),
1862                    col_inline_fk("creator_user_id", "user"),
1863                    col_inline_fk("project_id", "project"),
1864                ],
1865                constraints: vec![],
1866            };
1867
1868            let order = TableDef {
1869                name: "order".to_string(),
1870                description: None,
1871                columns: vec![
1872                    col_pk("id"),
1873                    col_inline_fk("user_id", "user"),
1874                    col_inline_fk("project_id", "project"),
1875                    col_inline_fk("product_id", "product"),
1876                    col_inline_fk("code_id", "code"),
1877                ],
1878                constraints: vec![],
1879            };
1880
1881            let payment = TableDef {
1882                name: "payment".to_string(),
1883                description: None,
1884                columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1885                constraints: vec![],
1886            };
1887
1888            // Pass in arbitrary order - should NOT return circular dependency error
1889            let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1890            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1891
1892            let plan = result.unwrap();
1893            let create_order: Vec<&str> = plan
1894                .actions
1895                .iter()
1896                .filter_map(|a| {
1897                    if let MigrationAction::CreateTable { table, .. } = a {
1898                        Some(table.as_str())
1899                    } else {
1900                        None
1901                    }
1902                })
1903                .collect();
1904
1905            // Verify order respects FK dependencies
1906            let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1907
1908            // user and product have no deps, can be in any order
1909            // project depends on user
1910            assert!(
1911                get_pos("user") < get_pos("project"),
1912                "user must come before project"
1913            );
1914            // code depends on product, user, project
1915            assert!(
1916                get_pos("product") < get_pos("code"),
1917                "product must come before code"
1918            );
1919            assert!(
1920                get_pos("user") < get_pos("code"),
1921                "user must come before code"
1922            );
1923            assert!(
1924                get_pos("project") < get_pos("code"),
1925                "project must come before code"
1926            );
1927            // order depends on user, project, product, code
1928            assert!(
1929                get_pos("code") < get_pos("order"),
1930                "code must come before order"
1931            );
1932            // payment depends on order
1933            assert!(
1934                get_pos("order") < get_pos("payment"),
1935                "order must come before payment"
1936            );
1937        }
1938
1939        /// Test that AddConstraint FK to a new table comes AFTER CreateTable for that table
1940        #[test]
1941        fn add_constraint_fk_to_new_table_comes_after_create_table() {
1942            use super::*;
1943
1944            // Existing table: notification (with broadcast_id column)
1945            let notification_from = table(
1946                "notification",
1947                vec![
1948                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1949                    col(
1950                        "broadcast_id",
1951                        ColumnType::Simple(SimpleColumnType::Integer),
1952                    ),
1953                ],
1954                vec![],
1955            );
1956
1957            // New table: notification_broadcast
1958            let notification_broadcast = table(
1959                "notification_broadcast",
1960                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1961                vec![],
1962            );
1963
1964            // Modified notification with FK constraint to the new table
1965            let notification_to = table(
1966                "notification",
1967                vec![
1968                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1969                    col(
1970                        "broadcast_id",
1971                        ColumnType::Simple(SimpleColumnType::Integer),
1972                    ),
1973                ],
1974                vec![TableConstraint::ForeignKey {
1975                    name: None,
1976                    columns: vec!["broadcast_id".into()],
1977                    ref_table: "notification_broadcast".into(),
1978                    ref_columns: vec!["id".into()],
1979                    on_delete: None,
1980                    on_update: None,
1981                }],
1982            );
1983
1984            let from_schema = vec![notification_from];
1985            let to_schema = vec![notification_to, notification_broadcast];
1986
1987            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1988
1989            // Find positions
1990            let create_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "notification_broadcast"));
1991            let add_constraint_pos = plan.actions.iter().position(|a| {
1992                matches!(a, MigrationAction::AddConstraint {
1993                    constraint: TableConstraint::ForeignKey { ref_table, .. }, ..
1994                } if ref_table == "notification_broadcast")
1995            });
1996
1997            assert!(
1998                create_pos.is_some(),
1999                "Should have CreateTable for notification_broadcast"
2000            );
2001            assert!(
2002                add_constraint_pos.is_some(),
2003                "Should have AddConstraint for FK to notification_broadcast"
2004            );
2005            assert!(
2006                create_pos.unwrap() < add_constraint_pos.unwrap(),
2007                "CreateTable must come BEFORE AddConstraint FK that references it. Got CreateTable at {}, AddConstraint at {}",
2008                create_pos.unwrap(),
2009                add_constraint_pos.unwrap()
2010            );
2011        }
2012
2013        /// Test sort_create_before_add_constraint with multiple action types
2014        /// Covers lines 218, 221, 223, 225 in sort_create_before_add_constraint
2015        #[test]
2016        fn sort_create_before_add_constraint_all_branches() {
2017            use super::*;
2018
2019            // Scenario: Existing table gets modified (column change) AND gets FK to new table
2020            // Plus another existing table gets a regular index added (not FK to new table)
2021            // This creates:
2022            // - ModifyColumnComment (doesn't ref created table)
2023            // - AddConstraint FK (refs created table)
2024            // - AddConstraint Index (doesn't ref created table)
2025            // - CreateTable
2026
2027            let users_from = table(
2028                "users",
2029                vec![
2030                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2031                    {
2032                        let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2033                        c.comment = Some("Old comment".into());
2034                        c
2035                    },
2036                    col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2037                ],
2038                vec![],
2039            );
2040
2041            let posts_from = table(
2042                "posts",
2043                vec![
2044                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2045                    col("title", ColumnType::Simple(SimpleColumnType::Text)),
2046                ],
2047                vec![],
2048            );
2049
2050            // New table: roles
2051            let roles = table(
2052                "roles",
2053                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2054                vec![],
2055            );
2056
2057            // Modified users: comment change + FK to new roles table
2058            let users_to = table(
2059                "users",
2060                vec![
2061                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2062                    {
2063                        let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2064                        c.comment = Some("New comment".into());
2065                        c
2066                    },
2067                    col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2068                ],
2069                vec![TableConstraint::ForeignKey {
2070                    name: None,
2071                    columns: vec!["role_id".into()],
2072                    ref_table: "roles".into(),
2073                    ref_columns: vec!["id".into()],
2074                    on_delete: None,
2075                    on_update: None,
2076                }],
2077            );
2078
2079            // Modified posts: add index (not FK to new table)
2080            let posts_to = table(
2081                "posts",
2082                vec![
2083                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2084                    col("title", ColumnType::Simple(SimpleColumnType::Text)),
2085                ],
2086                vec![TableConstraint::Index {
2087                    name: Some("idx_title".into()),
2088                    columns: vec!["title".into()],
2089                }],
2090            );
2091
2092            let from_schema = vec![users_from, posts_from];
2093            let to_schema = vec![users_to, posts_to, roles];
2094
2095            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2096
2097            // Verify CreateTable comes first
2098            let create_pos = plan
2099                .actions
2100                .iter()
2101                .position(
2102                    |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2103                )
2104                .expect("Should have CreateTable for roles");
2105
2106            // ModifyColumnComment should come after CreateTable (line 218: non-create vs create)
2107            let modify_pos = plan
2108                .actions
2109                .iter()
2110                .position(|a| matches!(a, MigrationAction::ModifyColumnComment { .. }))
2111                .expect("Should have ModifyColumnComment");
2112
2113            // AddConstraint Index (not FK to created) should come after CreateTable (line 218)
2114            let add_index_pos = plan
2115                .actions
2116                .iter()
2117                .position(|a| {
2118                    matches!(
2119                        a,
2120                        MigrationAction::AddConstraint {
2121                            constraint: TableConstraint::Index { .. },
2122                            ..
2123                        }
2124                    )
2125                })
2126                .expect("Should have AddConstraint Index");
2127
2128            // AddConstraint FK to roles should come last (line 221: refs created, others don't)
2129            let add_fk_pos = plan
2130                .actions
2131                .iter()
2132                .position(|a| {
2133                    matches!(
2134                        a,
2135                        MigrationAction::AddConstraint {
2136                            constraint: TableConstraint::ForeignKey { ref_table, .. },
2137                            ..
2138                        } if ref_table == "roles"
2139                    )
2140                })
2141                .expect("Should have AddConstraint FK to roles");
2142
2143            assert!(
2144                create_pos < modify_pos,
2145                "CreateTable must come before ModifyColumnComment"
2146            );
2147            assert!(
2148                create_pos < add_index_pos,
2149                "CreateTable must come before AddConstraint Index"
2150            );
2151            assert!(
2152                create_pos < add_fk_pos,
2153                "CreateTable must come before AddConstraint FK"
2154            );
2155            // FK to created table should come after non-FK-to-created actions
2156            assert!(
2157                add_index_pos < add_fk_pos,
2158                "AddConstraint Index (not referencing created) should come before AddConstraint FK (referencing created)"
2159            );
2160        }
2161
2162        /// Test that two AddConstraint FKs both referencing created tables maintain stable order
2163        /// Covers line 225: both ref created tables
2164        #[test]
2165        fn sort_multiple_fks_to_created_tables() {
2166            use super::*;
2167
2168            // Two existing tables, each getting FK to a different new table
2169            let users_from = table(
2170                "users",
2171                vec![
2172                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2173                    col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2174                ],
2175                vec![],
2176            );
2177
2178            let posts_from = table(
2179                "posts",
2180                vec![
2181                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2182                    col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2183                ],
2184                vec![],
2185            );
2186
2187            // Two new tables
2188            let roles = table(
2189                "roles",
2190                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2191                vec![],
2192            );
2193            let categories = table(
2194                "categories",
2195                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2196                vec![],
2197            );
2198
2199            let users_to = table(
2200                "users",
2201                vec![
2202                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2203                    col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2204                ],
2205                vec![TableConstraint::ForeignKey {
2206                    name: None,
2207                    columns: vec!["role_id".into()],
2208                    ref_table: "roles".into(),
2209                    ref_columns: vec!["id".into()],
2210                    on_delete: None,
2211                    on_update: None,
2212                }],
2213            );
2214
2215            let posts_to = table(
2216                "posts",
2217                vec![
2218                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2219                    col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2220                ],
2221                vec![TableConstraint::ForeignKey {
2222                    name: None,
2223                    columns: vec!["category_id".into()],
2224                    ref_table: "categories".into(),
2225                    ref_columns: vec!["id".into()],
2226                    on_delete: None,
2227                    on_update: None,
2228                }],
2229            );
2230
2231            let from_schema = vec![users_from, posts_from];
2232            let to_schema = vec![users_to, posts_to, roles, categories];
2233
2234            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2235
2236            // Both CreateTable should come before both AddConstraint FK
2237            let create_roles_pos = plan.actions.iter().position(
2238                |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2239            );
2240            let create_categories_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "categories"));
2241            let add_fk_roles_pos = plan.actions.iter().position(|a| {
2242                matches!(
2243                    a,
2244                    MigrationAction::AddConstraint {
2245                        constraint: TableConstraint::ForeignKey { ref_table, .. },
2246                        ..
2247                    } if ref_table == "roles"
2248                )
2249            });
2250            let add_fk_categories_pos = plan.actions.iter().position(|a| {
2251                matches!(
2252                    a,
2253                    MigrationAction::AddConstraint {
2254                        constraint: TableConstraint::ForeignKey { ref_table, .. },
2255                        ..
2256                    } if ref_table == "categories"
2257                )
2258            });
2259
2260            assert!(create_roles_pos.is_some());
2261            assert!(create_categories_pos.is_some());
2262            assert!(add_fk_roles_pos.is_some());
2263            assert!(add_fk_categories_pos.is_some());
2264
2265            // All CreateTable before all AddConstraint FK
2266            let max_create = create_roles_pos
2267                .unwrap()
2268                .max(create_categories_pos.unwrap());
2269            let min_add_fk = add_fk_roles_pos
2270                .unwrap()
2271                .min(add_fk_categories_pos.unwrap());
2272            assert!(
2273                max_create < min_add_fk,
2274                "All CreateTable actions must come before all AddConstraint FK actions"
2275            );
2276        }
2277
2278        /// Test that multiple FKs to the same table are deduplicated correctly
2279        #[test]
2280        fn create_tables_with_duplicate_fk_references() {
2281            use super::*;
2282            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
2283            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
2284
2285            fn col_pk(name: &str) -> ColumnDef {
2286                ColumnDef {
2287                    name: name.to_string(),
2288                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
2289                    nullable: false,
2290                    default: None,
2291                    comment: None,
2292                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
2293                    unique: None,
2294                    index: None,
2295                    foreign_key: None,
2296                }
2297            }
2298
2299            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
2300                ColumnDef {
2301                    name: name.to_string(),
2302                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
2303                    nullable: true,
2304                    default: None,
2305                    comment: None,
2306                    primary_key: None,
2307                    unique: None,
2308                    index: None,
2309                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
2310                }
2311            }
2312
2313            // Table with multiple FKs referencing the same table (like code.creator_user_id and code.used_by_user_id)
2314            let user = TableDef {
2315                name: "user".to_string(),
2316                description: None,
2317                columns: vec![col_pk("id")],
2318                constraints: vec![],
2319            };
2320
2321            let code = TableDef {
2322                name: "code".to_string(),
2323                description: None,
2324                columns: vec![
2325                    col_pk("id"),
2326                    col_inline_fk("creator_user_id", "user"),
2327                    col_inline_fk("used_by_user_id", "user"), // Second FK to same table
2328                ],
2329                constraints: vec![],
2330            };
2331
2332            // This should NOT return circular dependency error even with duplicate FK refs
2333            let result = diff_schemas(&[], &[code, user]);
2334            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
2335
2336            let plan = result.unwrap();
2337            let create_order: Vec<&str> = plan
2338                .actions
2339                .iter()
2340                .filter_map(|a| {
2341                    if let MigrationAction::CreateTable { table, .. } = a {
2342                        Some(table.as_str())
2343                    } else {
2344                        None
2345                    }
2346                })
2347                .collect();
2348
2349            // user must come before code
2350            let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
2351            let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
2352            assert!(user_pos < code_pos, "user must come before code");
2353        }
2354    }
2355
2356    mod primary_key_changes {
2357        use super::*;
2358
2359        fn pk(columns: Vec<&str>) -> TableConstraint {
2360            TableConstraint::PrimaryKey {
2361                auto_increment: false,
2362                columns: columns.into_iter().map(|s| s.to_string()).collect(),
2363            }
2364        }
2365
2366        #[test]
2367        fn add_column_to_composite_pk() {
2368            // Primary key: [id] -> [id, tenant_id]
2369            let from = vec![table(
2370                "users",
2371                vec![
2372                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2373                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2374                ],
2375                vec![pk(vec!["id"])],
2376            )];
2377
2378            let to = vec![table(
2379                "users",
2380                vec![
2381                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2382                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2383                ],
2384                vec![pk(vec!["id", "tenant_id"])],
2385            )];
2386
2387            let plan = diff_schemas(&from, &to).unwrap();
2388
2389            // Should remove old PK and add new composite PK
2390            assert_eq!(plan.actions.len(), 2);
2391
2392            let has_remove = plan.actions.iter().any(|a| {
2393                matches!(
2394                    a,
2395                    MigrationAction::RemoveConstraint {
2396                        table,
2397                        constraint: TableConstraint::PrimaryKey { columns, .. }
2398                    } if table == "users" && columns == &vec!["id".to_string()]
2399                )
2400            });
2401            assert!(has_remove, "Should have RemoveConstraint for old PK");
2402
2403            let has_add = plan.actions.iter().any(|a| {
2404                matches!(
2405                    a,
2406                    MigrationAction::AddConstraint {
2407                        table,
2408                        constraint: TableConstraint::PrimaryKey { columns, .. }
2409                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2410                )
2411            });
2412            assert!(has_add, "Should have AddConstraint for new composite PK");
2413        }
2414
2415        #[test]
2416        fn remove_column_from_composite_pk() {
2417            // Primary key: [id, tenant_id] -> [id]
2418            let from = vec![table(
2419                "users",
2420                vec![
2421                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2422                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2423                ],
2424                vec![pk(vec!["id", "tenant_id"])],
2425            )];
2426
2427            let to = vec![table(
2428                "users",
2429                vec![
2430                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2431                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2432                ],
2433                vec![pk(vec!["id"])],
2434            )];
2435
2436            let plan = diff_schemas(&from, &to).unwrap();
2437
2438            // Should remove old composite PK and add new single-column PK
2439            assert_eq!(plan.actions.len(), 2);
2440
2441            let has_remove = plan.actions.iter().any(|a| {
2442                matches!(
2443                    a,
2444                    MigrationAction::RemoveConstraint {
2445                        table,
2446                        constraint: TableConstraint::PrimaryKey { columns, .. }
2447                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2448                )
2449            });
2450            assert!(
2451                has_remove,
2452                "Should have RemoveConstraint for old composite PK"
2453            );
2454
2455            let has_add = plan.actions.iter().any(|a| {
2456                matches!(
2457                    a,
2458                    MigrationAction::AddConstraint {
2459                        table,
2460                        constraint: TableConstraint::PrimaryKey { columns, .. }
2461                    } if table == "users" && columns == &vec!["id".to_string()]
2462                )
2463            });
2464            assert!(
2465                has_add,
2466                "Should have AddConstraint for new single-column PK"
2467            );
2468        }
2469
2470        #[test]
2471        fn change_pk_columns_entirely() {
2472            // Primary key: [id] -> [uuid]
2473            let from = vec![table(
2474                "users",
2475                vec![
2476                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2477                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2478                ],
2479                vec![pk(vec!["id"])],
2480            )];
2481
2482            let to = vec![table(
2483                "users",
2484                vec![
2485                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2486                    col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2487                ],
2488                vec![pk(vec!["uuid"])],
2489            )];
2490
2491            let plan = diff_schemas(&from, &to).unwrap();
2492
2493            assert_eq!(plan.actions.len(), 2);
2494
2495            let has_remove = plan.actions.iter().any(|a| {
2496                matches!(
2497                    a,
2498                    MigrationAction::RemoveConstraint {
2499                        table,
2500                        constraint: TableConstraint::PrimaryKey { columns, .. }
2501                    } if table == "users" && columns == &vec!["id".to_string()]
2502                )
2503            });
2504            assert!(has_remove, "Should have RemoveConstraint for old PK");
2505
2506            let has_add = plan.actions.iter().any(|a| {
2507                matches!(
2508                    a,
2509                    MigrationAction::AddConstraint {
2510                        table,
2511                        constraint: TableConstraint::PrimaryKey { columns, .. }
2512                    } if table == "users" && columns == &vec!["uuid".to_string()]
2513                )
2514            });
2515            assert!(has_add, "Should have AddConstraint for new PK");
2516        }
2517
2518        #[test]
2519        fn add_multiple_columns_to_composite_pk() {
2520            // Primary key: [id] -> [id, tenant_id, region_id]
2521            let from = vec![table(
2522                "users",
2523                vec![
2524                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2525                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2526                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2527                ],
2528                vec![pk(vec!["id"])],
2529            )];
2530
2531            let to = vec![table(
2532                "users",
2533                vec![
2534                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2535                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2536                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2537                ],
2538                vec![pk(vec!["id", "tenant_id", "region_id"])],
2539            )];
2540
2541            let plan = diff_schemas(&from, &to).unwrap();
2542
2543            assert_eq!(plan.actions.len(), 2);
2544
2545            let has_remove = plan.actions.iter().any(|a| {
2546                matches!(
2547                    a,
2548                    MigrationAction::RemoveConstraint {
2549                        table,
2550                        constraint: TableConstraint::PrimaryKey { columns, .. }
2551                    } if table == "users" && columns == &vec!["id".to_string()]
2552                )
2553            });
2554            assert!(
2555                has_remove,
2556                "Should have RemoveConstraint for old single-column PK"
2557            );
2558
2559            let has_add = plan.actions.iter().any(|a| {
2560                matches!(
2561                    a,
2562                    MigrationAction::AddConstraint {
2563                        table,
2564                        constraint: TableConstraint::PrimaryKey { columns, .. }
2565                    } if table == "users" && columns == &vec![
2566                        "id".to_string(),
2567                        "tenant_id".to_string(),
2568                        "region_id".to_string()
2569                    ]
2570                )
2571            });
2572            assert!(
2573                has_add,
2574                "Should have AddConstraint for new 3-column composite PK"
2575            );
2576        }
2577
2578        #[test]
2579        fn remove_multiple_columns_from_composite_pk() {
2580            // Primary key: [id, tenant_id, region_id] -> [id]
2581            let from = vec![table(
2582                "users",
2583                vec![
2584                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2585                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2586                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2587                ],
2588                vec![pk(vec!["id", "tenant_id", "region_id"])],
2589            )];
2590
2591            let to = vec![table(
2592                "users",
2593                vec![
2594                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2595                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2596                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2597                ],
2598                vec![pk(vec!["id"])],
2599            )];
2600
2601            let plan = diff_schemas(&from, &to).unwrap();
2602
2603            assert_eq!(plan.actions.len(), 2);
2604
2605            let has_remove = plan.actions.iter().any(|a| {
2606                matches!(
2607                    a,
2608                    MigrationAction::RemoveConstraint {
2609                        table,
2610                        constraint: TableConstraint::PrimaryKey { columns, .. }
2611                    } if table == "users" && columns == &vec![
2612                        "id".to_string(),
2613                        "tenant_id".to_string(),
2614                        "region_id".to_string()
2615                    ]
2616                )
2617            });
2618            assert!(
2619                has_remove,
2620                "Should have RemoveConstraint for old 3-column composite PK"
2621            );
2622
2623            let has_add = plan.actions.iter().any(|a| {
2624                matches!(
2625                    a,
2626                    MigrationAction::AddConstraint {
2627                        table,
2628                        constraint: TableConstraint::PrimaryKey { columns, .. }
2629                    } if table == "users" && columns == &vec!["id".to_string()]
2630                )
2631            });
2632            assert!(
2633                has_add,
2634                "Should have AddConstraint for new single-column PK"
2635            );
2636        }
2637
2638        #[test]
2639        fn change_composite_pk_columns_partially() {
2640            // Primary key: [id, tenant_id] -> [id, region_id]
2641            // One column kept, one removed, one added
2642            let from = vec![table(
2643                "users",
2644                vec![
2645                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2646                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2647                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2648                ],
2649                vec![pk(vec!["id", "tenant_id"])],
2650            )];
2651
2652            let to = vec![table(
2653                "users",
2654                vec![
2655                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2656                    col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2657                    col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2658                ],
2659                vec![pk(vec!["id", "region_id"])],
2660            )];
2661
2662            let plan = diff_schemas(&from, &to).unwrap();
2663
2664            assert_eq!(plan.actions.len(), 2);
2665
2666            let has_remove = plan.actions.iter().any(|a| {
2667                matches!(
2668                    a,
2669                    MigrationAction::RemoveConstraint {
2670                        table,
2671                        constraint: TableConstraint::PrimaryKey { columns, .. }
2672                    } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2673                )
2674            });
2675            assert!(
2676                has_remove,
2677                "Should have RemoveConstraint for old PK with tenant_id"
2678            );
2679
2680            let has_add = plan.actions.iter().any(|a| {
2681                matches!(
2682                    a,
2683                    MigrationAction::AddConstraint {
2684                        table,
2685                        constraint: TableConstraint::PrimaryKey { columns, .. }
2686                    } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2687                )
2688            });
2689            assert!(
2690                has_add,
2691                "Should have AddConstraint for new PK with region_id"
2692            );
2693        }
2694    }
2695
2696    mod default_changes {
2697        use super::*;
2698
2699        fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2700            ColumnDef {
2701                name: name.to_string(),
2702                r#type: ty,
2703                nullable: true,
2704                default: default.map(|s| s.into()),
2705                comment: None,
2706                primary_key: None,
2707                unique: None,
2708                index: None,
2709                foreign_key: None,
2710            }
2711        }
2712
2713        #[test]
2714        fn add_default_value() {
2715            // Column: no default -> has default
2716            let from = vec![table(
2717                "users",
2718                vec![
2719                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2720                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2721                ],
2722                vec![],
2723            )];
2724
2725            let to = vec![table(
2726                "users",
2727                vec![
2728                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2729                    col_with_default(
2730                        "status",
2731                        ColumnType::Simple(SimpleColumnType::Text),
2732                        Some("'active'"),
2733                    ),
2734                ],
2735                vec![],
2736            )];
2737
2738            let plan = diff_schemas(&from, &to).unwrap();
2739
2740            assert_eq!(plan.actions.len(), 1);
2741            assert!(matches!(
2742                &plan.actions[0],
2743                MigrationAction::ModifyColumnDefault {
2744                    table,
2745                    column,
2746                    new_default: Some(default),
2747                } if table == "users" && column == "status" && default == "'active'"
2748            ));
2749        }
2750
2751        #[test]
2752        fn remove_default_value() {
2753            // Column: has default -> no default
2754            let from = vec![table(
2755                "users",
2756                vec![
2757                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2758                    col_with_default(
2759                        "status",
2760                        ColumnType::Simple(SimpleColumnType::Text),
2761                        Some("'active'"),
2762                    ),
2763                ],
2764                vec![],
2765            )];
2766
2767            let to = vec![table(
2768                "users",
2769                vec![
2770                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2771                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2772                ],
2773                vec![],
2774            )];
2775
2776            let plan = diff_schemas(&from, &to).unwrap();
2777
2778            assert_eq!(plan.actions.len(), 1);
2779            assert!(matches!(
2780                &plan.actions[0],
2781                MigrationAction::ModifyColumnDefault {
2782                    table,
2783                    column,
2784                    new_default: None,
2785                } if table == "users" && column == "status"
2786            ));
2787        }
2788
2789        #[test]
2790        fn change_default_value() {
2791            // Column: 'active' -> 'pending'
2792            let from = vec![table(
2793                "users",
2794                vec![
2795                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2796                    col_with_default(
2797                        "status",
2798                        ColumnType::Simple(SimpleColumnType::Text),
2799                        Some("'active'"),
2800                    ),
2801                ],
2802                vec![],
2803            )];
2804
2805            let to = vec![table(
2806                "users",
2807                vec![
2808                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2809                    col_with_default(
2810                        "status",
2811                        ColumnType::Simple(SimpleColumnType::Text),
2812                        Some("'pending'"),
2813                    ),
2814                ],
2815                vec![],
2816            )];
2817
2818            let plan = diff_schemas(&from, &to).unwrap();
2819
2820            assert_eq!(plan.actions.len(), 1);
2821            assert!(matches!(
2822                &plan.actions[0],
2823                MigrationAction::ModifyColumnDefault {
2824                    table,
2825                    column,
2826                    new_default: Some(default),
2827                } if table == "users" && column == "status" && default == "'pending'"
2828            ));
2829        }
2830
2831        #[test]
2832        fn no_change_same_default() {
2833            // Column: same default -> no action
2834            let from = vec![table(
2835                "users",
2836                vec![
2837                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2838                    col_with_default(
2839                        "status",
2840                        ColumnType::Simple(SimpleColumnType::Text),
2841                        Some("'active'"),
2842                    ),
2843                ],
2844                vec![],
2845            )];
2846
2847            let to = vec![table(
2848                "users",
2849                vec![
2850                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2851                    col_with_default(
2852                        "status",
2853                        ColumnType::Simple(SimpleColumnType::Text),
2854                        Some("'active'"),
2855                    ),
2856                ],
2857                vec![],
2858            )];
2859
2860            let plan = diff_schemas(&from, &to).unwrap();
2861
2862            assert!(plan.actions.is_empty());
2863        }
2864
2865        #[test]
2866        fn multiple_columns_default_changes() {
2867            // Multiple columns with default changes
2868            let from = vec![table(
2869                "users",
2870                vec![
2871                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2872                    col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2873                    col_with_default(
2874                        "role",
2875                        ColumnType::Simple(SimpleColumnType::Text),
2876                        Some("'user'"),
2877                    ),
2878                    col_with_default(
2879                        "active",
2880                        ColumnType::Simple(SimpleColumnType::Boolean),
2881                        Some("true"),
2882                    ),
2883                ],
2884                vec![],
2885            )];
2886
2887            let to = vec![table(
2888                "users",
2889                vec![
2890                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2891                    col_with_default(
2892                        "status",
2893                        ColumnType::Simple(SimpleColumnType::Text),
2894                        Some("'pending'"),
2895                    ), // None -> 'pending'
2896                    col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), // 'user' -> None
2897                    col_with_default(
2898                        "active",
2899                        ColumnType::Simple(SimpleColumnType::Boolean),
2900                        Some("true"),
2901                    ), // no change
2902                ],
2903                vec![],
2904            )];
2905
2906            let plan = diff_schemas(&from, &to).unwrap();
2907
2908            assert_eq!(plan.actions.len(), 2);
2909
2910            let has_status_change = plan.actions.iter().any(|a| {
2911                matches!(
2912                    a,
2913                    MigrationAction::ModifyColumnDefault {
2914                        table,
2915                        column,
2916                        new_default: Some(default),
2917                    } if table == "users" && column == "status" && default == "'pending'"
2918                )
2919            });
2920            assert!(has_status_change, "Should detect status default added");
2921
2922            let has_role_change = plan.actions.iter().any(|a| {
2923                matches!(
2924                    a,
2925                    MigrationAction::ModifyColumnDefault {
2926                        table,
2927                        column,
2928                        new_default: None,
2929                    } if table == "users" && column == "role"
2930                )
2931            });
2932            assert!(has_role_change, "Should detect role default removed");
2933        }
2934
2935        #[test]
2936        fn default_change_with_type_change() {
2937            // Column changing both type and default
2938            let from = vec![table(
2939                "users",
2940                vec![
2941                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2942                    col_with_default(
2943                        "count",
2944                        ColumnType::Simple(SimpleColumnType::Integer),
2945                        Some("0"),
2946                    ),
2947                ],
2948                vec![],
2949            )];
2950
2951            let to = vec![table(
2952                "users",
2953                vec![
2954                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2955                    col_with_default(
2956                        "count",
2957                        ColumnType::Simple(SimpleColumnType::Text),
2958                        Some("'0'"),
2959                    ),
2960                ],
2961                vec![],
2962            )];
2963
2964            let plan = diff_schemas(&from, &to).unwrap();
2965
2966            // Should generate both ModifyColumnType and ModifyColumnDefault
2967            assert_eq!(plan.actions.len(), 2);
2968
2969            let has_type_change = plan.actions.iter().any(|a| {
2970                matches!(
2971                    a,
2972                    MigrationAction::ModifyColumnType { table, column, .. }
2973                    if table == "users" && column == "count"
2974                )
2975            });
2976            assert!(has_type_change, "Should detect type change");
2977
2978            let has_default_change = plan.actions.iter().any(|a| {
2979                matches!(
2980                    a,
2981                    MigrationAction::ModifyColumnDefault {
2982                        table,
2983                        column,
2984                        new_default: Some(default),
2985                    } if table == "users" && column == "count" && default == "'0'"
2986                )
2987            });
2988            assert!(has_default_change, "Should detect default change");
2989        }
2990    }
2991
2992    mod comment_changes {
2993        use super::*;
2994
2995        fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2996            ColumnDef {
2997                name: name.to_string(),
2998                r#type: ty,
2999                nullable: true,
3000                default: None,
3001                comment: comment.map(|s| s.to_string()),
3002                primary_key: None,
3003                unique: None,
3004                index: None,
3005                foreign_key: None,
3006            }
3007        }
3008
3009        #[test]
3010        fn add_comment() {
3011            // Column: no comment -> has comment
3012            let from = vec![table(
3013                "users",
3014                vec![
3015                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3016                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3017                ],
3018                vec![],
3019            )];
3020
3021            let to = vec![table(
3022                "users",
3023                vec![
3024                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3025                    col_with_comment(
3026                        "email",
3027                        ColumnType::Simple(SimpleColumnType::Text),
3028                        Some("User's email address"),
3029                    ),
3030                ],
3031                vec![],
3032            )];
3033
3034            let plan = diff_schemas(&from, &to).unwrap();
3035
3036            assert_eq!(plan.actions.len(), 1);
3037            assert!(matches!(
3038                &plan.actions[0],
3039                MigrationAction::ModifyColumnComment {
3040                    table,
3041                    column,
3042                    new_comment: Some(comment),
3043                } if table == "users" && column == "email" && comment == "User's email address"
3044            ));
3045        }
3046
3047        #[test]
3048        fn remove_comment() {
3049            // Column: has comment -> no comment
3050            let from = vec![table(
3051                "users",
3052                vec![
3053                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3054                    col_with_comment(
3055                        "email",
3056                        ColumnType::Simple(SimpleColumnType::Text),
3057                        Some("User's email address"),
3058                    ),
3059                ],
3060                vec![],
3061            )];
3062
3063            let to = vec![table(
3064                "users",
3065                vec![
3066                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3067                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3068                ],
3069                vec![],
3070            )];
3071
3072            let plan = diff_schemas(&from, &to).unwrap();
3073
3074            assert_eq!(plan.actions.len(), 1);
3075            assert!(matches!(
3076                &plan.actions[0],
3077                MigrationAction::ModifyColumnComment {
3078                    table,
3079                    column,
3080                    new_comment: None,
3081                } if table == "users" && column == "email"
3082            ));
3083        }
3084
3085        #[test]
3086        fn change_comment() {
3087            // Column: 'old comment' -> 'new comment'
3088            let from = vec![table(
3089                "users",
3090                vec![
3091                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3092                    col_with_comment(
3093                        "email",
3094                        ColumnType::Simple(SimpleColumnType::Text),
3095                        Some("Old comment"),
3096                    ),
3097                ],
3098                vec![],
3099            )];
3100
3101            let to = vec![table(
3102                "users",
3103                vec![
3104                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3105                    col_with_comment(
3106                        "email",
3107                        ColumnType::Simple(SimpleColumnType::Text),
3108                        Some("New comment"),
3109                    ),
3110                ],
3111                vec![],
3112            )];
3113
3114            let plan = diff_schemas(&from, &to).unwrap();
3115
3116            assert_eq!(plan.actions.len(), 1);
3117            assert!(matches!(
3118                &plan.actions[0],
3119                MigrationAction::ModifyColumnComment {
3120                    table,
3121                    column,
3122                    new_comment: Some(comment),
3123                } if table == "users" && column == "email" && comment == "New comment"
3124            ));
3125        }
3126
3127        #[test]
3128        fn no_change_same_comment() {
3129            // Column: same comment -> no action
3130            let from = vec![table(
3131                "users",
3132                vec![
3133                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3134                    col_with_comment(
3135                        "email",
3136                        ColumnType::Simple(SimpleColumnType::Text),
3137                        Some("Same comment"),
3138                    ),
3139                ],
3140                vec![],
3141            )];
3142
3143            let to = vec![table(
3144                "users",
3145                vec![
3146                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3147                    col_with_comment(
3148                        "email",
3149                        ColumnType::Simple(SimpleColumnType::Text),
3150                        Some("Same comment"),
3151                    ),
3152                ],
3153                vec![],
3154            )];
3155
3156            let plan = diff_schemas(&from, &to).unwrap();
3157
3158            assert!(plan.actions.is_empty());
3159        }
3160
3161        #[test]
3162        fn multiple_columns_comment_changes() {
3163            // Multiple columns with comment changes
3164            let from = vec![table(
3165                "users",
3166                vec![
3167                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3168                    col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3169                    col_with_comment(
3170                        "name",
3171                        ColumnType::Simple(SimpleColumnType::Text),
3172                        Some("User name"),
3173                    ),
3174                    col_with_comment(
3175                        "phone",
3176                        ColumnType::Simple(SimpleColumnType::Text),
3177                        Some("Phone number"),
3178                    ),
3179                ],
3180                vec![],
3181            )];
3182
3183            let to = vec![table(
3184                "users",
3185                vec![
3186                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3187                    col_with_comment(
3188                        "email",
3189                        ColumnType::Simple(SimpleColumnType::Text),
3190                        Some("Email address"),
3191                    ), // None -> "Email address"
3192                    col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), // "User name" -> None
3193                    col_with_comment(
3194                        "phone",
3195                        ColumnType::Simple(SimpleColumnType::Text),
3196                        Some("Phone number"),
3197                    ), // no change
3198                ],
3199                vec![],
3200            )];
3201
3202            let plan = diff_schemas(&from, &to).unwrap();
3203
3204            assert_eq!(plan.actions.len(), 2);
3205
3206            let has_email_change = plan.actions.iter().any(|a| {
3207                matches!(
3208                    a,
3209                    MigrationAction::ModifyColumnComment {
3210                        table,
3211                        column,
3212                        new_comment: Some(comment),
3213                    } if table == "users" && column == "email" && comment == "Email address"
3214                )
3215            });
3216            assert!(has_email_change, "Should detect email comment added");
3217
3218            let has_name_change = plan.actions.iter().any(|a| {
3219                matches!(
3220                    a,
3221                    MigrationAction::ModifyColumnComment {
3222                        table,
3223                        column,
3224                        new_comment: None,
3225                    } if table == "users" && column == "name"
3226                )
3227            });
3228            assert!(has_name_change, "Should detect name comment removed");
3229        }
3230
3231        #[test]
3232        fn comment_change_with_nullable_change() {
3233            // Column changing both nullable and comment
3234            let from = vec![table(
3235                "users",
3236                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3237                    let mut c =
3238                        col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
3239                    c.nullable = true;
3240                    c
3241                }],
3242                vec![],
3243            )];
3244
3245            let to = vec![table(
3246                "users",
3247                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3248                    let mut c = col_with_comment(
3249                        "email",
3250                        ColumnType::Simple(SimpleColumnType::Text),
3251                        Some("Required email"),
3252                    );
3253                    c.nullable = false;
3254                    c
3255                }],
3256                vec![],
3257            )];
3258
3259            let plan = diff_schemas(&from, &to).unwrap();
3260
3261            // Should generate both ModifyColumnNullable and ModifyColumnComment
3262            assert_eq!(plan.actions.len(), 2);
3263
3264            let has_nullable_change = plan.actions.iter().any(|a| {
3265                matches!(
3266                    a,
3267                    MigrationAction::ModifyColumnNullable {
3268                        table,
3269                        column,
3270                        nullable: false,
3271                        ..
3272                    } if table == "users" && column == "email"
3273                )
3274            });
3275            assert!(has_nullable_change, "Should detect nullable change");
3276
3277            let has_comment_change = plan.actions.iter().any(|a| {
3278                matches!(
3279                    a,
3280                    MigrationAction::ModifyColumnComment {
3281                        table,
3282                        column,
3283                        new_comment: Some(comment),
3284                    } if table == "users" && column == "email" && comment == "Required email"
3285                )
3286            });
3287            assert!(has_comment_change, "Should detect comment change");
3288        }
3289    }
3290
3291    mod nullable_changes {
3292        use super::*;
3293
3294        fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
3295            ColumnDef {
3296                name: name.to_string(),
3297                r#type: ty,
3298                nullable,
3299                default: None,
3300                comment: None,
3301                primary_key: None,
3302                unique: None,
3303                index: None,
3304                foreign_key: None,
3305            }
3306        }
3307
3308        #[test]
3309        fn column_nullable_to_non_nullable() {
3310            // Column: nullable -> non-nullable
3311            let from = vec![table(
3312                "users",
3313                vec![
3314                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3315                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3316                ],
3317                vec![],
3318            )];
3319
3320            let to = vec![table(
3321                "users",
3322                vec![
3323                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3324                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3325                ],
3326                vec![],
3327            )];
3328
3329            let plan = diff_schemas(&from, &to).unwrap();
3330
3331            assert_eq!(plan.actions.len(), 1);
3332            assert!(matches!(
3333                &plan.actions[0],
3334                MigrationAction::ModifyColumnNullable {
3335                    table,
3336                    column,
3337                    nullable: false,
3338                    fill_with: None,
3339                } if table == "users" && column == "email"
3340            ));
3341        }
3342
3343        #[test]
3344        fn column_non_nullable_to_nullable() {
3345            // Column: non-nullable -> nullable
3346            let from = vec![table(
3347                "users",
3348                vec![
3349                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3350                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3351                ],
3352                vec![],
3353            )];
3354
3355            let to = vec![table(
3356                "users",
3357                vec![
3358                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3359                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3360                ],
3361                vec![],
3362            )];
3363
3364            let plan = diff_schemas(&from, &to).unwrap();
3365
3366            assert_eq!(plan.actions.len(), 1);
3367            assert!(matches!(
3368                &plan.actions[0],
3369                MigrationAction::ModifyColumnNullable {
3370                    table,
3371                    column,
3372                    nullable: true,
3373                    fill_with: None,
3374                } if table == "users" && column == "email"
3375            ));
3376        }
3377
3378        #[test]
3379        fn multiple_columns_nullable_changes() {
3380            // Multiple columns changing nullability at once
3381            let from = vec![table(
3382                "users",
3383                vec![
3384                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3385                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3386                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
3387                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
3388                ],
3389                vec![],
3390            )];
3391
3392            let to = vec![table(
3393                "users",
3394                vec![
3395                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3396                    col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), // nullable -> non-nullable
3397                    col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), // non-nullable -> nullable
3398                    col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), // no change
3399                ],
3400                vec![],
3401            )];
3402
3403            let plan = diff_schemas(&from, &to).unwrap();
3404
3405            assert_eq!(plan.actions.len(), 2);
3406
3407            let has_email_change = plan.actions.iter().any(|a| {
3408                matches!(
3409                    a,
3410                    MigrationAction::ModifyColumnNullable {
3411                        table,
3412                        column,
3413                        nullable: false,
3414                        ..
3415                    } if table == "users" && column == "email"
3416                )
3417            });
3418            assert!(
3419                has_email_change,
3420                "Should detect email nullable -> non-nullable"
3421            );
3422
3423            let has_name_change = plan.actions.iter().any(|a| {
3424                matches!(
3425                    a,
3426                    MigrationAction::ModifyColumnNullable {
3427                        table,
3428                        column,
3429                        nullable: true,
3430                        ..
3431                    } if table == "users" && column == "name"
3432                )
3433            });
3434            assert!(
3435                has_name_change,
3436                "Should detect name non-nullable -> nullable"
3437            );
3438        }
3439
3440        #[test]
3441        fn nullable_change_with_type_change() {
3442            // Column changing both type and nullability
3443            let from = vec![table(
3444                "users",
3445                vec![
3446                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3447                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
3448                ],
3449                vec![],
3450            )];
3451
3452            let to = vec![table(
3453                "users",
3454                vec![
3455                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3456                    col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
3457                ],
3458                vec![],
3459            )];
3460
3461            let plan = diff_schemas(&from, &to).unwrap();
3462
3463            // Should generate both ModifyColumnType and ModifyColumnNullable
3464            assert_eq!(plan.actions.len(), 2);
3465
3466            let has_type_change = plan.actions.iter().any(|a| {
3467                matches!(
3468                    a,
3469                    MigrationAction::ModifyColumnType { table, column, .. }
3470                    if table == "users" && column == "age"
3471                )
3472            });
3473            assert!(has_type_change, "Should detect type change");
3474
3475            let has_nullable_change = plan.actions.iter().any(|a| {
3476                matches!(
3477                    a,
3478                    MigrationAction::ModifyColumnNullable {
3479                        table,
3480                        column,
3481                        nullable: false,
3482                        ..
3483                    } if table == "users" && column == "age"
3484                )
3485            });
3486            assert!(has_nullable_change, "Should detect nullable change");
3487        }
3488    }
3489
3490    mod diff_tables {
3491        use insta::assert_debug_snapshot;
3492
3493        use super::*;
3494
3495        #[test]
3496        fn create_table_with_inline_index() {
3497            let base = [table(
3498                "users",
3499                vec![
3500                    ColumnDef {
3501                        name: "id".to_string(),
3502                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3503                        nullable: false,
3504                        default: None,
3505                        comment: None,
3506                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3507                        unique: None,
3508                        index: Some(StrOrBoolOrArray::Bool(false)),
3509                        foreign_key: None,
3510                    },
3511                    ColumnDef {
3512                        name: "name".to_string(),
3513                        r#type: ColumnType::Simple(SimpleColumnType::Text),
3514                        nullable: true,
3515                        default: None,
3516                        comment: None,
3517                        primary_key: None,
3518                        unique: Some(StrOrBoolOrArray::Bool(true)),
3519                        index: Some(StrOrBoolOrArray::Bool(true)),
3520                        foreign_key: None,
3521                    },
3522                ],
3523                vec![],
3524            )];
3525            let plan = diff_schemas(&[], &base).unwrap();
3526
3527            assert_eq!(plan.actions.len(), 1);
3528            assert_debug_snapshot!(plan.actions);
3529
3530            let plan = diff_schemas(
3531                &base,
3532                &[table(
3533                    "users",
3534                    vec![
3535                        ColumnDef {
3536                            name: "id".to_string(),
3537                            r#type: ColumnType::Simple(SimpleColumnType::Integer),
3538                            nullable: false,
3539                            default: None,
3540                            comment: None,
3541                            primary_key: Some(PrimaryKeySyntax::Bool(true)),
3542                            unique: None,
3543                            index: Some(StrOrBoolOrArray::Bool(false)),
3544                            foreign_key: None,
3545                        },
3546                        ColumnDef {
3547                            name: "name".to_string(),
3548                            r#type: ColumnType::Simple(SimpleColumnType::Text),
3549                            nullable: true,
3550                            default: None,
3551                            comment: None,
3552                            primary_key: None,
3553                            unique: Some(StrOrBoolOrArray::Bool(true)),
3554                            index: Some(StrOrBoolOrArray::Bool(false)),
3555                            foreign_key: None,
3556                        },
3557                    ],
3558                    vec![],
3559                )],
3560            )
3561            .unwrap();
3562
3563            assert_eq!(plan.actions.len(), 1);
3564            assert_debug_snapshot!(plan.actions);
3565        }
3566
3567        #[rstest]
3568        #[case(
3569            "add_index",
3570            vec![table(
3571                "users",
3572                vec![
3573                    ColumnDef {
3574                        name: "id".to_string(),
3575                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3576                        nullable: false,
3577                        default: None,
3578                        comment: None,
3579                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3580                        unique: None,
3581                        index: None,
3582                        foreign_key: None,
3583                    },
3584                ],
3585                vec![],
3586            )],
3587            vec![table(
3588                "users",
3589                vec![
3590                    ColumnDef {
3591                        name: "id".to_string(),
3592                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3593                        nullable: false,
3594                        default: None,
3595                        comment: None,
3596                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3597                        unique: None,
3598                        index: Some(StrOrBoolOrArray::Bool(true)),
3599                        foreign_key: None,
3600                    },
3601                ],
3602                vec![],
3603            )],
3604        )]
3605        #[case(
3606            "remove_index",
3607            vec![table(
3608                "users",
3609                vec![
3610                    ColumnDef {
3611                        name: "id".to_string(),
3612                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3613                        nullable: false,
3614                        default: None,
3615                        comment: None,
3616                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3617                        unique: None,
3618                        index: Some(StrOrBoolOrArray::Bool(true)),
3619                        foreign_key: None,
3620                    },
3621                ],
3622                vec![],
3623            )],
3624            vec![table(
3625                "users",
3626                vec![
3627                    ColumnDef {
3628                        name: "id".to_string(),
3629                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3630                        nullable: false,
3631                        default: None,
3632                        comment: None,
3633                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3634                        unique: None,
3635                        index: Some(StrOrBoolOrArray::Bool(false)),
3636                        foreign_key: None,
3637                    },
3638                ],
3639                vec![],
3640            )],
3641        )]
3642        #[case(
3643            "add_named_index",
3644            vec![table(
3645                "users",
3646                vec![
3647                    ColumnDef {
3648                        name: "id".to_string(),
3649                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3650                        nullable: false,
3651                        default: None,
3652                        comment: None,
3653                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3654                        unique: None,
3655                        index: None,
3656                        foreign_key: None,
3657                    },
3658                ],
3659                vec![],
3660            )],
3661            vec![table(
3662                "users",
3663                vec![
3664                    ColumnDef {
3665                        name: "id".to_string(),
3666                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3667                        nullable: false,
3668                        default: None,
3669                        comment: None,
3670                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3671                        unique: None,
3672                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3673                        foreign_key: None,
3674                    },
3675                ],
3676                vec![],
3677            )],
3678        )]
3679        #[case(
3680            "remove_named_index",
3681            vec![table(
3682                "users",
3683                vec![
3684                    ColumnDef {
3685                        name: "id".to_string(),
3686                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3687                        nullable: false,
3688                        default: None,
3689                        comment: None,
3690                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3691                        unique: None,
3692                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3693                        foreign_key: None,
3694                    },
3695                ],
3696                vec![],
3697            )],
3698            vec![table(
3699                "users",
3700                vec![
3701                    ColumnDef {
3702                        name: "id".to_string(),
3703                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
3704                        nullable: false,
3705                        default: None,
3706                        comment: None,
3707                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
3708                        unique: None,
3709                        index: None,
3710                        foreign_key: None,
3711                    },
3712                ],
3713                vec![],
3714            )],
3715        )]
3716        fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3717            use insta::with_settings;
3718
3719            let plan = diff_schemas(&base, &to).unwrap();
3720            with_settings!({ snapshot_suffix => name }, {
3721                assert_debug_snapshot!(plan.actions);
3722            });
3723        }
3724    }
3725
3726    // Explicit coverage tests for lines that tarpaulin might miss in rstest
3727    mod coverage_explicit {
3728        use super::*;
3729
3730        #[test]
3731        fn delete_column_explicit() {
3732            // Covers lines 292-294: DeleteColumn action inside modified table loop
3733            let from = vec![table(
3734                "users",
3735                vec![
3736                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3737                    col("name", ColumnType::Simple(SimpleColumnType::Text)),
3738                ],
3739                vec![],
3740            )];
3741
3742            let to = vec![table(
3743                "users",
3744                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3745                vec![],
3746            )];
3747
3748            let plan = diff_schemas(&from, &to).unwrap();
3749            assert_eq!(plan.actions.len(), 1);
3750            assert!(matches!(
3751                &plan.actions[0],
3752                MigrationAction::DeleteColumn { table, column }
3753                if table == "users" && column == "name"
3754            ));
3755        }
3756
3757        #[test]
3758        fn add_column_explicit() {
3759            // Covers lines 359-362: AddColumn action inside modified table loop
3760            let from = vec![table(
3761                "users",
3762                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3763                vec![],
3764            )];
3765
3766            let to = vec![table(
3767                "users",
3768                vec![
3769                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3770                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3771                ],
3772                vec![],
3773            )];
3774
3775            let plan = diff_schemas(&from, &to).unwrap();
3776            assert_eq!(plan.actions.len(), 1);
3777            assert!(matches!(
3778                &plan.actions[0],
3779                MigrationAction::AddColumn { table, column, .. }
3780                if table == "users" && column.name == "email"
3781            ));
3782        }
3783
3784        #[test]
3785        fn remove_constraint_explicit() {
3786            // Covers lines 370-372: RemoveConstraint action inside modified table loop
3787            let from = vec![table(
3788                "users",
3789                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3790                vec![idx("idx_users_id", vec!["id"])],
3791            )];
3792
3793            let to = vec![table(
3794                "users",
3795                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3796                vec![],
3797            )];
3798
3799            let plan = diff_schemas(&from, &to).unwrap();
3800            assert_eq!(plan.actions.len(), 1);
3801            assert!(matches!(
3802                &plan.actions[0],
3803                MigrationAction::RemoveConstraint { table, constraint }
3804                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3805            ));
3806        }
3807
3808        #[test]
3809        fn add_constraint_explicit() {
3810            // Covers lines 378-380: AddConstraint action inside modified table loop
3811            let from = vec![table(
3812                "users",
3813                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3814                vec![],
3815            )];
3816
3817            let to = vec![table(
3818                "users",
3819                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3820                vec![idx("idx_users_id", vec!["id"])],
3821            )];
3822
3823            let plan = diff_schemas(&from, &to).unwrap();
3824            assert_eq!(plan.actions.len(), 1);
3825            assert!(matches!(
3826                &plan.actions[0],
3827                MigrationAction::AddConstraint { table, constraint }
3828                if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3829            ));
3830        }
3831    }
3832
3833    mod constraint_removal_on_deleted_columns {
3834        use super::*;
3835
3836        fn fk(columns: Vec<&str>, ref_table: &str, ref_columns: Vec<&str>) -> TableConstraint {
3837            TableConstraint::ForeignKey {
3838                name: None,
3839                columns: columns.into_iter().map(|s| s.to_string()).collect(),
3840                ref_table: ref_table.to_string(),
3841                ref_columns: ref_columns.into_iter().map(|s| s.to_string()).collect(),
3842                on_delete: None,
3843                on_update: None,
3844            }
3845        }
3846
3847        #[test]
3848        fn skip_remove_constraint_when_all_columns_deleted() {
3849            // When a column with FK and index is deleted, the constraints should NOT
3850            // generate separate RemoveConstraint actions (they are dropped with the column)
3851            let from = vec![table(
3852                "project",
3853                vec![
3854                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3855                    col("template_id", ColumnType::Simple(SimpleColumnType::Integer)),
3856                ],
3857                vec![
3858                    fk(vec!["template_id"], "book_template", vec!["id"]),
3859                    idx("ix_project__template_id", vec!["template_id"]),
3860                ],
3861            )];
3862
3863            let to = vec![table(
3864                "project",
3865                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3866                vec![],
3867            )];
3868
3869            let plan = diff_schemas(&from, &to).unwrap();
3870
3871            // Should only have DeleteColumn, NO RemoveConstraint actions
3872            assert_eq!(plan.actions.len(), 1);
3873            assert!(matches!(
3874                &plan.actions[0],
3875                MigrationAction::DeleteColumn { table, column }
3876                if table == "project" && column == "template_id"
3877            ));
3878
3879            // Explicitly verify no RemoveConstraint
3880            let has_remove_constraint = plan
3881                .actions
3882                .iter()
3883                .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3884            assert!(
3885                !has_remove_constraint,
3886                "Should NOT have RemoveConstraint when column is deleted"
3887            );
3888        }
3889
3890        #[test]
3891        fn keep_remove_constraint_when_only_some_columns_deleted() {
3892            // If a composite constraint has some columns remaining, RemoveConstraint is needed
3893            let from = vec![table(
3894                "orders",
3895                vec![
3896                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3897                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3898                    col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3899                ],
3900                vec![idx(
3901                    "ix_orders__user_product",
3902                    vec!["user_id", "product_id"],
3903                )],
3904            )];
3905
3906            let to = vec![table(
3907                "orders",
3908                vec![
3909                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3910                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3911                    // product_id is deleted, but user_id remains
3912                ],
3913                vec![],
3914            )];
3915
3916            let plan = diff_schemas(&from, &to).unwrap();
3917
3918            // Should have both DeleteColumn AND RemoveConstraint
3919            // (because user_id is still there, the composite index needs explicit removal)
3920            let has_delete_column = plan.actions.iter().any(|a| {
3921                matches!(
3922                    a,
3923                    MigrationAction::DeleteColumn { table, column }
3924                    if table == "orders" && column == "product_id"
3925                )
3926            });
3927            assert!(has_delete_column, "Should have DeleteColumn for product_id");
3928
3929            let has_remove_constraint = plan.actions.iter().any(|a| {
3930                matches!(
3931                    a,
3932                    MigrationAction::RemoveConstraint { table, .. }
3933                    if table == "orders"
3934                )
3935            });
3936            assert!(
3937                has_remove_constraint,
3938                "Should have RemoveConstraint for composite index when only some columns deleted"
3939            );
3940        }
3941
3942        #[test]
3943        fn skip_remove_constraint_when_all_composite_columns_deleted() {
3944            // If ALL columns of a composite constraint are deleted, skip RemoveConstraint
3945            let from = vec![table(
3946                "orders",
3947                vec![
3948                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3949                    col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3950                    col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3951                ],
3952                vec![idx(
3953                    "ix_orders__user_product",
3954                    vec!["user_id", "product_id"],
3955                )],
3956            )];
3957
3958            let to = vec![table(
3959                "orders",
3960                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3961                vec![],
3962            )];
3963
3964            let plan = diff_schemas(&from, &to).unwrap();
3965
3966            // Should only have DeleteColumn actions, no RemoveConstraint
3967            let delete_columns: Vec<_> = plan
3968                .actions
3969                .iter()
3970                .filter(|a| matches!(a, MigrationAction::DeleteColumn { .. }))
3971                .collect();
3972            assert_eq!(
3973                delete_columns.len(),
3974                2,
3975                "Should have 2 DeleteColumn actions"
3976            );
3977
3978            let has_remove_constraint = plan
3979                .actions
3980                .iter()
3981                .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3982            assert!(
3983                !has_remove_constraint,
3984                "Should NOT have RemoveConstraint when all composite columns deleted"
3985            );
3986        }
3987
3988        #[test]
3989        fn keep_remove_constraint_when_no_columns_deleted() {
3990            // Normal case: constraint removed but columns remain
3991            let from = vec![table(
3992                "users",
3993                vec![
3994                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3995                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
3996                ],
3997                vec![idx("ix_users__email", vec!["email"])],
3998            )];
3999
4000            let to = vec![table(
4001                "users",
4002                vec![
4003                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4004                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
4005                ],
4006                vec![], // Index removed but column remains
4007            )];
4008
4009            let plan = diff_schemas(&from, &to).unwrap();
4010
4011            assert_eq!(plan.actions.len(), 1);
4012            assert!(matches!(
4013                &plan.actions[0],
4014                MigrationAction::RemoveConstraint { table, .. }
4015                if table == "users"
4016            ));
4017        }
4018    }
4019}