1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef};
4
5use crate::error::PlannerError;
6
7fn topological_sort_tables<'a>(tables: &[&'a TableDef]) -> Result<Vec<&'a TableDef>, PlannerError> {
11 if tables.is_empty() {
12 return Ok(vec![]);
13 }
14
15 let table_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect();
17
18 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 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 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 (table_name, deps) in &dependencies {
44 for _dep in deps {
45 }
48 *in_degree.entry(table_name).or_insert(0) += deps.len();
51 }
52
53 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 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 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
104fn 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 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 let delete_table_names: BTreeSet<&str> = delete_indices
136 .iter()
137 .map(|&i| extract_delete_table_name(&actions[i]))
138 .collect();
139
140 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 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 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 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 sorted_tables.reverse();
202
203 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 for (i, idx) in delete_indices.iter().enumerate() {
218 actions[*idx] = delete_actions[i].clone();
219 }
220}
221
222pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
226 let mut actions: Vec<MigrationAction> = Vec::new();
227
228 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 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 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 for (name, to_tbl) in &to_map {
270 if let Some(from_tbl) = from_map.get(name) {
271 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 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 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 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 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 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 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_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 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 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 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 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 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 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 assert_eq!(plan.actions.len(), 2);
978
979 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
980 assert_eq!(constraints.len(), 3);
982 } else {
983 panic!("Expected CreateTable action");
984 }
985
986 assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
988 }
989
990 #[test]
991 fn add_constraint_to_existing_table() {
992 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let posts = table_with_fk("posts", "users", "user_id", "id");
1401 let comments = table_with_fk("comments", "posts", "post_id", "id");
1402
1403 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 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 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 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 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 }
1478
1479 #[test]
1480 #[should_panic(expected = "Expected DeleteTable action")]
1481 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1482 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 extract_delete_table_name(&action);
1503 }
1504 }
1505}