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
224fn compare_actions_for_create_order(
227 a: &MigrationAction,
228 b: &MigrationAction,
229 created_tables: &BTreeSet<String>,
230) -> std::cmp::Ordering {
231 let a_is_create = matches!(a, MigrationAction::CreateTable { .. });
232 let b_is_create = matches!(b, MigrationAction::CreateTable { .. });
233
234 let a_refs_created = if let MigrationAction::AddConstraint {
236 constraint: TableConstraint::ForeignKey { ref_table, .. },
237 ..
238 } = a
239 {
240 created_tables.contains(ref_table)
241 } else {
242 false
243 };
244 let b_refs_created = if let MigrationAction::AddConstraint {
245 constraint: TableConstraint::ForeignKey { ref_table, .. },
246 ..
247 } = b
248 {
249 created_tables.contains(ref_table)
250 } else {
251 false
252 };
253
254 match (a_is_create, b_is_create, a_refs_created, b_refs_created) {
256 (true, true, _, _) => std::cmp::Ordering::Equal,
258 (true, false, _, _) => std::cmp::Ordering::Less,
260 (false, true, _, _) => std::cmp::Ordering::Greater,
262 (false, false, true, false) => std::cmp::Ordering::Greater,
265 (false, false, false, true) => std::cmp::Ordering::Less,
267 (false, false, _, _) => std::cmp::Ordering::Equal,
269 }
270}
271
272fn sort_create_before_add_constraint(actions: &mut [MigrationAction]) {
275 let created_tables: BTreeSet<String> = actions
277 .iter()
278 .filter_map(|a| {
279 if let MigrationAction::CreateTable { table, .. } = a {
280 Some(table.clone())
281 } else {
282 None
283 }
284 })
285 .collect();
286
287 if created_tables.is_empty() {
288 return;
289 }
290
291 actions.sort_by(|a, b| compare_actions_for_create_order(a, b, &created_tables));
292}
293
294pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
298 let mut actions: Vec<MigrationAction> = Vec::new();
299
300 let from_normalized: Vec<TableDef> = from
302 .iter()
303 .map(|t| {
304 t.normalize().map_err(|e| {
305 PlannerError::TableValidation(format!(
306 "Failed to normalize table '{}': {}",
307 t.name, e
308 ))
309 })
310 })
311 .collect::<Result<Vec<_>, _>>()?;
312 let to_normalized: Vec<TableDef> = to
313 .iter()
314 .map(|t| {
315 t.normalize().map_err(|e| {
316 PlannerError::TableValidation(format!(
317 "Failed to normalize table '{}': {}",
318 t.name, e
319 ))
320 })
321 })
322 .collect::<Result<Vec<_>, _>>()?;
323
324 let from_map: BTreeMap<_, _> = from_normalized
327 .iter()
328 .map(|t| (t.name.as_str(), t))
329 .collect();
330 let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
331
332 let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
334
335 for name in from_map.keys() {
337 if !to_map.contains_key(name) {
338 actions.push(MigrationAction::DeleteTable {
339 table: name.to_string(),
340 });
341 }
342 }
343
344 for (name, to_tbl) in &to_map {
346 if let Some(from_tbl) = from_map.get(name) {
347 let from_cols: BTreeMap<_, _> = from_tbl
349 .columns
350 .iter()
351 .map(|c| (c.name.as_str(), c))
352 .collect();
353 let to_cols: BTreeMap<_, _> = to_tbl
354 .columns
355 .iter()
356 .map(|c| (c.name.as_str(), c))
357 .collect();
358
359 let deleted_columns: BTreeSet<&str> = from_cols
361 .keys()
362 .filter(|col| !to_cols.contains_key(*col))
363 .copied()
364 .collect();
365
366 for col in &deleted_columns {
367 actions.push(MigrationAction::DeleteColumn {
368 table: name.to_string(),
369 column: col.to_string(),
370 });
371 }
372
373 for (col, to_def) in &to_cols {
375 if let Some(from_def) = from_cols.get(col)
376 && from_def.r#type.requires_migration(&to_def.r#type)
377 {
378 actions.push(MigrationAction::ModifyColumnType {
379 table: name.to_string(),
380 column: col.to_string(),
381 new_type: to_def.r#type.clone(),
382 });
383 }
384 }
385
386 for (col, to_def) in &to_cols {
388 if let Some(from_def) = from_cols.get(col)
389 && from_def.nullable != to_def.nullable
390 {
391 actions.push(MigrationAction::ModifyColumnNullable {
392 table: name.to_string(),
393 column: col.to_string(),
394 nullable: to_def.nullable,
395 fill_with: None,
396 });
397 }
398 }
399
400 for (col, to_def) in &to_cols {
402 if let Some(from_def) = from_cols.get(col) {
403 let from_default = from_def.default.as_ref().map(|d| d.to_sql());
404 let to_default = to_def.default.as_ref().map(|d| d.to_sql());
405 if from_default != to_default {
406 actions.push(MigrationAction::ModifyColumnDefault {
407 table: name.to_string(),
408 column: col.to_string(),
409 new_default: to_default,
410 });
411 }
412 }
413 }
414
415 for (col, to_def) in &to_cols {
417 if let Some(from_def) = from_cols.get(col)
418 && from_def.comment != to_def.comment
419 {
420 actions.push(MigrationAction::ModifyColumnComment {
421 table: name.to_string(),
422 column: col.to_string(),
423 new_comment: to_def.comment.clone(),
424 });
425 }
426 }
427
428 for (col, def) in &to_cols {
432 if !from_cols.contains_key(col) {
433 actions.push(MigrationAction::AddColumn {
434 table: name.to_string(),
435 column: Box::new((*def).clone()),
436 fill_with: None,
437 });
438 }
439 }
440
441 for from_constraint in &from_tbl.constraints {
445 if !to_tbl.constraints.contains(from_constraint) {
446 let constraint_columns = from_constraint.columns();
448
449 let all_columns_deleted = !constraint_columns.is_empty()
451 && constraint_columns
452 .iter()
453 .all(|col| deleted_columns.contains(col.as_str()));
454
455 if !all_columns_deleted {
456 actions.push(MigrationAction::RemoveConstraint {
457 table: name.to_string(),
458 constraint: from_constraint.clone(),
459 });
460 }
461 }
462 }
463 for to_constraint in &to_tbl.constraints {
464 if !from_tbl.constraints.contains(to_constraint) {
465 actions.push(MigrationAction::AddConstraint {
466 table: name.to_string(),
467 constraint: to_constraint.clone(),
468 });
469 }
470 }
471 }
472 }
473
474 let new_tables: Vec<&TableDef> = to_map
478 .iter()
479 .filter(|(name, _)| !from_map.contains_key(*name))
480 .map(|(_, tbl)| *tbl)
481 .collect();
482
483 let sorted_new_tables = topological_sort_tables(&new_tables)?;
484
485 for tbl in sorted_new_tables {
486 let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
488 actions.push(MigrationAction::CreateTable {
489 table: original_tbl.name.clone(),
490 columns: original_tbl.columns.clone(),
491 constraints: original_tbl.constraints.clone(),
492 });
493 }
494
495 sort_delete_tables(&mut actions, &from_map);
497
498 sort_create_before_add_constraint(&mut actions);
500
501 Ok(MigrationPlan {
502 comment: None,
503 created_at: None,
504 version: 0,
505 actions,
506 })
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use rstest::rstest;
513 use vespertide_core::{
514 ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
515 schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
516 };
517
518 fn col(name: &str, ty: ColumnType) -> ColumnDef {
519 ColumnDef {
520 name: name.to_string(),
521 r#type: ty,
522 nullable: true,
523 default: None,
524 comment: None,
525 primary_key: None,
526 unique: None,
527 index: None,
528 foreign_key: None,
529 }
530 }
531
532 fn table(
533 name: &str,
534 columns: Vec<ColumnDef>,
535 constraints: Vec<vespertide_core::TableConstraint>,
536 ) -> TableDef {
537 TableDef {
538 name: name.to_string(),
539 description: None,
540 columns,
541 constraints,
542 }
543 }
544
545 fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
546 TableConstraint::Index {
547 name: Some(name.to_string()),
548 columns: columns.into_iter().map(|s| s.to_string()).collect(),
549 }
550 }
551
552 #[rstest]
553 #[case::add_column_and_index(
554 vec![table(
555 "users",
556 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
557 vec![],
558 )],
559 vec![table(
560 "users",
561 vec![
562 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
563 col("name", ColumnType::Simple(SimpleColumnType::Text)),
564 ],
565 vec![idx("ix_users__name", vec!["name"])],
566 )],
567 vec![
568 MigrationAction::AddColumn {
569 table: "users".into(),
570 column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
571 fill_with: None,
572 },
573 MigrationAction::AddConstraint {
574 table: "users".into(),
575 constraint: idx("ix_users__name", vec!["name"]),
576 },
577 ]
578 )]
579 #[case::drop_table(
580 vec![table(
581 "users",
582 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
583 vec![],
584 )],
585 vec![],
586 vec![MigrationAction::DeleteTable {
587 table: "users".into()
588 }]
589 )]
590 #[case::add_table_with_index(
591 vec![],
592 vec![table(
593 "users",
594 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
595 vec![idx("idx_users_id", vec!["id"])],
596 )],
597 vec![
598 MigrationAction::CreateTable {
599 table: "users".into(),
600 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
601 constraints: vec![idx("idx_users_id", vec!["id"])],
602 },
603 ]
604 )]
605 #[case::delete_column(
606 vec![table(
607 "users",
608 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
609 vec![],
610 )],
611 vec![table(
612 "users",
613 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
614 vec![],
615 )],
616 vec![MigrationAction::DeleteColumn {
617 table: "users".into(),
618 column: "name".into(),
619 }]
620 )]
621 #[case::modify_column_type(
622 vec![table(
623 "users",
624 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
625 vec![],
626 )],
627 vec![table(
628 "users",
629 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
630 vec![],
631 )],
632 vec![MigrationAction::ModifyColumnType {
633 table: "users".into(),
634 column: "id".into(),
635 new_type: ColumnType::Simple(SimpleColumnType::Text),
636 }]
637 )]
638 #[case::remove_index(
639 vec![table(
640 "users",
641 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
642 vec![idx("idx_users_id", vec!["id"])],
643 )],
644 vec![table(
645 "users",
646 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
647 vec![],
648 )],
649 vec![MigrationAction::RemoveConstraint {
650 table: "users".into(),
651 constraint: idx("idx_users_id", vec!["id"]),
652 }]
653 )]
654 #[case::add_index_existing_table(
655 vec![table(
656 "users",
657 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
658 vec![],
659 )],
660 vec![table(
661 "users",
662 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
663 vec![idx("idx_users_id", vec!["id"])],
664 )],
665 vec![MigrationAction::AddConstraint {
666 table: "users".into(),
667 constraint: idx("idx_users_id", vec!["id"]),
668 }]
669 )]
670 fn diff_schemas_detects_additions(
671 #[case] from_schema: Vec<TableDef>,
672 #[case] to_schema: Vec<TableDef>,
673 #[case] expected_actions: Vec<MigrationAction>,
674 ) {
675 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
676 assert_eq!(plan.actions, expected_actions);
677 }
678
679 mod integer_enum {
681 use super::*;
682 use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
683
684 #[test]
685 fn integer_enum_values_changed_no_migration() {
686 let from = vec![table(
688 "orders",
689 vec![col(
690 "status",
691 ColumnType::Complex(ComplexColumnType::Enum {
692 name: "order_status".into(),
693 values: EnumValues::Integer(vec![
694 NumValue {
695 name: "Pending".into(),
696 value: 0,
697 },
698 NumValue {
699 name: "Shipped".into(),
700 value: 1,
701 },
702 ]),
703 }),
704 )],
705 vec![],
706 )];
707
708 let to = vec![table(
709 "orders",
710 vec![col(
711 "status",
712 ColumnType::Complex(ComplexColumnType::Enum {
713 name: "order_status".into(),
714 values: EnumValues::Integer(vec![
715 NumValue {
716 name: "Pending".into(),
717 value: 0,
718 },
719 NumValue {
720 name: "Shipped".into(),
721 value: 1,
722 },
723 NumValue {
724 name: "Delivered".into(),
725 value: 2,
726 },
727 NumValue {
728 name: "Cancelled".into(),
729 value: 100,
730 },
731 ]),
732 }),
733 )],
734 vec![],
735 )];
736
737 let plan = diff_schemas(&from, &to).unwrap();
738 assert!(
739 plan.actions.is_empty(),
740 "Expected no actions, got: {:?}",
741 plan.actions
742 );
743 }
744
745 #[test]
746 fn string_enum_values_changed_requires_migration() {
747 let from = vec![table(
749 "orders",
750 vec![col(
751 "status",
752 ColumnType::Complex(ComplexColumnType::Enum {
753 name: "order_status".into(),
754 values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
755 }),
756 )],
757 vec![],
758 )];
759
760 let to = vec![table(
761 "orders",
762 vec![col(
763 "status",
764 ColumnType::Complex(ComplexColumnType::Enum {
765 name: "order_status".into(),
766 values: EnumValues::String(vec![
767 "pending".into(),
768 "shipped".into(),
769 "delivered".into(),
770 ]),
771 }),
772 )],
773 vec![],
774 )];
775
776 let plan = diff_schemas(&from, &to).unwrap();
777 assert_eq!(plan.actions.len(), 1);
778 assert!(matches!(
779 &plan.actions[0],
780 MigrationAction::ModifyColumnType { table, column, .. }
781 if table == "orders" && column == "status"
782 ));
783 }
784 }
785
786 mod inline_constraints {
788 use super::*;
789 use vespertide_core::schema::foreign_key::ForeignKeyDef;
790 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
791 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
792 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
793
794 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
795 ColumnDef {
796 name: name.to_string(),
797 r#type: ty,
798 nullable: false,
799 default: None,
800 comment: None,
801 primary_key: Some(PrimaryKeySyntax::Bool(true)),
802 unique: None,
803 index: None,
804 foreign_key: None,
805 }
806 }
807
808 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
809 ColumnDef {
810 name: name.to_string(),
811 r#type: ty,
812 nullable: true,
813 default: None,
814 comment: None,
815 primary_key: None,
816 unique: Some(StrOrBoolOrArray::Bool(true)),
817 index: None,
818 foreign_key: None,
819 }
820 }
821
822 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
823 ColumnDef {
824 name: name.to_string(),
825 r#type: ty,
826 nullable: true,
827 default: None,
828 comment: None,
829 primary_key: None,
830 unique: None,
831 index: Some(StrOrBoolOrArray::Bool(true)),
832 foreign_key: None,
833 }
834 }
835
836 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
837 ColumnDef {
838 name: name.to_string(),
839 r#type: ty,
840 nullable: true,
841 default: None,
842 comment: None,
843 primary_key: None,
844 unique: None,
845 index: None,
846 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
847 ref_table: ref_table.to_string(),
848 ref_columns: vec![ref_col.to_string()],
849 on_delete: None,
850 on_update: None,
851 })),
852 }
853 }
854
855 #[test]
856 fn create_table_with_inline_pk() {
857 let plan = diff_schemas(
858 &[],
859 &[table(
860 "users",
861 vec![
862 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
863 col("name", ColumnType::Simple(SimpleColumnType::Text)),
864 ],
865 vec![],
866 )],
867 )
868 .unwrap();
869
870 assert_eq!(plan.actions.len(), 1);
872 if let MigrationAction::CreateTable {
873 columns,
874 constraints,
875 ..
876 } = &plan.actions[0]
877 {
878 assert_eq!(constraints.len(), 0);
880 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
882 assert!(id_col.primary_key.is_some());
883 } else {
884 panic!("Expected CreateTable action");
885 }
886 }
887
888 #[test]
889 fn create_table_with_inline_unique() {
890 let plan = diff_schemas(
891 &[],
892 &[table(
893 "users",
894 vec![
895 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
896 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
897 ],
898 vec![],
899 )],
900 )
901 .unwrap();
902
903 assert_eq!(plan.actions.len(), 1);
905 if let MigrationAction::CreateTable {
906 columns,
907 constraints,
908 ..
909 } = &plan.actions[0]
910 {
911 assert_eq!(constraints.len(), 0);
913 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
915 assert!(matches!(
916 email_col.unique,
917 Some(StrOrBoolOrArray::Bool(true))
918 ));
919 } else {
920 panic!("Expected CreateTable action");
921 }
922 }
923
924 #[test]
925 fn create_table_with_inline_index() {
926 let plan = diff_schemas(
927 &[],
928 &[table(
929 "users",
930 vec![
931 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
932 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
933 ],
934 vec![],
935 )],
936 )
937 .unwrap();
938
939 assert_eq!(plan.actions.len(), 1);
941 if let MigrationAction::CreateTable {
942 columns,
943 constraints,
944 ..
945 } = &plan.actions[0]
946 {
947 assert_eq!(constraints.len(), 0);
949 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
951 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
952 } else {
953 panic!("Expected CreateTable action");
954 }
955 }
956
957 #[test]
958 fn create_table_with_inline_fk() {
959 let plan = diff_schemas(
960 &[],
961 &[table(
962 "posts",
963 vec![
964 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
965 col_with_fk(
966 "user_id",
967 ColumnType::Simple(SimpleColumnType::Integer),
968 "users",
969 "id",
970 ),
971 ],
972 vec![],
973 )],
974 )
975 .unwrap();
976
977 assert_eq!(plan.actions.len(), 1);
979 if let MigrationAction::CreateTable {
980 columns,
981 constraints,
982 ..
983 } = &plan.actions[0]
984 {
985 assert_eq!(constraints.len(), 0);
987 let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
989 assert!(user_id_col.foreign_key.is_some());
990 } else {
991 panic!("Expected CreateTable action");
992 }
993 }
994
995 #[test]
996 fn add_index_via_inline_constraint() {
997 let plan = diff_schemas(
1000 &[table(
1001 "users",
1002 vec![
1003 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1004 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1005 ],
1006 vec![],
1007 )],
1008 &[table(
1009 "users",
1010 vec![
1011 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1012 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
1013 ],
1014 vec![],
1015 )],
1016 )
1017 .unwrap();
1018
1019 assert_eq!(plan.actions.len(), 1);
1021 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1022 assert_eq!(table, "users");
1023 if let TableConstraint::Index { name, columns } = constraint {
1024 assert_eq!(name, &None); assert_eq!(columns, &vec!["name".to_string()]);
1026 } else {
1027 panic!("Expected Index constraint, got {:?}", constraint);
1028 }
1029 } else {
1030 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1031 }
1032 }
1033
1034 #[test]
1035 fn create_table_with_all_inline_constraints() {
1036 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
1037 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
1038 id_col.nullable = false;
1039
1040 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
1041 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
1042
1043 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
1044 name_col.index = Some(StrOrBoolOrArray::Bool(true));
1045
1046 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
1047 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
1048 ref_table: "orgs".into(),
1049 ref_columns: vec!["id".into()],
1050 on_delete: None,
1051 on_update: None,
1052 }));
1053
1054 let plan = diff_schemas(
1055 &[],
1056 &[table(
1057 "users",
1058 vec![id_col, email_col, name_col, org_id_col],
1059 vec![],
1060 )],
1061 )
1062 .unwrap();
1063
1064 assert_eq!(plan.actions.len(), 1);
1066
1067 if let MigrationAction::CreateTable {
1068 columns,
1069 constraints,
1070 ..
1071 } = &plan.actions[0]
1072 {
1073 assert_eq!(constraints.len(), 0);
1075
1076 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
1078 assert!(id_col.primary_key.is_some());
1079
1080 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
1081 assert!(matches!(
1082 email_col.unique,
1083 Some(StrOrBoolOrArray::Bool(true))
1084 ));
1085
1086 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
1087 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
1088
1089 let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
1090 assert!(org_id_col.foreign_key.is_some());
1091 } else {
1092 panic!("Expected CreateTable action");
1093 }
1094 }
1095
1096 #[test]
1097 fn add_constraint_to_existing_table() {
1098 let from_schema = vec![table(
1100 "users",
1101 vec![
1102 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1103 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1104 ],
1105 vec![],
1106 )];
1107
1108 let to_schema = vec![table(
1109 "users",
1110 vec![
1111 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1112 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1113 ],
1114 vec![vespertide_core::TableConstraint::Unique {
1115 name: Some("uq_users_email".into()),
1116 columns: vec!["email".into()],
1117 }],
1118 )];
1119
1120 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1121 assert_eq!(plan.actions.len(), 1);
1122 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1123 assert_eq!(table, "users");
1124 assert!(matches!(
1125 constraint,
1126 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1127 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1128 ));
1129 } else {
1130 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1131 }
1132 }
1133
1134 #[test]
1135 fn remove_constraint_from_existing_table() {
1136 let from_schema = vec![table(
1138 "users",
1139 vec![
1140 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1141 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1142 ],
1143 vec![vespertide_core::TableConstraint::Unique {
1144 name: Some("uq_users_email".into()),
1145 columns: vec!["email".into()],
1146 }],
1147 )];
1148
1149 let to_schema = vec![table(
1150 "users",
1151 vec![
1152 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1153 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1154 ],
1155 vec![],
1156 )];
1157
1158 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1159 assert_eq!(plan.actions.len(), 1);
1160 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1161 assert_eq!(table, "users");
1162 assert!(matches!(
1163 constraint,
1164 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1165 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1166 ));
1167 } else {
1168 panic!(
1169 "Expected RemoveConstraint action, got {:?}",
1170 plan.actions[0]
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn diff_schemas_with_normalize_error() {
1177 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1179 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1180
1181 let table = TableDef {
1182 name: "test".into(),
1183 description: None,
1184 columns: vec![
1185 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1186 col1.clone(),
1187 {
1188 let mut c = col1.clone();
1190 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1191 c
1192 },
1193 ],
1194 constraints: vec![],
1195 };
1196
1197 let result = diff_schemas(&[], &[table]);
1198 assert!(result.is_err());
1199 if let Err(PlannerError::TableValidation(msg)) = result {
1200 assert!(msg.contains("Failed to normalize table"));
1201 assert!(msg.contains("Duplicate index"));
1202 } else {
1203 panic!("Expected TableValidation error, got {:?}", result);
1204 }
1205 }
1206
1207 #[test]
1208 fn diff_schemas_with_normalize_error_in_from_schema() {
1209 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1211 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1212
1213 let table = TableDef {
1214 name: "test".into(),
1215 description: None,
1216 columns: vec![
1217 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1218 col1.clone(),
1219 {
1220 let mut c = col1.clone();
1222 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1223 c
1224 },
1225 ],
1226 constraints: vec![],
1227 };
1228
1229 let result = diff_schemas(&[table], &[]);
1231 assert!(result.is_err());
1232 if let Err(PlannerError::TableValidation(msg)) = result {
1233 assert!(msg.contains("Failed to normalize table"));
1234 assert!(msg.contains("Duplicate index"));
1235 } else {
1236 panic!("Expected TableValidation error, got {:?}", result);
1237 }
1238 }
1239 }
1240
1241 mod sort_create_before_add_constraint_tests {
1243 use super::*;
1244 use crate::diff::{compare_actions_for_create_order, sort_create_before_add_constraint};
1245 use std::cmp::Ordering;
1246
1247 fn make_add_column(table: &str, col: &str) -> MigrationAction {
1248 MigrationAction::AddColumn {
1249 table: table.into(),
1250 column: Box::new(ColumnDef {
1251 name: col.into(),
1252 r#type: ColumnType::Simple(SimpleColumnType::Text),
1253 nullable: true,
1254 default: None,
1255 comment: None,
1256 primary_key: None,
1257 unique: None,
1258 index: None,
1259 foreign_key: None,
1260 }),
1261 fill_with: None,
1262 }
1263 }
1264
1265 fn make_create_table(name: &str) -> MigrationAction {
1266 MigrationAction::CreateTable {
1267 table: name.into(),
1268 columns: vec![],
1269 constraints: vec![],
1270 }
1271 }
1272
1273 fn make_add_fk(table: &str, ref_table: &str) -> MigrationAction {
1274 MigrationAction::AddConstraint {
1275 table: table.into(),
1276 constraint: TableConstraint::ForeignKey {
1277 name: None,
1278 columns: vec!["fk_col".into()],
1279 ref_table: ref_table.into(),
1280 ref_columns: vec!["id".into()],
1281 on_delete: None,
1282 on_update: None,
1283 },
1284 }
1285 }
1286
1287 #[test]
1290 fn test_compare_non_create_vs_create() {
1291 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1292
1293 let add_col = make_add_column("users", "name");
1294 let create_table = make_create_table("roles");
1295
1296 let result = compare_actions_for_create_order(&add_col, &create_table, &created_tables);
1298 assert_eq!(
1299 result,
1300 Ordering::Greater,
1301 "Non-CreateTable vs CreateTable should return Greater"
1302 );
1303 }
1304
1305 #[test]
1307 fn test_compare_create_vs_non_create() {
1308 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1309
1310 let create_table = make_create_table("roles");
1311 let add_col = make_add_column("users", "name");
1312
1313 let result = compare_actions_for_create_order(&create_table, &add_col, &created_tables);
1315 assert_eq!(
1316 result,
1317 Ordering::Less,
1318 "CreateTable vs Non-CreateTable should return Less"
1319 );
1320 }
1321
1322 #[test]
1324 fn test_compare_create_vs_create() {
1325 let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1326 .into_iter()
1327 .collect();
1328
1329 let create1 = make_create_table("roles");
1330 let create2 = make_create_table("categories");
1331
1332 let result = compare_actions_for_create_order(&create1, &create2, &created_tables);
1334 assert_eq!(
1335 result,
1336 Ordering::Equal,
1337 "CreateTable vs CreateTable should return Equal"
1338 );
1339 }
1340
1341 #[test]
1343 fn test_compare_refs_vs_non_refs() {
1344 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1345
1346 let add_fk = make_add_fk("users", "roles"); let add_col = make_add_column("posts", "title"); let result = compare_actions_for_create_order(&add_fk, &add_col, &created_tables);
1351 assert_eq!(
1352 result,
1353 Ordering::Greater,
1354 "FK-ref vs non-ref should return Greater"
1355 );
1356 }
1357
1358 #[test]
1360 fn test_compare_non_refs_vs_refs() {
1361 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1362
1363 let add_col = make_add_column("posts", "title"); let add_fk = make_add_fk("users", "roles"); let result = compare_actions_for_create_order(&add_col, &add_fk, &created_tables);
1368 assert_eq!(
1369 result,
1370 Ordering::Less,
1371 "Non-ref vs FK-ref should return Less"
1372 );
1373 }
1374
1375 #[test]
1377 fn test_compare_non_refs_vs_non_refs() {
1378 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1379
1380 let add_col1 = make_add_column("users", "name");
1381 let add_col2 = make_add_column("posts", "title");
1382
1383 let result = compare_actions_for_create_order(&add_col1, &add_col2, &created_tables);
1385 assert_eq!(
1386 result,
1387 Ordering::Equal,
1388 "Non-ref vs non-ref should return Equal"
1389 );
1390 }
1391
1392 #[test]
1394 fn test_compare_refs_vs_refs() {
1395 let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1396 .into_iter()
1397 .collect();
1398
1399 let add_fk1 = make_add_fk("users", "roles");
1400 let add_fk2 = make_add_fk("posts", "categories");
1401
1402 let result = compare_actions_for_create_order(&add_fk1, &add_fk2, &created_tables);
1404 assert_eq!(
1405 result,
1406 Ordering::Equal,
1407 "FK-ref vs FK-ref should return Equal"
1408 );
1409 }
1410
1411 #[test]
1413 fn test_sort_integration() {
1414 let mut actions = vec![
1415 make_add_column("t1", "c1"),
1416 make_add_fk("users", "roles"),
1417 make_create_table("roles"),
1418 ];
1419
1420 sort_create_before_add_constraint(&mut actions);
1421
1422 assert!(matches!(&actions[0], MigrationAction::CreateTable { .. }));
1424 assert!(matches!(&actions[1], MigrationAction::AddColumn { .. }));
1425 assert!(matches!(&actions[2], MigrationAction::AddConstraint { .. }));
1426 }
1427 }
1428
1429 mod fk_ordering {
1431 use super::*;
1432 use vespertide_core::TableConstraint;
1433
1434 fn table_with_fk(
1435 name: &str,
1436 ref_table: &str,
1437 fk_column: &str,
1438 ref_column: &str,
1439 ) -> TableDef {
1440 TableDef {
1441 name: name.to_string(),
1442 description: None,
1443 columns: vec![
1444 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1445 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1446 ],
1447 constraints: vec![TableConstraint::ForeignKey {
1448 name: None,
1449 columns: vec![fk_column.to_string()],
1450 ref_table: ref_table.to_string(),
1451 ref_columns: vec![ref_column.to_string()],
1452 on_delete: None,
1453 on_update: None,
1454 }],
1455 }
1456 }
1457
1458 fn simple_table(name: &str) -> TableDef {
1459 TableDef {
1460 name: name.to_string(),
1461 description: None,
1462 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1463 constraints: vec![],
1464 }
1465 }
1466
1467 #[test]
1468 fn create_tables_respects_fk_order() {
1469 let users = simple_table("users");
1472 let posts = table_with_fk("posts", "users", "user_id", "id");
1473
1474 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1475
1476 let create_order: Vec<&str> = plan
1478 .actions
1479 .iter()
1480 .filter_map(|a| {
1481 if let MigrationAction::CreateTable { table, .. } = a {
1482 Some(table.as_str())
1483 } else {
1484 None
1485 }
1486 })
1487 .collect();
1488
1489 assert_eq!(create_order, vec!["users", "posts"]);
1490 }
1491
1492 #[test]
1493 fn create_tables_chain_dependency() {
1494 let users = simple_table("users");
1499 let media = table_with_fk("media", "users", "owner_id", "id");
1500 let articles = table_with_fk("articles", "media", "media_id", "id");
1501
1502 let plan =
1504 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1505
1506 let create_order: Vec<&str> = plan
1507 .actions
1508 .iter()
1509 .filter_map(|a| {
1510 if let MigrationAction::CreateTable { table, .. } = a {
1511 Some(table.as_str())
1512 } else {
1513 None
1514 }
1515 })
1516 .collect();
1517
1518 assert_eq!(create_order, vec!["users", "media", "articles"]);
1519 }
1520
1521 #[test]
1522 fn create_tables_multiple_independent_branches() {
1523 let users = simple_table("users");
1527 let posts = table_with_fk("posts", "users", "user_id", "id");
1528 let categories = simple_table("categories");
1529 let products = table_with_fk("products", "categories", "category_id", "id");
1530
1531 let plan = diff_schemas(
1532 &[],
1533 &[
1534 products.clone(),
1535 posts.clone(),
1536 categories.clone(),
1537 users.clone(),
1538 ],
1539 )
1540 .unwrap();
1541
1542 let create_order: Vec<&str> = plan
1543 .actions
1544 .iter()
1545 .filter_map(|a| {
1546 if let MigrationAction::CreateTable { table, .. } = a {
1547 Some(table.as_str())
1548 } else {
1549 None
1550 }
1551 })
1552 .collect();
1553
1554 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1556 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1557 assert!(
1558 users_pos < posts_pos,
1559 "users should be created before posts"
1560 );
1561
1562 let categories_pos = create_order
1564 .iter()
1565 .position(|&t| t == "categories")
1566 .unwrap();
1567 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1568 assert!(
1569 categories_pos < products_pos,
1570 "categories should be created before products"
1571 );
1572 }
1573
1574 #[test]
1575 fn delete_tables_respects_fk_order() {
1576 let users = simple_table("users");
1579 let posts = table_with_fk("posts", "users", "user_id", "id");
1580
1581 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1582
1583 let delete_order: Vec<&str> = plan
1584 .actions
1585 .iter()
1586 .filter_map(|a| {
1587 if let MigrationAction::DeleteTable { table } = a {
1588 Some(table.as_str())
1589 } else {
1590 None
1591 }
1592 })
1593 .collect();
1594
1595 assert_eq!(delete_order, vec!["posts", "users"]);
1596 }
1597
1598 #[test]
1599 fn delete_tables_chain_dependency() {
1600 let users = simple_table("users");
1603 let media = table_with_fk("media", "users", "owner_id", "id");
1604 let articles = table_with_fk("articles", "media", "media_id", "id");
1605
1606 let plan =
1607 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1608
1609 let delete_order: Vec<&str> = plan
1610 .actions
1611 .iter()
1612 .filter_map(|a| {
1613 if let MigrationAction::DeleteTable { table } = a {
1614 Some(table.as_str())
1615 } else {
1616 None
1617 }
1618 })
1619 .collect();
1620
1621 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1623 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1624 assert!(
1625 articles_pos < media_pos,
1626 "articles should be deleted before media"
1627 );
1628
1629 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1631 assert!(
1632 media_pos < users_pos,
1633 "media should be deleted before users"
1634 );
1635 }
1636
1637 #[test]
1638 fn circular_fk_dependency_returns_error() {
1639 let table_a = TableDef {
1641 name: "table_a".to_string(),
1642 description: None,
1643 columns: vec![
1644 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1645 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1646 ],
1647 constraints: vec![TableConstraint::ForeignKey {
1648 name: None,
1649 columns: vec!["b_id".to_string()],
1650 ref_table: "table_b".to_string(),
1651 ref_columns: vec!["id".to_string()],
1652 on_delete: None,
1653 on_update: None,
1654 }],
1655 };
1656
1657 let table_b = TableDef {
1658 name: "table_b".to_string(),
1659 description: None,
1660 columns: vec![
1661 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1662 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1663 ],
1664 constraints: vec![TableConstraint::ForeignKey {
1665 name: None,
1666 columns: vec!["a_id".to_string()],
1667 ref_table: "table_a".to_string(),
1668 ref_columns: vec!["id".to_string()],
1669 on_delete: None,
1670 on_update: None,
1671 }],
1672 };
1673
1674 let result = diff_schemas(&[], &[table_a, table_b]);
1675 assert!(result.is_err());
1676 if let Err(PlannerError::TableValidation(msg)) = result {
1677 assert!(
1678 msg.contains("Circular foreign key dependency"),
1679 "Expected circular dependency error, got: {}",
1680 msg
1681 );
1682 } else {
1683 panic!("Expected TableValidation error, got {:?}", result);
1684 }
1685 }
1686
1687 #[test]
1688 fn fk_to_external_table_is_ignored() {
1689 let posts = table_with_fk("posts", "users", "user_id", "id");
1691 let comments = table_with_fk("comments", "posts", "post_id", "id");
1692
1693 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1695
1696 let create_order: Vec<&str> = plan
1697 .actions
1698 .iter()
1699 .filter_map(|a| {
1700 if let MigrationAction::CreateTable { table, .. } = a {
1701 Some(table.as_str())
1702 } else {
1703 None
1704 }
1705 })
1706 .collect();
1707
1708 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1710 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1711 assert!(
1712 posts_pos < comments_pos,
1713 "posts should be created before comments"
1714 );
1715 }
1716
1717 #[test]
1718 fn delete_tables_mixed_with_other_actions() {
1719 use crate::diff::diff_schemas;
1722
1723 let from_schema = vec![
1724 table(
1725 "users",
1726 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1727 vec![],
1728 ),
1729 table(
1730 "posts",
1731 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1732 vec![],
1733 ),
1734 ];
1735
1736 let to_schema = vec![
1737 table(
1739 "users",
1740 vec![
1741 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1742 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1743 ],
1744 vec![],
1745 ),
1746 ];
1747
1748 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1749
1750 assert!(
1752 plan.actions
1753 .iter()
1754 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1755 );
1756 assert!(
1757 plan.actions
1758 .iter()
1759 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1760 );
1761
1762 }
1765
1766 #[test]
1767 #[should_panic(expected = "Expected DeleteTable action")]
1768 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1769 use super::extract_delete_table_name;
1771
1772 let action = MigrationAction::AddColumn {
1773 table: "users".into(),
1774 column: Box::new(ColumnDef {
1775 name: "email".into(),
1776 r#type: ColumnType::Simple(SimpleColumnType::Text),
1777 nullable: true,
1778 default: None,
1779 comment: None,
1780 primary_key: None,
1781 unique: None,
1782 index: None,
1783 foreign_key: None,
1784 }),
1785 fill_with: None,
1786 };
1787
1788 extract_delete_table_name(&action);
1790 }
1791
1792 #[test]
1794 fn create_tables_with_inline_fk_chain() {
1795 use super::*;
1796 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1797 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1798
1799 fn col_pk(name: &str) -> ColumnDef {
1800 ColumnDef {
1801 name: name.to_string(),
1802 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1803 nullable: false,
1804 default: None,
1805 comment: None,
1806 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1807 unique: None,
1808 index: None,
1809 foreign_key: None,
1810 }
1811 }
1812
1813 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1814 ColumnDef {
1815 name: name.to_string(),
1816 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1817 nullable: true,
1818 default: None,
1819 comment: None,
1820 primary_key: None,
1821 unique: None,
1822 index: None,
1823 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1824 }
1825 }
1826
1827 let user = TableDef {
1836 name: "user".to_string(),
1837 description: None,
1838 columns: vec![col_pk("id")],
1839 constraints: vec![],
1840 };
1841
1842 let product = TableDef {
1843 name: "product".to_string(),
1844 description: None,
1845 columns: vec![col_pk("id")],
1846 constraints: vec![],
1847 };
1848
1849 let project = TableDef {
1850 name: "project".to_string(),
1851 description: None,
1852 columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1853 constraints: vec![],
1854 };
1855
1856 let code = TableDef {
1857 name: "code".to_string(),
1858 description: None,
1859 columns: vec![
1860 col_pk("id"),
1861 col_inline_fk("product_id", "product"),
1862 col_inline_fk("creator_user_id", "user"),
1863 col_inline_fk("project_id", "project"),
1864 ],
1865 constraints: vec![],
1866 };
1867
1868 let order = TableDef {
1869 name: "order".to_string(),
1870 description: None,
1871 columns: vec![
1872 col_pk("id"),
1873 col_inline_fk("user_id", "user"),
1874 col_inline_fk("project_id", "project"),
1875 col_inline_fk("product_id", "product"),
1876 col_inline_fk("code_id", "code"),
1877 ],
1878 constraints: vec![],
1879 };
1880
1881 let payment = TableDef {
1882 name: "payment".to_string(),
1883 description: None,
1884 columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1885 constraints: vec![],
1886 };
1887
1888 let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1890 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1891
1892 let plan = result.unwrap();
1893 let create_order: Vec<&str> = plan
1894 .actions
1895 .iter()
1896 .filter_map(|a| {
1897 if let MigrationAction::CreateTable { table, .. } = a {
1898 Some(table.as_str())
1899 } else {
1900 None
1901 }
1902 })
1903 .collect();
1904
1905 let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1907
1908 assert!(
1911 get_pos("user") < get_pos("project"),
1912 "user must come before project"
1913 );
1914 assert!(
1916 get_pos("product") < get_pos("code"),
1917 "product must come before code"
1918 );
1919 assert!(
1920 get_pos("user") < get_pos("code"),
1921 "user must come before code"
1922 );
1923 assert!(
1924 get_pos("project") < get_pos("code"),
1925 "project must come before code"
1926 );
1927 assert!(
1929 get_pos("code") < get_pos("order"),
1930 "code must come before order"
1931 );
1932 assert!(
1934 get_pos("order") < get_pos("payment"),
1935 "order must come before payment"
1936 );
1937 }
1938
1939 #[test]
1941 fn add_constraint_fk_to_new_table_comes_after_create_table() {
1942 use super::*;
1943
1944 let notification_from = table(
1946 "notification",
1947 vec![
1948 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1949 col(
1950 "broadcast_id",
1951 ColumnType::Simple(SimpleColumnType::Integer),
1952 ),
1953 ],
1954 vec![],
1955 );
1956
1957 let notification_broadcast = table(
1959 "notification_broadcast",
1960 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1961 vec![],
1962 );
1963
1964 let notification_to = table(
1966 "notification",
1967 vec![
1968 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1969 col(
1970 "broadcast_id",
1971 ColumnType::Simple(SimpleColumnType::Integer),
1972 ),
1973 ],
1974 vec![TableConstraint::ForeignKey {
1975 name: None,
1976 columns: vec!["broadcast_id".into()],
1977 ref_table: "notification_broadcast".into(),
1978 ref_columns: vec!["id".into()],
1979 on_delete: None,
1980 on_update: None,
1981 }],
1982 );
1983
1984 let from_schema = vec![notification_from];
1985 let to_schema = vec![notification_to, notification_broadcast];
1986
1987 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1988
1989 let create_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "notification_broadcast"));
1991 let add_constraint_pos = plan.actions.iter().position(|a| {
1992 matches!(a, MigrationAction::AddConstraint {
1993 constraint: TableConstraint::ForeignKey { ref_table, .. }, ..
1994 } if ref_table == "notification_broadcast")
1995 });
1996
1997 assert!(
1998 create_pos.is_some(),
1999 "Should have CreateTable for notification_broadcast"
2000 );
2001 assert!(
2002 add_constraint_pos.is_some(),
2003 "Should have AddConstraint for FK to notification_broadcast"
2004 );
2005 assert!(
2006 create_pos.unwrap() < add_constraint_pos.unwrap(),
2007 "CreateTable must come BEFORE AddConstraint FK that references it. Got CreateTable at {}, AddConstraint at {}",
2008 create_pos.unwrap(),
2009 add_constraint_pos.unwrap()
2010 );
2011 }
2012
2013 #[test]
2016 fn sort_create_before_add_constraint_all_branches() {
2017 use super::*;
2018
2019 let users_from = table(
2028 "users",
2029 vec![
2030 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2031 {
2032 let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2033 c.comment = Some("Old comment".into());
2034 c
2035 },
2036 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2037 ],
2038 vec![],
2039 );
2040
2041 let posts_from = table(
2042 "posts",
2043 vec![
2044 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2045 col("title", ColumnType::Simple(SimpleColumnType::Text)),
2046 ],
2047 vec![],
2048 );
2049
2050 let roles = table(
2052 "roles",
2053 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2054 vec![],
2055 );
2056
2057 let users_to = table(
2059 "users",
2060 vec![
2061 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2062 {
2063 let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2064 c.comment = Some("New comment".into());
2065 c
2066 },
2067 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2068 ],
2069 vec![TableConstraint::ForeignKey {
2070 name: None,
2071 columns: vec!["role_id".into()],
2072 ref_table: "roles".into(),
2073 ref_columns: vec!["id".into()],
2074 on_delete: None,
2075 on_update: None,
2076 }],
2077 );
2078
2079 let posts_to = table(
2081 "posts",
2082 vec![
2083 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2084 col("title", ColumnType::Simple(SimpleColumnType::Text)),
2085 ],
2086 vec![TableConstraint::Index {
2087 name: Some("idx_title".into()),
2088 columns: vec!["title".into()],
2089 }],
2090 );
2091
2092 let from_schema = vec![users_from, posts_from];
2093 let to_schema = vec![users_to, posts_to, roles];
2094
2095 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2096
2097 let create_pos = plan
2099 .actions
2100 .iter()
2101 .position(
2102 |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2103 )
2104 .expect("Should have CreateTable for roles");
2105
2106 let modify_pos = plan
2108 .actions
2109 .iter()
2110 .position(|a| matches!(a, MigrationAction::ModifyColumnComment { .. }))
2111 .expect("Should have ModifyColumnComment");
2112
2113 let add_index_pos = plan
2115 .actions
2116 .iter()
2117 .position(|a| {
2118 matches!(
2119 a,
2120 MigrationAction::AddConstraint {
2121 constraint: TableConstraint::Index { .. },
2122 ..
2123 }
2124 )
2125 })
2126 .expect("Should have AddConstraint Index");
2127
2128 let add_fk_pos = plan
2130 .actions
2131 .iter()
2132 .position(|a| {
2133 matches!(
2134 a,
2135 MigrationAction::AddConstraint {
2136 constraint: TableConstraint::ForeignKey { ref_table, .. },
2137 ..
2138 } if ref_table == "roles"
2139 )
2140 })
2141 .expect("Should have AddConstraint FK to roles");
2142
2143 assert!(
2144 create_pos < modify_pos,
2145 "CreateTable must come before ModifyColumnComment"
2146 );
2147 assert!(
2148 create_pos < add_index_pos,
2149 "CreateTable must come before AddConstraint Index"
2150 );
2151 assert!(
2152 create_pos < add_fk_pos,
2153 "CreateTable must come before AddConstraint FK"
2154 );
2155 assert!(
2157 add_index_pos < add_fk_pos,
2158 "AddConstraint Index (not referencing created) should come before AddConstraint FK (referencing created)"
2159 );
2160 }
2161
2162 #[test]
2165 fn sort_multiple_fks_to_created_tables() {
2166 use super::*;
2167
2168 let users_from = table(
2170 "users",
2171 vec![
2172 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2173 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2174 ],
2175 vec![],
2176 );
2177
2178 let posts_from = table(
2179 "posts",
2180 vec![
2181 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2182 col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2183 ],
2184 vec![],
2185 );
2186
2187 let roles = table(
2189 "roles",
2190 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2191 vec![],
2192 );
2193 let categories = table(
2194 "categories",
2195 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2196 vec![],
2197 );
2198
2199 let users_to = table(
2200 "users",
2201 vec![
2202 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2203 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2204 ],
2205 vec![TableConstraint::ForeignKey {
2206 name: None,
2207 columns: vec!["role_id".into()],
2208 ref_table: "roles".into(),
2209 ref_columns: vec!["id".into()],
2210 on_delete: None,
2211 on_update: None,
2212 }],
2213 );
2214
2215 let posts_to = table(
2216 "posts",
2217 vec![
2218 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2219 col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2220 ],
2221 vec![TableConstraint::ForeignKey {
2222 name: None,
2223 columns: vec!["category_id".into()],
2224 ref_table: "categories".into(),
2225 ref_columns: vec!["id".into()],
2226 on_delete: None,
2227 on_update: None,
2228 }],
2229 );
2230
2231 let from_schema = vec![users_from, posts_from];
2232 let to_schema = vec![users_to, posts_to, roles, categories];
2233
2234 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2235
2236 let create_roles_pos = plan.actions.iter().position(
2238 |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2239 );
2240 let create_categories_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "categories"));
2241 let add_fk_roles_pos = plan.actions.iter().position(|a| {
2242 matches!(
2243 a,
2244 MigrationAction::AddConstraint {
2245 constraint: TableConstraint::ForeignKey { ref_table, .. },
2246 ..
2247 } if ref_table == "roles"
2248 )
2249 });
2250 let add_fk_categories_pos = plan.actions.iter().position(|a| {
2251 matches!(
2252 a,
2253 MigrationAction::AddConstraint {
2254 constraint: TableConstraint::ForeignKey { ref_table, .. },
2255 ..
2256 } if ref_table == "categories"
2257 )
2258 });
2259
2260 assert!(create_roles_pos.is_some());
2261 assert!(create_categories_pos.is_some());
2262 assert!(add_fk_roles_pos.is_some());
2263 assert!(add_fk_categories_pos.is_some());
2264
2265 let max_create = create_roles_pos
2267 .unwrap()
2268 .max(create_categories_pos.unwrap());
2269 let min_add_fk = add_fk_roles_pos
2270 .unwrap()
2271 .min(add_fk_categories_pos.unwrap());
2272 assert!(
2273 max_create < min_add_fk,
2274 "All CreateTable actions must come before all AddConstraint FK actions"
2275 );
2276 }
2277
2278 #[test]
2280 fn create_tables_with_duplicate_fk_references() {
2281 use super::*;
2282 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
2283 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
2284
2285 fn col_pk(name: &str) -> ColumnDef {
2286 ColumnDef {
2287 name: name.to_string(),
2288 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2289 nullable: false,
2290 default: None,
2291 comment: None,
2292 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2293 unique: None,
2294 index: None,
2295 foreign_key: None,
2296 }
2297 }
2298
2299 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
2300 ColumnDef {
2301 name: name.to_string(),
2302 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2303 nullable: true,
2304 default: None,
2305 comment: None,
2306 primary_key: None,
2307 unique: None,
2308 index: None,
2309 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
2310 }
2311 }
2312
2313 let user = TableDef {
2315 name: "user".to_string(),
2316 description: None,
2317 columns: vec![col_pk("id")],
2318 constraints: vec![],
2319 };
2320
2321 let code = TableDef {
2322 name: "code".to_string(),
2323 description: None,
2324 columns: vec![
2325 col_pk("id"),
2326 col_inline_fk("creator_user_id", "user"),
2327 col_inline_fk("used_by_user_id", "user"), ],
2329 constraints: vec![],
2330 };
2331
2332 let result = diff_schemas(&[], &[code, user]);
2334 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
2335
2336 let plan = result.unwrap();
2337 let create_order: Vec<&str> = plan
2338 .actions
2339 .iter()
2340 .filter_map(|a| {
2341 if let MigrationAction::CreateTable { table, .. } = a {
2342 Some(table.as_str())
2343 } else {
2344 None
2345 }
2346 })
2347 .collect();
2348
2349 let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
2351 let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
2352 assert!(user_pos < code_pos, "user must come before code");
2353 }
2354 }
2355
2356 mod primary_key_changes {
2357 use super::*;
2358
2359 fn pk(columns: Vec<&str>) -> TableConstraint {
2360 TableConstraint::PrimaryKey {
2361 auto_increment: false,
2362 columns: columns.into_iter().map(|s| s.to_string()).collect(),
2363 }
2364 }
2365
2366 #[test]
2367 fn add_column_to_composite_pk() {
2368 let from = vec![table(
2370 "users",
2371 vec![
2372 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2373 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2374 ],
2375 vec![pk(vec!["id"])],
2376 )];
2377
2378 let to = vec![table(
2379 "users",
2380 vec![
2381 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2382 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2383 ],
2384 vec![pk(vec!["id", "tenant_id"])],
2385 )];
2386
2387 let plan = diff_schemas(&from, &to).unwrap();
2388
2389 assert_eq!(plan.actions.len(), 2);
2391
2392 let has_remove = plan.actions.iter().any(|a| {
2393 matches!(
2394 a,
2395 MigrationAction::RemoveConstraint {
2396 table,
2397 constraint: TableConstraint::PrimaryKey { columns, .. }
2398 } if table == "users" && columns == &vec!["id".to_string()]
2399 )
2400 });
2401 assert!(has_remove, "Should have RemoveConstraint for old PK");
2402
2403 let has_add = plan.actions.iter().any(|a| {
2404 matches!(
2405 a,
2406 MigrationAction::AddConstraint {
2407 table,
2408 constraint: TableConstraint::PrimaryKey { columns, .. }
2409 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2410 )
2411 });
2412 assert!(has_add, "Should have AddConstraint for new composite PK");
2413 }
2414
2415 #[test]
2416 fn remove_column_from_composite_pk() {
2417 let from = vec![table(
2419 "users",
2420 vec![
2421 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2422 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2423 ],
2424 vec![pk(vec!["id", "tenant_id"])],
2425 )];
2426
2427 let to = vec![table(
2428 "users",
2429 vec![
2430 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2431 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2432 ],
2433 vec![pk(vec!["id"])],
2434 )];
2435
2436 let plan = diff_schemas(&from, &to).unwrap();
2437
2438 assert_eq!(plan.actions.len(), 2);
2440
2441 let has_remove = plan.actions.iter().any(|a| {
2442 matches!(
2443 a,
2444 MigrationAction::RemoveConstraint {
2445 table,
2446 constraint: TableConstraint::PrimaryKey { columns, .. }
2447 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2448 )
2449 });
2450 assert!(
2451 has_remove,
2452 "Should have RemoveConstraint for old composite PK"
2453 );
2454
2455 let has_add = plan.actions.iter().any(|a| {
2456 matches!(
2457 a,
2458 MigrationAction::AddConstraint {
2459 table,
2460 constraint: TableConstraint::PrimaryKey { columns, .. }
2461 } if table == "users" && columns == &vec!["id".to_string()]
2462 )
2463 });
2464 assert!(
2465 has_add,
2466 "Should have AddConstraint for new single-column PK"
2467 );
2468 }
2469
2470 #[test]
2471 fn change_pk_columns_entirely() {
2472 let from = vec![table(
2474 "users",
2475 vec![
2476 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2477 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2478 ],
2479 vec![pk(vec!["id"])],
2480 )];
2481
2482 let to = vec![table(
2483 "users",
2484 vec![
2485 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2486 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2487 ],
2488 vec![pk(vec!["uuid"])],
2489 )];
2490
2491 let plan = diff_schemas(&from, &to).unwrap();
2492
2493 assert_eq!(plan.actions.len(), 2);
2494
2495 let has_remove = plan.actions.iter().any(|a| {
2496 matches!(
2497 a,
2498 MigrationAction::RemoveConstraint {
2499 table,
2500 constraint: TableConstraint::PrimaryKey { columns, .. }
2501 } if table == "users" && columns == &vec!["id".to_string()]
2502 )
2503 });
2504 assert!(has_remove, "Should have RemoveConstraint for old PK");
2505
2506 let has_add = plan.actions.iter().any(|a| {
2507 matches!(
2508 a,
2509 MigrationAction::AddConstraint {
2510 table,
2511 constraint: TableConstraint::PrimaryKey { columns, .. }
2512 } if table == "users" && columns == &vec!["uuid".to_string()]
2513 )
2514 });
2515 assert!(has_add, "Should have AddConstraint for new PK");
2516 }
2517
2518 #[test]
2519 fn add_multiple_columns_to_composite_pk() {
2520 let from = vec![table(
2522 "users",
2523 vec![
2524 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2525 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2526 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2527 ],
2528 vec![pk(vec!["id"])],
2529 )];
2530
2531 let to = vec![table(
2532 "users",
2533 vec![
2534 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2535 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2536 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2537 ],
2538 vec![pk(vec!["id", "tenant_id", "region_id"])],
2539 )];
2540
2541 let plan = diff_schemas(&from, &to).unwrap();
2542
2543 assert_eq!(plan.actions.len(), 2);
2544
2545 let has_remove = plan.actions.iter().any(|a| {
2546 matches!(
2547 a,
2548 MigrationAction::RemoveConstraint {
2549 table,
2550 constraint: TableConstraint::PrimaryKey { columns, .. }
2551 } if table == "users" && columns == &vec!["id".to_string()]
2552 )
2553 });
2554 assert!(
2555 has_remove,
2556 "Should have RemoveConstraint for old single-column PK"
2557 );
2558
2559 let has_add = plan.actions.iter().any(|a| {
2560 matches!(
2561 a,
2562 MigrationAction::AddConstraint {
2563 table,
2564 constraint: TableConstraint::PrimaryKey { columns, .. }
2565 } if table == "users" && columns == &vec![
2566 "id".to_string(),
2567 "tenant_id".to_string(),
2568 "region_id".to_string()
2569 ]
2570 )
2571 });
2572 assert!(
2573 has_add,
2574 "Should have AddConstraint for new 3-column composite PK"
2575 );
2576 }
2577
2578 #[test]
2579 fn remove_multiple_columns_from_composite_pk() {
2580 let from = vec![table(
2582 "users",
2583 vec![
2584 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2585 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2586 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2587 ],
2588 vec![pk(vec!["id", "tenant_id", "region_id"])],
2589 )];
2590
2591 let to = vec![table(
2592 "users",
2593 vec![
2594 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2595 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2596 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2597 ],
2598 vec![pk(vec!["id"])],
2599 )];
2600
2601 let plan = diff_schemas(&from, &to).unwrap();
2602
2603 assert_eq!(plan.actions.len(), 2);
2604
2605 let has_remove = plan.actions.iter().any(|a| {
2606 matches!(
2607 a,
2608 MigrationAction::RemoveConstraint {
2609 table,
2610 constraint: TableConstraint::PrimaryKey { columns, .. }
2611 } if table == "users" && columns == &vec![
2612 "id".to_string(),
2613 "tenant_id".to_string(),
2614 "region_id".to_string()
2615 ]
2616 )
2617 });
2618 assert!(
2619 has_remove,
2620 "Should have RemoveConstraint for old 3-column composite PK"
2621 );
2622
2623 let has_add = plan.actions.iter().any(|a| {
2624 matches!(
2625 a,
2626 MigrationAction::AddConstraint {
2627 table,
2628 constraint: TableConstraint::PrimaryKey { columns, .. }
2629 } if table == "users" && columns == &vec!["id".to_string()]
2630 )
2631 });
2632 assert!(
2633 has_add,
2634 "Should have AddConstraint for new single-column PK"
2635 );
2636 }
2637
2638 #[test]
2639 fn change_composite_pk_columns_partially() {
2640 let from = vec![table(
2643 "users",
2644 vec![
2645 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2646 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2647 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2648 ],
2649 vec![pk(vec!["id", "tenant_id"])],
2650 )];
2651
2652 let to = vec![table(
2653 "users",
2654 vec![
2655 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2656 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2657 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2658 ],
2659 vec![pk(vec!["id", "region_id"])],
2660 )];
2661
2662 let plan = diff_schemas(&from, &to).unwrap();
2663
2664 assert_eq!(plan.actions.len(), 2);
2665
2666 let has_remove = plan.actions.iter().any(|a| {
2667 matches!(
2668 a,
2669 MigrationAction::RemoveConstraint {
2670 table,
2671 constraint: TableConstraint::PrimaryKey { columns, .. }
2672 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2673 )
2674 });
2675 assert!(
2676 has_remove,
2677 "Should have RemoveConstraint for old PK with tenant_id"
2678 );
2679
2680 let has_add = plan.actions.iter().any(|a| {
2681 matches!(
2682 a,
2683 MigrationAction::AddConstraint {
2684 table,
2685 constraint: TableConstraint::PrimaryKey { columns, .. }
2686 } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2687 )
2688 });
2689 assert!(
2690 has_add,
2691 "Should have AddConstraint for new PK with region_id"
2692 );
2693 }
2694 }
2695
2696 mod default_changes {
2697 use super::*;
2698
2699 fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2700 ColumnDef {
2701 name: name.to_string(),
2702 r#type: ty,
2703 nullable: true,
2704 default: default.map(|s| s.into()),
2705 comment: None,
2706 primary_key: None,
2707 unique: None,
2708 index: None,
2709 foreign_key: None,
2710 }
2711 }
2712
2713 #[test]
2714 fn add_default_value() {
2715 let from = vec![table(
2717 "users",
2718 vec![
2719 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2720 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2721 ],
2722 vec![],
2723 )];
2724
2725 let to = vec![table(
2726 "users",
2727 vec![
2728 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2729 col_with_default(
2730 "status",
2731 ColumnType::Simple(SimpleColumnType::Text),
2732 Some("'active'"),
2733 ),
2734 ],
2735 vec![],
2736 )];
2737
2738 let plan = diff_schemas(&from, &to).unwrap();
2739
2740 assert_eq!(plan.actions.len(), 1);
2741 assert!(matches!(
2742 &plan.actions[0],
2743 MigrationAction::ModifyColumnDefault {
2744 table,
2745 column,
2746 new_default: Some(default),
2747 } if table == "users" && column == "status" && default == "'active'"
2748 ));
2749 }
2750
2751 #[test]
2752 fn remove_default_value() {
2753 let from = vec![table(
2755 "users",
2756 vec![
2757 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2758 col_with_default(
2759 "status",
2760 ColumnType::Simple(SimpleColumnType::Text),
2761 Some("'active'"),
2762 ),
2763 ],
2764 vec![],
2765 )];
2766
2767 let to = vec![table(
2768 "users",
2769 vec![
2770 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2771 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2772 ],
2773 vec![],
2774 )];
2775
2776 let plan = diff_schemas(&from, &to).unwrap();
2777
2778 assert_eq!(plan.actions.len(), 1);
2779 assert!(matches!(
2780 &plan.actions[0],
2781 MigrationAction::ModifyColumnDefault {
2782 table,
2783 column,
2784 new_default: None,
2785 } if table == "users" && column == "status"
2786 ));
2787 }
2788
2789 #[test]
2790 fn change_default_value() {
2791 let from = vec![table(
2793 "users",
2794 vec![
2795 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2796 col_with_default(
2797 "status",
2798 ColumnType::Simple(SimpleColumnType::Text),
2799 Some("'active'"),
2800 ),
2801 ],
2802 vec![],
2803 )];
2804
2805 let to = vec![table(
2806 "users",
2807 vec![
2808 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2809 col_with_default(
2810 "status",
2811 ColumnType::Simple(SimpleColumnType::Text),
2812 Some("'pending'"),
2813 ),
2814 ],
2815 vec![],
2816 )];
2817
2818 let plan = diff_schemas(&from, &to).unwrap();
2819
2820 assert_eq!(plan.actions.len(), 1);
2821 assert!(matches!(
2822 &plan.actions[0],
2823 MigrationAction::ModifyColumnDefault {
2824 table,
2825 column,
2826 new_default: Some(default),
2827 } if table == "users" && column == "status" && default == "'pending'"
2828 ));
2829 }
2830
2831 #[test]
2832 fn no_change_same_default() {
2833 let from = vec![table(
2835 "users",
2836 vec![
2837 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2838 col_with_default(
2839 "status",
2840 ColumnType::Simple(SimpleColumnType::Text),
2841 Some("'active'"),
2842 ),
2843 ],
2844 vec![],
2845 )];
2846
2847 let to = vec![table(
2848 "users",
2849 vec![
2850 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2851 col_with_default(
2852 "status",
2853 ColumnType::Simple(SimpleColumnType::Text),
2854 Some("'active'"),
2855 ),
2856 ],
2857 vec![],
2858 )];
2859
2860 let plan = diff_schemas(&from, &to).unwrap();
2861
2862 assert!(plan.actions.is_empty());
2863 }
2864
2865 #[test]
2866 fn multiple_columns_default_changes() {
2867 let from = vec![table(
2869 "users",
2870 vec![
2871 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2872 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2873 col_with_default(
2874 "role",
2875 ColumnType::Simple(SimpleColumnType::Text),
2876 Some("'user'"),
2877 ),
2878 col_with_default(
2879 "active",
2880 ColumnType::Simple(SimpleColumnType::Boolean),
2881 Some("true"),
2882 ),
2883 ],
2884 vec![],
2885 )];
2886
2887 let to = vec![table(
2888 "users",
2889 vec![
2890 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2891 col_with_default(
2892 "status",
2893 ColumnType::Simple(SimpleColumnType::Text),
2894 Some("'pending'"),
2895 ), col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), col_with_default(
2898 "active",
2899 ColumnType::Simple(SimpleColumnType::Boolean),
2900 Some("true"),
2901 ), ],
2903 vec![],
2904 )];
2905
2906 let plan = diff_schemas(&from, &to).unwrap();
2907
2908 assert_eq!(plan.actions.len(), 2);
2909
2910 let has_status_change = plan.actions.iter().any(|a| {
2911 matches!(
2912 a,
2913 MigrationAction::ModifyColumnDefault {
2914 table,
2915 column,
2916 new_default: Some(default),
2917 } if table == "users" && column == "status" && default == "'pending'"
2918 )
2919 });
2920 assert!(has_status_change, "Should detect status default added");
2921
2922 let has_role_change = plan.actions.iter().any(|a| {
2923 matches!(
2924 a,
2925 MigrationAction::ModifyColumnDefault {
2926 table,
2927 column,
2928 new_default: None,
2929 } if table == "users" && column == "role"
2930 )
2931 });
2932 assert!(has_role_change, "Should detect role default removed");
2933 }
2934
2935 #[test]
2936 fn default_change_with_type_change() {
2937 let from = vec![table(
2939 "users",
2940 vec![
2941 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2942 col_with_default(
2943 "count",
2944 ColumnType::Simple(SimpleColumnType::Integer),
2945 Some("0"),
2946 ),
2947 ],
2948 vec![],
2949 )];
2950
2951 let to = vec![table(
2952 "users",
2953 vec![
2954 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2955 col_with_default(
2956 "count",
2957 ColumnType::Simple(SimpleColumnType::Text),
2958 Some("'0'"),
2959 ),
2960 ],
2961 vec![],
2962 )];
2963
2964 let plan = diff_schemas(&from, &to).unwrap();
2965
2966 assert_eq!(plan.actions.len(), 2);
2968
2969 let has_type_change = plan.actions.iter().any(|a| {
2970 matches!(
2971 a,
2972 MigrationAction::ModifyColumnType { table, column, .. }
2973 if table == "users" && column == "count"
2974 )
2975 });
2976 assert!(has_type_change, "Should detect type change");
2977
2978 let has_default_change = plan.actions.iter().any(|a| {
2979 matches!(
2980 a,
2981 MigrationAction::ModifyColumnDefault {
2982 table,
2983 column,
2984 new_default: Some(default),
2985 } if table == "users" && column == "count" && default == "'0'"
2986 )
2987 });
2988 assert!(has_default_change, "Should detect default change");
2989 }
2990 }
2991
2992 mod comment_changes {
2993 use super::*;
2994
2995 fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2996 ColumnDef {
2997 name: name.to_string(),
2998 r#type: ty,
2999 nullable: true,
3000 default: None,
3001 comment: comment.map(|s| s.to_string()),
3002 primary_key: None,
3003 unique: None,
3004 index: None,
3005 foreign_key: None,
3006 }
3007 }
3008
3009 #[test]
3010 fn add_comment() {
3011 let from = vec![table(
3013 "users",
3014 vec![
3015 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3016 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3017 ],
3018 vec![],
3019 )];
3020
3021 let to = vec![table(
3022 "users",
3023 vec![
3024 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3025 col_with_comment(
3026 "email",
3027 ColumnType::Simple(SimpleColumnType::Text),
3028 Some("User's email address"),
3029 ),
3030 ],
3031 vec![],
3032 )];
3033
3034 let plan = diff_schemas(&from, &to).unwrap();
3035
3036 assert_eq!(plan.actions.len(), 1);
3037 assert!(matches!(
3038 &plan.actions[0],
3039 MigrationAction::ModifyColumnComment {
3040 table,
3041 column,
3042 new_comment: Some(comment),
3043 } if table == "users" && column == "email" && comment == "User's email address"
3044 ));
3045 }
3046
3047 #[test]
3048 fn remove_comment() {
3049 let from = vec![table(
3051 "users",
3052 vec![
3053 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3054 col_with_comment(
3055 "email",
3056 ColumnType::Simple(SimpleColumnType::Text),
3057 Some("User's email address"),
3058 ),
3059 ],
3060 vec![],
3061 )];
3062
3063 let to = vec![table(
3064 "users",
3065 vec![
3066 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3067 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3068 ],
3069 vec![],
3070 )];
3071
3072 let plan = diff_schemas(&from, &to).unwrap();
3073
3074 assert_eq!(plan.actions.len(), 1);
3075 assert!(matches!(
3076 &plan.actions[0],
3077 MigrationAction::ModifyColumnComment {
3078 table,
3079 column,
3080 new_comment: None,
3081 } if table == "users" && column == "email"
3082 ));
3083 }
3084
3085 #[test]
3086 fn change_comment() {
3087 let from = vec![table(
3089 "users",
3090 vec![
3091 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3092 col_with_comment(
3093 "email",
3094 ColumnType::Simple(SimpleColumnType::Text),
3095 Some("Old comment"),
3096 ),
3097 ],
3098 vec![],
3099 )];
3100
3101 let to = vec![table(
3102 "users",
3103 vec![
3104 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3105 col_with_comment(
3106 "email",
3107 ColumnType::Simple(SimpleColumnType::Text),
3108 Some("New comment"),
3109 ),
3110 ],
3111 vec![],
3112 )];
3113
3114 let plan = diff_schemas(&from, &to).unwrap();
3115
3116 assert_eq!(plan.actions.len(), 1);
3117 assert!(matches!(
3118 &plan.actions[0],
3119 MigrationAction::ModifyColumnComment {
3120 table,
3121 column,
3122 new_comment: Some(comment),
3123 } if table == "users" && column == "email" && comment == "New comment"
3124 ));
3125 }
3126
3127 #[test]
3128 fn no_change_same_comment() {
3129 let from = vec![table(
3131 "users",
3132 vec![
3133 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3134 col_with_comment(
3135 "email",
3136 ColumnType::Simple(SimpleColumnType::Text),
3137 Some("Same comment"),
3138 ),
3139 ],
3140 vec![],
3141 )];
3142
3143 let to = vec![table(
3144 "users",
3145 vec![
3146 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3147 col_with_comment(
3148 "email",
3149 ColumnType::Simple(SimpleColumnType::Text),
3150 Some("Same comment"),
3151 ),
3152 ],
3153 vec![],
3154 )];
3155
3156 let plan = diff_schemas(&from, &to).unwrap();
3157
3158 assert!(plan.actions.is_empty());
3159 }
3160
3161 #[test]
3162 fn multiple_columns_comment_changes() {
3163 let from = vec![table(
3165 "users",
3166 vec![
3167 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3168 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3169 col_with_comment(
3170 "name",
3171 ColumnType::Simple(SimpleColumnType::Text),
3172 Some("User name"),
3173 ),
3174 col_with_comment(
3175 "phone",
3176 ColumnType::Simple(SimpleColumnType::Text),
3177 Some("Phone number"),
3178 ),
3179 ],
3180 vec![],
3181 )];
3182
3183 let to = vec![table(
3184 "users",
3185 vec![
3186 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3187 col_with_comment(
3188 "email",
3189 ColumnType::Simple(SimpleColumnType::Text),
3190 Some("Email address"),
3191 ), col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), col_with_comment(
3194 "phone",
3195 ColumnType::Simple(SimpleColumnType::Text),
3196 Some("Phone number"),
3197 ), ],
3199 vec![],
3200 )];
3201
3202 let plan = diff_schemas(&from, &to).unwrap();
3203
3204 assert_eq!(plan.actions.len(), 2);
3205
3206 let has_email_change = plan.actions.iter().any(|a| {
3207 matches!(
3208 a,
3209 MigrationAction::ModifyColumnComment {
3210 table,
3211 column,
3212 new_comment: Some(comment),
3213 } if table == "users" && column == "email" && comment == "Email address"
3214 )
3215 });
3216 assert!(has_email_change, "Should detect email comment added");
3217
3218 let has_name_change = plan.actions.iter().any(|a| {
3219 matches!(
3220 a,
3221 MigrationAction::ModifyColumnComment {
3222 table,
3223 column,
3224 new_comment: None,
3225 } if table == "users" && column == "name"
3226 )
3227 });
3228 assert!(has_name_change, "Should detect name comment removed");
3229 }
3230
3231 #[test]
3232 fn comment_change_with_nullable_change() {
3233 let from = vec![table(
3235 "users",
3236 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3237 let mut c =
3238 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
3239 c.nullable = true;
3240 c
3241 }],
3242 vec![],
3243 )];
3244
3245 let to = vec![table(
3246 "users",
3247 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3248 let mut c = col_with_comment(
3249 "email",
3250 ColumnType::Simple(SimpleColumnType::Text),
3251 Some("Required email"),
3252 );
3253 c.nullable = false;
3254 c
3255 }],
3256 vec![],
3257 )];
3258
3259 let plan = diff_schemas(&from, &to).unwrap();
3260
3261 assert_eq!(plan.actions.len(), 2);
3263
3264 let has_nullable_change = plan.actions.iter().any(|a| {
3265 matches!(
3266 a,
3267 MigrationAction::ModifyColumnNullable {
3268 table,
3269 column,
3270 nullable: false,
3271 ..
3272 } if table == "users" && column == "email"
3273 )
3274 });
3275 assert!(has_nullable_change, "Should detect nullable change");
3276
3277 let has_comment_change = plan.actions.iter().any(|a| {
3278 matches!(
3279 a,
3280 MigrationAction::ModifyColumnComment {
3281 table,
3282 column,
3283 new_comment: Some(comment),
3284 } if table == "users" && column == "email" && comment == "Required email"
3285 )
3286 });
3287 assert!(has_comment_change, "Should detect comment change");
3288 }
3289 }
3290
3291 mod nullable_changes {
3292 use super::*;
3293
3294 fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
3295 ColumnDef {
3296 name: name.to_string(),
3297 r#type: ty,
3298 nullable,
3299 default: None,
3300 comment: None,
3301 primary_key: None,
3302 unique: None,
3303 index: None,
3304 foreign_key: None,
3305 }
3306 }
3307
3308 #[test]
3309 fn column_nullable_to_non_nullable() {
3310 let from = vec![table(
3312 "users",
3313 vec![
3314 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3315 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3316 ],
3317 vec![],
3318 )];
3319
3320 let to = vec![table(
3321 "users",
3322 vec![
3323 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3324 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3325 ],
3326 vec![],
3327 )];
3328
3329 let plan = diff_schemas(&from, &to).unwrap();
3330
3331 assert_eq!(plan.actions.len(), 1);
3332 assert!(matches!(
3333 &plan.actions[0],
3334 MigrationAction::ModifyColumnNullable {
3335 table,
3336 column,
3337 nullable: false,
3338 fill_with: None,
3339 } if table == "users" && column == "email"
3340 ));
3341 }
3342
3343 #[test]
3344 fn column_non_nullable_to_nullable() {
3345 let from = vec![table(
3347 "users",
3348 vec![
3349 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3350 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3351 ],
3352 vec![],
3353 )];
3354
3355 let to = vec![table(
3356 "users",
3357 vec![
3358 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3359 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3360 ],
3361 vec![],
3362 )];
3363
3364 let plan = diff_schemas(&from, &to).unwrap();
3365
3366 assert_eq!(plan.actions.len(), 1);
3367 assert!(matches!(
3368 &plan.actions[0],
3369 MigrationAction::ModifyColumnNullable {
3370 table,
3371 column,
3372 nullable: true,
3373 fill_with: None,
3374 } if table == "users" && column == "email"
3375 ));
3376 }
3377
3378 #[test]
3379 fn multiple_columns_nullable_changes() {
3380 let from = vec![table(
3382 "users",
3383 vec![
3384 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3385 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3386 col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
3387 col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
3388 ],
3389 vec![],
3390 )];
3391
3392 let to = vec![table(
3393 "users",
3394 vec![
3395 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3396 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), ],
3400 vec![],
3401 )];
3402
3403 let plan = diff_schemas(&from, &to).unwrap();
3404
3405 assert_eq!(plan.actions.len(), 2);
3406
3407 let has_email_change = plan.actions.iter().any(|a| {
3408 matches!(
3409 a,
3410 MigrationAction::ModifyColumnNullable {
3411 table,
3412 column,
3413 nullable: false,
3414 ..
3415 } if table == "users" && column == "email"
3416 )
3417 });
3418 assert!(
3419 has_email_change,
3420 "Should detect email nullable -> non-nullable"
3421 );
3422
3423 let has_name_change = plan.actions.iter().any(|a| {
3424 matches!(
3425 a,
3426 MigrationAction::ModifyColumnNullable {
3427 table,
3428 column,
3429 nullable: true,
3430 ..
3431 } if table == "users" && column == "name"
3432 )
3433 });
3434 assert!(
3435 has_name_change,
3436 "Should detect name non-nullable -> nullable"
3437 );
3438 }
3439
3440 #[test]
3441 fn nullable_change_with_type_change() {
3442 let from = vec![table(
3444 "users",
3445 vec![
3446 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3447 col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
3448 ],
3449 vec![],
3450 )];
3451
3452 let to = vec![table(
3453 "users",
3454 vec![
3455 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3456 col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
3457 ],
3458 vec![],
3459 )];
3460
3461 let plan = diff_schemas(&from, &to).unwrap();
3462
3463 assert_eq!(plan.actions.len(), 2);
3465
3466 let has_type_change = plan.actions.iter().any(|a| {
3467 matches!(
3468 a,
3469 MigrationAction::ModifyColumnType { table, column, .. }
3470 if table == "users" && column == "age"
3471 )
3472 });
3473 assert!(has_type_change, "Should detect type change");
3474
3475 let has_nullable_change = plan.actions.iter().any(|a| {
3476 matches!(
3477 a,
3478 MigrationAction::ModifyColumnNullable {
3479 table,
3480 column,
3481 nullable: false,
3482 ..
3483 } if table == "users" && column == "age"
3484 )
3485 });
3486 assert!(has_nullable_change, "Should detect nullable change");
3487 }
3488 }
3489
3490 mod diff_tables {
3491 use insta::assert_debug_snapshot;
3492
3493 use super::*;
3494
3495 #[test]
3496 fn create_table_with_inline_index() {
3497 let base = [table(
3498 "users",
3499 vec![
3500 ColumnDef {
3501 name: "id".to_string(),
3502 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3503 nullable: false,
3504 default: None,
3505 comment: None,
3506 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3507 unique: None,
3508 index: Some(StrOrBoolOrArray::Bool(false)),
3509 foreign_key: None,
3510 },
3511 ColumnDef {
3512 name: "name".to_string(),
3513 r#type: ColumnType::Simple(SimpleColumnType::Text),
3514 nullable: true,
3515 default: None,
3516 comment: None,
3517 primary_key: None,
3518 unique: Some(StrOrBoolOrArray::Bool(true)),
3519 index: Some(StrOrBoolOrArray::Bool(true)),
3520 foreign_key: None,
3521 },
3522 ],
3523 vec![],
3524 )];
3525 let plan = diff_schemas(&[], &base).unwrap();
3526
3527 assert_eq!(plan.actions.len(), 1);
3528 assert_debug_snapshot!(plan.actions);
3529
3530 let plan = diff_schemas(
3531 &base,
3532 &[table(
3533 "users",
3534 vec![
3535 ColumnDef {
3536 name: "id".to_string(),
3537 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3538 nullable: false,
3539 default: None,
3540 comment: None,
3541 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3542 unique: None,
3543 index: Some(StrOrBoolOrArray::Bool(false)),
3544 foreign_key: None,
3545 },
3546 ColumnDef {
3547 name: "name".to_string(),
3548 r#type: ColumnType::Simple(SimpleColumnType::Text),
3549 nullable: true,
3550 default: None,
3551 comment: None,
3552 primary_key: None,
3553 unique: Some(StrOrBoolOrArray::Bool(true)),
3554 index: Some(StrOrBoolOrArray::Bool(false)),
3555 foreign_key: None,
3556 },
3557 ],
3558 vec![],
3559 )],
3560 )
3561 .unwrap();
3562
3563 assert_eq!(plan.actions.len(), 1);
3564 assert_debug_snapshot!(plan.actions);
3565 }
3566
3567 #[rstest]
3568 #[case(
3569 "add_index",
3570 vec![table(
3571 "users",
3572 vec![
3573 ColumnDef {
3574 name: "id".to_string(),
3575 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3576 nullable: false,
3577 default: None,
3578 comment: None,
3579 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3580 unique: None,
3581 index: None,
3582 foreign_key: None,
3583 },
3584 ],
3585 vec![],
3586 )],
3587 vec![table(
3588 "users",
3589 vec![
3590 ColumnDef {
3591 name: "id".to_string(),
3592 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3593 nullable: false,
3594 default: None,
3595 comment: None,
3596 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3597 unique: None,
3598 index: Some(StrOrBoolOrArray::Bool(true)),
3599 foreign_key: None,
3600 },
3601 ],
3602 vec![],
3603 )],
3604 )]
3605 #[case(
3606 "remove_index",
3607 vec![table(
3608 "users",
3609 vec![
3610 ColumnDef {
3611 name: "id".to_string(),
3612 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3613 nullable: false,
3614 default: None,
3615 comment: None,
3616 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3617 unique: None,
3618 index: Some(StrOrBoolOrArray::Bool(true)),
3619 foreign_key: None,
3620 },
3621 ],
3622 vec![],
3623 )],
3624 vec![table(
3625 "users",
3626 vec![
3627 ColumnDef {
3628 name: "id".to_string(),
3629 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3630 nullable: false,
3631 default: None,
3632 comment: None,
3633 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3634 unique: None,
3635 index: Some(StrOrBoolOrArray::Bool(false)),
3636 foreign_key: None,
3637 },
3638 ],
3639 vec![],
3640 )],
3641 )]
3642 #[case(
3643 "add_named_index",
3644 vec![table(
3645 "users",
3646 vec![
3647 ColumnDef {
3648 name: "id".to_string(),
3649 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3650 nullable: false,
3651 default: None,
3652 comment: None,
3653 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3654 unique: None,
3655 index: None,
3656 foreign_key: None,
3657 },
3658 ],
3659 vec![],
3660 )],
3661 vec![table(
3662 "users",
3663 vec![
3664 ColumnDef {
3665 name: "id".to_string(),
3666 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3667 nullable: false,
3668 default: None,
3669 comment: None,
3670 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3671 unique: None,
3672 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3673 foreign_key: None,
3674 },
3675 ],
3676 vec![],
3677 )],
3678 )]
3679 #[case(
3680 "remove_named_index",
3681 vec![table(
3682 "users",
3683 vec![
3684 ColumnDef {
3685 name: "id".to_string(),
3686 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3687 nullable: false,
3688 default: None,
3689 comment: None,
3690 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3691 unique: None,
3692 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3693 foreign_key: None,
3694 },
3695 ],
3696 vec![],
3697 )],
3698 vec![table(
3699 "users",
3700 vec![
3701 ColumnDef {
3702 name: "id".to_string(),
3703 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3704 nullable: false,
3705 default: None,
3706 comment: None,
3707 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3708 unique: None,
3709 index: None,
3710 foreign_key: None,
3711 },
3712 ],
3713 vec![],
3714 )],
3715 )]
3716 fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3717 use insta::with_settings;
3718
3719 let plan = diff_schemas(&base, &to).unwrap();
3720 with_settings!({ snapshot_suffix => name }, {
3721 assert_debug_snapshot!(plan.actions);
3722 });
3723 }
3724 }
3725
3726 mod coverage_explicit {
3728 use super::*;
3729
3730 #[test]
3731 fn delete_column_explicit() {
3732 let from = vec![table(
3734 "users",
3735 vec![
3736 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3737 col("name", ColumnType::Simple(SimpleColumnType::Text)),
3738 ],
3739 vec![],
3740 )];
3741
3742 let to = vec![table(
3743 "users",
3744 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3745 vec![],
3746 )];
3747
3748 let plan = diff_schemas(&from, &to).unwrap();
3749 assert_eq!(plan.actions.len(), 1);
3750 assert!(matches!(
3751 &plan.actions[0],
3752 MigrationAction::DeleteColumn { table, column }
3753 if table == "users" && column == "name"
3754 ));
3755 }
3756
3757 #[test]
3758 fn add_column_explicit() {
3759 let from = vec![table(
3761 "users",
3762 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3763 vec![],
3764 )];
3765
3766 let to = vec![table(
3767 "users",
3768 vec![
3769 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3770 col("email", ColumnType::Simple(SimpleColumnType::Text)),
3771 ],
3772 vec![],
3773 )];
3774
3775 let plan = diff_schemas(&from, &to).unwrap();
3776 assert_eq!(plan.actions.len(), 1);
3777 assert!(matches!(
3778 &plan.actions[0],
3779 MigrationAction::AddColumn { table, column, .. }
3780 if table == "users" && column.name == "email"
3781 ));
3782 }
3783
3784 #[test]
3785 fn remove_constraint_explicit() {
3786 let from = vec![table(
3788 "users",
3789 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3790 vec![idx("idx_users_id", vec!["id"])],
3791 )];
3792
3793 let to = vec![table(
3794 "users",
3795 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3796 vec![],
3797 )];
3798
3799 let plan = diff_schemas(&from, &to).unwrap();
3800 assert_eq!(plan.actions.len(), 1);
3801 assert!(matches!(
3802 &plan.actions[0],
3803 MigrationAction::RemoveConstraint { table, constraint }
3804 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3805 ));
3806 }
3807
3808 #[test]
3809 fn add_constraint_explicit() {
3810 let from = vec![table(
3812 "users",
3813 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3814 vec![],
3815 )];
3816
3817 let to = vec![table(
3818 "users",
3819 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3820 vec![idx("idx_users_id", vec!["id"])],
3821 )];
3822
3823 let plan = diff_schemas(&from, &to).unwrap();
3824 assert_eq!(plan.actions.len(), 1);
3825 assert!(matches!(
3826 &plan.actions[0],
3827 MigrationAction::AddConstraint { table, constraint }
3828 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3829 ));
3830 }
3831 }
3832
3833 mod constraint_removal_on_deleted_columns {
3834 use super::*;
3835
3836 fn fk(columns: Vec<&str>, ref_table: &str, ref_columns: Vec<&str>) -> TableConstraint {
3837 TableConstraint::ForeignKey {
3838 name: None,
3839 columns: columns.into_iter().map(|s| s.to_string()).collect(),
3840 ref_table: ref_table.to_string(),
3841 ref_columns: ref_columns.into_iter().map(|s| s.to_string()).collect(),
3842 on_delete: None,
3843 on_update: None,
3844 }
3845 }
3846
3847 #[test]
3848 fn skip_remove_constraint_when_all_columns_deleted() {
3849 let from = vec![table(
3852 "project",
3853 vec![
3854 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3855 col("template_id", ColumnType::Simple(SimpleColumnType::Integer)),
3856 ],
3857 vec![
3858 fk(vec!["template_id"], "book_template", vec!["id"]),
3859 idx("ix_project__template_id", vec!["template_id"]),
3860 ],
3861 )];
3862
3863 let to = vec![table(
3864 "project",
3865 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3866 vec![],
3867 )];
3868
3869 let plan = diff_schemas(&from, &to).unwrap();
3870
3871 assert_eq!(plan.actions.len(), 1);
3873 assert!(matches!(
3874 &plan.actions[0],
3875 MigrationAction::DeleteColumn { table, column }
3876 if table == "project" && column == "template_id"
3877 ));
3878
3879 let has_remove_constraint = plan
3881 .actions
3882 .iter()
3883 .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3884 assert!(
3885 !has_remove_constraint,
3886 "Should NOT have RemoveConstraint when column is deleted"
3887 );
3888 }
3889
3890 #[test]
3891 fn keep_remove_constraint_when_only_some_columns_deleted() {
3892 let from = vec![table(
3894 "orders",
3895 vec![
3896 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3897 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3898 col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3899 ],
3900 vec![idx(
3901 "ix_orders__user_product",
3902 vec!["user_id", "product_id"],
3903 )],
3904 )];
3905
3906 let to = vec![table(
3907 "orders",
3908 vec![
3909 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3910 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3911 ],
3913 vec![],
3914 )];
3915
3916 let plan = diff_schemas(&from, &to).unwrap();
3917
3918 let has_delete_column = plan.actions.iter().any(|a| {
3921 matches!(
3922 a,
3923 MigrationAction::DeleteColumn { table, column }
3924 if table == "orders" && column == "product_id"
3925 )
3926 });
3927 assert!(has_delete_column, "Should have DeleteColumn for product_id");
3928
3929 let has_remove_constraint = plan.actions.iter().any(|a| {
3930 matches!(
3931 a,
3932 MigrationAction::RemoveConstraint { table, .. }
3933 if table == "orders"
3934 )
3935 });
3936 assert!(
3937 has_remove_constraint,
3938 "Should have RemoveConstraint for composite index when only some columns deleted"
3939 );
3940 }
3941
3942 #[test]
3943 fn skip_remove_constraint_when_all_composite_columns_deleted() {
3944 let from = vec![table(
3946 "orders",
3947 vec![
3948 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3949 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3950 col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3951 ],
3952 vec![idx(
3953 "ix_orders__user_product",
3954 vec!["user_id", "product_id"],
3955 )],
3956 )];
3957
3958 let to = vec![table(
3959 "orders",
3960 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3961 vec![],
3962 )];
3963
3964 let plan = diff_schemas(&from, &to).unwrap();
3965
3966 let delete_columns: Vec<_> = plan
3968 .actions
3969 .iter()
3970 .filter(|a| matches!(a, MigrationAction::DeleteColumn { .. }))
3971 .collect();
3972 assert_eq!(
3973 delete_columns.len(),
3974 2,
3975 "Should have 2 DeleteColumn actions"
3976 );
3977
3978 let has_remove_constraint = plan
3979 .actions
3980 .iter()
3981 .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3982 assert!(
3983 !has_remove_constraint,
3984 "Should NOT have RemoveConstraint when all composite columns deleted"
3985 );
3986 }
3987
3988 #[test]
3989 fn keep_remove_constraint_when_no_columns_deleted() {
3990 let from = vec![table(
3992 "users",
3993 vec![
3994 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3995 col("email", ColumnType::Simple(SimpleColumnType::Text)),
3996 ],
3997 vec![idx("ix_users__email", vec!["email"])],
3998 )];
3999
4000 let to = vec![table(
4001 "users",
4002 vec![
4003 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4004 col("email", ColumnType::Simple(SimpleColumnType::Text)),
4005 ],
4006 vec![], )];
4008
4009 let plan = diff_schemas(&from, &to).unwrap();
4010
4011 assert_eq!(plan.actions.len(), 1);
4012 assert!(matches!(
4013 &plan.actions[0],
4014 MigrationAction::RemoveConstraint { table, .. }
4015 if table == "users"
4016 ));
4017 }
4018 }
4019}