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    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
21    for table in tables {
22        let mut deps = Vec::new();
23        for constraint in &table.constraints {
24            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
25                // Only consider dependencies within the set of tables being created
26                if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
27                    deps.push(ref_table.as_str());
28                }
29            }
30        }
31        dependencies.insert(table.name.as_str(), deps);
32    }
33
34    // Kahn's algorithm for topological sort
35    // Calculate in-degrees (number of tables that depend on each table)
36    // Use BTreeMap for consistent ordering
37    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
38    for table in tables {
39        in_degree.entry(table.name.as_str()).or_insert(0);
40    }
41
42    // For each dependency, increment the in-degree of the dependent table
43    for (table_name, deps) in &dependencies {
44        for _dep in deps {
45            // The table has dependencies, so those referenced tables must come first
46            // We actually want the reverse: tables with dependencies have higher in-degree
47        }
48        // Actually, we need to track: if A depends on B, then A has in-degree from B
49        // So A cannot be processed until B is processed
50        *in_degree.entry(table_name).or_insert(0) += deps.len();
51    }
52
53    // Start with tables that have no dependencies
54    // BTreeMap iteration is already sorted by key
55    let mut queue: VecDeque<&str> = in_degree
56        .iter()
57        .filter(|(_, deg)| **deg == 0)
58        .map(|(name, _)| *name)
59        .collect();
60
61    let mut result: Vec<&TableDef> = Vec::new();
62    let table_map: BTreeMap<&str, &TableDef> =
63        tables.iter().map(|t| (t.name.as_str(), *t)).collect();
64
65    while let Some(table_name) = queue.pop_front() {
66        if let Some(&table) = table_map.get(table_name) {
67            result.push(table);
68        }
69
70        // Collect tables that become ready (in-degree becomes 0)
71        // Use BTreeSet for consistent ordering
72        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
73        for (dependent, deps) in &dependencies {
74            if deps.contains(&table_name)
75                && let Some(degree) = in_degree.get_mut(dependent)
76            {
77                *degree -= 1;
78                if *degree == 0 {
79                    ready_tables.insert(dependent);
80                }
81            }
82        }
83        for t in ready_tables {
84            queue.push_back(t);
85        }
86    }
87
88    // Check for cycles
89    if result.len() != tables.len() {
90        let remaining: Vec<&str> = tables
91            .iter()
92            .map(|t| t.name.as_str())
93            .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
94            .collect();
95        return Err(PlannerError::TableValidation(format!(
96            "Circular foreign key dependency detected among tables: {:?}",
97            remaining
98        )));
99    }
100
101    Ok(result)
102}
103
104/// Sort DeleteTable actions so that tables with FK references are deleted first.
105/// This is the reverse of creation order - use topological sort then reverse.
106/// Helper function to extract table name from DeleteTable action
107/// Safety: should only be called on DeleteTable actions
108fn extract_delete_table_name(action: &MigrationAction) -> &str {
109    match action {
110        MigrationAction::DeleteTable { table } => table.as_str(),
111        _ => panic!("Expected DeleteTable action"),
112    }
113}
114
115fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
116    // Collect DeleteTable actions and their indices
117    let delete_indices: Vec<usize> = actions
118        .iter()
119        .enumerate()
120        .filter_map(|(i, a)| {
121            if matches!(a, MigrationAction::DeleteTable { .. }) {
122                Some(i)
123            } else {
124                None
125            }
126        })
127        .collect();
128
129    if delete_indices.len() <= 1 {
130        return;
131    }
132
133    // Extract table names being deleted
134    // Use BTreeSet for consistent ordering
135    let delete_table_names: BTreeSet<&str> = delete_indices
136        .iter()
137        .map(|&i| extract_delete_table_name(&actions[i]))
138        .collect();
139
140    // Build dependency graph for tables being deleted
141    // dependencies[A] = [B] means A has FK referencing B
142    // Use BTreeMap for consistent ordering
143    let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
144    for &table_name in &delete_table_names {
145        let mut deps = Vec::new();
146        if let Some(table_def) = all_tables.get(table_name) {
147            for constraint in &table_def.constraints {
148                if let TableConstraint::ForeignKey { ref_table, .. } = constraint
149                    && delete_table_names.contains(ref_table.as_str())
150                    && ref_table != table_name
151                {
152                    deps.push(ref_table.as_str());
153                }
154            }
155        }
156        dependencies.insert(table_name, deps);
157    }
158
159    // Use Kahn's algorithm for topological sort
160    // in_degree[A] = number of tables A depends on
161    // Use BTreeMap for consistent ordering
162    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
163    for &table_name in &delete_table_names {
164        in_degree.insert(
165            table_name,
166            dependencies.get(table_name).map_or(0, |d| d.len()),
167        );
168    }
169
170    // Start with tables that have no dependencies (can be deleted last in creation order)
171    // BTreeMap iteration is already sorted
172    let mut queue: VecDeque<&str> = in_degree
173        .iter()
174        .filter(|(_, deg)| **deg == 0)
175        .map(|(name, _)| *name)
176        .collect();
177
178    let mut sorted_tables: Vec<&str> = Vec::new();
179    while let Some(table_name) = queue.pop_front() {
180        sorted_tables.push(table_name);
181
182        // For each table that has this one as a dependency, decrement its in-degree
183        // Use BTreeSet for consistent ordering of newly ready tables
184        let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
185        for (&dependent, deps) in &dependencies {
186            if deps.contains(&table_name)
187                && let Some(degree) = in_degree.get_mut(dependent)
188            {
189                *degree -= 1;
190                if *degree == 0 {
191                    ready_tables.insert(dependent);
192                }
193            }
194        }
195        for t in ready_tables {
196            queue.push_back(t);
197        }
198    }
199
200    // Reverse to get deletion order (tables with dependencies should be deleted first)
201    sorted_tables.reverse();
202
203    // Reorder the DeleteTable actions according to sorted order
204    let mut delete_actions: Vec<MigrationAction> =
205        delete_indices.iter().map(|&i| actions[i].clone()).collect();
206
207    delete_actions.sort_by(|a, b| {
208        let a_name = extract_delete_table_name(a);
209        let b_name = extract_delete_table_name(b);
210
211        let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
212        let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
213        a_pos.cmp(&b_pos)
214    });
215
216    // Put them back
217    for (i, idx) in delete_indices.iter().enumerate() {
218        actions[*idx] = delete_actions[i].clone();
219    }
220}
221
222/// Diff two schema snapshots into a migration plan.
223/// Both schemas are normalized to convert inline column constraints
224/// (primary_key, unique, index, foreign_key) to table-level constraints.
225pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
226    let mut actions: Vec<MigrationAction> = Vec::new();
227
228    // Normalize both schemas to ensure inline constraints are converted to table-level
229    let from_normalized: Vec<TableDef> = from
230        .iter()
231        .map(|t| {
232            t.normalize().map_err(|e| {
233                PlannerError::TableValidation(format!(
234                    "Failed to normalize table '{}': {}",
235                    t.name, e
236                ))
237            })
238        })
239        .collect::<Result<Vec<_>, _>>()?;
240    let to_normalized: Vec<TableDef> = to
241        .iter()
242        .map(|t| {
243            t.normalize().map_err(|e| {
244                PlannerError::TableValidation(format!(
245                    "Failed to normalize table '{}': {}",
246                    t.name, e
247                ))
248            })
249        })
250        .collect::<Result<Vec<_>, _>>()?;
251
252    // Use BTreeMap for consistent ordering
253    let from_map: BTreeMap<_, _> = from_normalized
254        .iter()
255        .map(|t| (t.name.as_str(), t))
256        .collect();
257    let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
258
259    // Drop tables that disappeared.
260    for name in from_map.keys() {
261        if !to_map.contains_key(name) {
262            actions.push(MigrationAction::DeleteTable {
263                table: (*name).to_string(),
264            });
265        }
266    }
267
268    // Update existing tables and their indexes/columns.
269    for (name, to_tbl) in &to_map {
270        if let Some(from_tbl) = from_map.get(name) {
271            // Columns - use BTreeMap for consistent ordering
272            let from_cols: BTreeMap<_, _> = from_tbl
273                .columns
274                .iter()
275                .map(|c| (c.name.as_str(), c))
276                .collect();
277            let to_cols: BTreeMap<_, _> = to_tbl
278                .columns
279                .iter()
280                .map(|c| (c.name.as_str(), c))
281                .collect();
282
283            // Deleted columns
284            for col in from_cols.keys() {
285                if !to_cols.contains_key(col) {
286                    actions.push(MigrationAction::DeleteColumn {
287                        table: (*name).to_string(),
288                        column: (*col).to_string(),
289                    });
290                }
291            }
292
293            // Modified columns
294            for (col, to_def) in &to_cols {
295                if let Some(from_def) = from_cols.get(col)
296                    && from_def.r#type != to_def.r#type
297                {
298                    actions.push(MigrationAction::ModifyColumnType {
299                        table: (*name).to_string(),
300                        column: (*col).to_string(),
301                        new_type: to_def.r#type.clone(),
302                    });
303                }
304            }
305
306            // Added columns
307            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
308            // by normalize(), so they will be handled in the constraint diff below.
309            for (col, def) in &to_cols {
310                if !from_cols.contains_key(col) {
311                    actions.push(MigrationAction::AddColumn {
312                        table: (*name).to_string(),
313                        column: (*def).clone(),
314                        fill_with: None,
315                    });
316                }
317            }
318
319            // Indexes - use BTreeMap for consistent ordering
320            let from_indexes: BTreeMap<_, _> = from_tbl
321                .indexes
322                .iter()
323                .map(|i| (i.name.as_str(), i))
324                .collect();
325            let to_indexes: BTreeMap<_, _> = to_tbl
326                .indexes
327                .iter()
328                .map(|i| (i.name.as_str(), i))
329                .collect();
330
331            for idx in from_indexes.keys() {
332                if !to_indexes.contains_key(idx) {
333                    actions.push(MigrationAction::RemoveIndex {
334                        table: (*name).to_string(),
335                        name: (*idx).to_string(),
336                    });
337                }
338            }
339            for (idx, def) in &to_indexes {
340                if !from_indexes.contains_key(idx) {
341                    actions.push(MigrationAction::AddIndex {
342                        table: (*name).to_string(),
343                        index: (*def).clone(),
344                    });
345                }
346            }
347
348            // Constraints - compare and detect additions/removals
349            for from_constraint in &from_tbl.constraints {
350                if !to_tbl.constraints.contains(from_constraint) {
351                    actions.push(MigrationAction::RemoveConstraint {
352                        table: (*name).to_string(),
353                        constraint: from_constraint.clone(),
354                    });
355                }
356            }
357            for to_constraint in &to_tbl.constraints {
358                if !from_tbl.constraints.contains(to_constraint) {
359                    actions.push(MigrationAction::AddConstraint {
360                        table: (*name).to_string(),
361                        constraint: to_constraint.clone(),
362                    });
363                }
364            }
365        }
366    }
367
368    // Create new tables (and their indexes).
369    // Collect new tables first, then topologically sort them by FK dependencies.
370    let new_tables: Vec<&TableDef> = to_map
371        .iter()
372        .filter(|(name, _)| !from_map.contains_key(*name))
373        .map(|(_, tbl)| *tbl)
374        .collect();
375
376    let sorted_new_tables = topological_sort_tables(&new_tables)?;
377
378    for tbl in sorted_new_tables {
379        actions.push(MigrationAction::CreateTable {
380            table: tbl.name.clone(),
381            columns: tbl.columns.clone(),
382            constraints: tbl.constraints.clone(),
383        });
384        for idx in &tbl.indexes {
385            actions.push(MigrationAction::AddIndex {
386                table: tbl.name.clone(),
387                index: idx.clone(),
388            });
389        }
390    }
391
392    // Sort DeleteTable actions so tables with FK dependencies are deleted first
393    sort_delete_tables(&mut actions, &from_map);
394
395    Ok(MigrationPlan {
396        comment: None,
397        created_at: None,
398        version: 0,
399        actions,
400    })
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use rstest::rstest;
407    use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction, SimpleColumnType};
408
409    fn col(name: &str, ty: ColumnType) -> ColumnDef {
410        ColumnDef {
411            name: name.to_string(),
412            r#type: ty,
413            nullable: true,
414            default: None,
415            comment: None,
416            primary_key: None,
417            unique: None,
418            index: None,
419            foreign_key: None,
420        }
421    }
422
423    fn table(
424        name: &str,
425        columns: Vec<ColumnDef>,
426        constraints: Vec<vespertide_core::TableConstraint>,
427        indexes: Vec<IndexDef>,
428    ) -> TableDef {
429        TableDef {
430            name: name.to_string(),
431            columns,
432            constraints,
433            indexes,
434        }
435    }
436
437    #[rstest]
438    #[case::add_column_and_index(
439        vec![table(
440            "users",
441            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
442            vec![],
443            vec![],
444        )],
445        vec![table(
446            "users",
447            vec![
448                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
449                col("name", ColumnType::Simple(SimpleColumnType::Text)),
450            ],
451            vec![],
452            vec![IndexDef {
453                name: "idx_users_name".into(),
454                columns: vec!["name".into()],
455                unique: false,
456            }],
457        )],
458        vec![
459            MigrationAction::AddColumn {
460                table: "users".into(),
461                column: col("name", ColumnType::Simple(SimpleColumnType::Text)),
462                fill_with: None,
463            },
464            MigrationAction::AddIndex {
465                table: "users".into(),
466                index: IndexDef {
467                    name: "idx_users_name".into(),
468                    columns: vec!["name".into()],
469                    unique: false,
470                },
471            },
472        ]
473    )]
474    #[case::drop_table(
475        vec![table(
476            "users",
477            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
478            vec![],
479            vec![],
480        )],
481        vec![],
482        vec![MigrationAction::DeleteTable {
483            table: "users".into()
484        }]
485    )]
486    #[case::add_table(
487        vec![],
488        vec![table(
489            "users",
490            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
491            vec![],
492            vec![IndexDef {
493                name: "idx_users_id".into(),
494                columns: vec!["id".into()],
495                unique: true,
496            }],
497        )],
498        vec![
499            MigrationAction::CreateTable {
500                table: "users".into(),
501                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
502                constraints: vec![],
503            },
504            MigrationAction::AddIndex {
505                table: "users".into(),
506                index: IndexDef {
507                    name: "idx_users_id".into(),
508                    columns: vec!["id".into()],
509                    unique: true,
510                },
511            },
512        ]
513    )]
514    #[case::delete_column(
515        vec![table(
516            "users",
517            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
518            vec![],
519            vec![],
520        )],
521        vec![table(
522            "users",
523            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
524            vec![],
525            vec![],
526        )],
527        vec![MigrationAction::DeleteColumn {
528            table: "users".into(),
529            column: "name".into(),
530        }]
531    )]
532    #[case::modify_column_type(
533        vec![table(
534            "users",
535            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
536            vec![],
537            vec![],
538        )],
539        vec![table(
540            "users",
541            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
542            vec![],
543            vec![],
544        )],
545        vec![MigrationAction::ModifyColumnType {
546            table: "users".into(),
547            column: "id".into(),
548            new_type: ColumnType::Simple(SimpleColumnType::Text),
549        }]
550    )]
551    #[case::remove_index(
552        vec![table(
553            "users",
554            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
555            vec![],
556            vec![IndexDef {
557                name: "idx_users_id".into(),
558                columns: vec!["id".into()],
559                unique: false,
560            }],
561        )],
562        vec![table(
563            "users",
564            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
565            vec![],
566            vec![],
567        )],
568        vec![MigrationAction::RemoveIndex {
569            table: "users".into(),
570            name: "idx_users_id".into(),
571        }]
572    )]
573    #[case::add_index_existing_table(
574        vec![table(
575            "users",
576            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
577            vec![],
578            vec![],
579        )],
580        vec![table(
581            "users",
582            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
583            vec![],
584            vec![IndexDef {
585                name: "idx_users_id".into(),
586                columns: vec!["id".into()],
587                unique: true,
588            }],
589        )],
590        vec![MigrationAction::AddIndex {
591            table: "users".into(),
592            index: IndexDef {
593                name: "idx_users_id".into(),
594                columns: vec!["id".into()],
595                unique: true,
596            },
597        }]
598    )]
599    fn diff_schemas_detects_additions(
600        #[case] from_schema: Vec<TableDef>,
601        #[case] to_schema: Vec<TableDef>,
602        #[case] expected_actions: Vec<MigrationAction>,
603    ) {
604        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
605        assert_eq!(plan.actions, expected_actions);
606    }
607
608    // Tests for inline column constraints normalization
609    mod inline_constraints {
610        use super::*;
611        use vespertide_core::schema::foreign_key::ForeignKeyDef;
612        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
613        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
614        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
615
616        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
617            ColumnDef {
618                name: name.to_string(),
619                r#type: ty,
620                nullable: false,
621                default: None,
622                comment: None,
623                primary_key: Some(PrimaryKeySyntax::Bool(true)),
624                unique: None,
625                index: None,
626                foreign_key: None,
627            }
628        }
629
630        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
631            ColumnDef {
632                name: name.to_string(),
633                r#type: ty,
634                nullable: true,
635                default: None,
636                comment: None,
637                primary_key: None,
638                unique: Some(StrOrBoolOrArray::Bool(true)),
639                index: None,
640                foreign_key: None,
641            }
642        }
643
644        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
645            ColumnDef {
646                name: name.to_string(),
647                r#type: ty,
648                nullable: true,
649                default: None,
650                comment: None,
651                primary_key: None,
652                unique: None,
653                index: Some(StrOrBoolOrArray::Bool(true)),
654                foreign_key: None,
655            }
656        }
657
658        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
659            ColumnDef {
660                name: name.to_string(),
661                r#type: ty,
662                nullable: true,
663                default: None,
664                comment: None,
665                primary_key: None,
666                unique: None,
667                index: None,
668                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
669                    ref_table: ref_table.to_string(),
670                    ref_columns: vec![ref_col.to_string()],
671                    on_delete: None,
672                    on_update: None,
673                })),
674            }
675        }
676
677        #[test]
678        fn create_table_with_inline_pk() {
679            let plan = diff_schemas(
680                &[],
681                &[table(
682                    "users",
683                    vec![
684                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
685                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
686                    ],
687                    vec![],
688                    vec![],
689                )],
690            )
691            .unwrap();
692
693            assert_eq!(plan.actions.len(), 1);
694            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
695                assert_eq!(constraints.len(), 1);
696                assert!(matches!(
697                    &constraints[0],
698                    TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
699                ));
700            } else {
701                panic!("Expected CreateTable action");
702            }
703        }
704
705        #[test]
706        fn create_table_with_inline_unique() {
707            let plan = diff_schemas(
708                &[],
709                &[table(
710                    "users",
711                    vec![
712                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
713                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
714                    ],
715                    vec![],
716                    vec![],
717                )],
718            )
719            .unwrap();
720
721            assert_eq!(plan.actions.len(), 1);
722            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
723                assert_eq!(constraints.len(), 1);
724                assert!(matches!(
725                    &constraints[0],
726                    TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
727                ));
728            } else {
729                panic!("Expected CreateTable action");
730            }
731        }
732
733        #[test]
734        fn create_table_with_inline_index() {
735            let plan = diff_schemas(
736                &[],
737                &[table(
738                    "users",
739                    vec![
740                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
741                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
742                    ],
743                    vec![],
744                    vec![],
745                )],
746            )
747            .unwrap();
748
749            // Should have CreateTable + AddIndex
750            assert_eq!(plan.actions.len(), 2);
751            assert!(matches!(
752                &plan.actions[0],
753                MigrationAction::CreateTable { .. }
754            ));
755            if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
756                assert_eq!(index.name, "idx_users_name");
757                assert_eq!(index.columns, vec!["name".to_string()]);
758            } else {
759                panic!("Expected AddIndex action");
760            }
761        }
762
763        #[test]
764        fn create_table_with_inline_fk() {
765            let plan = diff_schemas(
766                &[],
767                &[table(
768                    "posts",
769                    vec![
770                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
771                        col_with_fk(
772                            "user_id",
773                            ColumnType::Simple(SimpleColumnType::Integer),
774                            "users",
775                            "id",
776                        ),
777                    ],
778                    vec![],
779                    vec![],
780                )],
781            )
782            .unwrap();
783
784            assert_eq!(plan.actions.len(), 1);
785            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
786                assert_eq!(constraints.len(), 1);
787                assert!(matches!(
788                    &constraints[0],
789                    TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
790                        if columns == &["user_id".to_string()]
791                        && ref_table == "users"
792                        && ref_columns == &["id".to_string()]
793                ));
794            } else {
795                panic!("Expected CreateTable action");
796            }
797        }
798
799        #[test]
800        fn add_index_via_inline_constraint() {
801            // Existing table without index -> table with inline index
802            let plan = diff_schemas(
803                &[table(
804                    "users",
805                    vec![
806                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
807                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
808                    ],
809                    vec![],
810                    vec![],
811                )],
812                &[table(
813                    "users",
814                    vec![
815                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
816                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
817                    ],
818                    vec![],
819                    vec![],
820                )],
821            )
822            .unwrap();
823
824            assert_eq!(plan.actions.len(), 1);
825            if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
826                assert_eq!(table, "users");
827                assert_eq!(index.name, "idx_users_name");
828                assert_eq!(index.columns, vec!["name".to_string()]);
829            } else {
830                panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
831            }
832        }
833
834        #[test]
835        fn create_table_with_all_inline_constraints() {
836            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
837            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
838            id_col.nullable = false;
839
840            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
841            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
842
843            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
844            name_col.index = Some(StrOrBoolOrArray::Bool(true));
845
846            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
847            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
848                ref_table: "orgs".into(),
849                ref_columns: vec!["id".into()],
850                on_delete: None,
851                on_update: None,
852            }));
853
854            let plan = diff_schemas(
855                &[],
856                &[table(
857                    "users",
858                    vec![id_col, email_col, name_col, org_id_col],
859                    vec![],
860                    vec![],
861                )],
862            )
863            .unwrap();
864
865            // Should have CreateTable + AddIndex
866            assert_eq!(plan.actions.len(), 2);
867
868            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
869                // Should have: PrimaryKey, Unique, ForeignKey (3 constraints)
870                assert_eq!(constraints.len(), 3);
871            } else {
872                panic!("Expected CreateTable action");
873            }
874
875            // Check for AddIndex action
876            assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
877        }
878
879        #[test]
880        fn add_constraint_to_existing_table() {
881            // Add a unique constraint to an existing table
882            let from_schema = vec![table(
883                "users",
884                vec![
885                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
886                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
887                ],
888                vec![],
889                vec![],
890            )];
891
892            let to_schema = vec![table(
893                "users",
894                vec![
895                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
896                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
897                ],
898                vec![vespertide_core::TableConstraint::Unique {
899                    name: Some("uq_users_email".into()),
900                    columns: vec!["email".into()],
901                }],
902                vec![],
903            )];
904
905            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
906            assert_eq!(plan.actions.len(), 1);
907            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
908                assert_eq!(table, "users");
909                assert!(matches!(
910                    constraint,
911                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
912                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
913                ));
914            } else {
915                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
916            }
917        }
918
919        #[test]
920        fn remove_constraint_from_existing_table() {
921            // Remove a unique constraint from an existing table
922            let from_schema = vec![table(
923                "users",
924                vec![
925                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
926                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
927                ],
928                vec![vespertide_core::TableConstraint::Unique {
929                    name: Some("uq_users_email".into()),
930                    columns: vec!["email".into()],
931                }],
932                vec![],
933            )];
934
935            let to_schema = vec![table(
936                "users",
937                vec![
938                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
939                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
940                ],
941                vec![],
942                vec![],
943            )];
944
945            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
946            assert_eq!(plan.actions.len(), 1);
947            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
948                assert_eq!(table, "users");
949                assert!(matches!(
950                    constraint,
951                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
952                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
953                ));
954            } else {
955                panic!(
956                    "Expected RemoveConstraint action, got {:?}",
957                    plan.actions[0]
958                );
959            }
960        }
961
962        #[test]
963        fn diff_schemas_with_normalize_error() {
964            // Test that normalize errors are properly propagated
965            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
966            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
967
968            let table = TableDef {
969                name: "test".into(),
970                columns: vec![
971                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
972                    col1.clone(),
973                    {
974                        // Same column with same index name - should error
975                        let mut c = col1.clone();
976                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
977                        c
978                    },
979                ],
980                constraints: vec![],
981                indexes: vec![],
982            };
983
984            let result = diff_schemas(&[], &[table]);
985            assert!(result.is_err());
986            if let Err(PlannerError::TableValidation(msg)) = result {
987                assert!(msg.contains("Failed to normalize table"));
988                assert!(msg.contains("Duplicate index"));
989            } else {
990                panic!("Expected TableValidation error, got {:?}", result);
991            }
992        }
993
994        #[test]
995        fn diff_schemas_with_normalize_error_in_from_schema() {
996            // Test that normalize errors in 'from' schema are properly propagated
997            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
998            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
999
1000            let table = TableDef {
1001                name: "test".into(),
1002                columns: vec![
1003                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1004                    col1.clone(),
1005                    {
1006                        // Same column with same index name - should error
1007                        let mut c = col1.clone();
1008                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1009                        c
1010                    },
1011                ],
1012                constraints: vec![],
1013                indexes: vec![],
1014            };
1015
1016            // 'from' schema has the invalid table
1017            let result = diff_schemas(&[table], &[]);
1018            assert!(result.is_err());
1019            if let Err(PlannerError::TableValidation(msg)) = result {
1020                assert!(msg.contains("Failed to normalize table"));
1021                assert!(msg.contains("Duplicate index"));
1022            } else {
1023                panic!("Expected TableValidation error, got {:?}", result);
1024            }
1025        }
1026    }
1027
1028    // Tests for foreign key dependency ordering
1029    mod fk_ordering {
1030        use super::*;
1031        use vespertide_core::TableConstraint;
1032
1033        fn table_with_fk(
1034            name: &str,
1035            ref_table: &str,
1036            fk_column: &str,
1037            ref_column: &str,
1038        ) -> TableDef {
1039            TableDef {
1040                name: name.to_string(),
1041                columns: vec![
1042                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1043                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1044                ],
1045                constraints: vec![TableConstraint::ForeignKey {
1046                    name: None,
1047                    columns: vec![fk_column.to_string()],
1048                    ref_table: ref_table.to_string(),
1049                    ref_columns: vec![ref_column.to_string()],
1050                    on_delete: None,
1051                    on_update: None,
1052                }],
1053                indexes: vec![],
1054            }
1055        }
1056
1057        fn simple_table(name: &str) -> TableDef {
1058            TableDef {
1059                name: name.to_string(),
1060                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1061                constraints: vec![],
1062                indexes: vec![],
1063            }
1064        }
1065
1066        #[test]
1067        fn create_tables_respects_fk_order() {
1068            // Create users and posts tables where posts references users
1069            // The order should be: users first, then posts
1070            let users = simple_table("users");
1071            let posts = table_with_fk("posts", "users", "user_id", "id");
1072
1073            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1074
1075            // Extract CreateTable actions in order
1076            let create_order: Vec<&str> = plan
1077                .actions
1078                .iter()
1079                .filter_map(|a| {
1080                    if let MigrationAction::CreateTable { table, .. } = a {
1081                        Some(table.as_str())
1082                    } else {
1083                        None
1084                    }
1085                })
1086                .collect();
1087
1088            assert_eq!(create_order, vec!["users", "posts"]);
1089        }
1090
1091        #[test]
1092        fn create_tables_chain_dependency() {
1093            // Chain: users <- media <- articles
1094            // users has no FK
1095            // media references users
1096            // articles references media
1097            let users = simple_table("users");
1098            let media = table_with_fk("media", "users", "owner_id", "id");
1099            let articles = table_with_fk("articles", "media", "media_id", "id");
1100
1101            // Pass in reverse order to ensure sorting works
1102            let plan =
1103                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1104
1105            let create_order: Vec<&str> = plan
1106                .actions
1107                .iter()
1108                .filter_map(|a| {
1109                    if let MigrationAction::CreateTable { table, .. } = a {
1110                        Some(table.as_str())
1111                    } else {
1112                        None
1113                    }
1114                })
1115                .collect();
1116
1117            assert_eq!(create_order, vec!["users", "media", "articles"]);
1118        }
1119
1120        #[test]
1121        fn create_tables_multiple_independent_branches() {
1122            // Two independent branches:
1123            // users <- posts
1124            // categories <- products
1125            let users = simple_table("users");
1126            let posts = table_with_fk("posts", "users", "user_id", "id");
1127            let categories = simple_table("categories");
1128            let products = table_with_fk("products", "categories", "category_id", "id");
1129
1130            let plan = diff_schemas(
1131                &[],
1132                &[
1133                    products.clone(),
1134                    posts.clone(),
1135                    categories.clone(),
1136                    users.clone(),
1137                ],
1138            )
1139            .unwrap();
1140
1141            let create_order: Vec<&str> = plan
1142                .actions
1143                .iter()
1144                .filter_map(|a| {
1145                    if let MigrationAction::CreateTable { table, .. } = a {
1146                        Some(table.as_str())
1147                    } else {
1148                        None
1149                    }
1150                })
1151                .collect();
1152
1153            // users must come before posts
1154            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1155            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1156            assert!(
1157                users_pos < posts_pos,
1158                "users should be created before posts"
1159            );
1160
1161            // categories must come before products
1162            let categories_pos = create_order
1163                .iter()
1164                .position(|&t| t == "categories")
1165                .unwrap();
1166            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1167            assert!(
1168                categories_pos < products_pos,
1169                "categories should be created before products"
1170            );
1171        }
1172
1173        #[test]
1174        fn delete_tables_respects_fk_order() {
1175            // When deleting users and posts where posts references users,
1176            // posts should be deleted first (reverse of creation order)
1177            let users = simple_table("users");
1178            let posts = table_with_fk("posts", "users", "user_id", "id");
1179
1180            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1181
1182            let delete_order: Vec<&str> = plan
1183                .actions
1184                .iter()
1185                .filter_map(|a| {
1186                    if let MigrationAction::DeleteTable { table } = a {
1187                        Some(table.as_str())
1188                    } else {
1189                        None
1190                    }
1191                })
1192                .collect();
1193
1194            assert_eq!(delete_order, vec!["posts", "users"]);
1195        }
1196
1197        #[test]
1198        fn delete_tables_chain_dependency() {
1199            // Chain: users <- media <- articles
1200            // Delete order should be: articles, media, users
1201            let users = simple_table("users");
1202            let media = table_with_fk("media", "users", "owner_id", "id");
1203            let articles = table_with_fk("articles", "media", "media_id", "id");
1204
1205            let plan =
1206                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1207
1208            let delete_order: Vec<&str> = plan
1209                .actions
1210                .iter()
1211                .filter_map(|a| {
1212                    if let MigrationAction::DeleteTable { table } = a {
1213                        Some(table.as_str())
1214                    } else {
1215                        None
1216                    }
1217                })
1218                .collect();
1219
1220            // articles must be deleted before media
1221            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1222            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1223            assert!(
1224                articles_pos < media_pos,
1225                "articles should be deleted before media"
1226            );
1227
1228            // media must be deleted before users
1229            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1230            assert!(
1231                media_pos < users_pos,
1232                "media should be deleted before users"
1233            );
1234        }
1235
1236        #[test]
1237        fn circular_fk_dependency_returns_error() {
1238            // Create circular dependency: A -> B -> A
1239            let table_a = TableDef {
1240                name: "table_a".to_string(),
1241                columns: vec![
1242                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1243                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1244                ],
1245                constraints: vec![TableConstraint::ForeignKey {
1246                    name: None,
1247                    columns: vec!["b_id".to_string()],
1248                    ref_table: "table_b".to_string(),
1249                    ref_columns: vec!["id".to_string()],
1250                    on_delete: None,
1251                    on_update: None,
1252                }],
1253                indexes: vec![],
1254            };
1255
1256            let table_b = TableDef {
1257                name: "table_b".to_string(),
1258                columns: vec![
1259                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1260                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1261                ],
1262                constraints: vec![TableConstraint::ForeignKey {
1263                    name: None,
1264                    columns: vec!["a_id".to_string()],
1265                    ref_table: "table_a".to_string(),
1266                    ref_columns: vec!["id".to_string()],
1267                    on_delete: None,
1268                    on_update: None,
1269                }],
1270                indexes: vec![],
1271            };
1272
1273            let result = diff_schemas(&[], &[table_a, table_b]);
1274            assert!(result.is_err());
1275            if let Err(PlannerError::TableValidation(msg)) = result {
1276                assert!(
1277                    msg.contains("Circular foreign key dependency"),
1278                    "Expected circular dependency error, got: {}",
1279                    msg
1280                );
1281            } else {
1282                panic!("Expected TableValidation error, got {:?}", result);
1283            }
1284        }
1285
1286        #[test]
1287        fn fk_to_external_table_is_ignored() {
1288            // FK referencing a table not in the migration should not affect ordering
1289            let posts = table_with_fk("posts", "users", "user_id", "id");
1290            let comments = table_with_fk("comments", "posts", "post_id", "id");
1291
1292            // users is NOT being created in this migration
1293            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1294
1295            let create_order: Vec<&str> = plan
1296                .actions
1297                .iter()
1298                .filter_map(|a| {
1299                    if let MigrationAction::CreateTable { table, .. } = a {
1300                        Some(table.as_str())
1301                    } else {
1302                        None
1303                    }
1304                })
1305                .collect();
1306
1307            // posts must come before comments (comments depends on posts)
1308            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1309            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1310            assert!(
1311                posts_pos < comments_pos,
1312                "posts should be created before comments"
1313            );
1314        }
1315
1316        #[test]
1317        fn delete_tables_mixed_with_other_actions() {
1318            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1319            // This tests lines 124, 193, 198 (the else branches)
1320            use crate::diff::diff_schemas;
1321
1322            let from_schema = vec![
1323                table(
1324                    "users",
1325                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1326                    vec![],
1327                    vec![],
1328                ),
1329                table(
1330                    "posts",
1331                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1332                    vec![],
1333                    vec![],
1334                ),
1335            ];
1336
1337            let to_schema = vec![
1338                // Drop posts table, but also add a new column to users
1339                table(
1340                    "users",
1341                    vec![
1342                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1343                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1344                    ],
1345                    vec![],
1346                    vec![],
1347                ),
1348            ];
1349
1350            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1351
1352            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1353            assert!(
1354                plan.actions
1355                    .iter()
1356                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1357            );
1358            assert!(
1359                plan.actions
1360                    .iter()
1361                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1362            );
1363
1364            // The else branches in sort_delete_actions should handle AddColumn gracefully
1365            // (returning empty string for table name, which sorts it to position 0)
1366        }
1367
1368        #[test]
1369        #[should_panic(expected = "Expected DeleteTable action")]
1370        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1371            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1372            use super::extract_delete_table_name;
1373
1374            let action = MigrationAction::AddColumn {
1375                table: "users".into(),
1376                column: ColumnDef {
1377                    name: "email".into(),
1378                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1379                    nullable: true,
1380                    default: None,
1381                    comment: None,
1382                    primary_key: None,
1383                    unique: None,
1384                    index: None,
1385                    foreign_key: None,
1386                },
1387                fill_with: None,
1388            };
1389
1390            // This should panic
1391            extract_delete_table_name(&action);
1392        }
1393    }
1394}