vespertide_planner/
diff.rs

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