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.requires_migration(&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: Box::new((*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: Box::new(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 integer enum handling
609    mod integer_enum {
610        use super::*;
611        use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
612
613        #[test]
614        fn integer_enum_values_changed_no_migration() {
615            // Integer enum values changed - should NOT generate ModifyColumnType
616            let from = vec![table(
617                "orders",
618                vec![col(
619                    "status",
620                    ColumnType::Complex(ComplexColumnType::Enum {
621                        name: "order_status".into(),
622                        values: EnumValues::Integer(vec![
623                            NumValue {
624                                name: "Pending".into(),
625                                value: 0,
626                            },
627                            NumValue {
628                                name: "Shipped".into(),
629                                value: 1,
630                            },
631                        ]),
632                    }),
633                )],
634                vec![],
635                vec![],
636            )];
637
638            let to = vec![table(
639                "orders",
640                vec![col(
641                    "status",
642                    ColumnType::Complex(ComplexColumnType::Enum {
643                        name: "order_status".into(),
644                        values: EnumValues::Integer(vec![
645                            NumValue {
646                                name: "Pending".into(),
647                                value: 0,
648                            },
649                            NumValue {
650                                name: "Shipped".into(),
651                                value: 1,
652                            },
653                            NumValue {
654                                name: "Delivered".into(),
655                                value: 2,
656                            },
657                            NumValue {
658                                name: "Cancelled".into(),
659                                value: 100,
660                            },
661                        ]),
662                    }),
663                )],
664                vec![],
665                vec![],
666            )];
667
668            let plan = diff_schemas(&from, &to).unwrap();
669            assert!(
670                plan.actions.is_empty(),
671                "Expected no actions, got: {:?}",
672                plan.actions
673            );
674        }
675
676        #[test]
677        fn string_enum_values_changed_requires_migration() {
678            // String enum values changed - SHOULD generate ModifyColumnType
679            let from = vec![table(
680                "orders",
681                vec![col(
682                    "status",
683                    ColumnType::Complex(ComplexColumnType::Enum {
684                        name: "order_status".into(),
685                        values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
686                    }),
687                )],
688                vec![],
689                vec![],
690            )];
691
692            let to = vec![table(
693                "orders",
694                vec![col(
695                    "status",
696                    ColumnType::Complex(ComplexColumnType::Enum {
697                        name: "order_status".into(),
698                        values: EnumValues::String(vec![
699                            "pending".into(),
700                            "shipped".into(),
701                            "delivered".into(),
702                        ]),
703                    }),
704                )],
705                vec![],
706                vec![],
707            )];
708
709            let plan = diff_schemas(&from, &to).unwrap();
710            assert_eq!(plan.actions.len(), 1);
711            assert!(matches!(
712                &plan.actions[0],
713                MigrationAction::ModifyColumnType { table, column, .. }
714                if table == "orders" && column == "status"
715            ));
716        }
717    }
718
719    // Tests for inline column constraints normalization
720    mod inline_constraints {
721        use super::*;
722        use vespertide_core::schema::foreign_key::ForeignKeyDef;
723        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
724        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
725        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
726
727        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
728            ColumnDef {
729                name: name.to_string(),
730                r#type: ty,
731                nullable: false,
732                default: None,
733                comment: None,
734                primary_key: Some(PrimaryKeySyntax::Bool(true)),
735                unique: None,
736                index: None,
737                foreign_key: None,
738            }
739        }
740
741        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
742            ColumnDef {
743                name: name.to_string(),
744                r#type: ty,
745                nullable: true,
746                default: None,
747                comment: None,
748                primary_key: None,
749                unique: Some(StrOrBoolOrArray::Bool(true)),
750                index: None,
751                foreign_key: None,
752            }
753        }
754
755        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
756            ColumnDef {
757                name: name.to_string(),
758                r#type: ty,
759                nullable: true,
760                default: None,
761                comment: None,
762                primary_key: None,
763                unique: None,
764                index: Some(StrOrBoolOrArray::Bool(true)),
765                foreign_key: None,
766            }
767        }
768
769        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
770            ColumnDef {
771                name: name.to_string(),
772                r#type: ty,
773                nullable: true,
774                default: None,
775                comment: None,
776                primary_key: None,
777                unique: None,
778                index: None,
779                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
780                    ref_table: ref_table.to_string(),
781                    ref_columns: vec![ref_col.to_string()],
782                    on_delete: None,
783                    on_update: None,
784                })),
785            }
786        }
787
788        #[test]
789        fn create_table_with_inline_pk() {
790            let plan = diff_schemas(
791                &[],
792                &[table(
793                    "users",
794                    vec![
795                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
796                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
797                    ],
798                    vec![],
799                    vec![],
800                )],
801            )
802            .unwrap();
803
804            assert_eq!(plan.actions.len(), 1);
805            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
806                assert_eq!(constraints.len(), 1);
807                assert!(matches!(
808                    &constraints[0],
809                    TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
810                ));
811            } else {
812                panic!("Expected CreateTable action");
813            }
814        }
815
816        #[test]
817        fn create_table_with_inline_unique() {
818            let plan = diff_schemas(
819                &[],
820                &[table(
821                    "users",
822                    vec![
823                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
824                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
825                    ],
826                    vec![],
827                    vec![],
828                )],
829            )
830            .unwrap();
831
832            assert_eq!(plan.actions.len(), 1);
833            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
834                assert_eq!(constraints.len(), 1);
835                assert!(matches!(
836                    &constraints[0],
837                    TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
838                ));
839            } else {
840                panic!("Expected CreateTable action");
841            }
842        }
843
844        #[test]
845        fn create_table_with_inline_index() {
846            let plan = diff_schemas(
847                &[],
848                &[table(
849                    "users",
850                    vec![
851                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
852                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
853                    ],
854                    vec![],
855                    vec![],
856                )],
857            )
858            .unwrap();
859
860            // Should have CreateTable + AddIndex
861            assert_eq!(plan.actions.len(), 2);
862            assert!(matches!(
863                &plan.actions[0],
864                MigrationAction::CreateTable { .. }
865            ));
866            if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
867                assert_eq!(index.name, "idx_users_name");
868                assert_eq!(index.columns, vec!["name".to_string()]);
869            } else {
870                panic!("Expected AddIndex action");
871            }
872        }
873
874        #[test]
875        fn create_table_with_inline_fk() {
876            let plan = diff_schemas(
877                &[],
878                &[table(
879                    "posts",
880                    vec![
881                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
882                        col_with_fk(
883                            "user_id",
884                            ColumnType::Simple(SimpleColumnType::Integer),
885                            "users",
886                            "id",
887                        ),
888                    ],
889                    vec![],
890                    vec![],
891                )],
892            )
893            .unwrap();
894
895            assert_eq!(plan.actions.len(), 1);
896            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
897                assert_eq!(constraints.len(), 1);
898                assert!(matches!(
899                    &constraints[0],
900                    TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
901                        if columns == &["user_id".to_string()]
902                        && ref_table == "users"
903                        && ref_columns == &["id".to_string()]
904                ));
905            } else {
906                panic!("Expected CreateTable action");
907            }
908        }
909
910        #[test]
911        fn add_index_via_inline_constraint() {
912            // Existing table without index -> table with inline index
913            let plan = diff_schemas(
914                &[table(
915                    "users",
916                    vec![
917                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
918                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
919                    ],
920                    vec![],
921                    vec![],
922                )],
923                &[table(
924                    "users",
925                    vec![
926                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
927                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
928                    ],
929                    vec![],
930                    vec![],
931                )],
932            )
933            .unwrap();
934
935            assert_eq!(plan.actions.len(), 1);
936            if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
937                assert_eq!(table, "users");
938                assert_eq!(index.name, "idx_users_name");
939                assert_eq!(index.columns, vec!["name".to_string()]);
940            } else {
941                panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
942            }
943        }
944
945        #[test]
946        fn create_table_with_all_inline_constraints() {
947            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
948            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
949            id_col.nullable = false;
950
951            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
952            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
953
954            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
955            name_col.index = Some(StrOrBoolOrArray::Bool(true));
956
957            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
958            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
959                ref_table: "orgs".into(),
960                ref_columns: vec!["id".into()],
961                on_delete: None,
962                on_update: None,
963            }));
964
965            let plan = diff_schemas(
966                &[],
967                &[table(
968                    "users",
969                    vec![id_col, email_col, name_col, org_id_col],
970                    vec![],
971                    vec![],
972                )],
973            )
974            .unwrap();
975
976            // Should have CreateTable + AddIndex
977            assert_eq!(plan.actions.len(), 2);
978
979            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
980                // Should have: PrimaryKey, Unique, ForeignKey (3 constraints)
981                assert_eq!(constraints.len(), 3);
982            } else {
983                panic!("Expected CreateTable action");
984            }
985
986            // Check for AddIndex action
987            assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
988        }
989
990        #[test]
991        fn add_constraint_to_existing_table() {
992            // Add a unique constraint to an existing table
993            let from_schema = vec![table(
994                "users",
995                vec![
996                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
997                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
998                ],
999                vec![],
1000                vec![],
1001            )];
1002
1003            let to_schema = vec![table(
1004                "users",
1005                vec![
1006                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1007                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1008                ],
1009                vec![vespertide_core::TableConstraint::Unique {
1010                    name: Some("uq_users_email".into()),
1011                    columns: vec!["email".into()],
1012                }],
1013                vec![],
1014            )];
1015
1016            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1017            assert_eq!(plan.actions.len(), 1);
1018            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1019                assert_eq!(table, "users");
1020                assert!(matches!(
1021                    constraint,
1022                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1023                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1024                ));
1025            } else {
1026                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1027            }
1028        }
1029
1030        #[test]
1031        fn remove_constraint_from_existing_table() {
1032            // Remove a unique constraint from an existing table
1033            let from_schema = vec![table(
1034                "users",
1035                vec![
1036                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1037                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1038                ],
1039                vec![vespertide_core::TableConstraint::Unique {
1040                    name: Some("uq_users_email".into()),
1041                    columns: vec!["email".into()],
1042                }],
1043                vec![],
1044            )];
1045
1046            let to_schema = vec![table(
1047                "users",
1048                vec![
1049                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1050                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
1051                ],
1052                vec![],
1053                vec![],
1054            )];
1055
1056            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1057            assert_eq!(plan.actions.len(), 1);
1058            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1059                assert_eq!(table, "users");
1060                assert!(matches!(
1061                    constraint,
1062                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1063                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
1064                ));
1065            } else {
1066                panic!(
1067                    "Expected RemoveConstraint action, got {:?}",
1068                    plan.actions[0]
1069                );
1070            }
1071        }
1072
1073        #[test]
1074        fn diff_schemas_with_normalize_error() {
1075            // Test that normalize errors are properly propagated
1076            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1077            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1078
1079            let table = TableDef {
1080                name: "test".into(),
1081                columns: vec![
1082                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1083                    col1.clone(),
1084                    {
1085                        // Same column with same index name - should error
1086                        let mut c = col1.clone();
1087                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1088                        c
1089                    },
1090                ],
1091                constraints: vec![],
1092                indexes: vec![],
1093            };
1094
1095            let result = diff_schemas(&[], &[table]);
1096            assert!(result.is_err());
1097            if let Err(PlannerError::TableValidation(msg)) = result {
1098                assert!(msg.contains("Failed to normalize table"));
1099                assert!(msg.contains("Duplicate index"));
1100            } else {
1101                panic!("Expected TableValidation error, got {:?}", result);
1102            }
1103        }
1104
1105        #[test]
1106        fn diff_schemas_with_normalize_error_in_from_schema() {
1107            // Test that normalize errors in 'from' schema are properly propagated
1108            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1109            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1110
1111            let table = TableDef {
1112                name: "test".into(),
1113                columns: vec![
1114                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1115                    col1.clone(),
1116                    {
1117                        // Same column with same index name - should error
1118                        let mut c = col1.clone();
1119                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1120                        c
1121                    },
1122                ],
1123                constraints: vec![],
1124                indexes: vec![],
1125            };
1126
1127            // 'from' schema has the invalid table
1128            let result = diff_schemas(&[table], &[]);
1129            assert!(result.is_err());
1130            if let Err(PlannerError::TableValidation(msg)) = result {
1131                assert!(msg.contains("Failed to normalize table"));
1132                assert!(msg.contains("Duplicate index"));
1133            } else {
1134                panic!("Expected TableValidation error, got {:?}", result);
1135            }
1136        }
1137    }
1138
1139    // Tests for foreign key dependency ordering
1140    mod fk_ordering {
1141        use super::*;
1142        use vespertide_core::TableConstraint;
1143
1144        fn table_with_fk(
1145            name: &str,
1146            ref_table: &str,
1147            fk_column: &str,
1148            ref_column: &str,
1149        ) -> TableDef {
1150            TableDef {
1151                name: name.to_string(),
1152                columns: vec![
1153                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1154                    col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1155                ],
1156                constraints: vec![TableConstraint::ForeignKey {
1157                    name: None,
1158                    columns: vec![fk_column.to_string()],
1159                    ref_table: ref_table.to_string(),
1160                    ref_columns: vec![ref_column.to_string()],
1161                    on_delete: None,
1162                    on_update: None,
1163                }],
1164                indexes: vec![],
1165            }
1166        }
1167
1168        fn simple_table(name: &str) -> TableDef {
1169            TableDef {
1170                name: name.to_string(),
1171                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1172                constraints: vec![],
1173                indexes: vec![],
1174            }
1175        }
1176
1177        #[test]
1178        fn create_tables_respects_fk_order() {
1179            // Create users and posts tables where posts references users
1180            // The order should be: users first, then posts
1181            let users = simple_table("users");
1182            let posts = table_with_fk("posts", "users", "user_id", "id");
1183
1184            let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1185
1186            // Extract CreateTable actions in order
1187            let create_order: Vec<&str> = plan
1188                .actions
1189                .iter()
1190                .filter_map(|a| {
1191                    if let MigrationAction::CreateTable { table, .. } = a {
1192                        Some(table.as_str())
1193                    } else {
1194                        None
1195                    }
1196                })
1197                .collect();
1198
1199            assert_eq!(create_order, vec!["users", "posts"]);
1200        }
1201
1202        #[test]
1203        fn create_tables_chain_dependency() {
1204            // Chain: users <- media <- articles
1205            // users has no FK
1206            // media references users
1207            // articles references media
1208            let users = simple_table("users");
1209            let media = table_with_fk("media", "users", "owner_id", "id");
1210            let articles = table_with_fk("articles", "media", "media_id", "id");
1211
1212            // Pass in reverse order to ensure sorting works
1213            let plan =
1214                diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1215
1216            let create_order: Vec<&str> = plan
1217                .actions
1218                .iter()
1219                .filter_map(|a| {
1220                    if let MigrationAction::CreateTable { table, .. } = a {
1221                        Some(table.as_str())
1222                    } else {
1223                        None
1224                    }
1225                })
1226                .collect();
1227
1228            assert_eq!(create_order, vec!["users", "media", "articles"]);
1229        }
1230
1231        #[test]
1232        fn create_tables_multiple_independent_branches() {
1233            // Two independent branches:
1234            // users <- posts
1235            // categories <- products
1236            let users = simple_table("users");
1237            let posts = table_with_fk("posts", "users", "user_id", "id");
1238            let categories = simple_table("categories");
1239            let products = table_with_fk("products", "categories", "category_id", "id");
1240
1241            let plan = diff_schemas(
1242                &[],
1243                &[
1244                    products.clone(),
1245                    posts.clone(),
1246                    categories.clone(),
1247                    users.clone(),
1248                ],
1249            )
1250            .unwrap();
1251
1252            let create_order: Vec<&str> = plan
1253                .actions
1254                .iter()
1255                .filter_map(|a| {
1256                    if let MigrationAction::CreateTable { table, .. } = a {
1257                        Some(table.as_str())
1258                    } else {
1259                        None
1260                    }
1261                })
1262                .collect();
1263
1264            // users must come before posts
1265            let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1266            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1267            assert!(
1268                users_pos < posts_pos,
1269                "users should be created before posts"
1270            );
1271
1272            // categories must come before products
1273            let categories_pos = create_order
1274                .iter()
1275                .position(|&t| t == "categories")
1276                .unwrap();
1277            let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1278            assert!(
1279                categories_pos < products_pos,
1280                "categories should be created before products"
1281            );
1282        }
1283
1284        #[test]
1285        fn delete_tables_respects_fk_order() {
1286            // When deleting users and posts where posts references users,
1287            // posts should be deleted first (reverse of creation order)
1288            let users = simple_table("users");
1289            let posts = table_with_fk("posts", "users", "user_id", "id");
1290
1291            let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1292
1293            let delete_order: Vec<&str> = plan
1294                .actions
1295                .iter()
1296                .filter_map(|a| {
1297                    if let MigrationAction::DeleteTable { table } = a {
1298                        Some(table.as_str())
1299                    } else {
1300                        None
1301                    }
1302                })
1303                .collect();
1304
1305            assert_eq!(delete_order, vec!["posts", "users"]);
1306        }
1307
1308        #[test]
1309        fn delete_tables_chain_dependency() {
1310            // Chain: users <- media <- articles
1311            // Delete order should be: articles, media, users
1312            let users = simple_table("users");
1313            let media = table_with_fk("media", "users", "owner_id", "id");
1314            let articles = table_with_fk("articles", "media", "media_id", "id");
1315
1316            let plan =
1317                diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1318
1319            let delete_order: Vec<&str> = plan
1320                .actions
1321                .iter()
1322                .filter_map(|a| {
1323                    if let MigrationAction::DeleteTable { table } = a {
1324                        Some(table.as_str())
1325                    } else {
1326                        None
1327                    }
1328                })
1329                .collect();
1330
1331            // articles must be deleted before media
1332            let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1333            let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1334            assert!(
1335                articles_pos < media_pos,
1336                "articles should be deleted before media"
1337            );
1338
1339            // media must be deleted before users
1340            let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1341            assert!(
1342                media_pos < users_pos,
1343                "media should be deleted before users"
1344            );
1345        }
1346
1347        #[test]
1348        fn circular_fk_dependency_returns_error() {
1349            // Create circular dependency: A -> B -> A
1350            let table_a = TableDef {
1351                name: "table_a".to_string(),
1352                columns: vec![
1353                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1354                    col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1355                ],
1356                constraints: vec![TableConstraint::ForeignKey {
1357                    name: None,
1358                    columns: vec!["b_id".to_string()],
1359                    ref_table: "table_b".to_string(),
1360                    ref_columns: vec!["id".to_string()],
1361                    on_delete: None,
1362                    on_update: None,
1363                }],
1364                indexes: vec![],
1365            };
1366
1367            let table_b = TableDef {
1368                name: "table_b".to_string(),
1369                columns: vec![
1370                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1371                    col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1372                ],
1373                constraints: vec![TableConstraint::ForeignKey {
1374                    name: None,
1375                    columns: vec!["a_id".to_string()],
1376                    ref_table: "table_a".to_string(),
1377                    ref_columns: vec!["id".to_string()],
1378                    on_delete: None,
1379                    on_update: None,
1380                }],
1381                indexes: vec![],
1382            };
1383
1384            let result = diff_schemas(&[], &[table_a, table_b]);
1385            assert!(result.is_err());
1386            if let Err(PlannerError::TableValidation(msg)) = result {
1387                assert!(
1388                    msg.contains("Circular foreign key dependency"),
1389                    "Expected circular dependency error, got: {}",
1390                    msg
1391                );
1392            } else {
1393                panic!("Expected TableValidation error, got {:?}", result);
1394            }
1395        }
1396
1397        #[test]
1398        fn fk_to_external_table_is_ignored() {
1399            // FK referencing a table not in the migration should not affect ordering
1400            let posts = table_with_fk("posts", "users", "user_id", "id");
1401            let comments = table_with_fk("comments", "posts", "post_id", "id");
1402
1403            // users is NOT being created in this migration
1404            let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1405
1406            let create_order: Vec<&str> = plan
1407                .actions
1408                .iter()
1409                .filter_map(|a| {
1410                    if let MigrationAction::CreateTable { table, .. } = a {
1411                        Some(table.as_str())
1412                    } else {
1413                        None
1414                    }
1415                })
1416                .collect();
1417
1418            // posts must come before comments (comments depends on posts)
1419            let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1420            let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1421            assert!(
1422                posts_pos < comments_pos,
1423                "posts should be created before comments"
1424            );
1425        }
1426
1427        #[test]
1428        fn delete_tables_mixed_with_other_actions() {
1429            // Test that sort_delete_actions correctly handles actions that are not DeleteTable
1430            // This tests lines 124, 193, 198 (the else branches)
1431            use crate::diff::diff_schemas;
1432
1433            let from_schema = vec![
1434                table(
1435                    "users",
1436                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1437                    vec![],
1438                    vec![],
1439                ),
1440                table(
1441                    "posts",
1442                    vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1443                    vec![],
1444                    vec![],
1445                ),
1446            ];
1447
1448            let to_schema = vec![
1449                // Drop posts table, but also add a new column to users
1450                table(
1451                    "users",
1452                    vec![
1453                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1454                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
1455                    ],
1456                    vec![],
1457                    vec![],
1458                ),
1459            ];
1460
1461            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1462
1463            // Should have: AddColumn (for users.name) and DeleteTable (for posts)
1464            assert!(
1465                plan.actions
1466                    .iter()
1467                    .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1468            );
1469            assert!(
1470                plan.actions
1471                    .iter()
1472                    .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1473            );
1474
1475            // The else branches in sort_delete_actions should handle AddColumn gracefully
1476            // (returning empty string for table name, which sorts it to position 0)
1477        }
1478
1479        #[test]
1480        #[should_panic(expected = "Expected DeleteTable action")]
1481        fn test_extract_delete_table_name_panics_on_non_delete_action() {
1482            // Test that extract_delete_table_name panics when called with non-DeleteTable action
1483            use super::extract_delete_table_name;
1484
1485            let action = MigrationAction::AddColumn {
1486                table: "users".into(),
1487                column: Box::new(ColumnDef {
1488                    name: "email".into(),
1489                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1490                    nullable: true,
1491                    default: None,
1492                    comment: None,
1493                    primary_key: None,
1494                    unique: None,
1495                    index: None,
1496                    foreign_key: None,
1497                }),
1498                fill_with: None,
1499            };
1500
1501            // This should panic
1502            extract_delete_table_name(&action);
1503        }
1504    }
1505}