Skip to main content

vespertide_planner/
diff.rs

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