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 != 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: (*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: 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 inline_constraints {
610 use super::*;
611 use vespertide_core::schema::foreign_key::ForeignKeyDef;
612 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
613 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
614 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
615
616 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
617 ColumnDef {
618 name: name.to_string(),
619 r#type: ty,
620 nullable: false,
621 default: None,
622 comment: None,
623 primary_key: Some(PrimaryKeySyntax::Bool(true)),
624 unique: None,
625 index: None,
626 foreign_key: None,
627 }
628 }
629
630 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
631 ColumnDef {
632 name: name.to_string(),
633 r#type: ty,
634 nullable: true,
635 default: None,
636 comment: None,
637 primary_key: None,
638 unique: Some(StrOrBoolOrArray::Bool(true)),
639 index: None,
640 foreign_key: None,
641 }
642 }
643
644 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
645 ColumnDef {
646 name: name.to_string(),
647 r#type: ty,
648 nullable: true,
649 default: None,
650 comment: None,
651 primary_key: None,
652 unique: None,
653 index: Some(StrOrBoolOrArray::Bool(true)),
654 foreign_key: None,
655 }
656 }
657
658 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
659 ColumnDef {
660 name: name.to_string(),
661 r#type: ty,
662 nullable: true,
663 default: None,
664 comment: None,
665 primary_key: None,
666 unique: None,
667 index: None,
668 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
669 ref_table: ref_table.to_string(),
670 ref_columns: vec![ref_col.to_string()],
671 on_delete: None,
672 on_update: None,
673 })),
674 }
675 }
676
677 #[test]
678 fn create_table_with_inline_pk() {
679 let plan = diff_schemas(
680 &[],
681 &[table(
682 "users",
683 vec![
684 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
685 col("name", ColumnType::Simple(SimpleColumnType::Text)),
686 ],
687 vec![],
688 vec![],
689 )],
690 )
691 .unwrap();
692
693 assert_eq!(plan.actions.len(), 1);
694 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
695 assert_eq!(constraints.len(), 1);
696 assert!(matches!(
697 &constraints[0],
698 TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
699 ));
700 } else {
701 panic!("Expected CreateTable action");
702 }
703 }
704
705 #[test]
706 fn create_table_with_inline_unique() {
707 let plan = diff_schemas(
708 &[],
709 &[table(
710 "users",
711 vec![
712 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
713 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
714 ],
715 vec![],
716 vec![],
717 )],
718 )
719 .unwrap();
720
721 assert_eq!(plan.actions.len(), 1);
722 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
723 assert_eq!(constraints.len(), 1);
724 assert!(matches!(
725 &constraints[0],
726 TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
727 ));
728 } else {
729 panic!("Expected CreateTable action");
730 }
731 }
732
733 #[test]
734 fn create_table_with_inline_index() {
735 let plan = diff_schemas(
736 &[],
737 &[table(
738 "users",
739 vec![
740 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
741 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
742 ],
743 vec![],
744 vec![],
745 )],
746 )
747 .unwrap();
748
749 assert_eq!(plan.actions.len(), 2);
751 assert!(matches!(
752 &plan.actions[0],
753 MigrationAction::CreateTable { .. }
754 ));
755 if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
756 assert_eq!(index.name, "idx_users_name");
757 assert_eq!(index.columns, vec!["name".to_string()]);
758 } else {
759 panic!("Expected AddIndex action");
760 }
761 }
762
763 #[test]
764 fn create_table_with_inline_fk() {
765 let plan = diff_schemas(
766 &[],
767 &[table(
768 "posts",
769 vec![
770 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
771 col_with_fk(
772 "user_id",
773 ColumnType::Simple(SimpleColumnType::Integer),
774 "users",
775 "id",
776 ),
777 ],
778 vec![],
779 vec![],
780 )],
781 )
782 .unwrap();
783
784 assert_eq!(plan.actions.len(), 1);
785 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
786 assert_eq!(constraints.len(), 1);
787 assert!(matches!(
788 &constraints[0],
789 TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
790 if columns == &["user_id".to_string()]
791 && ref_table == "users"
792 && ref_columns == &["id".to_string()]
793 ));
794 } else {
795 panic!("Expected CreateTable action");
796 }
797 }
798
799 #[test]
800 fn add_index_via_inline_constraint() {
801 let plan = diff_schemas(
803 &[table(
804 "users",
805 vec![
806 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
807 col("name", ColumnType::Simple(SimpleColumnType::Text)),
808 ],
809 vec![],
810 vec![],
811 )],
812 &[table(
813 "users",
814 vec![
815 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
816 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
817 ],
818 vec![],
819 vec![],
820 )],
821 )
822 .unwrap();
823
824 assert_eq!(plan.actions.len(), 1);
825 if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
826 assert_eq!(table, "users");
827 assert_eq!(index.name, "idx_users_name");
828 assert_eq!(index.columns, vec!["name".to_string()]);
829 } else {
830 panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
831 }
832 }
833
834 #[test]
835 fn create_table_with_all_inline_constraints() {
836 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
837 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
838 id_col.nullable = false;
839
840 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
841 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
842
843 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
844 name_col.index = Some(StrOrBoolOrArray::Bool(true));
845
846 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
847 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
848 ref_table: "orgs".into(),
849 ref_columns: vec!["id".into()],
850 on_delete: None,
851 on_update: None,
852 }));
853
854 let plan = diff_schemas(
855 &[],
856 &[table(
857 "users",
858 vec![id_col, email_col, name_col, org_id_col],
859 vec![],
860 vec![],
861 )],
862 )
863 .unwrap();
864
865 assert_eq!(plan.actions.len(), 2);
867
868 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
869 assert_eq!(constraints.len(), 3);
871 } else {
872 panic!("Expected CreateTable action");
873 }
874
875 assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
877 }
878
879 #[test]
880 fn add_constraint_to_existing_table() {
881 let from_schema = vec![table(
883 "users",
884 vec![
885 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
886 col("email", ColumnType::Simple(SimpleColumnType::Text)),
887 ],
888 vec![],
889 vec![],
890 )];
891
892 let to_schema = vec![table(
893 "users",
894 vec![
895 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
896 col("email", ColumnType::Simple(SimpleColumnType::Text)),
897 ],
898 vec![vespertide_core::TableConstraint::Unique {
899 name: Some("uq_users_email".into()),
900 columns: vec!["email".into()],
901 }],
902 vec![],
903 )];
904
905 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
906 assert_eq!(plan.actions.len(), 1);
907 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
908 assert_eq!(table, "users");
909 assert!(matches!(
910 constraint,
911 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
912 if n == "uq_users_email" && columns == &vec!["email".to_string()]
913 ));
914 } else {
915 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
916 }
917 }
918
919 #[test]
920 fn remove_constraint_from_existing_table() {
921 let from_schema = vec![table(
923 "users",
924 vec![
925 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
926 col("email", ColumnType::Simple(SimpleColumnType::Text)),
927 ],
928 vec![vespertide_core::TableConstraint::Unique {
929 name: Some("uq_users_email".into()),
930 columns: vec!["email".into()],
931 }],
932 vec![],
933 )];
934
935 let to_schema = vec![table(
936 "users",
937 vec![
938 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
939 col("email", ColumnType::Simple(SimpleColumnType::Text)),
940 ],
941 vec![],
942 vec![],
943 )];
944
945 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
946 assert_eq!(plan.actions.len(), 1);
947 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
948 assert_eq!(table, "users");
949 assert!(matches!(
950 constraint,
951 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
952 if n == "uq_users_email" && columns == &vec!["email".to_string()]
953 ));
954 } else {
955 panic!(
956 "Expected RemoveConstraint action, got {:?}",
957 plan.actions[0]
958 );
959 }
960 }
961
962 #[test]
963 fn diff_schemas_with_normalize_error() {
964 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
966 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
967
968 let table = TableDef {
969 name: "test".into(),
970 columns: vec![
971 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
972 col1.clone(),
973 {
974 let mut c = col1.clone();
976 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
977 c
978 },
979 ],
980 constraints: vec![],
981 indexes: vec![],
982 };
983
984 let result = diff_schemas(&[], &[table]);
985 assert!(result.is_err());
986 if let Err(PlannerError::TableValidation(msg)) = result {
987 assert!(msg.contains("Failed to normalize table"));
988 assert!(msg.contains("Duplicate index"));
989 } else {
990 panic!("Expected TableValidation error, got {:?}", result);
991 }
992 }
993
994 #[test]
995 fn diff_schemas_with_normalize_error_in_from_schema() {
996 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
998 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
999
1000 let table = TableDef {
1001 name: "test".into(),
1002 columns: vec![
1003 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1004 col1.clone(),
1005 {
1006 let mut c = col1.clone();
1008 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1009 c
1010 },
1011 ],
1012 constraints: vec![],
1013 indexes: vec![],
1014 };
1015
1016 let result = diff_schemas(&[table], &[]);
1018 assert!(result.is_err());
1019 if let Err(PlannerError::TableValidation(msg)) = result {
1020 assert!(msg.contains("Failed to normalize table"));
1021 assert!(msg.contains("Duplicate index"));
1022 } else {
1023 panic!("Expected TableValidation error, got {:?}", result);
1024 }
1025 }
1026 }
1027
1028 mod fk_ordering {
1030 use super::*;
1031 use vespertide_core::TableConstraint;
1032
1033 fn table_with_fk(
1034 name: &str,
1035 ref_table: &str,
1036 fk_column: &str,
1037 ref_column: &str,
1038 ) -> TableDef {
1039 TableDef {
1040 name: name.to_string(),
1041 columns: vec![
1042 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1043 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1044 ],
1045 constraints: vec![TableConstraint::ForeignKey {
1046 name: None,
1047 columns: vec![fk_column.to_string()],
1048 ref_table: ref_table.to_string(),
1049 ref_columns: vec![ref_column.to_string()],
1050 on_delete: None,
1051 on_update: None,
1052 }],
1053 indexes: vec![],
1054 }
1055 }
1056
1057 fn simple_table(name: &str) -> TableDef {
1058 TableDef {
1059 name: name.to_string(),
1060 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1061 constraints: vec![],
1062 indexes: vec![],
1063 }
1064 }
1065
1066 #[test]
1067 fn create_tables_respects_fk_order() {
1068 let users = simple_table("users");
1071 let posts = table_with_fk("posts", "users", "user_id", "id");
1072
1073 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1074
1075 let create_order: Vec<&str> = plan
1077 .actions
1078 .iter()
1079 .filter_map(|a| {
1080 if let MigrationAction::CreateTable { table, .. } = a {
1081 Some(table.as_str())
1082 } else {
1083 None
1084 }
1085 })
1086 .collect();
1087
1088 assert_eq!(create_order, vec!["users", "posts"]);
1089 }
1090
1091 #[test]
1092 fn create_tables_chain_dependency() {
1093 let users = simple_table("users");
1098 let media = table_with_fk("media", "users", "owner_id", "id");
1099 let articles = table_with_fk("articles", "media", "media_id", "id");
1100
1101 let plan =
1103 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1104
1105 let create_order: Vec<&str> = plan
1106 .actions
1107 .iter()
1108 .filter_map(|a| {
1109 if let MigrationAction::CreateTable { table, .. } = a {
1110 Some(table.as_str())
1111 } else {
1112 None
1113 }
1114 })
1115 .collect();
1116
1117 assert_eq!(create_order, vec!["users", "media", "articles"]);
1118 }
1119
1120 #[test]
1121 fn create_tables_multiple_independent_branches() {
1122 let users = simple_table("users");
1126 let posts = table_with_fk("posts", "users", "user_id", "id");
1127 let categories = simple_table("categories");
1128 let products = table_with_fk("products", "categories", "category_id", "id");
1129
1130 let plan = diff_schemas(
1131 &[],
1132 &[
1133 products.clone(),
1134 posts.clone(),
1135 categories.clone(),
1136 users.clone(),
1137 ],
1138 )
1139 .unwrap();
1140
1141 let create_order: Vec<&str> = plan
1142 .actions
1143 .iter()
1144 .filter_map(|a| {
1145 if let MigrationAction::CreateTable { table, .. } = a {
1146 Some(table.as_str())
1147 } else {
1148 None
1149 }
1150 })
1151 .collect();
1152
1153 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1155 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1156 assert!(
1157 users_pos < posts_pos,
1158 "users should be created before posts"
1159 );
1160
1161 let categories_pos = create_order
1163 .iter()
1164 .position(|&t| t == "categories")
1165 .unwrap();
1166 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1167 assert!(
1168 categories_pos < products_pos,
1169 "categories should be created before products"
1170 );
1171 }
1172
1173 #[test]
1174 fn delete_tables_respects_fk_order() {
1175 let users = simple_table("users");
1178 let posts = table_with_fk("posts", "users", "user_id", "id");
1179
1180 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1181
1182 let delete_order: Vec<&str> = plan
1183 .actions
1184 .iter()
1185 .filter_map(|a| {
1186 if let MigrationAction::DeleteTable { table } = a {
1187 Some(table.as_str())
1188 } else {
1189 None
1190 }
1191 })
1192 .collect();
1193
1194 assert_eq!(delete_order, vec!["posts", "users"]);
1195 }
1196
1197 #[test]
1198 fn delete_tables_chain_dependency() {
1199 let users = simple_table("users");
1202 let media = table_with_fk("media", "users", "owner_id", "id");
1203 let articles = table_with_fk("articles", "media", "media_id", "id");
1204
1205 let plan =
1206 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1207
1208 let delete_order: Vec<&str> = plan
1209 .actions
1210 .iter()
1211 .filter_map(|a| {
1212 if let MigrationAction::DeleteTable { table } = a {
1213 Some(table.as_str())
1214 } else {
1215 None
1216 }
1217 })
1218 .collect();
1219
1220 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1222 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1223 assert!(
1224 articles_pos < media_pos,
1225 "articles should be deleted before media"
1226 );
1227
1228 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1230 assert!(
1231 media_pos < users_pos,
1232 "media should be deleted before users"
1233 );
1234 }
1235
1236 #[test]
1237 fn circular_fk_dependency_returns_error() {
1238 let table_a = TableDef {
1240 name: "table_a".to_string(),
1241 columns: vec![
1242 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1243 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1244 ],
1245 constraints: vec![TableConstraint::ForeignKey {
1246 name: None,
1247 columns: vec!["b_id".to_string()],
1248 ref_table: "table_b".to_string(),
1249 ref_columns: vec!["id".to_string()],
1250 on_delete: None,
1251 on_update: None,
1252 }],
1253 indexes: vec![],
1254 };
1255
1256 let table_b = TableDef {
1257 name: "table_b".to_string(),
1258 columns: vec![
1259 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1260 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1261 ],
1262 constraints: vec![TableConstraint::ForeignKey {
1263 name: None,
1264 columns: vec!["a_id".to_string()],
1265 ref_table: "table_a".to_string(),
1266 ref_columns: vec!["id".to_string()],
1267 on_delete: None,
1268 on_update: None,
1269 }],
1270 indexes: vec![],
1271 };
1272
1273 let result = diff_schemas(&[], &[table_a, table_b]);
1274 assert!(result.is_err());
1275 if let Err(PlannerError::TableValidation(msg)) = result {
1276 assert!(
1277 msg.contains("Circular foreign key dependency"),
1278 "Expected circular dependency error, got: {}",
1279 msg
1280 );
1281 } else {
1282 panic!("Expected TableValidation error, got {:?}", result);
1283 }
1284 }
1285
1286 #[test]
1287 fn fk_to_external_table_is_ignored() {
1288 let posts = table_with_fk("posts", "users", "user_id", "id");
1290 let comments = table_with_fk("comments", "posts", "post_id", "id");
1291
1292 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1294
1295 let create_order: Vec<&str> = plan
1296 .actions
1297 .iter()
1298 .filter_map(|a| {
1299 if let MigrationAction::CreateTable { table, .. } = a {
1300 Some(table.as_str())
1301 } else {
1302 None
1303 }
1304 })
1305 .collect();
1306
1307 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1309 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1310 assert!(
1311 posts_pos < comments_pos,
1312 "posts should be created before comments"
1313 );
1314 }
1315
1316 #[test]
1317 fn delete_tables_mixed_with_other_actions() {
1318 use crate::diff::diff_schemas;
1321
1322 let from_schema = vec![
1323 table(
1324 "users",
1325 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1326 vec![],
1327 vec![],
1328 ),
1329 table(
1330 "posts",
1331 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1332 vec![],
1333 vec![],
1334 ),
1335 ];
1336
1337 let to_schema = vec![
1338 table(
1340 "users",
1341 vec![
1342 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1343 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1344 ],
1345 vec![],
1346 vec![],
1347 ),
1348 ];
1349
1350 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1351
1352 assert!(
1354 plan.actions
1355 .iter()
1356 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1357 );
1358 assert!(
1359 plan.actions
1360 .iter()
1361 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1362 );
1363
1364 }
1367
1368 #[test]
1369 #[should_panic(expected = "Expected DeleteTable action")]
1370 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1371 use super::extract_delete_table_name;
1373
1374 let action = MigrationAction::AddColumn {
1375 table: "users".into(),
1376 column: ColumnDef {
1377 name: "email".into(),
1378 r#type: ColumnType::Simple(SimpleColumnType::Text),
1379 nullable: true,
1380 default: None,
1381 comment: None,
1382 primary_key: None,
1383 unique: None,
1384 index: None,
1385 foreign_key: None,
1386 },
1387 fill_with: None,
1388 };
1389
1390 extract_delete_table_name(&action);
1392 }
1393 }
1394}