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