vespertide_planner/
diff.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef};
4
5use crate::error::PlannerError;
6
7/// Topologically sort tables based on foreign key dependencies.
8/// Returns tables in order where tables with no FK dependencies come first,
9/// and tables that reference other tables come after their referenced tables.
10fn topological_sort_tables<'a>(tables: &[&'a TableDef]) -> Result<Vec<&'a TableDef>, PlannerError> {
11    if tables.is_empty() {
12        return Ok(vec![]);
13    }
14
15    // Build a map of table names for quick lookup
16    let table_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect();
17
18    // Build adjacency list: for each table, list the tables it depends on (via FK)
19    // Use BTreeMap for consistent ordering
20    // Use BTreeSet to avoid duplicate dependencies (e.g., multiple FKs referencing the same table)
21    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
22    for table in tables {
23        let mut deps_set: BTreeSet<&str> = BTreeSet::new();
24        for constraint in &table.constraints {
25            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
26                // Only consider dependencies within the set of tables being created
27                if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
28                    deps_set.insert(ref_table.as_str());
29                }
30            }
31        }
32        dependencies.insert(table.name.as_str(), deps_set.into_iter().collect());
33    }
34
35    // Kahn's algorithm for topological sort
36    // Calculate in-degrees (number of tables that depend on each table)
37    // Use BTreeMap for consistent ordering
38    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
39    for table in tables {
40        in_degree.entry(table.name.as_str()).or_insert(0);
41    }
42
43    // For each dependency, increment the in-degree of the dependent table
44    for (table_name, deps) in &dependencies {
45        for _dep in deps {
46            // The table has dependencies, so those referenced tables must come first
47            // We actually want the reverse: tables with dependencies have higher in-degree
48        }
49        // Actually, we need to track: if A depends on B, then A has in-degree from B
50        // So A cannot be processed until B is processed
51        *in_degree.entry(table_name).or_insert(0) += deps.len();
52    }
53
54    // Start with tables that have no dependencies
55    // BTreeMap iteration is already sorted by key
56    let mut queue: VecDeque<&str> = in_degree
57        .iter()
58        .filter(|(_, deg)| **deg == 0)
59        .map(|(name, _)| *name)
60        .collect();
61
62    let mut result: Vec<&TableDef> = Vec::new();
63    let table_map: BTreeMap<&str, &TableDef> =
64        tables.iter().map(|t| (t.name.as_str(), *t)).collect();
65
66    while let Some(table_name) = queue.pop_front() {
67        if let Some(&table) = table_map.get(table_name) {
68            result.push(table);
69        }
70
71        // Collect tables that become ready (in-degree becomes 0)
72        // Use BTreeSet for consistent ordering
73        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
74        for (dependent, deps) in &dependencies {
75            if deps.contains(&table_name)
76                && let Some(degree) = in_degree.get_mut(dependent)
77            {
78                *degree -= 1;
79                if *degree == 0 {
80                    ready_tables.insert(dependent);
81                }
82            }
83        }
84        for t in ready_tables {
85            queue.push_back(t);
86        }
87    }
88
89    // Check for cycles
90    if result.len() != tables.len() {
91        let remaining: Vec<&str> = tables
92            .iter()
93            .map(|t| t.name.as_str())
94            .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
95            .collect();
96        return Err(PlannerError::TableValidation(format!(
97            "Circular foreign key dependency detected among tables: {:?}",
98            remaining
99        )));
100    }
101
102    Ok(result)
103}
104
105/// Sort DeleteTable actions so that tables with FK references are deleted first.
106/// This is the reverse of creation order - use topological sort then reverse.
107/// Helper function to extract table name from DeleteTable action
108/// Safety: should only be called on DeleteTable actions
109fn extract_delete_table_name(action: &MigrationAction) -> &str {
110    match action {
111        MigrationAction::DeleteTable { table } => table.as_str(),
112        _ => panic!("Expected DeleteTable action"),
113    }
114}
115
116fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
117    // Collect DeleteTable actions and their indices
118    let delete_indices: Vec<usize> = actions
119        .iter()
120        .enumerate()
121        .filter_map(|(i, a)| {
122            if matches!(a, MigrationAction::DeleteTable { .. }) {
123                Some(i)
124            } else {
125                None
126            }
127        })
128        .collect();
129
130    if delete_indices.len() <= 1 {
131        return;
132    }
133
134    // Extract table names being deleted
135    // Use BTreeSet for consistent ordering
136    let delete_table_names: BTreeSet<&str> = delete_indices
137        .iter()
138        .map(|&i| extract_delete_table_name(&actions[i]))
139        .collect();
140
141    // Build dependency graph for tables being deleted
142    // dependencies[A] = [B] means A has FK referencing B
143    // Use BTreeMap for consistent ordering
144    // Use BTreeSet to avoid duplicate dependencies (e.g., multiple FKs referencing the same table)
145    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
146    for &table_name in &delete_table_names {
147        let mut deps_set: BTreeSet<&str> = BTreeSet::new();
148        if let Some(table_def) = all_tables.get(table_name) {
149            for constraint in &table_def.constraints {
150                if let TableConstraint::ForeignKey { ref_table, .. } = constraint
151                    && delete_table_names.contains(ref_table.as_str())
152                    && ref_table != table_name
153                {
154                    deps_set.insert(ref_table.as_str());
155                }
156            }
157        }
158        dependencies.insert(table_name, deps_set.into_iter().collect());
159    }
160
161    // Use Kahn's algorithm for topological sort
162    // in_degree[A] = number of tables A depends on
163    // Use BTreeMap for consistent ordering
164    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
165    for &table_name in &delete_table_names {
166        in_degree.insert(
167            table_name,
168            dependencies.get(table_name).map_or(0, |d| d.len()),
169        );
170    }
171
172    // Start with tables that have no dependencies (can be deleted last in creation order)
173    // BTreeMap iteration is already sorted
174    let mut queue: VecDeque<&str> = in_degree
175        .iter()
176        .filter(|(_, deg)| **deg == 0)
177        .map(|(name, _)| *name)
178        .collect();
179
180    let mut sorted_tables: Vec<&str> = Vec::new();
181    while let Some(table_name) = queue.pop_front() {
182        sorted_tables.push(table_name);
183
184        // For each table that has this one as a dependency, decrement its in-degree
185        // Use BTreeSet for consistent ordering of newly ready tables
186        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
187        for (&dependent, deps) in &dependencies {
188            if deps.contains(&table_name)
189                && let Some(degree) = in_degree.get_mut(dependent)
190            {
191                *degree -= 1;
192                if *degree == 0 {
193                    ready_tables.insert(dependent);
194                }
195            }
196        }
197        for t in ready_tables {
198            queue.push_back(t);
199        }
200    }
201
202    // Reverse to get deletion order (tables with dependencies should be deleted first)
203    sorted_tables.reverse();
204
205    // Reorder the DeleteTable actions according to sorted order
206    let mut delete_actions: Vec<MigrationAction> =
207        delete_indices.iter().map(|&i| actions[i].clone()).collect();
208
209    delete_actions.sort_by(|a, b| {
210        let a_name = extract_delete_table_name(a);
211        let b_name = extract_delete_table_name(b);
212
213        let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
214        let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
215        a_pos.cmp(&b_pos)
216    });
217
218    // Put them back
219    for (i, idx) in delete_indices.iter().enumerate() {
220        actions[*idx] = delete_actions[i].clone();
221    }
222}
223
224/// Diff two schema snapshots into a migration plan.
225/// Schemas are normalized for comparison purposes, but the original (non-normalized)
226/// tables are used in migration actions to preserve inline constraint definitions.
227pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
228    let mut actions: Vec<MigrationAction> = Vec::new();
229
230    // Normalize both schemas for comparison (to ensure inline and table-level constraints are treated equally)
231    let from_normalized: Vec<TableDef> = from
232        .iter()
233        .map(|t| {
234            t.normalize().map_err(|e| {
235                PlannerError::TableValidation(format!(
236                    "Failed to normalize table '{}': {}",
237                    t.name, e
238                ))
239            })
240        })
241        .collect::<Result<Vec<_>, _>>()?;
242    let to_normalized: Vec<TableDef> = to
243        .iter()
244        .map(|t| {
245            t.normalize().map_err(|e| {
246                PlannerError::TableValidation(format!(
247                    "Failed to normalize table '{}': {}",
248                    t.name, e
249                ))
250            })
251        })
252        .collect::<Result<Vec<_>, _>>()?;
253
254    // Use BTreeMap for consistent ordering
255    // Normalized versions for comparison
256    let from_map: BTreeMap<_, _> = from_normalized
257        .iter()
258        .map(|t| (t.name.as_str(), t))
259        .collect();
260    let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
261
262    // Original (non-normalized) versions for migration storage
263    let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
264
265    // Drop tables that disappeared.
266    for name in from_map.keys() {
267        if !to_map.contains_key(name) {
268            actions.push(MigrationAction::DeleteTable {
269                table: (*name).to_string(),
270            });
271        }
272    }
273
274    // Update existing tables and their indexes/columns.
275    for (name, to_tbl) in &to_map {
276        if let Some(from_tbl) = from_map.get(name) {
277            // Columns - use BTreeMap for consistent ordering
278            let from_cols: BTreeMap<_, _> = from_tbl
279                .columns
280                .iter()
281                .map(|c| (c.name.as_str(), c))
282                .collect();
283            let to_cols: BTreeMap<_, _> = to_tbl
284                .columns
285                .iter()
286                .map(|c| (c.name.as_str(), c))
287                .collect();
288
289            // Deleted columns
290            for col in from_cols.keys() {
291                if !to_cols.contains_key(col) {
292                    actions.push(MigrationAction::DeleteColumn {
293                        table: (*name).to_string(),
294                        column: (*col).to_string(),
295                    });
296                }
297            }
298
299            // Modified columns
300            for (col, to_def) in &to_cols {
301                if let Some(from_def) = from_cols.get(col)
302                    && from_def.r#type.requires_migration(&to_def.r#type)
303                {
304                    actions.push(MigrationAction::ModifyColumnType {
305                        table: (*name).to_string(),
306                        column: (*col).to_string(),
307                        new_type: to_def.r#type.clone(),
308                    });
309                }
310            }
311
312            // Added columns
313            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
314            // by normalize(), so they will be handled in the constraint diff below.
315            for (col, def) in &to_cols {
316                if !from_cols.contains_key(col) {
317                    actions.push(MigrationAction::AddColumn {
318                        table: (*name).to_string(),
319                        column: Box::new((*def).clone()),
320                        fill_with: None,
321                    });
322                }
323            }
324
325            // Constraints - compare and detect additions/removals (includes indexes)
326            for from_constraint in &from_tbl.constraints {
327                if !to_tbl.constraints.contains(from_constraint) {
328                    actions.push(MigrationAction::RemoveConstraint {
329                        table: (*name).to_string(),
330                        constraint: from_constraint.clone(),
331                    });
332                }
333            }
334            for to_constraint in &to_tbl.constraints {
335                if !from_tbl.constraints.contains(to_constraint) {
336                    actions.push(MigrationAction::AddConstraint {
337                        table: (*name).to_string(),
338                        constraint: to_constraint.clone(),
339                    });
340                }
341            }
342        }
343    }
344
345    // Create new tables (and their indexes).
346    // Use original (non-normalized) tables to preserve inline constraint definitions.
347    // Collect new tables first, then topologically sort them by FK dependencies.
348    let new_tables: Vec<&TableDef> = to_map
349        .iter()
350        .filter(|(name, _)| !from_map.contains_key(*name))
351        .map(|(_, tbl)| *tbl)
352        .collect();
353
354    let sorted_new_tables = topological_sort_tables(&new_tables)?;
355
356    for tbl in sorted_new_tables {
357        // Get the original (non-normalized) table to preserve inline constraints
358        let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
359        actions.push(MigrationAction::CreateTable {
360            table: original_tbl.name.clone(),
361            columns: original_tbl.columns.clone(),
362            constraints: original_tbl.constraints.clone(),
363        });
364    }
365
366    // Sort DeleteTable actions so tables with FK dependencies are deleted first
367    sort_delete_tables(&mut actions, &from_map);
368
369    Ok(MigrationPlan {
370        comment: None,
371        created_at: None,
372        version: 0,
373        actions,
374    })
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use rstest::rstest;
381    use vespertide_core::{
382        ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
383        schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
384    };
385
386    fn col(name: &str, ty: ColumnType) -> ColumnDef {
387        ColumnDef {
388            name: name.to_string(),
389            r#type: ty,
390            nullable: true,
391            default: None,
392            comment: None,
393            primary_key: None,
394            unique: None,
395            index: None,
396            foreign_key: None,
397        }
398    }
399
400    fn table(
401        name: &str,
402        columns: Vec<ColumnDef>,
403        constraints: Vec<vespertide_core::TableConstraint>,
404    ) -> TableDef {
405        TableDef {
406            name: name.to_string(),
407            columns,
408            constraints,
409        }
410    }
411
412    fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
413        TableConstraint::Index {
414            name: Some(name.to_string()),
415            columns: columns.into_iter().map(|s| s.to_string()).collect(),
416        }
417    }
418
419    #[rstest]
420    #[case::add_column_and_index(
421        vec![table(
422            "users",
423            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
424            vec![],
425        )],
426        vec![table(
427            "users",
428            vec![
429                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
430                col("name", ColumnType::Simple(SimpleColumnType::Text)),
431            ],
432            vec![idx("ix_users__name", vec!["name"])],
433        )],
434        vec![
435            MigrationAction::AddColumn {
436                table: "users".into(),
437                column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
438                fill_with: None,
439            },
440            MigrationAction::AddConstraint {
441                table: "users".into(),
442                constraint: idx("ix_users__name", vec!["name"]),
443            },
444        ]
445    )]
446    #[case::drop_table(
447        vec![table(
448            "users",
449            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
450            vec![],
451        )],
452        vec![],
453        vec![MigrationAction::DeleteTable {
454            table: "users".into()
455        }]
456    )]
457    #[case::add_table_with_index(
458        vec![],
459        vec![table(
460            "users",
461            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
462            vec![idx("idx_users_id", vec!["id"])],
463        )],
464        vec![
465            MigrationAction::CreateTable {
466                table: "users".into(),
467                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
468                constraints: vec![idx("idx_users_id", vec!["id"])],
469            },
470        ]
471    )]
472    #[case::delete_column(
473        vec![table(
474            "users",
475            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
476            vec![],
477        )],
478        vec![table(
479            "users",
480            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
481            vec![],
482        )],
483        vec![MigrationAction::DeleteColumn {
484            table: "users".into(),
485            column: "name".into(),
486        }]
487    )]
488    #[case::modify_column_type(
489        vec![table(
490            "users",
491            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
492            vec![],
493        )],
494        vec![table(
495            "users",
496            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
497            vec![],
498        )],
499        vec![MigrationAction::ModifyColumnType {
500            table: "users".into(),
501            column: "id".into(),
502            new_type: ColumnType::Simple(SimpleColumnType::Text),
503        }]
504    )]
505    #[case::remove_index(
506        vec![table(
507            "users",
508            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
509            vec![idx("idx_users_id", vec!["id"])],
510        )],
511        vec![table(
512            "users",
513            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
514            vec![],
515        )],
516        vec![MigrationAction::RemoveConstraint {
517            table: "users".into(),
518            constraint: idx("idx_users_id", vec!["id"]),
519        }]
520    )]
521    #[case::add_index_existing_table(
522        vec![table(
523            "users",
524            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
525            vec![],
526        )],
527        vec![table(
528            "users",
529            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
530            vec![idx("idx_users_id", vec!["id"])],
531        )],
532        vec![MigrationAction::AddConstraint {
533            table: "users".into(),
534            constraint: idx("idx_users_id", vec!["id"]),
535        }]
536    )]
537    fn diff_schemas_detects_additions(
538        #[case] from_schema: Vec<TableDef>,
539        #[case] to_schema: Vec<TableDef>,
540        #[case] expected_actions: Vec<MigrationAction>,
541    ) {
542        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
543        assert_eq!(plan.actions, expected_actions);
544    }
545
546    // Tests for integer enum handling
547    mod integer_enum {
548        use super::*;
549        use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
550
551        #[test]
552        fn integer_enum_values_changed_no_migration() {
553            // Integer enum values changed - should NOT generate ModifyColumnType
554            let from = vec![table(
555                "orders",
556                vec![col(
557                    "status",
558                    ColumnType::Complex(ComplexColumnType::Enum {
559                        name: "order_status".into(),
560                        values: EnumValues::Integer(vec![
561                            NumValue {
562                                name: "Pending".into(),
563                                value: 0,
564                            },
565                            NumValue {
566                                name: "Shipped".into(),
567                                value: 1,
568                            },
569                        ]),
570                    }),
571                )],
572                vec![],
573            )];
574
575            let to = vec![table(
576                "orders",
577                vec![col(
578                    "status",
579                    ColumnType::Complex(ComplexColumnType::Enum {
580                        name: "order_status".into(),
581                        values: EnumValues::Integer(vec![
582                            NumValue {
583                                name: "Pending".into(),
584                                value: 0,
585                            },
586                            NumValue {
587                                name: "Shipped".into(),
588                                value: 1,
589                            },
590                            NumValue {
591                                name: "Delivered".into(),
592                                value: 2,
593                            },
594                            NumValue {
595                                name: "Cancelled".into(),
596                                value: 100,
597                            },
598                        ]),
599                    }),
600                )],
601                vec![],
602            )];
603
604            let plan = diff_schemas(&from, &to).unwrap();
605            assert!(
606                plan.actions.is_empty(),
607                "Expected no actions, got: {:?}",
608                plan.actions
609            );
610        }
611
612        #[test]
613        fn string_enum_values_changed_requires_migration() {
614            // String enum values changed - SHOULD generate ModifyColumnType
615            let from = vec![table(
616                "orders",
617                vec![col(
618                    "status",
619                    ColumnType::Complex(ComplexColumnType::Enum {
620                        name: "order_status".into(),
621                        values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
622                    }),
623                )],
624                vec![],
625            )];
626
627            let to = vec![table(
628                "orders",
629                vec![col(
630                    "status",
631                    ColumnType::Complex(ComplexColumnType::Enum {
632                        name: "order_status".into(),
633                        values: EnumValues::String(vec![
634                            "pending".into(),
635                            "shipped".into(),
636                            "delivered".into(),
637                        ]),
638                    }),
639                )],
640                vec![],
641            )];
642
643            let plan = diff_schemas(&from, &to).unwrap();
644            assert_eq!(plan.actions.len(), 1);
645            assert!(matches!(
646                &plan.actions[0],
647                MigrationAction::ModifyColumnType { table, column, .. }
648                if table == "orders" && column == "status"
649            ));
650        }
651    }
652
653    // Tests for inline column constraints normalization
654    mod inline_constraints {
655        use super::*;
656        use vespertide_core::schema::foreign_key::ForeignKeyDef;
657        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
658        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
659        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
660
661        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
662            ColumnDef {
663                name: name.to_string(),
664                r#type: ty,
665                nullable: false,
666                default: None,
667                comment: None,
668                primary_key: Some(PrimaryKeySyntax::Bool(true)),
669                unique: None,
670                index: None,
671                foreign_key: None,
672            }
673        }
674
675        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
676            ColumnDef {
677                name: name.to_string(),
678                r#type: ty,
679                nullable: true,
680                default: None,
681                comment: None,
682                primary_key: None,
683                unique: Some(StrOrBoolOrArray::Bool(true)),
684                index: None,
685                foreign_key: None,
686            }
687        }
688
689        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
690            ColumnDef {
691                name: name.to_string(),
692                r#type: ty,
693                nullable: true,
694                default: None,
695                comment: None,
696                primary_key: None,
697                unique: None,
698                index: Some(StrOrBoolOrArray::Bool(true)),
699                foreign_key: None,
700            }
701        }
702
703        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
704            ColumnDef {
705                name: name.to_string(),
706                r#type: ty,
707                nullable: true,
708                default: None,
709                comment: None,
710                primary_key: None,
711                unique: None,
712                index: None,
713                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
714                    ref_table: ref_table.to_string(),
715                    ref_columns: vec![ref_col.to_string()],
716                    on_delete: None,
717                    on_update: None,
718                })),
719            }
720        }
721
722        #[test]
723        fn create_table_with_inline_pk() {
724            let plan = diff_schemas(
725                &[],
726                &[table(
727                    "users",
728                    vec![
729                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
730                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
731                    ],
732                    vec![],
733                )],
734            )
735            .unwrap();
736
737            // Inline PK should be preserved in column definition
738            assert_eq!(plan.actions.len(), 1);
739            if let MigrationAction::CreateTable {
740                columns,
741                constraints,
742                ..
743            } = &plan.actions[0]
744            {
745                // Constraints should be empty (inline PK not moved here)
746                assert_eq!(constraints.len(), 0);
747                // Check that the column has inline PK
748                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
749                assert!(id_col.primary_key.is_some());
750            } else {
751                panic!("Expected CreateTable action");
752            }
753        }
754
755        #[test]
756        fn create_table_with_inline_unique() {
757            let plan = diff_schemas(
758                &[],
759                &[table(
760                    "users",
761                    vec![
762                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
763                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
764                    ],
765                    vec![],
766                )],
767            )
768            .unwrap();
769
770            // Inline unique should be preserved in column definition
771            assert_eq!(plan.actions.len(), 1);
772            if let MigrationAction::CreateTable {
773                columns,
774                constraints,
775                ..
776            } = &plan.actions[0]
777            {
778                // Constraints should be empty (inline unique not moved here)
779                assert_eq!(constraints.len(), 0);
780                // Check that the column has inline unique
781                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
782                assert!(matches!(
783                    email_col.unique,
784                    Some(StrOrBoolOrArray::Bool(true))
785                ));
786            } else {
787                panic!("Expected CreateTable action");
788            }
789        }
790
791        #[test]
792        fn create_table_with_inline_index() {
793            let plan = diff_schemas(
794                &[],
795                &[table(
796                    "users",
797                    vec![
798                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
799                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
800                    ],
801                    vec![],
802                )],
803            )
804            .unwrap();
805
806            // Inline index should be preserved in column definition, not moved to constraints
807            assert_eq!(plan.actions.len(), 1);
808            if let MigrationAction::CreateTable {
809                columns,
810                constraints,
811                ..
812            } = &plan.actions[0]
813            {
814                // Constraints should be empty (inline index not moved here)
815                assert_eq!(constraints.len(), 0);
816                // Check that the column has inline index
817                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
818                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
819            } else {
820                panic!("Expected CreateTable action");
821            }
822        }
823
824        #[test]
825        fn create_table_with_inline_fk() {
826            let plan = diff_schemas(
827                &[],
828                &[table(
829                    "posts",
830                    vec![
831                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
832                        col_with_fk(
833                            "user_id",
834                            ColumnType::Simple(SimpleColumnType::Integer),
835                            "users",
836                            "id",
837                        ),
838                    ],
839                    vec![],
840                )],
841            )
842            .unwrap();
843
844            // Inline FK should be preserved in column definition
845            assert_eq!(plan.actions.len(), 1);
846            if let MigrationAction::CreateTable {
847                columns,
848                constraints,
849                ..
850            } = &plan.actions[0]
851            {
852                // Constraints should be empty (inline FK not moved here)
853                assert_eq!(constraints.len(), 0);
854                // Check that the column has inline FK
855                let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
856                assert!(user_id_col.foreign_key.is_some());
857            } else {
858                panic!("Expected CreateTable action");
859            }
860        }
861
862        #[test]
863        fn add_index_via_inline_constraint() {
864            // Existing table without index -> table with inline index
865            // Inline index (Bool(true)) is normalized to a named table-level constraint
866            let plan = diff_schemas(
867                &[table(
868                    "users",
869                    vec![
870                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
871                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
872                    ],
873                    vec![],
874                )],
875                &[table(
876                    "users",
877                    vec![
878                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
879                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
880                    ],
881                    vec![],
882                )],
883            )
884            .unwrap();
885
886            // Should generate AddConstraint with name: None (auto-generated indexes)
887            assert_eq!(plan.actions.len(), 1);
888            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
889                assert_eq!(table, "users");
890                if let TableConstraint::Index { name, columns } = constraint {
891                    assert_eq!(name, &None); // Auto-generated indexes use None
892                    assert_eq!(columns, &vec!["name".to_string()]);
893                } else {
894                    panic!("Expected Index constraint, got {:?}", constraint);
895                }
896            } else {
897                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
898            }
899        }
900
901        #[test]
902        fn create_table_with_all_inline_constraints() {
903            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
904            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
905            id_col.nullable = false;
906
907            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
908            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
909
910            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
911            name_col.index = Some(StrOrBoolOrArray::Bool(true));
912
913            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
914            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
915                ref_table: "orgs".into(),
916                ref_columns: vec!["id".into()],
917                on_delete: None,
918                on_update: None,
919            }));
920
921            let plan = diff_schemas(
922                &[],
923                &[table(
924                    "users",
925                    vec![id_col, email_col, name_col, org_id_col],
926                    vec![],
927                )],
928            )
929            .unwrap();
930
931            // All inline constraints should be preserved in column definitions
932            assert_eq!(plan.actions.len(), 1);
933
934            if let MigrationAction::CreateTable {
935                columns,
936                constraints,
937                ..
938            } = &plan.actions[0]
939            {
940                // Constraints should be empty (all inline)
941                assert_eq!(constraints.len(), 0);
942
943                // Check each column has its inline constraint
944                let id_col = columns.iter().find(|c| c.name == "id").unwrap();
945                assert!(id_col.primary_key.is_some());
946
947                let email_col = columns.iter().find(|c| c.name == "email").unwrap();
948                assert!(matches!(
949                    email_col.unique,
950                    Some(StrOrBoolOrArray::Bool(true))
951                ));
952
953                let name_col = columns.iter().find(|c| c.name == "name").unwrap();
954                assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
955
956                let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
957                assert!(org_id_col.foreign_key.is_some());
958            } else {
959                panic!("Expected CreateTable action");
960            }
961        }
962
963        #[test]
964        fn add_constraint_to_existing_table() {
965            // Add a unique constraint to an existing table
966            let from_schema = vec![table(
967                "users",
968                vec![
969                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
970                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
971                ],
972                vec![],
973            )];
974
975            let to_schema = vec![table(
976                "users",
977                vec![
978                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
979                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
980                ],
981                vec![vespertide_core::TableConstraint::Unique {
982                    name: Some("uq_users_email".into()),
983                    columns: vec!["email".into()],
984                }],
985            )];
986
987            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
988            assert_eq!(plan.actions.len(), 1);
989            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
990                assert_eq!(table, "users");
991                assert!(matches!(
992                    constraint,
993                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
994                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
995                ));
996            } else {
997                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
998            }
999        }
1000
1001        #[test]
1002        fn remove_constraint_from_existing_table() {
1003            // Remove a unique constraint from an existing table
1004            let from_schema = vec![table(
1005                "users",
1006                vec![
1007                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1008                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1009                ],
1010                vec![vespertide_core::TableConstraint::Unique {
1011                    name: Some("uq_users_email".into()),
1012                    columns: vec!["email".into()],
1013                }],
1014            )];
1015
1016            let to_schema = vec![table(
1017                "users",
1018                vec![
1019                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1020                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1021                ],
1022                vec![],
1023            )];
1024
1025            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1026            assert_eq!(plan.actions.len(), 1);
1027            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1028                assert_eq!(table, "users");
1029                assert!(matches!(
1030                    constraint,
1031                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1032                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1033                ));
1034            } else {
1035                panic!(
1036                    "Expected RemoveConstraint action, got {:?}",
1037                    plan.actions[0]
1038                );
1039            }
1040        }
1041
1042        #[test]
1043        fn diff_schemas_with_normalize_error() {
1044            // Test that normalize errors are properly propagated
1045            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1046            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1047
1048            let table = TableDef {
1049                name: "test".into(),
1050                columns: vec![
1051                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1052                    col1.clone(),
1053                    {
1054                        // Same column with same index name - should error
1055                        let mut c = col1.clone();
1056                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1057                        c
1058                    },
1059                ],
1060                constraints: vec![],
1061            };
1062
1063            let result = diff_schemas(&[], &[table]);
1064            assert!(result.is_err());
1065            if let Err(PlannerError::TableValidation(msg)) = result {
1066                assert!(msg.contains("Failed to normalize table"));
1067                assert!(msg.contains("Duplicate index"));
1068            } else {
1069                panic!("Expected TableValidation error, got {:?}", result);
1070            }
1071        }
1072
1073        #[test]
1074        fn diff_schemas_with_normalize_error_in_from_schema() {
1075            // Test that normalize errors in 'from' schema are properly propagated
1076            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1077            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1078
1079            let table = TableDef {
1080                name: "test".into(),
1081                columns: vec![
1082                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1083                    col1.clone(),
1084                    {
1085                        // Same column with same index name - should error
1086                        let mut c = col1.clone();
1087                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1088                        c
1089                    },
1090                ],
1091                constraints: vec![],
1092            };
1093
1094            // 'from' schema has the invalid table
1095            let result = diff_schemas(&[table], &[]);
1096            assert!(result.is_err());
1097            if let Err(PlannerError::TableValidation(msg)) = result {
1098                assert!(msg.contains("Failed to normalize table"));
1099                assert!(msg.contains("Duplicate index"));
1100            } else {
1101                panic!("Expected TableValidation error, got {:?}", result);
1102            }
1103        }
1104    }
1105
1106    // Tests for foreign key dependency ordering
1107    mod fk_ordering {
1108        use super::*;
1109        use vespertide_core::TableConstraint;
1110
1111        fn table_with_fk(
1112            name: &str,
1113            ref_table: &str,
1114            fk_column: &str,
1115            ref_column: &str,
1116        ) -> TableDef {
1117            TableDef {
1118                name: name.to_string(),
1119                columns: vec![
1120                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1121                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1122                ],
1123                constraints: vec![TableConstraint::ForeignKey {
1124                    name: None,
1125                    columns: vec![fk_column.to_string()],
1126                    ref_table: ref_table.to_string(),
1127                    ref_columns: vec![ref_column.to_string()],
1128                    on_delete: None,
1129                    on_update: None,
1130                }],
1131            }
1132        }
1133
1134        fn simple_table(name: &str) -> TableDef {
1135            TableDef {
1136                name: name.to_string(),
1137                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1138                constraints: vec![],
1139            }
1140        }
1141
1142        #[test]
1143        fn create_tables_respects_fk_order() {
1144            // Create users and posts tables where posts references users
1145            // The order should be: users first, then posts
1146            let users = simple_table("users");
1147            let posts = table_with_fk("posts", "users", "user_id", "id");
1148
1149            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1150
1151            // Extract CreateTable actions in order
1152            let create_order: Vec<&str> = plan
1153                .actions
1154                .iter()
1155                .filter_map(|a| {
1156                    if let MigrationAction::CreateTable { table, .. } = a {
1157                        Some(table.as_str())
1158                    } else {
1159                        None
1160                    }
1161                })
1162                .collect();
1163
1164            assert_eq!(create_order, vec!["users", "posts"]);
1165        }
1166
1167        #[test]
1168        fn create_tables_chain_dependency() {
1169            // Chain: users <- media <- articles
1170            // users has no FK
1171            // media references users
1172            // articles references media
1173            let users = simple_table("users");
1174            let media = table_with_fk("media", "users", "owner_id", "id");
1175            let articles = table_with_fk("articles", "media", "media_id", "id");
1176
1177            // Pass in reverse order to ensure sorting works
1178            let plan =
1179                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1180
1181            let create_order: Vec<&str> = plan
1182                .actions
1183                .iter()
1184                .filter_map(|a| {
1185                    if let MigrationAction::CreateTable { table, .. } = a {
1186                        Some(table.as_str())
1187                    } else {
1188                        None
1189                    }
1190                })
1191                .collect();
1192
1193            assert_eq!(create_order, vec!["users", "media", "articles"]);
1194        }
1195
1196        #[test]
1197        fn create_tables_multiple_independent_branches() {
1198            // Two independent branches:
1199            // users <- posts
1200            // categories <- products
1201            let users = simple_table("users");
1202            let posts = table_with_fk("posts", "users", "user_id", "id");
1203            let categories = simple_table("categories");
1204            let products = table_with_fk("products", "categories", "category_id", "id");
1205
1206            let plan = diff_schemas(
1207                &[],
1208                &[
1209                    products.clone(),
1210                    posts.clone(),
1211                    categories.clone(),
1212                    users.clone(),
1213                ],
1214            )
1215            .unwrap();
1216
1217            let create_order: Vec<&str> = plan
1218                .actions
1219                .iter()
1220                .filter_map(|a| {
1221                    if let MigrationAction::CreateTable { table, .. } = a {
1222                        Some(table.as_str())
1223                    } else {
1224                        None
1225                    }
1226                })
1227                .collect();
1228
1229            // users must come before posts
1230            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1231            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1232            assert!(
1233                users_pos < posts_pos,
1234                "users should be created before posts"
1235            );
1236
1237            // categories must come before products
1238            let categories_pos = create_order
1239                .iter()
1240                .position(|&t| t == "categories")
1241                .unwrap();
1242            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1243            assert!(
1244                categories_pos < products_pos,
1245                "categories should be created before products"
1246            );
1247        }
1248
1249        #[test]
1250        fn delete_tables_respects_fk_order() {
1251            // When deleting users and posts where posts references users,
1252            // posts should be deleted first (reverse of creation order)
1253            let users = simple_table("users");
1254            let posts = table_with_fk("posts", "users", "user_id", "id");
1255
1256            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1257
1258            let delete_order: Vec<&str> = plan
1259                .actions
1260                .iter()
1261                .filter_map(|a| {
1262                    if let MigrationAction::DeleteTable { table } = a {
1263                        Some(table.as_str())
1264                    } else {
1265                        None
1266                    }
1267                })
1268                .collect();
1269
1270            assert_eq!(delete_order, vec!["posts", "users"]);
1271        }
1272
1273        #[test]
1274        fn delete_tables_chain_dependency() {
1275            // Chain: users <- media <- articles
1276            // Delete order should be: articles, media, users
1277            let users = simple_table("users");
1278            let media = table_with_fk("media", "users", "owner_id", "id");
1279            let articles = table_with_fk("articles", "media", "media_id", "id");
1280
1281            let plan =
1282                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1283
1284            let delete_order: Vec<&str> = plan
1285                .actions
1286                .iter()
1287                .filter_map(|a| {
1288                    if let MigrationAction::DeleteTable { table } = a {
1289                        Some(table.as_str())
1290                    } else {
1291                        None
1292                    }
1293                })
1294                .collect();
1295
1296            // articles must be deleted before media
1297            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1298            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1299            assert!(
1300                articles_pos < media_pos,
1301                "articles should be deleted before media"
1302            );
1303
1304            // media must be deleted before users
1305            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1306            assert!(
1307                media_pos < users_pos,
1308                "media should be deleted before users"
1309            );
1310        }
1311
1312        #[test]
1313        fn circular_fk_dependency_returns_error() {
1314            // Create circular dependency: A -> B -> A
1315            let table_a = TableDef {
1316                name: "table_a".to_string(),
1317                columns: vec![
1318                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1319                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1320                ],
1321                constraints: vec![TableConstraint::ForeignKey {
1322                    name: None,
1323                    columns: vec!["b_id".to_string()],
1324                    ref_table: "table_b".to_string(),
1325                    ref_columns: vec!["id".to_string()],
1326                    on_delete: None,
1327                    on_update: None,
1328                }],
1329            };
1330
1331            let table_b = TableDef {
1332                name: "table_b".to_string(),
1333                columns: vec![
1334                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1335                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1336                ],
1337                constraints: vec![TableConstraint::ForeignKey {
1338                    name: None,
1339                    columns: vec!["a_id".to_string()],
1340                    ref_table: "table_a".to_string(),
1341                    ref_columns: vec!["id".to_string()],
1342                    on_delete: None,
1343                    on_update: None,
1344                }],
1345            };
1346
1347            let result = diff_schemas(&[], &[table_a, table_b]);
1348            assert!(result.is_err());
1349            if let Err(PlannerError::TableValidation(msg)) = result {
1350                assert!(
1351                    msg.contains("Circular foreign key dependency"),
1352                    "Expected circular dependency error, got: {}",
1353                    msg
1354                );
1355            } else {
1356                panic!("Expected TableValidation error, got {:?}", result);
1357            }
1358        }
1359
1360        #[test]
1361        fn fk_to_external_table_is_ignored() {
1362            // FK referencing a table not in the migration should not affect ordering
1363            let posts = table_with_fk("posts", "users", "user_id", "id");
1364            let comments = table_with_fk("comments", "posts", "post_id", "id");
1365
1366            // users is NOT being created in this migration
1367            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1368
1369            let create_order: Vec<&str> = plan
1370                .actions
1371                .iter()
1372                .filter_map(|a| {
1373                    if let MigrationAction::CreateTable { table, .. } = a {
1374                        Some(table.as_str())
1375                    } else {
1376                        None
1377                    }
1378                })
1379                .collect();
1380
1381            // posts must come before comments (comments depends on posts)
1382            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1383            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1384            assert!(
1385                posts_pos < comments_pos,
1386                "posts should be created before comments"
1387            );
1388        }
1389
1390        #[test]
1391        fn delete_tables_mixed_with_other_actions() {
1392            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1393            // This tests lines 124, 193, 198 (the else branches)
1394            use crate::diff::diff_schemas;
1395
1396            let from_schema = vec![
1397                table(
1398                    "users",
1399                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1400                    vec![],
1401                ),
1402                table(
1403                    "posts",
1404                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1405                    vec![],
1406                ),
1407            ];
1408
1409            let to_schema = vec![
1410                // Drop posts table, but also add a new column to users
1411                table(
1412                    "users",
1413                    vec![
1414                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1415                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1416                    ],
1417                    vec![],
1418                ),
1419            ];
1420
1421            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1422
1423            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1424            assert!(
1425                plan.actions
1426                    .iter()
1427                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1428            );
1429            assert!(
1430                plan.actions
1431                    .iter()
1432                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1433            );
1434
1435            // The else branches in sort_delete_actions should handle AddColumn gracefully
1436            // (returning empty string for table name, which sorts it to position 0)
1437        }
1438
1439        #[test]
1440        #[should_panic(expected = "Expected DeleteTable action")]
1441        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1442            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1443            use super::extract_delete_table_name;
1444
1445            let action = MigrationAction::AddColumn {
1446                table: "users".into(),
1447                column: Box::new(ColumnDef {
1448                    name: "email".into(),
1449                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1450                    nullable: true,
1451                    default: None,
1452                    comment: None,
1453                    primary_key: None,
1454                    unique: None,
1455                    index: None,
1456                    foreign_key: None,
1457                }),
1458                fill_with: None,
1459            };
1460
1461            // This should panic
1462            extract_delete_table_name(&action);
1463        }
1464
1465        /// Test that inline FK across multiple tables works correctly with topological sort
1466        #[test]
1467        fn create_tables_with_inline_fk_chain() {
1468            use super::*;
1469            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1470            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1471
1472            fn col_pk(name: &str) -> ColumnDef {
1473                ColumnDef {
1474                    name: name.to_string(),
1475                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1476                    nullable: false,
1477                    default: None,
1478                    comment: None,
1479                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1480                    unique: None,
1481                    index: None,
1482                    foreign_key: None,
1483                }
1484            }
1485
1486            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1487                ColumnDef {
1488                    name: name.to_string(),
1489                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1490                    nullable: true,
1491                    default: None,
1492                    comment: None,
1493                    primary_key: None,
1494                    unique: None,
1495                    index: None,
1496                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1497                }
1498            }
1499
1500            // Reproduce the app example structure:
1501            // user -> (no deps)
1502            // product -> (no deps)
1503            // project -> user
1504            // code -> product, user, project
1505            // order -> user, project, product, code
1506            // payment -> order
1507
1508            let user = TableDef {
1509                name: "user".to_string(),
1510                columns: vec![col_pk("id")],
1511                constraints: vec![],
1512            };
1513
1514            let product = TableDef {
1515                name: "product".to_string(),
1516                columns: vec![col_pk("id")],
1517                constraints: vec![],
1518            };
1519
1520            let project = TableDef {
1521                name: "project".to_string(),
1522                columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1523                constraints: vec![],
1524            };
1525
1526            let code = TableDef {
1527                name: "code".to_string(),
1528                columns: vec![
1529                    col_pk("id"),
1530                    col_inline_fk("product_id", "product"),
1531                    col_inline_fk("creator_user_id", "user"),
1532                    col_inline_fk("project_id", "project"),
1533                ],
1534                constraints: vec![],
1535            };
1536
1537            let order = TableDef {
1538                name: "order".to_string(),
1539                columns: vec![
1540                    col_pk("id"),
1541                    col_inline_fk("user_id", "user"),
1542                    col_inline_fk("project_id", "project"),
1543                    col_inline_fk("product_id", "product"),
1544                    col_inline_fk("code_id", "code"),
1545                ],
1546                constraints: vec![],
1547            };
1548
1549            let payment = TableDef {
1550                name: "payment".to_string(),
1551                columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1552                constraints: vec![],
1553            };
1554
1555            // Pass in arbitrary order - should NOT return circular dependency error
1556            let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1557            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1558
1559            let plan = result.unwrap();
1560            let create_order: Vec<&str> = plan
1561                .actions
1562                .iter()
1563                .filter_map(|a| {
1564                    if let MigrationAction::CreateTable { table, .. } = a {
1565                        Some(table.as_str())
1566                    } else {
1567                        None
1568                    }
1569                })
1570                .collect();
1571
1572            // Verify order respects FK dependencies
1573            let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1574
1575            // user and product have no deps, can be in any order
1576            // project depends on user
1577            assert!(
1578                get_pos("user") < get_pos("project"),
1579                "user must come before project"
1580            );
1581            // code depends on product, user, project
1582            assert!(
1583                get_pos("product") < get_pos("code"),
1584                "product must come before code"
1585            );
1586            assert!(
1587                get_pos("user") < get_pos("code"),
1588                "user must come before code"
1589            );
1590            assert!(
1591                get_pos("project") < get_pos("code"),
1592                "project must come before code"
1593            );
1594            // order depends on user, project, product, code
1595            assert!(
1596                get_pos("code") < get_pos("order"),
1597                "code must come before order"
1598            );
1599            // payment depends on order
1600            assert!(
1601                get_pos("order") < get_pos("payment"),
1602                "order must come before payment"
1603            );
1604        }
1605
1606        /// Test that multiple FKs to the same table are deduplicated correctly
1607        #[test]
1608        fn create_tables_with_duplicate_fk_references() {
1609            use super::*;
1610            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1611            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1612
1613            fn col_pk(name: &str) -> ColumnDef {
1614                ColumnDef {
1615                    name: name.to_string(),
1616                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1617                    nullable: false,
1618                    default: None,
1619                    comment: None,
1620                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1621                    unique: None,
1622                    index: None,
1623                    foreign_key: None,
1624                }
1625            }
1626
1627            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1628                ColumnDef {
1629                    name: name.to_string(),
1630                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1631                    nullable: true,
1632                    default: None,
1633                    comment: None,
1634                    primary_key: None,
1635                    unique: None,
1636                    index: None,
1637                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1638                }
1639            }
1640
1641            // Table with multiple FKs referencing the same table (like code.creator_user_id and code.used_by_user_id)
1642            let user = TableDef {
1643                name: "user".to_string(),
1644                columns: vec![col_pk("id")],
1645                constraints: vec![],
1646            };
1647
1648            let code = TableDef {
1649                name: "code".to_string(),
1650                columns: vec![
1651                    col_pk("id"),
1652                    col_inline_fk("creator_user_id", "user"),
1653                    col_inline_fk("used_by_user_id", "user"), // Second FK to same table
1654                ],
1655                constraints: vec![],
1656            };
1657
1658            // This should NOT return circular dependency error even with duplicate FK refs
1659            let result = diff_schemas(&[], &[code, user]);
1660            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1661
1662            let plan = result.unwrap();
1663            let create_order: Vec<&str> = plan
1664                .actions
1665                .iter()
1666                .filter_map(|a| {
1667                    if let MigrationAction::CreateTable { table, .. } = a {
1668                        Some(table.as_str())
1669                    } else {
1670                        None
1671                    }
1672                })
1673                .collect();
1674
1675            // user must come before code
1676            let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1677            let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1678            assert!(user_pos < code_pos, "user must come before code");
1679        }
1680    }
1681
1682    mod diff_tables {
1683        use insta::assert_debug_snapshot;
1684
1685        use super::*;
1686
1687        #[test]
1688        fn create_table_with_inline_index() {
1689            let base = [table(
1690                "users",
1691                vec![
1692                    ColumnDef {
1693                        name: "id".to_string(),
1694                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1695                        nullable: false,
1696                        default: None,
1697                        comment: None,
1698                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1699                        unique: None,
1700                        index: Some(StrOrBoolOrArray::Bool(false)),
1701                        foreign_key: None,
1702                    },
1703                    ColumnDef {
1704                        name: "name".to_string(),
1705                        r#type: ColumnType::Simple(SimpleColumnType::Text),
1706                        nullable: true,
1707                        default: None,
1708                        comment: None,
1709                        primary_key: None,
1710                        unique: Some(StrOrBoolOrArray::Bool(true)),
1711                        index: Some(StrOrBoolOrArray::Bool(true)),
1712                        foreign_key: None,
1713                    },
1714                ],
1715                vec![],
1716            )];
1717            let plan = diff_schemas(&[], &base).unwrap();
1718
1719            assert_eq!(plan.actions.len(), 1);
1720            assert_debug_snapshot!(plan.actions);
1721
1722            let plan = diff_schemas(
1723                &base,
1724                &[table(
1725                    "users",
1726                    vec![
1727                        ColumnDef {
1728                            name: "id".to_string(),
1729                            r#type: ColumnType::Simple(SimpleColumnType::Integer),
1730                            nullable: false,
1731                            default: None,
1732                            comment: None,
1733                            primary_key: Some(PrimaryKeySyntax::Bool(true)),
1734                            unique: None,
1735                            index: Some(StrOrBoolOrArray::Bool(false)),
1736                            foreign_key: None,
1737                        },
1738                        ColumnDef {
1739                            name: "name".to_string(),
1740                            r#type: ColumnType::Simple(SimpleColumnType::Text),
1741                            nullable: true,
1742                            default: None,
1743                            comment: None,
1744                            primary_key: None,
1745                            unique: Some(StrOrBoolOrArray::Bool(true)),
1746                            index: Some(StrOrBoolOrArray::Bool(false)),
1747                            foreign_key: None,
1748                        },
1749                    ],
1750                    vec![],
1751                )],
1752            )
1753            .unwrap();
1754
1755            assert_eq!(plan.actions.len(), 1);
1756            assert_debug_snapshot!(plan.actions);
1757        }
1758
1759        #[rstest]
1760        #[case(
1761            "add_index",
1762            vec![table(
1763                "users",
1764                vec![
1765                    ColumnDef {
1766                        name: "id".to_string(),
1767                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1768                        nullable: false,
1769                        default: None,
1770                        comment: None,
1771                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1772                        unique: None,
1773                        index: None,
1774                        foreign_key: None,
1775                    },
1776                ],
1777                vec![],
1778            )],
1779            vec![table(
1780                "users",
1781                vec![
1782                    ColumnDef {
1783                        name: "id".to_string(),
1784                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1785                        nullable: false,
1786                        default: None,
1787                        comment: None,
1788                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1789                        unique: None,
1790                        index: Some(StrOrBoolOrArray::Bool(true)),
1791                        foreign_key: None,
1792                    },
1793                ],
1794                vec![],
1795            )],
1796        )]
1797        #[case(
1798            "remove_index",
1799            vec![table(
1800                "users",
1801                vec![
1802                    ColumnDef {
1803                        name: "id".to_string(),
1804                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1805                        nullable: false,
1806                        default: None,
1807                        comment: None,
1808                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1809                        unique: None,
1810                        index: Some(StrOrBoolOrArray::Bool(true)),
1811                        foreign_key: None,
1812                    },
1813                ],
1814                vec![],
1815            )],
1816            vec![table(
1817                "users",
1818                vec![
1819                    ColumnDef {
1820                        name: "id".to_string(),
1821                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1822                        nullable: false,
1823                        default: None,
1824                        comment: None,
1825                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1826                        unique: None,
1827                        index: Some(StrOrBoolOrArray::Bool(false)),
1828                        foreign_key: None,
1829                    },
1830                ],
1831                vec![],
1832            )],
1833        )]
1834        #[case(
1835            "add_named_index",
1836            vec![table(
1837                "users",
1838                vec![
1839                    ColumnDef {
1840                        name: "id".to_string(),
1841                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1842                        nullable: false,
1843                        default: None,
1844                        comment: None,
1845                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1846                        unique: None,
1847                        index: None,
1848                        foreign_key: None,
1849                    },
1850                ],
1851                vec![],
1852            )],
1853            vec![table(
1854                "users",
1855                vec![
1856                    ColumnDef {
1857                        name: "id".to_string(),
1858                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1859                        nullable: false,
1860                        default: None,
1861                        comment: None,
1862                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1863                        unique: None,
1864                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
1865                        foreign_key: None,
1866                    },
1867                ],
1868                vec![],
1869            )],
1870        )]
1871        #[case(
1872            "remove_named_index",
1873            vec![table(
1874                "users",
1875                vec![
1876                    ColumnDef {
1877                        name: "id".to_string(),
1878                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1879                        nullable: false,
1880                        default: None,
1881                        comment: None,
1882                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1883                        unique: None,
1884                        index: Some(StrOrBoolOrArray::Str("hello".to_string())),
1885                        foreign_key: None,
1886                    },
1887                ],
1888                vec![],
1889            )],
1890            vec![table(
1891                "users",
1892                vec![
1893                    ColumnDef {
1894                        name: "id".to_string(),
1895                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
1896                        nullable: false,
1897                        default: None,
1898                        comment: None,
1899                        primary_key: Some(PrimaryKeySyntax::Bool(true)),
1900                        unique: None,
1901                        index: None,
1902                        foreign_key: None,
1903                    },
1904                ],
1905                vec![],
1906            )],
1907        )]
1908        fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
1909            use insta::with_settings;
1910
1911            let plan = diff_schemas(&base, &to).unwrap();
1912            with_settings!({ snapshot_suffix => name }, {
1913                assert_debug_snapshot!(plan.actions);
1914            });
1915        }
1916    }
1917}