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/// Both schemas are normalized to convert inline column constraints
226/// (primary_key, unique, index, foreign_key) to table-level constraints.
227pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
228    let mut actions: Vec<MigrationAction> = Vec::new();
229
230    // Normalize both schemas to ensure inline constraints are converted to table-level
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    let from_map: BTreeMap<_, _> = from_normalized
256        .iter()
257        .map(|t| (t.name.as_str(), t))
258        .collect();
259    let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
260
261    // Drop tables that disappeared.
262    for name in from_map.keys() {
263        if !to_map.contains_key(name) {
264            actions.push(MigrationAction::DeleteTable {
265                table: (*name).to_string(),
266            });
267        }
268    }
269
270    // Update existing tables and their indexes/columns.
271    for (name, to_tbl) in &to_map {
272        if let Some(from_tbl) = from_map.get(name) {
273            // Columns - use BTreeMap for consistent ordering
274            let from_cols: BTreeMap<_, _> = from_tbl
275                .columns
276                .iter()
277                .map(|c| (c.name.as_str(), c))
278                .collect();
279            let to_cols: BTreeMap<_, _> = to_tbl
280                .columns
281                .iter()
282                .map(|c| (c.name.as_str(), c))
283                .collect();
284
285            // Deleted columns
286            for col in from_cols.keys() {
287                if !to_cols.contains_key(col) {
288                    actions.push(MigrationAction::DeleteColumn {
289                        table: (*name).to_string(),
290                        column: (*col).to_string(),
291                    });
292                }
293            }
294
295            // Modified columns
296            for (col, to_def) in &to_cols {
297                if let Some(from_def) = from_cols.get(col)
298                    && from_def.r#type.requires_migration(&to_def.r#type)
299                {
300                    actions.push(MigrationAction::ModifyColumnType {
301                        table: (*name).to_string(),
302                        column: (*col).to_string(),
303                        new_type: to_def.r#type.clone(),
304                    });
305                }
306            }
307
308            // Added columns
309            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
310            // by normalize(), so they will be handled in the constraint diff below.
311            for (col, def) in &to_cols {
312                if !from_cols.contains_key(col) {
313                    actions.push(MigrationAction::AddColumn {
314                        table: (*name).to_string(),
315                        column: Box::new((*def).clone()),
316                        fill_with: None,
317                    });
318                }
319            }
320
321            // Indexes - use BTreeMap for consistent ordering
322            let from_indexes: BTreeMap<_, _> = from_tbl
323                .indexes
324                .iter()
325                .map(|i| (i.name.as_str(), i))
326                .collect();
327            let to_indexes: BTreeMap<_, _> = to_tbl
328                .indexes
329                .iter()
330                .map(|i| (i.name.as_str(), i))
331                .collect();
332
333            for idx in from_indexes.keys() {
334                if !to_indexes.contains_key(idx) {
335                    actions.push(MigrationAction::RemoveIndex {
336                        table: (*name).to_string(),
337                        name: (*idx).to_string(),
338                    });
339                }
340            }
341            for (idx, def) in &to_indexes {
342                if !from_indexes.contains_key(idx) {
343                    actions.push(MigrationAction::AddIndex {
344                        table: (*name).to_string(),
345                        index: (*def).clone(),
346                    });
347                }
348            }
349
350            // Constraints - compare and detect additions/removals
351            for from_constraint in &from_tbl.constraints {
352                if !to_tbl.constraints.contains(from_constraint) {
353                    actions.push(MigrationAction::RemoveConstraint {
354                        table: (*name).to_string(),
355                        constraint: from_constraint.clone(),
356                    });
357                }
358            }
359            for to_constraint in &to_tbl.constraints {
360                if !from_tbl.constraints.contains(to_constraint) {
361                    actions.push(MigrationAction::AddConstraint {
362                        table: (*name).to_string(),
363                        constraint: to_constraint.clone(),
364                    });
365                }
366            }
367        }
368    }
369
370    // Create new tables (and their indexes).
371    // Collect new tables first, then topologically sort them by FK dependencies.
372    let new_tables: Vec<&TableDef> = to_map
373        .iter()
374        .filter(|(name, _)| !from_map.contains_key(*name))
375        .map(|(_, tbl)| *tbl)
376        .collect();
377
378    let sorted_new_tables = topological_sort_tables(&new_tables)?;
379
380    for tbl in sorted_new_tables {
381        actions.push(MigrationAction::CreateTable {
382            table: tbl.name.clone(),
383            columns: tbl.columns.clone(),
384            constraints: tbl.constraints.clone(),
385        });
386        for idx in &tbl.indexes {
387            actions.push(MigrationAction::AddIndex {
388                table: tbl.name.clone(),
389                index: idx.clone(),
390            });
391        }
392    }
393
394    // Sort DeleteTable actions so tables with FK dependencies are deleted first
395    sort_delete_tables(&mut actions, &from_map);
396
397    Ok(MigrationPlan {
398        comment: None,
399        created_at: None,
400        version: 0,
401        actions,
402    })
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use rstest::rstest;
409    use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction, SimpleColumnType};
410
411    fn col(name: &str, ty: ColumnType) -> ColumnDef {
412        ColumnDef {
413            name: name.to_string(),
414            r#type: ty,
415            nullable: true,
416            default: None,
417            comment: None,
418            primary_key: None,
419            unique: None,
420            index: None,
421            foreign_key: None,
422        }
423    }
424
425    fn table(
426        name: &str,
427        columns: Vec<ColumnDef>,
428        constraints: Vec<vespertide_core::TableConstraint>,
429        indexes: Vec<IndexDef>,
430    ) -> TableDef {
431        TableDef {
432            name: name.to_string(),
433            columns,
434            constraints,
435            indexes,
436        }
437    }
438
439    #[rstest]
440    #[case::add_column_and_index(
441        vec![table(
442            "users",
443            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
444            vec![],
445            vec![],
446        )],
447        vec![table(
448            "users",
449            vec![
450                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
451                col("name", ColumnType::Simple(SimpleColumnType::Text)),
452            ],
453            vec![],
454            vec![IndexDef {
455                name: "idx_users_name".into(),
456                columns: vec!["name".into()],
457                unique: false,
458            }],
459        )],
460        vec![
461            MigrationAction::AddColumn {
462                table: "users".into(),
463                column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
464                fill_with: None,
465            },
466            MigrationAction::AddIndex {
467                table: "users".into(),
468                index: IndexDef {
469                    name: "idx_users_name".into(),
470                    columns: vec!["name".into()],
471                    unique: false,
472                },
473            },
474        ]
475    )]
476    #[case::drop_table(
477        vec![table(
478            "users",
479            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
480            vec![],
481            vec![],
482        )],
483        vec![],
484        vec![MigrationAction::DeleteTable {
485            table: "users".into()
486        }]
487    )]
488    #[case::add_table(
489        vec![],
490        vec![table(
491            "users",
492            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
493            vec![],
494            vec![IndexDef {
495                name: "idx_users_id".into(),
496                columns: vec!["id".into()],
497                unique: true,
498            }],
499        )],
500        vec![
501            MigrationAction::CreateTable {
502                table: "users".into(),
503                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
504                constraints: vec![],
505            },
506            MigrationAction::AddIndex {
507                table: "users".into(),
508                index: IndexDef {
509                    name: "idx_users_id".into(),
510                    columns: vec!["id".into()],
511                    unique: true,
512                },
513            },
514        ]
515    )]
516    #[case::delete_column(
517        vec![table(
518            "users",
519            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
520            vec![],
521            vec![],
522        )],
523        vec![table(
524            "users",
525            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
526            vec![],
527            vec![],
528        )],
529        vec![MigrationAction::DeleteColumn {
530            table: "users".into(),
531            column: "name".into(),
532        }]
533    )]
534    #[case::modify_column_type(
535        vec![table(
536            "users",
537            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
538            vec![],
539            vec![],
540        )],
541        vec![table(
542            "users",
543            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
544            vec![],
545            vec![],
546        )],
547        vec![MigrationAction::ModifyColumnType {
548            table: "users".into(),
549            column: "id".into(),
550            new_type: ColumnType::Simple(SimpleColumnType::Text),
551        }]
552    )]
553    #[case::remove_index(
554        vec![table(
555            "users",
556            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
557            vec![],
558            vec![IndexDef {
559                name: "idx_users_id".into(),
560                columns: vec!["id".into()],
561                unique: false,
562            }],
563        )],
564        vec![table(
565            "users",
566            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
567            vec![],
568            vec![],
569        )],
570        vec![MigrationAction::RemoveIndex {
571            table: "users".into(),
572            name: "idx_users_id".into(),
573        }]
574    )]
575    #[case::add_index_existing_table(
576        vec![table(
577            "users",
578            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
579            vec![],
580            vec![],
581        )],
582        vec![table(
583            "users",
584            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
585            vec![],
586            vec![IndexDef {
587                name: "idx_users_id".into(),
588                columns: vec!["id".into()],
589                unique: true,
590            }],
591        )],
592        vec![MigrationAction::AddIndex {
593            table: "users".into(),
594            index: IndexDef {
595                name: "idx_users_id".into(),
596                columns: vec!["id".into()],
597                unique: true,
598            },
599        }]
600    )]
601    fn diff_schemas_detects_additions(
602        #[case] from_schema: Vec<TableDef>,
603        #[case] to_schema: Vec<TableDef>,
604        #[case] expected_actions: Vec<MigrationAction>,
605    ) {
606        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
607        assert_eq!(plan.actions, expected_actions);
608    }
609
610    // Tests for integer enum handling
611    mod integer_enum {
612        use super::*;
613        use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
614
615        #[test]
616        fn integer_enum_values_changed_no_migration() {
617            // Integer enum values changed - should NOT generate ModifyColumnType
618            let from = vec![table(
619                "orders",
620                vec![col(
621                    "status",
622                    ColumnType::Complex(ComplexColumnType::Enum {
623                        name: "order_status".into(),
624                        values: EnumValues::Integer(vec![
625                            NumValue {
626                                name: "Pending".into(),
627                                value: 0,
628                            },
629                            NumValue {
630                                name: "Shipped".into(),
631                                value: 1,
632                            },
633                        ]),
634                    }),
635                )],
636                vec![],
637                vec![],
638            )];
639
640            let to = vec![table(
641                "orders",
642                vec![col(
643                    "status",
644                    ColumnType::Complex(ComplexColumnType::Enum {
645                        name: "order_status".into(),
646                        values: EnumValues::Integer(vec![
647                            NumValue {
648                                name: "Pending".into(),
649                                value: 0,
650                            },
651                            NumValue {
652                                name: "Shipped".into(),
653                                value: 1,
654                            },
655                            NumValue {
656                                name: "Delivered".into(),
657                                value: 2,
658                            },
659                            NumValue {
660                                name: "Cancelled".into(),
661                                value: 100,
662                            },
663                        ]),
664                    }),
665                )],
666                vec![],
667                vec![],
668            )];
669
670            let plan = diff_schemas(&from, &to).unwrap();
671            assert!(
672                plan.actions.is_empty(),
673                "Expected no actions, got: {:?}",
674                plan.actions
675            );
676        }
677
678        #[test]
679        fn string_enum_values_changed_requires_migration() {
680            // String enum values changed - SHOULD generate ModifyColumnType
681            let from = vec![table(
682                "orders",
683                vec![col(
684                    "status",
685                    ColumnType::Complex(ComplexColumnType::Enum {
686                        name: "order_status".into(),
687                        values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
688                    }),
689                )],
690                vec![],
691                vec![],
692            )];
693
694            let to = vec![table(
695                "orders",
696                vec![col(
697                    "status",
698                    ColumnType::Complex(ComplexColumnType::Enum {
699                        name: "order_status".into(),
700                        values: EnumValues::String(vec![
701                            "pending".into(),
702                            "shipped".into(),
703                            "delivered".into(),
704                        ]),
705                    }),
706                )],
707                vec![],
708                vec![],
709            )];
710
711            let plan = diff_schemas(&from, &to).unwrap();
712            assert_eq!(plan.actions.len(), 1);
713            assert!(matches!(
714                &plan.actions[0],
715                MigrationAction::ModifyColumnType { table, column, .. }
716                if table == "orders" && column == "status"
717            ));
718        }
719    }
720
721    // Tests for inline column constraints normalization
722    mod inline_constraints {
723        use super::*;
724        use vespertide_core::schema::foreign_key::ForeignKeyDef;
725        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
726        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
727        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
728
729        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
730            ColumnDef {
731                name: name.to_string(),
732                r#type: ty,
733                nullable: false,
734                default: None,
735                comment: None,
736                primary_key: Some(PrimaryKeySyntax::Bool(true)),
737                unique: None,
738                index: None,
739                foreign_key: None,
740            }
741        }
742
743        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
744            ColumnDef {
745                name: name.to_string(),
746                r#type: ty,
747                nullable: true,
748                default: None,
749                comment: None,
750                primary_key: None,
751                unique: Some(StrOrBoolOrArray::Bool(true)),
752                index: None,
753                foreign_key: None,
754            }
755        }
756
757        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
758            ColumnDef {
759                name: name.to_string(),
760                r#type: ty,
761                nullable: true,
762                default: None,
763                comment: None,
764                primary_key: None,
765                unique: None,
766                index: Some(StrOrBoolOrArray::Bool(true)),
767                foreign_key: None,
768            }
769        }
770
771        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
772            ColumnDef {
773                name: name.to_string(),
774                r#type: ty,
775                nullable: true,
776                default: None,
777                comment: None,
778                primary_key: None,
779                unique: None,
780                index: None,
781                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
782                    ref_table: ref_table.to_string(),
783                    ref_columns: vec![ref_col.to_string()],
784                    on_delete: None,
785                    on_update: None,
786                })),
787            }
788        }
789
790        #[test]
791        fn create_table_with_inline_pk() {
792            let plan = diff_schemas(
793                &[],
794                &[table(
795                    "users",
796                    vec![
797                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
798                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
799                    ],
800                    vec![],
801                    vec![],
802                )],
803            )
804            .unwrap();
805
806            assert_eq!(plan.actions.len(), 1);
807            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
808                assert_eq!(constraints.len(), 1);
809                assert!(matches!(
810                    &constraints[0],
811                    TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
812                ));
813            } else {
814                panic!("Expected CreateTable action");
815            }
816        }
817
818        #[test]
819        fn create_table_with_inline_unique() {
820            let plan = diff_schemas(
821                &[],
822                &[table(
823                    "users",
824                    vec![
825                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
826                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
827                    ],
828                    vec![],
829                    vec![],
830                )],
831            )
832            .unwrap();
833
834            assert_eq!(plan.actions.len(), 1);
835            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
836                assert_eq!(constraints.len(), 1);
837                assert!(matches!(
838                    &constraints[0],
839                    TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
840                ));
841            } else {
842                panic!("Expected CreateTable action");
843            }
844        }
845
846        #[test]
847        fn create_table_with_inline_index() {
848            let plan = diff_schemas(
849                &[],
850                &[table(
851                    "users",
852                    vec![
853                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
854                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
855                    ],
856                    vec![],
857                    vec![],
858                )],
859            )
860            .unwrap();
861
862            // Should have CreateTable + AddIndex
863            assert_eq!(plan.actions.len(), 2);
864            assert!(matches!(
865                &plan.actions[0],
866                MigrationAction::CreateTable { .. }
867            ));
868            if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
869                assert_eq!(index.name, "idx_users_name");
870                assert_eq!(index.columns, vec!["name".to_string()]);
871            } else {
872                panic!("Expected AddIndex action");
873            }
874        }
875
876        #[test]
877        fn create_table_with_inline_fk() {
878            let plan = diff_schemas(
879                &[],
880                &[table(
881                    "posts",
882                    vec![
883                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
884                        col_with_fk(
885                            "user_id",
886                            ColumnType::Simple(SimpleColumnType::Integer),
887                            "users",
888                            "id",
889                        ),
890                    ],
891                    vec![],
892                    vec![],
893                )],
894            )
895            .unwrap();
896
897            assert_eq!(plan.actions.len(), 1);
898            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
899                assert_eq!(constraints.len(), 1);
900                assert!(matches!(
901                    &constraints[0],
902                    TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
903                        if columns == &["user_id".to_string()]
904                        && ref_table == "users"
905                        && ref_columns == &["id".to_string()]
906                ));
907            } else {
908                panic!("Expected CreateTable action");
909            }
910        }
911
912        #[test]
913        fn add_index_via_inline_constraint() {
914            // Existing table without index -> table with inline index
915            let plan = diff_schemas(
916                &[table(
917                    "users",
918                    vec![
919                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
920                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
921                    ],
922                    vec![],
923                    vec![],
924                )],
925                &[table(
926                    "users",
927                    vec![
928                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
929                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
930                    ],
931                    vec![],
932                    vec![],
933                )],
934            )
935            .unwrap();
936
937            assert_eq!(plan.actions.len(), 1);
938            if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
939                assert_eq!(table, "users");
940                assert_eq!(index.name, "idx_users_name");
941                assert_eq!(index.columns, vec!["name".to_string()]);
942            } else {
943                panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
944            }
945        }
946
947        #[test]
948        fn create_table_with_all_inline_constraints() {
949            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
950            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
951            id_col.nullable = false;
952
953            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
954            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
955
956            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
957            name_col.index = Some(StrOrBoolOrArray::Bool(true));
958
959            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
960            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
961                ref_table: "orgs".into(),
962                ref_columns: vec!["id".into()],
963                on_delete: None,
964                on_update: None,
965            }));
966
967            let plan = diff_schemas(
968                &[],
969                &[table(
970                    "users",
971                    vec![id_col, email_col, name_col, org_id_col],
972                    vec![],
973                    vec![],
974                )],
975            )
976            .unwrap();
977
978            // Should have CreateTable + AddIndex
979            assert_eq!(plan.actions.len(), 2);
980
981            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
982                // Should have: PrimaryKey, Unique, ForeignKey (3 constraints)
983                assert_eq!(constraints.len(), 3);
984            } else {
985                panic!("Expected CreateTable action");
986            }
987
988            // Check for AddIndex action
989            assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
990        }
991
992        #[test]
993        fn add_constraint_to_existing_table() {
994            // Add a unique constraint to an existing table
995            let from_schema = vec![table(
996                "users",
997                vec![
998                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
999                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1000                ],
1001                vec![],
1002                vec![],
1003            )];
1004
1005            let to_schema = vec![table(
1006                "users",
1007                vec![
1008                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1009                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1010                ],
1011                vec![vespertide_core::TableConstraint::Unique {
1012                    name: Some("uq_users_email".into()),
1013                    columns: vec!["email".into()],
1014                }],
1015                vec![],
1016            )];
1017
1018            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1019            assert_eq!(plan.actions.len(), 1);
1020            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1021                assert_eq!(table, "users");
1022                assert!(matches!(
1023                    constraint,
1024                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1025                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1026                ));
1027            } else {
1028                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1029            }
1030        }
1031
1032        #[test]
1033        fn remove_constraint_from_existing_table() {
1034            // Remove a unique constraint from an existing table
1035            let from_schema = vec![table(
1036                "users",
1037                vec![
1038                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1039                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1040                ],
1041                vec![vespertide_core::TableConstraint::Unique {
1042                    name: Some("uq_users_email".into()),
1043                    columns: vec!["email".into()],
1044                }],
1045                vec![],
1046            )];
1047
1048            let to_schema = vec![table(
1049                "users",
1050                vec![
1051                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1052                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1053                ],
1054                vec![],
1055                vec![],
1056            )];
1057
1058            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1059            assert_eq!(plan.actions.len(), 1);
1060            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1061                assert_eq!(table, "users");
1062                assert!(matches!(
1063                    constraint,
1064                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1065                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1066                ));
1067            } else {
1068                panic!(
1069                    "Expected RemoveConstraint action, got {:?}",
1070                    plan.actions[0]
1071                );
1072            }
1073        }
1074
1075        #[test]
1076        fn diff_schemas_with_normalize_error() {
1077            // Test that normalize errors are properly propagated
1078            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1079            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1080
1081            let table = TableDef {
1082                name: "test".into(),
1083                columns: vec![
1084                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1085                    col1.clone(),
1086                    {
1087                        // Same column with same index name - should error
1088                        let mut c = col1.clone();
1089                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1090                        c
1091                    },
1092                ],
1093                constraints: vec![],
1094                indexes: vec![],
1095            };
1096
1097            let result = diff_schemas(&[], &[table]);
1098            assert!(result.is_err());
1099            if let Err(PlannerError::TableValidation(msg)) = result {
1100                assert!(msg.contains("Failed to normalize table"));
1101                assert!(msg.contains("Duplicate index"));
1102            } else {
1103                panic!("Expected TableValidation error, got {:?}", result);
1104            }
1105        }
1106
1107        #[test]
1108        fn diff_schemas_with_normalize_error_in_from_schema() {
1109            // Test that normalize errors in 'from' schema are properly propagated
1110            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1111            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1112
1113            let table = TableDef {
1114                name: "test".into(),
1115                columns: vec![
1116                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1117                    col1.clone(),
1118                    {
1119                        // Same column with same index name - should error
1120                        let mut c = col1.clone();
1121                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1122                        c
1123                    },
1124                ],
1125                constraints: vec![],
1126                indexes: vec![],
1127            };
1128
1129            // 'from' schema has the invalid table
1130            let result = diff_schemas(&[table], &[]);
1131            assert!(result.is_err());
1132            if let Err(PlannerError::TableValidation(msg)) = result {
1133                assert!(msg.contains("Failed to normalize table"));
1134                assert!(msg.contains("Duplicate index"));
1135            } else {
1136                panic!("Expected TableValidation error, got {:?}", result);
1137            }
1138        }
1139    }
1140
1141    // Tests for foreign key dependency ordering
1142    mod fk_ordering {
1143        use super::*;
1144        use vespertide_core::TableConstraint;
1145
1146        fn table_with_fk(
1147            name: &str,
1148            ref_table: &str,
1149            fk_column: &str,
1150            ref_column: &str,
1151        ) -> TableDef {
1152            TableDef {
1153                name: name.to_string(),
1154                columns: vec![
1155                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1156                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1157                ],
1158                constraints: vec![TableConstraint::ForeignKey {
1159                    name: None,
1160                    columns: vec![fk_column.to_string()],
1161                    ref_table: ref_table.to_string(),
1162                    ref_columns: vec![ref_column.to_string()],
1163                    on_delete: None,
1164                    on_update: None,
1165                }],
1166                indexes: vec![],
1167            }
1168        }
1169
1170        fn simple_table(name: &str) -> TableDef {
1171            TableDef {
1172                name: name.to_string(),
1173                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1174                constraints: vec![],
1175                indexes: vec![],
1176            }
1177        }
1178
1179        #[test]
1180        fn create_tables_respects_fk_order() {
1181            // Create users and posts tables where posts references users
1182            // The order should be: users first, then posts
1183            let users = simple_table("users");
1184            let posts = table_with_fk("posts", "users", "user_id", "id");
1185
1186            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1187
1188            // Extract CreateTable actions in order
1189            let create_order: Vec<&str> = plan
1190                .actions
1191                .iter()
1192                .filter_map(|a| {
1193                    if let MigrationAction::CreateTable { table, .. } = a {
1194                        Some(table.as_str())
1195                    } else {
1196                        None
1197                    }
1198                })
1199                .collect();
1200
1201            assert_eq!(create_order, vec!["users", "posts"]);
1202        }
1203
1204        #[test]
1205        fn create_tables_chain_dependency() {
1206            // Chain: users <- media <- articles
1207            // users has no FK
1208            // media references users
1209            // articles references media
1210            let users = simple_table("users");
1211            let media = table_with_fk("media", "users", "owner_id", "id");
1212            let articles = table_with_fk("articles", "media", "media_id", "id");
1213
1214            // Pass in reverse order to ensure sorting works
1215            let plan =
1216                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1217
1218            let create_order: Vec<&str> = plan
1219                .actions
1220                .iter()
1221                .filter_map(|a| {
1222                    if let MigrationAction::CreateTable { table, .. } = a {
1223                        Some(table.as_str())
1224                    } else {
1225                        None
1226                    }
1227                })
1228                .collect();
1229
1230            assert_eq!(create_order, vec!["users", "media", "articles"]);
1231        }
1232
1233        #[test]
1234        fn create_tables_multiple_independent_branches() {
1235            // Two independent branches:
1236            // users <- posts
1237            // categories <- products
1238            let users = simple_table("users");
1239            let posts = table_with_fk("posts", "users", "user_id", "id");
1240            let categories = simple_table("categories");
1241            let products = table_with_fk("products", "categories", "category_id", "id");
1242
1243            let plan = diff_schemas(
1244                &[],
1245                &[
1246                    products.clone(),
1247                    posts.clone(),
1248                    categories.clone(),
1249                    users.clone(),
1250                ],
1251            )
1252            .unwrap();
1253
1254            let create_order: Vec<&str> = plan
1255                .actions
1256                .iter()
1257                .filter_map(|a| {
1258                    if let MigrationAction::CreateTable { table, .. } = a {
1259                        Some(table.as_str())
1260                    } else {
1261                        None
1262                    }
1263                })
1264                .collect();
1265
1266            // users must come before posts
1267            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1268            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1269            assert!(
1270                users_pos < posts_pos,
1271                "users should be created before posts"
1272            );
1273
1274            // categories must come before products
1275            let categories_pos = create_order
1276                .iter()
1277                .position(|&t| t == "categories")
1278                .unwrap();
1279            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1280            assert!(
1281                categories_pos < products_pos,
1282                "categories should be created before products"
1283            );
1284        }
1285
1286        #[test]
1287        fn delete_tables_respects_fk_order() {
1288            // When deleting users and posts where posts references users,
1289            // posts should be deleted first (reverse of creation order)
1290            let users = simple_table("users");
1291            let posts = table_with_fk("posts", "users", "user_id", "id");
1292
1293            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1294
1295            let delete_order: Vec<&str> = plan
1296                .actions
1297                .iter()
1298                .filter_map(|a| {
1299                    if let MigrationAction::DeleteTable { table } = a {
1300                        Some(table.as_str())
1301                    } else {
1302                        None
1303                    }
1304                })
1305                .collect();
1306
1307            assert_eq!(delete_order, vec!["posts", "users"]);
1308        }
1309
1310        #[test]
1311        fn delete_tables_chain_dependency() {
1312            // Chain: users <- media <- articles
1313            // Delete order should be: articles, media, users
1314            let users = simple_table("users");
1315            let media = table_with_fk("media", "users", "owner_id", "id");
1316            let articles = table_with_fk("articles", "media", "media_id", "id");
1317
1318            let plan =
1319                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1320
1321            let delete_order: Vec<&str> = plan
1322                .actions
1323                .iter()
1324                .filter_map(|a| {
1325                    if let MigrationAction::DeleteTable { table } = a {
1326                        Some(table.as_str())
1327                    } else {
1328                        None
1329                    }
1330                })
1331                .collect();
1332
1333            // articles must be deleted before media
1334            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1335            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1336            assert!(
1337                articles_pos < media_pos,
1338                "articles should be deleted before media"
1339            );
1340
1341            // media must be deleted before users
1342            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1343            assert!(
1344                media_pos < users_pos,
1345                "media should be deleted before users"
1346            );
1347        }
1348
1349        #[test]
1350        fn circular_fk_dependency_returns_error() {
1351            // Create circular dependency: A -> B -> A
1352            let table_a = TableDef {
1353                name: "table_a".to_string(),
1354                columns: vec![
1355                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1356                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1357                ],
1358                constraints: vec![TableConstraint::ForeignKey {
1359                    name: None,
1360                    columns: vec!["b_id".to_string()],
1361                    ref_table: "table_b".to_string(),
1362                    ref_columns: vec!["id".to_string()],
1363                    on_delete: None,
1364                    on_update: None,
1365                }],
1366                indexes: vec![],
1367            };
1368
1369            let table_b = TableDef {
1370                name: "table_b".to_string(),
1371                columns: vec![
1372                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1373                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1374                ],
1375                constraints: vec![TableConstraint::ForeignKey {
1376                    name: None,
1377                    columns: vec!["a_id".to_string()],
1378                    ref_table: "table_a".to_string(),
1379                    ref_columns: vec!["id".to_string()],
1380                    on_delete: None,
1381                    on_update: None,
1382                }],
1383                indexes: vec![],
1384            };
1385
1386            let result = diff_schemas(&[], &[table_a, table_b]);
1387            assert!(result.is_err());
1388            if let Err(PlannerError::TableValidation(msg)) = result {
1389                assert!(
1390                    msg.contains("Circular foreign key dependency"),
1391                    "Expected circular dependency error, got: {}",
1392                    msg
1393                );
1394            } else {
1395                panic!("Expected TableValidation error, got {:?}", result);
1396            }
1397        }
1398
1399        #[test]
1400        fn fk_to_external_table_is_ignored() {
1401            // FK referencing a table not in the migration should not affect ordering
1402            let posts = table_with_fk("posts", "users", "user_id", "id");
1403            let comments = table_with_fk("comments", "posts", "post_id", "id");
1404
1405            // users is NOT being created in this migration
1406            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1407
1408            let create_order: Vec<&str> = plan
1409                .actions
1410                .iter()
1411                .filter_map(|a| {
1412                    if let MigrationAction::CreateTable { table, .. } = a {
1413                        Some(table.as_str())
1414                    } else {
1415                        None
1416                    }
1417                })
1418                .collect();
1419
1420            // posts must come before comments (comments depends on posts)
1421            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1422            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1423            assert!(
1424                posts_pos < comments_pos,
1425                "posts should be created before comments"
1426            );
1427        }
1428
1429        #[test]
1430        fn delete_tables_mixed_with_other_actions() {
1431            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1432            // This tests lines 124, 193, 198 (the else branches)
1433            use crate::diff::diff_schemas;
1434
1435            let from_schema = vec![
1436                table(
1437                    "users",
1438                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1439                    vec![],
1440                    vec![],
1441                ),
1442                table(
1443                    "posts",
1444                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1445                    vec![],
1446                    vec![],
1447                ),
1448            ];
1449
1450            let to_schema = vec![
1451                // Drop posts table, but also add a new column to users
1452                table(
1453                    "users",
1454                    vec![
1455                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1456                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1457                    ],
1458                    vec![],
1459                    vec![],
1460                ),
1461            ];
1462
1463            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1464
1465            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1466            assert!(
1467                plan.actions
1468                    .iter()
1469                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1470            );
1471            assert!(
1472                plan.actions
1473                    .iter()
1474                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1475            );
1476
1477            // The else branches in sort_delete_actions should handle AddColumn gracefully
1478            // (returning empty string for table name, which sorts it to position 0)
1479        }
1480
1481        #[test]
1482        #[should_panic(expected = "Expected DeleteTable action")]
1483        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1484            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1485            use super::extract_delete_table_name;
1486
1487            let action = MigrationAction::AddColumn {
1488                table: "users".into(),
1489                column: Box::new(ColumnDef {
1490                    name: "email".into(),
1491                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1492                    nullable: true,
1493                    default: None,
1494                    comment: None,
1495                    primary_key: None,
1496                    unique: None,
1497                    index: None,
1498                    foreign_key: None,
1499                }),
1500                fill_with: None,
1501            };
1502
1503            // This should panic
1504            extract_delete_table_name(&action);
1505        }
1506
1507        /// Test that inline FK across multiple tables works correctly with topological sort
1508        #[test]
1509        fn create_tables_with_inline_fk_chain() {
1510            use super::*;
1511            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1512            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1513
1514            fn col_pk(name: &str) -> ColumnDef {
1515                ColumnDef {
1516                    name: name.to_string(),
1517                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1518                    nullable: false,
1519                    default: None,
1520                    comment: None,
1521                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1522                    unique: None,
1523                    index: None,
1524                    foreign_key: None,
1525                }
1526            }
1527
1528            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1529                ColumnDef {
1530                    name: name.to_string(),
1531                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1532                    nullable: true,
1533                    default: None,
1534                    comment: None,
1535                    primary_key: None,
1536                    unique: None,
1537                    index: None,
1538                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1539                }
1540            }
1541
1542            // Reproduce the app example structure:
1543            // user -> (no deps)
1544            // product -> (no deps)
1545            // project -> user
1546            // code -> product, user, project
1547            // order -> user, project, product, code
1548            // payment -> order
1549
1550            let user = TableDef {
1551                name: "user".to_string(),
1552                columns: vec![col_pk("id")],
1553                constraints: vec![],
1554                indexes: vec![],
1555            };
1556
1557            let product = TableDef {
1558                name: "product".to_string(),
1559                columns: vec![col_pk("id")],
1560                constraints: vec![],
1561                indexes: vec![],
1562            };
1563
1564            let project = TableDef {
1565                name: "project".to_string(),
1566                columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1567                constraints: vec![],
1568                indexes: vec![],
1569            };
1570
1571            let code = TableDef {
1572                name: "code".to_string(),
1573                columns: vec![
1574                    col_pk("id"),
1575                    col_inline_fk("product_id", "product"),
1576                    col_inline_fk("creator_user_id", "user"),
1577                    col_inline_fk("project_id", "project"),
1578                ],
1579                constraints: vec![],
1580                indexes: vec![],
1581            };
1582
1583            let order = TableDef {
1584                name: "order".to_string(),
1585                columns: vec![
1586                    col_pk("id"),
1587                    col_inline_fk("user_id", "user"),
1588                    col_inline_fk("project_id", "project"),
1589                    col_inline_fk("product_id", "product"),
1590                    col_inline_fk("code_id", "code"),
1591                ],
1592                constraints: vec![],
1593                indexes: vec![],
1594            };
1595
1596            let payment = TableDef {
1597                name: "payment".to_string(),
1598                columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1599                constraints: vec![],
1600                indexes: vec![],
1601            };
1602
1603            // Pass in arbitrary order - should NOT return circular dependency error
1604            let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1605            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1606
1607            let plan = result.unwrap();
1608            let create_order: Vec<&str> = plan
1609                .actions
1610                .iter()
1611                .filter_map(|a| {
1612                    if let MigrationAction::CreateTable { table, .. } = a {
1613                        Some(table.as_str())
1614                    } else {
1615                        None
1616                    }
1617                })
1618                .collect();
1619
1620            // Verify order respects FK dependencies
1621            let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1622
1623            // user and product have no deps, can be in any order
1624            // project depends on user
1625            assert!(
1626                get_pos("user") < get_pos("project"),
1627                "user must come before project"
1628            );
1629            // code depends on product, user, project
1630            assert!(
1631                get_pos("product") < get_pos("code"),
1632                "product must come before code"
1633            );
1634            assert!(
1635                get_pos("user") < get_pos("code"),
1636                "user must come before code"
1637            );
1638            assert!(
1639                get_pos("project") < get_pos("code"),
1640                "project must come before code"
1641            );
1642            // order depends on user, project, product, code
1643            assert!(
1644                get_pos("code") < get_pos("order"),
1645                "code must come before order"
1646            );
1647            // payment depends on order
1648            assert!(
1649                get_pos("order") < get_pos("payment"),
1650                "order must come before payment"
1651            );
1652        }
1653
1654        /// Test that multiple FKs to the same table are deduplicated correctly
1655        #[test]
1656        fn create_tables_with_duplicate_fk_references() {
1657            use super::*;
1658            use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1659            use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1660
1661            fn col_pk(name: &str) -> ColumnDef {
1662                ColumnDef {
1663                    name: name.to_string(),
1664                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1665                    nullable: false,
1666                    default: None,
1667                    comment: None,
1668                    primary_key: Some(PrimaryKeySyntax::Bool(true)),
1669                    unique: None,
1670                    index: None,
1671                    foreign_key: None,
1672                }
1673            }
1674
1675            fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1676                ColumnDef {
1677                    name: name.to_string(),
1678                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
1679                    nullable: true,
1680                    default: None,
1681                    comment: None,
1682                    primary_key: None,
1683                    unique: None,
1684                    index: None,
1685                    foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1686                }
1687            }
1688
1689            // Table with multiple FKs referencing the same table (like code.creator_user_id and code.used_by_user_id)
1690            let user = TableDef {
1691                name: "user".to_string(),
1692                columns: vec![col_pk("id")],
1693                constraints: vec![],
1694                indexes: vec![],
1695            };
1696
1697            let code = TableDef {
1698                name: "code".to_string(),
1699                columns: vec![
1700                    col_pk("id"),
1701                    col_inline_fk("creator_user_id", "user"),
1702                    col_inline_fk("used_by_user_id", "user"), // Second FK to same table
1703                ],
1704                constraints: vec![],
1705                indexes: vec![],
1706            };
1707
1708            // This should NOT return circular dependency error even with duplicate FK refs
1709            let result = diff_schemas(&[], &[code, user]);
1710            assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1711
1712            let plan = result.unwrap();
1713            let create_order: Vec<&str> = plan
1714                .actions
1715                .iter()
1716                .filter_map(|a| {
1717                    if let MigrationAction::CreateTable { table, .. } = a {
1718                        Some(table.as_str())
1719                    } else {
1720                        None
1721                    }
1722                })
1723                .collect();
1724
1725            // user must come before code
1726            let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1727            let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1728            assert!(user_pos < code_pos, "user must come before code");
1729        }
1730    }
1731}