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();
22 for table in tables {
23 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
24 for constraint in &table.constraints {
25 if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
26 if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
28 deps_set.insert(ref_table.as_str());
29 }
30 }
31 }
32 dependencies.insert(table.name.as_str(), deps_set.into_iter().collect());
33 }
34
35 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
39 for table in tables {
40 in_degree.entry(table.name.as_str()).or_insert(0);
41 }
42
43 for (table_name, deps) in &dependencies {
45 for _dep in deps {
46 }
49 *in_degree.entry(table_name).or_insert(0) += deps.len();
52 }
53
54 let mut queue: VecDeque<&str> = in_degree
57 .iter()
58 .filter(|(_, deg)| **deg == 0)
59 .map(|(name, _)| *name)
60 .collect();
61
62 let mut result: Vec<&TableDef> = Vec::new();
63 let table_map: BTreeMap<&str, &TableDef> =
64 tables.iter().map(|t| (t.name.as_str(), *t)).collect();
65
66 while let Some(table_name) = queue.pop_front() {
67 if let Some(&table) = table_map.get(table_name) {
68 result.push(table);
69 }
70
71 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
74 for (dependent, deps) in &dependencies {
75 if deps.contains(&table_name)
76 && let Some(degree) = in_degree.get_mut(dependent)
77 {
78 *degree -= 1;
79 if *degree == 0 {
80 ready_tables.insert(dependent);
81 }
82 }
83 }
84 for t in ready_tables {
85 queue.push_back(t);
86 }
87 }
88
89 if result.len() != tables.len() {
91 let remaining: Vec<&str> = tables
92 .iter()
93 .map(|t| t.name.as_str())
94 .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
95 .collect();
96 return Err(PlannerError::TableValidation(format!(
97 "Circular foreign key dependency detected among tables: {:?}",
98 remaining
99 )));
100 }
101
102 Ok(result)
103}
104
105fn extract_delete_table_name(action: &MigrationAction) -> &str {
110 match action {
111 MigrationAction::DeleteTable { table } => table.as_str(),
112 _ => panic!("Expected DeleteTable action"),
113 }
114}
115
116fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
117 let delete_indices: Vec<usize> = actions
119 .iter()
120 .enumerate()
121 .filter_map(|(i, a)| {
122 if matches!(a, MigrationAction::DeleteTable { .. }) {
123 Some(i)
124 } else {
125 None
126 }
127 })
128 .collect();
129
130 if delete_indices.len() <= 1 {
131 return;
132 }
133
134 let delete_table_names: BTreeSet<&str> = delete_indices
137 .iter()
138 .map(|&i| extract_delete_table_name(&actions[i]))
139 .collect();
140
141 let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
146 for &table_name in &delete_table_names {
147 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
148 if let Some(table_def) = all_tables.get(table_name) {
149 for constraint in &table_def.constraints {
150 if let TableConstraint::ForeignKey { ref_table, .. } = constraint
151 && delete_table_names.contains(ref_table.as_str())
152 && ref_table != table_name
153 {
154 deps_set.insert(ref_table.as_str());
155 }
156 }
157 }
158 dependencies.insert(table_name, deps_set.into_iter().collect());
159 }
160
161 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
165 for &table_name in &delete_table_names {
166 in_degree.insert(
167 table_name,
168 dependencies.get(table_name).map_or(0, |d| d.len()),
169 );
170 }
171
172 let mut queue: VecDeque<&str> = in_degree
175 .iter()
176 .filter(|(_, deg)| **deg == 0)
177 .map(|(name, _)| *name)
178 .collect();
179
180 let mut sorted_tables: Vec<&str> = Vec::new();
181 while let Some(table_name) = queue.pop_front() {
182 sorted_tables.push(table_name);
183
184 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
187 for (&dependent, deps) in &dependencies {
188 if deps.contains(&table_name)
189 && let Some(degree) = in_degree.get_mut(dependent)
190 {
191 *degree -= 1;
192 if *degree == 0 {
193 ready_tables.insert(dependent);
194 }
195 }
196 }
197 for t in ready_tables {
198 queue.push_back(t);
199 }
200 }
201
202 sorted_tables.reverse();
204
205 let mut delete_actions: Vec<MigrationAction> =
207 delete_indices.iter().map(|&i| actions[i].clone()).collect();
208
209 delete_actions.sort_by(|a, b| {
210 let a_name = extract_delete_table_name(a);
211 let b_name = extract_delete_table_name(b);
212
213 let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
214 let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
215 a_pos.cmp(&b_pos)
216 });
217
218 for (i, idx) in delete_indices.iter().enumerate() {
220 actions[*idx] = delete_actions[i].clone();
221 }
222}
223
224pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
228 let mut actions: Vec<MigrationAction> = Vec::new();
229
230 let from_normalized: Vec<TableDef> = from
232 .iter()
233 .map(|t| {
234 t.normalize().map_err(|e| {
235 PlannerError::TableValidation(format!(
236 "Failed to normalize table '{}': {}",
237 t.name, e
238 ))
239 })
240 })
241 .collect::<Result<Vec<_>, _>>()?;
242 let to_normalized: Vec<TableDef> = to
243 .iter()
244 .map(|t| {
245 t.normalize().map_err(|e| {
246 PlannerError::TableValidation(format!(
247 "Failed to normalize table '{}': {}",
248 t.name, e
249 ))
250 })
251 })
252 .collect::<Result<Vec<_>, _>>()?;
253
254 let from_map: BTreeMap<_, _> = from_normalized
256 .iter()
257 .map(|t| (t.name.as_str(), t))
258 .collect();
259 let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
260
261 for name in from_map.keys() {
263 if !to_map.contains_key(name) {
264 actions.push(MigrationAction::DeleteTable {
265 table: (*name).to_string(),
266 });
267 }
268 }
269
270 for (name, to_tbl) in &to_map {
272 if let Some(from_tbl) = from_map.get(name) {
273 let from_cols: BTreeMap<_, _> = from_tbl
275 .columns
276 .iter()
277 .map(|c| (c.name.as_str(), c))
278 .collect();
279 let to_cols: BTreeMap<_, _> = to_tbl
280 .columns
281 .iter()
282 .map(|c| (c.name.as_str(), c))
283 .collect();
284
285 for col in from_cols.keys() {
287 if !to_cols.contains_key(col) {
288 actions.push(MigrationAction::DeleteColumn {
289 table: (*name).to_string(),
290 column: (*col).to_string(),
291 });
292 }
293 }
294
295 for (col, to_def) in &to_cols {
297 if let Some(from_def) = from_cols.get(col)
298 && from_def.r#type.requires_migration(&to_def.r#type)
299 {
300 actions.push(MigrationAction::ModifyColumnType {
301 table: (*name).to_string(),
302 column: (*col).to_string(),
303 new_type: to_def.r#type.clone(),
304 });
305 }
306 }
307
308 for (col, def) in &to_cols {
312 if !from_cols.contains_key(col) {
313 actions.push(MigrationAction::AddColumn {
314 table: (*name).to_string(),
315 column: Box::new((*def).clone()),
316 fill_with: None,
317 });
318 }
319 }
320
321 let from_indexes: BTreeMap<_, _> = from_tbl
323 .indexes
324 .iter()
325 .map(|i| (i.name.as_str(), i))
326 .collect();
327 let to_indexes: BTreeMap<_, _> = to_tbl
328 .indexes
329 .iter()
330 .map(|i| (i.name.as_str(), i))
331 .collect();
332
333 for idx in from_indexes.keys() {
334 if !to_indexes.contains_key(idx) {
335 actions.push(MigrationAction::RemoveIndex {
336 table: (*name).to_string(),
337 name: (*idx).to_string(),
338 });
339 }
340 }
341 for (idx, def) in &to_indexes {
342 if !from_indexes.contains_key(idx) {
343 actions.push(MigrationAction::AddIndex {
344 table: (*name).to_string(),
345 index: (*def).clone(),
346 });
347 }
348 }
349
350 for from_constraint in &from_tbl.constraints {
352 if !to_tbl.constraints.contains(from_constraint) {
353 actions.push(MigrationAction::RemoveConstraint {
354 table: (*name).to_string(),
355 constraint: from_constraint.clone(),
356 });
357 }
358 }
359 for to_constraint in &to_tbl.constraints {
360 if !from_tbl.constraints.contains(to_constraint) {
361 actions.push(MigrationAction::AddConstraint {
362 table: (*name).to_string(),
363 constraint: to_constraint.clone(),
364 });
365 }
366 }
367 }
368 }
369
370 let new_tables: Vec<&TableDef> = to_map
373 .iter()
374 .filter(|(name, _)| !from_map.contains_key(*name))
375 .map(|(_, tbl)| *tbl)
376 .collect();
377
378 let sorted_new_tables = topological_sort_tables(&new_tables)?;
379
380 for tbl in sorted_new_tables {
381 actions.push(MigrationAction::CreateTable {
382 table: tbl.name.clone(),
383 columns: tbl.columns.clone(),
384 constraints: tbl.constraints.clone(),
385 });
386 for idx in &tbl.indexes {
387 actions.push(MigrationAction::AddIndex {
388 table: tbl.name.clone(),
389 index: idx.clone(),
390 });
391 }
392 }
393
394 sort_delete_tables(&mut actions, &from_map);
396
397 Ok(MigrationPlan {
398 comment: None,
399 created_at: None,
400 version: 0,
401 actions,
402 })
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use rstest::rstest;
409 use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction, SimpleColumnType};
410
411 fn col(name: &str, ty: ColumnType) -> ColumnDef {
412 ColumnDef {
413 name: name.to_string(),
414 r#type: ty,
415 nullable: true,
416 default: None,
417 comment: None,
418 primary_key: None,
419 unique: None,
420 index: None,
421 foreign_key: None,
422 }
423 }
424
425 fn table(
426 name: &str,
427 columns: Vec<ColumnDef>,
428 constraints: Vec<vespertide_core::TableConstraint>,
429 indexes: Vec<IndexDef>,
430 ) -> TableDef {
431 TableDef {
432 name: name.to_string(),
433 columns,
434 constraints,
435 indexes,
436 }
437 }
438
439 #[rstest]
440 #[case::add_column_and_index(
441 vec![table(
442 "users",
443 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
444 vec![],
445 vec![],
446 )],
447 vec![table(
448 "users",
449 vec![
450 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
451 col("name", ColumnType::Simple(SimpleColumnType::Text)),
452 ],
453 vec![],
454 vec![IndexDef {
455 name: "idx_users_name".into(),
456 columns: vec!["name".into()],
457 unique: false,
458 }],
459 )],
460 vec![
461 MigrationAction::AddColumn {
462 table: "users".into(),
463 column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
464 fill_with: None,
465 },
466 MigrationAction::AddIndex {
467 table: "users".into(),
468 index: IndexDef {
469 name: "idx_users_name".into(),
470 columns: vec!["name".into()],
471 unique: false,
472 },
473 },
474 ]
475 )]
476 #[case::drop_table(
477 vec![table(
478 "users",
479 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
480 vec![],
481 vec![],
482 )],
483 vec![],
484 vec![MigrationAction::DeleteTable {
485 table: "users".into()
486 }]
487 )]
488 #[case::add_table(
489 vec![],
490 vec![table(
491 "users",
492 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
493 vec![],
494 vec![IndexDef {
495 name: "idx_users_id".into(),
496 columns: vec!["id".into()],
497 unique: true,
498 }],
499 )],
500 vec![
501 MigrationAction::CreateTable {
502 table: "users".into(),
503 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
504 constraints: vec![],
505 },
506 MigrationAction::AddIndex {
507 table: "users".into(),
508 index: IndexDef {
509 name: "idx_users_id".into(),
510 columns: vec!["id".into()],
511 unique: true,
512 },
513 },
514 ]
515 )]
516 #[case::delete_column(
517 vec![table(
518 "users",
519 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
520 vec![],
521 vec![],
522 )],
523 vec![table(
524 "users",
525 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
526 vec![],
527 vec![],
528 )],
529 vec![MigrationAction::DeleteColumn {
530 table: "users".into(),
531 column: "name".into(),
532 }]
533 )]
534 #[case::modify_column_type(
535 vec![table(
536 "users",
537 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
538 vec![],
539 vec![],
540 )],
541 vec![table(
542 "users",
543 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
544 vec![],
545 vec![],
546 )],
547 vec![MigrationAction::ModifyColumnType {
548 table: "users".into(),
549 column: "id".into(),
550 new_type: ColumnType::Simple(SimpleColumnType::Text),
551 }]
552 )]
553 #[case::remove_index(
554 vec![table(
555 "users",
556 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
557 vec![],
558 vec![IndexDef {
559 name: "idx_users_id".into(),
560 columns: vec!["id".into()],
561 unique: false,
562 }],
563 )],
564 vec![table(
565 "users",
566 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
567 vec![],
568 vec![],
569 )],
570 vec![MigrationAction::RemoveIndex {
571 table: "users".into(),
572 name: "idx_users_id".into(),
573 }]
574 )]
575 #[case::add_index_existing_table(
576 vec![table(
577 "users",
578 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
579 vec![],
580 vec![],
581 )],
582 vec![table(
583 "users",
584 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
585 vec![],
586 vec![IndexDef {
587 name: "idx_users_id".into(),
588 columns: vec!["id".into()],
589 unique: true,
590 }],
591 )],
592 vec![MigrationAction::AddIndex {
593 table: "users".into(),
594 index: IndexDef {
595 name: "idx_users_id".into(),
596 columns: vec!["id".into()],
597 unique: true,
598 },
599 }]
600 )]
601 fn diff_schemas_detects_additions(
602 #[case] from_schema: Vec<TableDef>,
603 #[case] to_schema: Vec<TableDef>,
604 #[case] expected_actions: Vec<MigrationAction>,
605 ) {
606 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
607 assert_eq!(plan.actions, expected_actions);
608 }
609
610 mod integer_enum {
612 use super::*;
613 use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
614
615 #[test]
616 fn integer_enum_values_changed_no_migration() {
617 let from = vec![table(
619 "orders",
620 vec![col(
621 "status",
622 ColumnType::Complex(ComplexColumnType::Enum {
623 name: "order_status".into(),
624 values: EnumValues::Integer(vec![
625 NumValue {
626 name: "Pending".into(),
627 value: 0,
628 },
629 NumValue {
630 name: "Shipped".into(),
631 value: 1,
632 },
633 ]),
634 }),
635 )],
636 vec![],
637 vec![],
638 )];
639
640 let to = vec![table(
641 "orders",
642 vec![col(
643 "status",
644 ColumnType::Complex(ComplexColumnType::Enum {
645 name: "order_status".into(),
646 values: EnumValues::Integer(vec![
647 NumValue {
648 name: "Pending".into(),
649 value: 0,
650 },
651 NumValue {
652 name: "Shipped".into(),
653 value: 1,
654 },
655 NumValue {
656 name: "Delivered".into(),
657 value: 2,
658 },
659 NumValue {
660 name: "Cancelled".into(),
661 value: 100,
662 },
663 ]),
664 }),
665 )],
666 vec![],
667 vec![],
668 )];
669
670 let plan = diff_schemas(&from, &to).unwrap();
671 assert!(
672 plan.actions.is_empty(),
673 "Expected no actions, got: {:?}",
674 plan.actions
675 );
676 }
677
678 #[test]
679 fn string_enum_values_changed_requires_migration() {
680 let from = vec![table(
682 "orders",
683 vec![col(
684 "status",
685 ColumnType::Complex(ComplexColumnType::Enum {
686 name: "order_status".into(),
687 values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
688 }),
689 )],
690 vec![],
691 vec![],
692 )];
693
694 let to = vec![table(
695 "orders",
696 vec![col(
697 "status",
698 ColumnType::Complex(ComplexColumnType::Enum {
699 name: "order_status".into(),
700 values: EnumValues::String(vec![
701 "pending".into(),
702 "shipped".into(),
703 "delivered".into(),
704 ]),
705 }),
706 )],
707 vec![],
708 vec![],
709 )];
710
711 let plan = diff_schemas(&from, &to).unwrap();
712 assert_eq!(plan.actions.len(), 1);
713 assert!(matches!(
714 &plan.actions[0],
715 MigrationAction::ModifyColumnType { table, column, .. }
716 if table == "orders" && column == "status"
717 ));
718 }
719 }
720
721 mod inline_constraints {
723 use super::*;
724 use vespertide_core::schema::foreign_key::ForeignKeyDef;
725 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
726 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
727 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
728
729 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
730 ColumnDef {
731 name: name.to_string(),
732 r#type: ty,
733 nullable: false,
734 default: None,
735 comment: None,
736 primary_key: Some(PrimaryKeySyntax::Bool(true)),
737 unique: None,
738 index: None,
739 foreign_key: None,
740 }
741 }
742
743 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
744 ColumnDef {
745 name: name.to_string(),
746 r#type: ty,
747 nullable: true,
748 default: None,
749 comment: None,
750 primary_key: None,
751 unique: Some(StrOrBoolOrArray::Bool(true)),
752 index: None,
753 foreign_key: None,
754 }
755 }
756
757 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
758 ColumnDef {
759 name: name.to_string(),
760 r#type: ty,
761 nullable: true,
762 default: None,
763 comment: None,
764 primary_key: None,
765 unique: None,
766 index: Some(StrOrBoolOrArray::Bool(true)),
767 foreign_key: None,
768 }
769 }
770
771 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
772 ColumnDef {
773 name: name.to_string(),
774 r#type: ty,
775 nullable: true,
776 default: None,
777 comment: None,
778 primary_key: None,
779 unique: None,
780 index: None,
781 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
782 ref_table: ref_table.to_string(),
783 ref_columns: vec![ref_col.to_string()],
784 on_delete: None,
785 on_update: None,
786 })),
787 }
788 }
789
790 #[test]
791 fn create_table_with_inline_pk() {
792 let plan = diff_schemas(
793 &[],
794 &[table(
795 "users",
796 vec![
797 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
798 col("name", ColumnType::Simple(SimpleColumnType::Text)),
799 ],
800 vec![],
801 vec![],
802 )],
803 )
804 .unwrap();
805
806 assert_eq!(plan.actions.len(), 1);
807 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
808 assert_eq!(constraints.len(), 1);
809 assert!(matches!(
810 &constraints[0],
811 TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
812 ));
813 } else {
814 panic!("Expected CreateTable action");
815 }
816 }
817
818 #[test]
819 fn create_table_with_inline_unique() {
820 let plan = diff_schemas(
821 &[],
822 &[table(
823 "users",
824 vec![
825 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
826 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
827 ],
828 vec![],
829 vec![],
830 )],
831 )
832 .unwrap();
833
834 assert_eq!(plan.actions.len(), 1);
835 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
836 assert_eq!(constraints.len(), 1);
837 assert!(matches!(
838 &constraints[0],
839 TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
840 ));
841 } else {
842 panic!("Expected CreateTable action");
843 }
844 }
845
846 #[test]
847 fn create_table_with_inline_index() {
848 let plan = diff_schemas(
849 &[],
850 &[table(
851 "users",
852 vec![
853 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
854 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
855 ],
856 vec![],
857 vec![],
858 )],
859 )
860 .unwrap();
861
862 assert_eq!(plan.actions.len(), 2);
864 assert!(matches!(
865 &plan.actions[0],
866 MigrationAction::CreateTable { .. }
867 ));
868 if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
869 assert_eq!(index.name, "idx_users_name");
870 assert_eq!(index.columns, vec!["name".to_string()]);
871 } else {
872 panic!("Expected AddIndex action");
873 }
874 }
875
876 #[test]
877 fn create_table_with_inline_fk() {
878 let plan = diff_schemas(
879 &[],
880 &[table(
881 "posts",
882 vec![
883 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
884 col_with_fk(
885 "user_id",
886 ColumnType::Simple(SimpleColumnType::Integer),
887 "users",
888 "id",
889 ),
890 ],
891 vec![],
892 vec![],
893 )],
894 )
895 .unwrap();
896
897 assert_eq!(plan.actions.len(), 1);
898 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
899 assert_eq!(constraints.len(), 1);
900 assert!(matches!(
901 &constraints[0],
902 TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
903 if columns == &["user_id".to_string()]
904 && ref_table == "users"
905 && ref_columns == &["id".to_string()]
906 ));
907 } else {
908 panic!("Expected CreateTable action");
909 }
910 }
911
912 #[test]
913 fn add_index_via_inline_constraint() {
914 let plan = diff_schemas(
916 &[table(
917 "users",
918 vec![
919 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
920 col("name", ColumnType::Simple(SimpleColumnType::Text)),
921 ],
922 vec![],
923 vec![],
924 )],
925 &[table(
926 "users",
927 vec![
928 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
929 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
930 ],
931 vec![],
932 vec![],
933 )],
934 )
935 .unwrap();
936
937 assert_eq!(plan.actions.len(), 1);
938 if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
939 assert_eq!(table, "users");
940 assert_eq!(index.name, "idx_users_name");
941 assert_eq!(index.columns, vec!["name".to_string()]);
942 } else {
943 panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
944 }
945 }
946
947 #[test]
948 fn create_table_with_all_inline_constraints() {
949 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
950 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
951 id_col.nullable = false;
952
953 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
954 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
955
956 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
957 name_col.index = Some(StrOrBoolOrArray::Bool(true));
958
959 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
960 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
961 ref_table: "orgs".into(),
962 ref_columns: vec!["id".into()],
963 on_delete: None,
964 on_update: None,
965 }));
966
967 let plan = diff_schemas(
968 &[],
969 &[table(
970 "users",
971 vec![id_col, email_col, name_col, org_id_col],
972 vec![],
973 vec![],
974 )],
975 )
976 .unwrap();
977
978 assert_eq!(plan.actions.len(), 2);
980
981 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
982 assert_eq!(constraints.len(), 3);
984 } else {
985 panic!("Expected CreateTable action");
986 }
987
988 assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
990 }
991
992 #[test]
993 fn add_constraint_to_existing_table() {
994 let from_schema = vec![table(
996 "users",
997 vec![
998 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
999 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1000 ],
1001 vec![],
1002 vec![],
1003 )];
1004
1005 let to_schema = vec![table(
1006 "users",
1007 vec![
1008 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1009 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1010 ],
1011 vec![vespertide_core::TableConstraint::Unique {
1012 name: Some("uq_users_email".into()),
1013 columns: vec!["email".into()],
1014 }],
1015 vec![],
1016 )];
1017
1018 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1019 assert_eq!(plan.actions.len(), 1);
1020 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1021 assert_eq!(table, "users");
1022 assert!(matches!(
1023 constraint,
1024 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1025 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1026 ));
1027 } else {
1028 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1029 }
1030 }
1031
1032 #[test]
1033 fn remove_constraint_from_existing_table() {
1034 let from_schema = vec![table(
1036 "users",
1037 vec![
1038 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1039 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1040 ],
1041 vec![vespertide_core::TableConstraint::Unique {
1042 name: Some("uq_users_email".into()),
1043 columns: vec!["email".into()],
1044 }],
1045 vec![],
1046 )];
1047
1048 let to_schema = vec![table(
1049 "users",
1050 vec![
1051 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1052 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1053 ],
1054 vec![],
1055 vec![],
1056 )];
1057
1058 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1059 assert_eq!(plan.actions.len(), 1);
1060 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1061 assert_eq!(table, "users");
1062 assert!(matches!(
1063 constraint,
1064 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1065 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1066 ));
1067 } else {
1068 panic!(
1069 "Expected RemoveConstraint action, got {:?}",
1070 plan.actions[0]
1071 );
1072 }
1073 }
1074
1075 #[test]
1076 fn diff_schemas_with_normalize_error() {
1077 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1079 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1080
1081 let table = TableDef {
1082 name: "test".into(),
1083 columns: vec![
1084 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1085 col1.clone(),
1086 {
1087 let mut c = col1.clone();
1089 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1090 c
1091 },
1092 ],
1093 constraints: vec![],
1094 indexes: vec![],
1095 };
1096
1097 let result = diff_schemas(&[], &[table]);
1098 assert!(result.is_err());
1099 if let Err(PlannerError::TableValidation(msg)) = result {
1100 assert!(msg.contains("Failed to normalize table"));
1101 assert!(msg.contains("Duplicate index"));
1102 } else {
1103 panic!("Expected TableValidation error, got {:?}", result);
1104 }
1105 }
1106
1107 #[test]
1108 fn diff_schemas_with_normalize_error_in_from_schema() {
1109 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1111 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1112
1113 let table = TableDef {
1114 name: "test".into(),
1115 columns: vec![
1116 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1117 col1.clone(),
1118 {
1119 let mut c = col1.clone();
1121 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1122 c
1123 },
1124 ],
1125 constraints: vec![],
1126 indexes: vec![],
1127 };
1128
1129 let result = diff_schemas(&[table], &[]);
1131 assert!(result.is_err());
1132 if let Err(PlannerError::TableValidation(msg)) = result {
1133 assert!(msg.contains("Failed to normalize table"));
1134 assert!(msg.contains("Duplicate index"));
1135 } else {
1136 panic!("Expected TableValidation error, got {:?}", result);
1137 }
1138 }
1139 }
1140
1141 mod fk_ordering {
1143 use super::*;
1144 use vespertide_core::TableConstraint;
1145
1146 fn table_with_fk(
1147 name: &str,
1148 ref_table: &str,
1149 fk_column: &str,
1150 ref_column: &str,
1151 ) -> TableDef {
1152 TableDef {
1153 name: name.to_string(),
1154 columns: vec![
1155 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1156 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1157 ],
1158 constraints: vec![TableConstraint::ForeignKey {
1159 name: None,
1160 columns: vec![fk_column.to_string()],
1161 ref_table: ref_table.to_string(),
1162 ref_columns: vec![ref_column.to_string()],
1163 on_delete: None,
1164 on_update: None,
1165 }],
1166 indexes: vec![],
1167 }
1168 }
1169
1170 fn simple_table(name: &str) -> TableDef {
1171 TableDef {
1172 name: name.to_string(),
1173 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1174 constraints: vec![],
1175 indexes: vec![],
1176 }
1177 }
1178
1179 #[test]
1180 fn create_tables_respects_fk_order() {
1181 let users = simple_table("users");
1184 let posts = table_with_fk("posts", "users", "user_id", "id");
1185
1186 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1187
1188 let create_order: Vec<&str> = plan
1190 .actions
1191 .iter()
1192 .filter_map(|a| {
1193 if let MigrationAction::CreateTable { table, .. } = a {
1194 Some(table.as_str())
1195 } else {
1196 None
1197 }
1198 })
1199 .collect();
1200
1201 assert_eq!(create_order, vec!["users", "posts"]);
1202 }
1203
1204 #[test]
1205 fn create_tables_chain_dependency() {
1206 let users = simple_table("users");
1211 let media = table_with_fk("media", "users", "owner_id", "id");
1212 let articles = table_with_fk("articles", "media", "media_id", "id");
1213
1214 let plan =
1216 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1217
1218 let create_order: Vec<&str> = plan
1219 .actions
1220 .iter()
1221 .filter_map(|a| {
1222 if let MigrationAction::CreateTable { table, .. } = a {
1223 Some(table.as_str())
1224 } else {
1225 None
1226 }
1227 })
1228 .collect();
1229
1230 assert_eq!(create_order, vec!["users", "media", "articles"]);
1231 }
1232
1233 #[test]
1234 fn create_tables_multiple_independent_branches() {
1235 let users = simple_table("users");
1239 let posts = table_with_fk("posts", "users", "user_id", "id");
1240 let categories = simple_table("categories");
1241 let products = table_with_fk("products", "categories", "category_id", "id");
1242
1243 let plan = diff_schemas(
1244 &[],
1245 &[
1246 products.clone(),
1247 posts.clone(),
1248 categories.clone(),
1249 users.clone(),
1250 ],
1251 )
1252 .unwrap();
1253
1254 let create_order: Vec<&str> = plan
1255 .actions
1256 .iter()
1257 .filter_map(|a| {
1258 if let MigrationAction::CreateTable { table, .. } = a {
1259 Some(table.as_str())
1260 } else {
1261 None
1262 }
1263 })
1264 .collect();
1265
1266 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1268 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1269 assert!(
1270 users_pos < posts_pos,
1271 "users should be created before posts"
1272 );
1273
1274 let categories_pos = create_order
1276 .iter()
1277 .position(|&t| t == "categories")
1278 .unwrap();
1279 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1280 assert!(
1281 categories_pos < products_pos,
1282 "categories should be created before products"
1283 );
1284 }
1285
1286 #[test]
1287 fn delete_tables_respects_fk_order() {
1288 let users = simple_table("users");
1291 let posts = table_with_fk("posts", "users", "user_id", "id");
1292
1293 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1294
1295 let delete_order: Vec<&str> = plan
1296 .actions
1297 .iter()
1298 .filter_map(|a| {
1299 if let MigrationAction::DeleteTable { table } = a {
1300 Some(table.as_str())
1301 } else {
1302 None
1303 }
1304 })
1305 .collect();
1306
1307 assert_eq!(delete_order, vec!["posts", "users"]);
1308 }
1309
1310 #[test]
1311 fn delete_tables_chain_dependency() {
1312 let users = simple_table("users");
1315 let media = table_with_fk("media", "users", "owner_id", "id");
1316 let articles = table_with_fk("articles", "media", "media_id", "id");
1317
1318 let plan =
1319 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1320
1321 let delete_order: Vec<&str> = plan
1322 .actions
1323 .iter()
1324 .filter_map(|a| {
1325 if let MigrationAction::DeleteTable { table } = a {
1326 Some(table.as_str())
1327 } else {
1328 None
1329 }
1330 })
1331 .collect();
1332
1333 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1335 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1336 assert!(
1337 articles_pos < media_pos,
1338 "articles should be deleted before media"
1339 );
1340
1341 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1343 assert!(
1344 media_pos < users_pos,
1345 "media should be deleted before users"
1346 );
1347 }
1348
1349 #[test]
1350 fn circular_fk_dependency_returns_error() {
1351 let table_a = TableDef {
1353 name: "table_a".to_string(),
1354 columns: vec![
1355 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1356 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1357 ],
1358 constraints: vec![TableConstraint::ForeignKey {
1359 name: None,
1360 columns: vec!["b_id".to_string()],
1361 ref_table: "table_b".to_string(),
1362 ref_columns: vec!["id".to_string()],
1363 on_delete: None,
1364 on_update: None,
1365 }],
1366 indexes: vec![],
1367 };
1368
1369 let table_b = TableDef {
1370 name: "table_b".to_string(),
1371 columns: vec![
1372 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1373 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1374 ],
1375 constraints: vec![TableConstraint::ForeignKey {
1376 name: None,
1377 columns: vec!["a_id".to_string()],
1378 ref_table: "table_a".to_string(),
1379 ref_columns: vec!["id".to_string()],
1380 on_delete: None,
1381 on_update: None,
1382 }],
1383 indexes: vec![],
1384 };
1385
1386 let result = diff_schemas(&[], &[table_a, table_b]);
1387 assert!(result.is_err());
1388 if let Err(PlannerError::TableValidation(msg)) = result {
1389 assert!(
1390 msg.contains("Circular foreign key dependency"),
1391 "Expected circular dependency error, got: {}",
1392 msg
1393 );
1394 } else {
1395 panic!("Expected TableValidation error, got {:?}", result);
1396 }
1397 }
1398
1399 #[test]
1400 fn fk_to_external_table_is_ignored() {
1401 let posts = table_with_fk("posts", "users", "user_id", "id");
1403 let comments = table_with_fk("comments", "posts", "post_id", "id");
1404
1405 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1407
1408 let create_order: Vec<&str> = plan
1409 .actions
1410 .iter()
1411 .filter_map(|a| {
1412 if let MigrationAction::CreateTable { table, .. } = a {
1413 Some(table.as_str())
1414 } else {
1415 None
1416 }
1417 })
1418 .collect();
1419
1420 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1422 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1423 assert!(
1424 posts_pos < comments_pos,
1425 "posts should be created before comments"
1426 );
1427 }
1428
1429 #[test]
1430 fn delete_tables_mixed_with_other_actions() {
1431 use crate::diff::diff_schemas;
1434
1435 let from_schema = vec![
1436 table(
1437 "users",
1438 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1439 vec![],
1440 vec![],
1441 ),
1442 table(
1443 "posts",
1444 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1445 vec![],
1446 vec![],
1447 ),
1448 ];
1449
1450 let to_schema = vec![
1451 table(
1453 "users",
1454 vec![
1455 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1456 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1457 ],
1458 vec![],
1459 vec![],
1460 ),
1461 ];
1462
1463 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1464
1465 assert!(
1467 plan.actions
1468 .iter()
1469 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1470 );
1471 assert!(
1472 plan.actions
1473 .iter()
1474 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1475 );
1476
1477 }
1480
1481 #[test]
1482 #[should_panic(expected = "Expected DeleteTable action")]
1483 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1484 use super::extract_delete_table_name;
1486
1487 let action = MigrationAction::AddColumn {
1488 table: "users".into(),
1489 column: Box::new(ColumnDef {
1490 name: "email".into(),
1491 r#type: ColumnType::Simple(SimpleColumnType::Text),
1492 nullable: true,
1493 default: None,
1494 comment: None,
1495 primary_key: None,
1496 unique: None,
1497 index: None,
1498 foreign_key: None,
1499 }),
1500 fill_with: None,
1501 };
1502
1503 extract_delete_table_name(&action);
1505 }
1506
1507 #[test]
1509 fn create_tables_with_inline_fk_chain() {
1510 use super::*;
1511 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1512 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1513
1514 fn col_pk(name: &str) -> ColumnDef {
1515 ColumnDef {
1516 name: name.to_string(),
1517 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1518 nullable: false,
1519 default: None,
1520 comment: None,
1521 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1522 unique: None,
1523 index: None,
1524 foreign_key: None,
1525 }
1526 }
1527
1528 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1529 ColumnDef {
1530 name: name.to_string(),
1531 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1532 nullable: true,
1533 default: None,
1534 comment: None,
1535 primary_key: None,
1536 unique: None,
1537 index: None,
1538 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1539 }
1540 }
1541
1542 let user = TableDef {
1551 name: "user".to_string(),
1552 columns: vec![col_pk("id")],
1553 constraints: vec![],
1554 indexes: vec![],
1555 };
1556
1557 let product = TableDef {
1558 name: "product".to_string(),
1559 columns: vec![col_pk("id")],
1560 constraints: vec![],
1561 indexes: vec![],
1562 };
1563
1564 let project = TableDef {
1565 name: "project".to_string(),
1566 columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1567 constraints: vec![],
1568 indexes: vec![],
1569 };
1570
1571 let code = TableDef {
1572 name: "code".to_string(),
1573 columns: vec![
1574 col_pk("id"),
1575 col_inline_fk("product_id", "product"),
1576 col_inline_fk("creator_user_id", "user"),
1577 col_inline_fk("project_id", "project"),
1578 ],
1579 constraints: vec![],
1580 indexes: vec![],
1581 };
1582
1583 let order = TableDef {
1584 name: "order".to_string(),
1585 columns: vec![
1586 col_pk("id"),
1587 col_inline_fk("user_id", "user"),
1588 col_inline_fk("project_id", "project"),
1589 col_inline_fk("product_id", "product"),
1590 col_inline_fk("code_id", "code"),
1591 ],
1592 constraints: vec![],
1593 indexes: vec![],
1594 };
1595
1596 let payment = TableDef {
1597 name: "payment".to_string(),
1598 columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1599 constraints: vec![],
1600 indexes: vec![],
1601 };
1602
1603 let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1605 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1606
1607 let plan = result.unwrap();
1608 let create_order: Vec<&str> = plan
1609 .actions
1610 .iter()
1611 .filter_map(|a| {
1612 if let MigrationAction::CreateTable { table, .. } = a {
1613 Some(table.as_str())
1614 } else {
1615 None
1616 }
1617 })
1618 .collect();
1619
1620 let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1622
1623 assert!(
1626 get_pos("user") < get_pos("project"),
1627 "user must come before project"
1628 );
1629 assert!(
1631 get_pos("product") < get_pos("code"),
1632 "product must come before code"
1633 );
1634 assert!(
1635 get_pos("user") < get_pos("code"),
1636 "user must come before code"
1637 );
1638 assert!(
1639 get_pos("project") < get_pos("code"),
1640 "project must come before code"
1641 );
1642 assert!(
1644 get_pos("code") < get_pos("order"),
1645 "code must come before order"
1646 );
1647 assert!(
1649 get_pos("order") < get_pos("payment"),
1650 "order must come before payment"
1651 );
1652 }
1653
1654 #[test]
1656 fn create_tables_with_duplicate_fk_references() {
1657 use super::*;
1658 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1659 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1660
1661 fn col_pk(name: &str) -> ColumnDef {
1662 ColumnDef {
1663 name: name.to_string(),
1664 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1665 nullable: false,
1666 default: None,
1667 comment: None,
1668 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1669 unique: None,
1670 index: None,
1671 foreign_key: None,
1672 }
1673 }
1674
1675 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1676 ColumnDef {
1677 name: name.to_string(),
1678 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1679 nullable: true,
1680 default: None,
1681 comment: None,
1682 primary_key: None,
1683 unique: None,
1684 index: None,
1685 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1686 }
1687 }
1688
1689 let user = TableDef {
1691 name: "user".to_string(),
1692 columns: vec![col_pk("id")],
1693 constraints: vec![],
1694 indexes: vec![],
1695 };
1696
1697 let code = TableDef {
1698 name: "code".to_string(),
1699 columns: vec![
1700 col_pk("id"),
1701 col_inline_fk("creator_user_id", "user"),
1702 col_inline_fk("used_by_user_id", "user"), ],
1704 constraints: vec![],
1705 indexes: vec![],
1706 };
1707
1708 let result = diff_schemas(&[], &[code, user]);
1710 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1711
1712 let plan = result.unwrap();
1713 let create_order: Vec<&str> = plan
1714 .actions
1715 .iter()
1716 .filter_map(|a| {
1717 if let MigrationAction::CreateTable { table, .. } = a {
1718 Some(table.as_str())
1719 } else {
1720 None
1721 }
1722 })
1723 .collect();
1724
1725 let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1727 let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1728 assert!(user_pos < code_pos, "user must come before code");
1729 }
1730 }
1731}