1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use vespertide_core::{
4 ColumnType, ComplexColumnType, EnumValues, MigrationAction, MigrationPlan, TableConstraint,
5 TableDef,
6};
7
8use crate::error::PlannerError;
9
10fn topological_sort_tables<'a>(tables: &[&'a TableDef]) -> Result<Vec<&'a TableDef>, PlannerError> {
14 if tables.is_empty() {
15 return Ok(vec![]);
16 }
17
18 let table_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect();
20
21 let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
25 for table in tables {
26 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
27 for constraint in &table.constraints {
28 if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
29 if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
31 deps_set.insert(ref_table.as_str());
32 }
33 }
34 }
35 dependencies.insert(table.name.as_str(), deps_set.into_iter().collect());
36 }
37
38 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
42 for table in tables {
43 in_degree.entry(table.name.as_str()).or_insert(0);
44 }
45
46 for (table_name, deps) in &dependencies {
48 for _dep in deps {
49 }
52 *in_degree.entry(table_name).or_insert(0) += deps.len();
55 }
56
57 let mut queue: VecDeque<&str> = in_degree
60 .iter()
61 .filter(|(_, deg)| **deg == 0)
62 .map(|(name, _)| *name)
63 .collect();
64
65 let mut result: Vec<&TableDef> = Vec::new();
66 let table_map: BTreeMap<&str, &TableDef> =
67 tables.iter().map(|t| (t.name.as_str(), *t)).collect();
68
69 while let Some(table_name) = queue.pop_front() {
70 if let Some(&table) = table_map.get(table_name) {
71 result.push(table);
72 }
73
74 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
77 for (dependent, deps) in &dependencies {
78 if deps.contains(&table_name)
79 && let Some(degree) = in_degree.get_mut(dependent)
80 {
81 *degree -= 1;
82 if *degree == 0 {
83 ready_tables.insert(dependent);
84 }
85 }
86 }
87 for t in ready_tables {
88 queue.push_back(t);
89 }
90 }
91
92 if result.len() != tables.len() {
94 let remaining: Vec<&str> = tables
95 .iter()
96 .map(|t| t.name.as_str())
97 .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
98 .collect();
99 return Err(PlannerError::TableValidation(format!(
100 "Circular foreign key dependency detected among tables: {:?}",
101 remaining
102 )));
103 }
104
105 Ok(result)
106}
107
108fn extract_delete_table_name(action: &MigrationAction) -> &str {
113 match action {
114 MigrationAction::DeleteTable { table } => table.as_str(),
115 _ => panic!("Expected DeleteTable action"),
116 }
117}
118
119fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
120 let delete_indices: Vec<usize> = actions
122 .iter()
123 .enumerate()
124 .filter_map(|(i, a)| {
125 if matches!(a, MigrationAction::DeleteTable { .. }) {
126 Some(i)
127 } else {
128 None
129 }
130 })
131 .collect();
132
133 if delete_indices.len() <= 1 {
134 return;
135 }
136
137 let delete_table_names: BTreeSet<&str> = delete_indices
140 .iter()
141 .map(|&i| extract_delete_table_name(&actions[i]))
142 .collect();
143
144 let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
149 for &table_name in &delete_table_names {
150 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
151 if let Some(table_def) = all_tables.get(table_name) {
152 for constraint in &table_def.constraints {
153 if let TableConstraint::ForeignKey { ref_table, .. } = constraint
154 && delete_table_names.contains(ref_table.as_str())
155 && ref_table != table_name
156 {
157 deps_set.insert(ref_table.as_str());
158 }
159 }
160 }
161 dependencies.insert(table_name, deps_set.into_iter().collect());
162 }
163
164 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
168 for &table_name in &delete_table_names {
169 in_degree.insert(
170 table_name,
171 dependencies.get(table_name).map_or(0, |d| d.len()),
172 );
173 }
174
175 let mut queue: VecDeque<&str> = in_degree
178 .iter()
179 .filter(|(_, deg)| **deg == 0)
180 .map(|(name, _)| *name)
181 .collect();
182
183 let mut sorted_tables: Vec<&str> = Vec::new();
184 while let Some(table_name) = queue.pop_front() {
185 sorted_tables.push(table_name);
186
187 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
190 for (&dependent, deps) in &dependencies {
191 if deps.contains(&table_name)
192 && let Some(degree) = in_degree.get_mut(dependent)
193 {
194 *degree -= 1;
195 if *degree == 0 {
196 ready_tables.insert(dependent);
197 }
198 }
199 }
200 for t in ready_tables {
201 queue.push_back(t);
202 }
203 }
204
205 sorted_tables.reverse();
207
208 let mut delete_actions: Vec<MigrationAction> =
210 delete_indices.iter().map(|&i| actions[i].clone()).collect();
211
212 delete_actions.sort_by(|a, b| {
213 let a_name = extract_delete_table_name(a);
214 let b_name = extract_delete_table_name(b);
215
216 let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
217 let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
218 a_pos.cmp(&b_pos)
219 });
220
221 for (i, idx) in delete_indices.iter().enumerate() {
223 actions[*idx] = delete_actions[i].clone();
224 }
225}
226
227fn compare_actions_for_create_order(
230 a: &MigrationAction,
231 b: &MigrationAction,
232 created_tables: &BTreeSet<String>,
233) -> std::cmp::Ordering {
234 let a_is_create = matches!(a, MigrationAction::CreateTable { .. });
235 let b_is_create = matches!(b, MigrationAction::CreateTable { .. });
236
237 let a_refs_created = if let MigrationAction::AddConstraint {
239 constraint: TableConstraint::ForeignKey { ref_table, .. },
240 ..
241 } = a
242 {
243 created_tables.contains(ref_table)
244 } else {
245 false
246 };
247 let b_refs_created = if let MigrationAction::AddConstraint {
248 constraint: TableConstraint::ForeignKey { ref_table, .. },
249 ..
250 } = b
251 {
252 created_tables.contains(ref_table)
253 } else {
254 false
255 };
256
257 match (a_is_create, b_is_create, a_refs_created, b_refs_created) {
259 (true, true, _, _) => std::cmp::Ordering::Equal,
261 (true, false, _, _) => std::cmp::Ordering::Less,
263 (false, true, _, _) => std::cmp::Ordering::Greater,
265 (false, false, true, false) => std::cmp::Ordering::Greater,
268 (false, false, false, true) => std::cmp::Ordering::Less,
270 (false, false, _, _) => std::cmp::Ordering::Equal,
272 }
273}
274
275fn sort_create_before_add_constraint(actions: &mut [MigrationAction]) {
278 let created_tables: BTreeSet<String> = actions
280 .iter()
281 .filter_map(|a| {
282 if let MigrationAction::CreateTable { table, .. } = a {
283 Some(table.clone())
284 } else {
285 None
286 }
287 })
288 .collect();
289
290 if created_tables.is_empty() {
291 return;
292 }
293
294 actions.sort_by(|a, b| compare_actions_for_create_order(a, b, &created_tables));
295}
296
297fn get_removed_string_enum_values(
300 from_type: &ColumnType,
301 to_type: &ColumnType,
302) -> Option<Vec<String>> {
303 match (from_type, to_type) {
304 (
305 ColumnType::Complex(ComplexColumnType::Enum {
306 values: EnumValues::String(from_values),
307 ..
308 }),
309 ColumnType::Complex(ComplexColumnType::Enum {
310 values: EnumValues::String(to_values),
311 ..
312 }),
313 ) => {
314 let to_set: HashSet<&str> = to_values.iter().map(|s| s.as_str()).collect();
315 let removed: Vec<String> = from_values
316 .iter()
317 .filter(|v| !to_set.contains(v.as_str()))
318 .cloned()
319 .collect();
320 if removed.is_empty() {
321 None
322 } else {
323 Some(removed)
324 }
325 }
326 _ => None,
327 }
328}
329
330fn extract_unquoted_default(default_sql: &str) -> &str {
334 let trimmed = default_sql.trim();
335 if trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2 {
336 &trimmed[1..trimmed.len() - 1]
337 } else {
338 trimmed
339 }
340}
341
342fn sort_enum_default_dependencies(
350 actions: &mut [MigrationAction],
351 from_map: &BTreeMap<&str, &TableDef>,
352) {
353 let mut type_changes: BTreeMap<(&str, &str), (usize, &ColumnType)> = BTreeMap::new();
356 let mut default_changes: BTreeMap<(&str, &str), usize> = BTreeMap::new();
357
358 for (i, action) in actions.iter().enumerate() {
359 match action {
360 MigrationAction::ModifyColumnType {
361 table,
362 column,
363 new_type,
364 } => {
365 type_changes.insert((table.as_str(), column.as_str()), (i, new_type));
366 }
367 MigrationAction::ModifyColumnDefault { table, column, .. } => {
368 default_changes.insert((table.as_str(), column.as_str()), i);
369 }
370 _ => {}
371 }
372 }
373
374 let mut swaps: Vec<(usize, usize)> = Vec::new();
376
377 for ((table, column), (type_idx, new_type)) in &type_changes {
378 if let Some(&default_idx) = default_changes.get(&(*table, *column))
379 && let Some(from_table) = from_map.get(table)
380 && let Some(from_column) = from_table.columns.iter().find(|c| c.name == *column)
381 && let Some(removed_values) =
382 get_removed_string_enum_values(&from_column.r#type, new_type)
383 && let Some(ref old_default) = from_column.default
384 {
385 let old_default_sql = old_default.to_sql();
388 let old_default_unquoted = extract_unquoted_default(&old_default_sql);
389
390 if removed_values.iter().any(|v| v == old_default_unquoted) && *type_idx < default_idx {
391 swaps.push((*type_idx, default_idx));
393 }
394 }
395 }
396
397 for (i, j) in swaps {
399 actions.swap(i, j);
400 }
401}
402
403pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
407 let mut actions: Vec<MigrationAction> = Vec::new();
408
409 let from_normalized: Vec<TableDef> = from
411 .iter()
412 .map(|t| {
413 t.normalize().map_err(|e| {
414 PlannerError::TableValidation(format!(
415 "Failed to normalize table '{}': {}",
416 t.name, e
417 ))
418 })
419 })
420 .collect::<Result<Vec<_>, _>>()?;
421 let to_normalized: Vec<TableDef> = to
422 .iter()
423 .map(|t| {
424 t.normalize().map_err(|e| {
425 PlannerError::TableValidation(format!(
426 "Failed to normalize table '{}': {}",
427 t.name, e
428 ))
429 })
430 })
431 .collect::<Result<Vec<_>, _>>()?;
432
433 let from_map: BTreeMap<_, _> = from_normalized
436 .iter()
437 .map(|t| (t.name.as_str(), t))
438 .collect();
439 let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
440
441 let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
443
444 for name in from_map.keys() {
446 if !to_map.contains_key(name) {
447 actions.push(MigrationAction::DeleteTable {
448 table: name.to_string(),
449 });
450 }
451 }
452
453 for (name, to_tbl) in &to_map {
455 if let Some(from_tbl) = from_map.get(name) {
456 let from_cols: BTreeMap<_, _> = from_tbl
458 .columns
459 .iter()
460 .map(|c| (c.name.as_str(), c))
461 .collect();
462 let to_cols: BTreeMap<_, _> = to_tbl
463 .columns
464 .iter()
465 .map(|c| (c.name.as_str(), c))
466 .collect();
467
468 let deleted_columns: BTreeSet<&str> = from_cols
470 .keys()
471 .filter(|col| !to_cols.contains_key(*col))
472 .copied()
473 .collect();
474
475 for col in &deleted_columns {
476 actions.push(MigrationAction::DeleteColumn {
477 table: name.to_string(),
478 column: col.to_string(),
479 });
480 }
481
482 for (col, to_def) in &to_cols {
484 if let Some(from_def) = from_cols.get(col)
485 && from_def.r#type.requires_migration(&to_def.r#type)
486 {
487 actions.push(MigrationAction::ModifyColumnType {
488 table: name.to_string(),
489 column: col.to_string(),
490 new_type: to_def.r#type.clone(),
491 });
492 }
493 }
494
495 for (col, to_def) in &to_cols {
497 if let Some(from_def) = from_cols.get(col)
498 && from_def.nullable != to_def.nullable
499 {
500 actions.push(MigrationAction::ModifyColumnNullable {
501 table: name.to_string(),
502 column: col.to_string(),
503 nullable: to_def.nullable,
504 fill_with: None,
505 });
506 }
507 }
508
509 for (col, to_def) in &to_cols {
511 if let Some(from_def) = from_cols.get(col) {
512 let from_default = from_def.default.as_ref().map(|d| d.to_sql());
513 let to_default = to_def.default.as_ref().map(|d| d.to_sql());
514 if from_default != to_default {
515 actions.push(MigrationAction::ModifyColumnDefault {
516 table: name.to_string(),
517 column: col.to_string(),
518 new_default: to_default,
519 });
520 }
521 }
522 }
523
524 for (col, to_def) in &to_cols {
526 if let Some(from_def) = from_cols.get(col)
527 && from_def.comment != to_def.comment
528 {
529 actions.push(MigrationAction::ModifyColumnComment {
530 table: name.to_string(),
531 column: col.to_string(),
532 new_comment: to_def.comment.clone(),
533 });
534 }
535 }
536
537 for (col, def) in &to_cols {
541 if !from_cols.contains_key(col) {
542 actions.push(MigrationAction::AddColumn {
543 table: name.to_string(),
544 column: Box::new((*def).clone()),
545 fill_with: None,
546 });
547 }
548 }
549
550 for from_constraint in &from_tbl.constraints {
554 if !to_tbl.constraints.contains(from_constraint) {
555 let constraint_columns = from_constraint.columns();
557
558 let all_columns_deleted = !constraint_columns.is_empty()
560 && constraint_columns
561 .iter()
562 .all(|col| deleted_columns.contains(col.as_str()));
563
564 if !all_columns_deleted {
565 actions.push(MigrationAction::RemoveConstraint {
566 table: name.to_string(),
567 constraint: from_constraint.clone(),
568 });
569 }
570 }
571 }
572 for to_constraint in &to_tbl.constraints {
573 if !from_tbl.constraints.contains(to_constraint) {
574 actions.push(MigrationAction::AddConstraint {
575 table: name.to_string(),
576 constraint: to_constraint.clone(),
577 });
578 }
579 }
580 }
581 }
582
583 let new_tables: Vec<&TableDef> = to_map
587 .iter()
588 .filter(|(name, _)| !from_map.contains_key(*name))
589 .map(|(_, tbl)| *tbl)
590 .collect();
591
592 let sorted_new_tables = topological_sort_tables(&new_tables)?;
593
594 for tbl in sorted_new_tables {
595 let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
597 actions.push(MigrationAction::CreateTable {
598 table: original_tbl.name.clone(),
599 columns: original_tbl.columns.clone(),
600 constraints: original_tbl.constraints.clone(),
601 });
602 }
603
604 sort_delete_tables(&mut actions, &from_map);
606
607 sort_create_before_add_constraint(&mut actions);
609
610 sort_enum_default_dependencies(&mut actions, &from_map);
613
614 Ok(MigrationPlan {
615 comment: None,
616 created_at: None,
617 version: 0,
618 actions,
619 })
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use rstest::rstest;
626 use vespertide_core::{
627 ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
628 schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
629 };
630
631 fn col(name: &str, ty: ColumnType) -> ColumnDef {
632 ColumnDef {
633 name: name.to_string(),
634 r#type: ty,
635 nullable: true,
636 default: None,
637 comment: None,
638 primary_key: None,
639 unique: None,
640 index: None,
641 foreign_key: None,
642 }
643 }
644
645 fn table(
646 name: &str,
647 columns: Vec<ColumnDef>,
648 constraints: Vec<vespertide_core::TableConstraint>,
649 ) -> TableDef {
650 TableDef {
651 name: name.to_string(),
652 description: None,
653 columns,
654 constraints,
655 }
656 }
657
658 fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
659 TableConstraint::Index {
660 name: Some(name.to_string()),
661 columns: columns.into_iter().map(|s| s.to_string()).collect(),
662 }
663 }
664
665 #[rstest]
666 #[case::add_column_and_index(
667 vec![table(
668 "users",
669 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
670 vec![],
671 )],
672 vec![table(
673 "users",
674 vec![
675 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
676 col("name", ColumnType::Simple(SimpleColumnType::Text)),
677 ],
678 vec![idx("ix_users__name", vec!["name"])],
679 )],
680 vec![
681 MigrationAction::AddColumn {
682 table: "users".into(),
683 column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
684 fill_with: None,
685 },
686 MigrationAction::AddConstraint {
687 table: "users".into(),
688 constraint: idx("ix_users__name", vec!["name"]),
689 },
690 ]
691 )]
692 #[case::drop_table(
693 vec![table(
694 "users",
695 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
696 vec![],
697 )],
698 vec![],
699 vec![MigrationAction::DeleteTable {
700 table: "users".into()
701 }]
702 )]
703 #[case::add_table_with_index(
704 vec![],
705 vec![table(
706 "users",
707 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
708 vec![idx("idx_users_id", vec!["id"])],
709 )],
710 vec![
711 MigrationAction::CreateTable {
712 table: "users".into(),
713 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
714 constraints: vec![idx("idx_users_id", vec!["id"])],
715 },
716 ]
717 )]
718 #[case::delete_column(
719 vec![table(
720 "users",
721 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
722 vec![],
723 )],
724 vec![table(
725 "users",
726 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
727 vec![],
728 )],
729 vec![MigrationAction::DeleteColumn {
730 table: "users".into(),
731 column: "name".into(),
732 }]
733 )]
734 #[case::modify_column_type(
735 vec![table(
736 "users",
737 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
738 vec![],
739 )],
740 vec![table(
741 "users",
742 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
743 vec![],
744 )],
745 vec![MigrationAction::ModifyColumnType {
746 table: "users".into(),
747 column: "id".into(),
748 new_type: ColumnType::Simple(SimpleColumnType::Text),
749 }]
750 )]
751 #[case::remove_index(
752 vec![table(
753 "users",
754 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
755 vec![idx("idx_users_id", vec!["id"])],
756 )],
757 vec![table(
758 "users",
759 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
760 vec![],
761 )],
762 vec![MigrationAction::RemoveConstraint {
763 table: "users".into(),
764 constraint: idx("idx_users_id", vec!["id"]),
765 }]
766 )]
767 #[case::add_index_existing_table(
768 vec![table(
769 "users",
770 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
771 vec![],
772 )],
773 vec![table(
774 "users",
775 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
776 vec![idx("idx_users_id", vec!["id"])],
777 )],
778 vec![MigrationAction::AddConstraint {
779 table: "users".into(),
780 constraint: idx("idx_users_id", vec!["id"]),
781 }]
782 )]
783 fn diff_schemas_detects_additions(
784 #[case] from_schema: Vec<TableDef>,
785 #[case] to_schema: Vec<TableDef>,
786 #[case] expected_actions: Vec<MigrationAction>,
787 ) {
788 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
789 assert_eq!(plan.actions, expected_actions);
790 }
791
792 mod integer_enum {
794 use super::*;
795 use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
796
797 #[test]
798 fn integer_enum_values_changed_no_migration() {
799 let from = vec![table(
801 "orders",
802 vec![col(
803 "status",
804 ColumnType::Complex(ComplexColumnType::Enum {
805 name: "order_status".into(),
806 values: EnumValues::Integer(vec![
807 NumValue {
808 name: "Pending".into(),
809 value: 0,
810 },
811 NumValue {
812 name: "Shipped".into(),
813 value: 1,
814 },
815 ]),
816 }),
817 )],
818 vec![],
819 )];
820
821 let to = vec![table(
822 "orders",
823 vec![col(
824 "status",
825 ColumnType::Complex(ComplexColumnType::Enum {
826 name: "order_status".into(),
827 values: EnumValues::Integer(vec![
828 NumValue {
829 name: "Pending".into(),
830 value: 0,
831 },
832 NumValue {
833 name: "Shipped".into(),
834 value: 1,
835 },
836 NumValue {
837 name: "Delivered".into(),
838 value: 2,
839 },
840 NumValue {
841 name: "Cancelled".into(),
842 value: 100,
843 },
844 ]),
845 }),
846 )],
847 vec![],
848 )];
849
850 let plan = diff_schemas(&from, &to).unwrap();
851 assert!(
852 plan.actions.is_empty(),
853 "Expected no actions, got: {:?}",
854 plan.actions
855 );
856 }
857
858 #[test]
859 fn string_enum_values_changed_requires_migration() {
860 let from = vec![table(
862 "orders",
863 vec![col(
864 "status",
865 ColumnType::Complex(ComplexColumnType::Enum {
866 name: "order_status".into(),
867 values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
868 }),
869 )],
870 vec![],
871 )];
872
873 let to = vec![table(
874 "orders",
875 vec![col(
876 "status",
877 ColumnType::Complex(ComplexColumnType::Enum {
878 name: "order_status".into(),
879 values: EnumValues::String(vec![
880 "pending".into(),
881 "shipped".into(),
882 "delivered".into(),
883 ]),
884 }),
885 )],
886 vec![],
887 )];
888
889 let plan = diff_schemas(&from, &to).unwrap();
890 assert_eq!(plan.actions.len(), 1);
891 assert!(matches!(
892 &plan.actions[0],
893 MigrationAction::ModifyColumnType { table, column, .. }
894 if table == "orders" && column == "status"
895 ));
896 }
897 }
898
899 mod enum_default_ordering {
901 use super::*;
902 use vespertide_core::{ComplexColumnType, EnumValues};
903
904 fn col_enum_with_default(
905 name: &str,
906 enum_name: &str,
907 values: Vec<&str>,
908 default: &str,
909 ) -> ColumnDef {
910 ColumnDef {
911 name: name.to_string(),
912 r#type: ColumnType::Complex(ComplexColumnType::Enum {
913 name: enum_name.to_string(),
914 values: EnumValues::String(values.into_iter().map(String::from).collect()),
915 }),
916 nullable: false,
917 default: Some(default.into()),
918 comment: None,
919 primary_key: None,
920 unique: None,
921 index: None,
922 foreign_key: None,
923 }
924 }
925
926 #[test]
927 fn enum_add_value_with_new_default() {
928 let from = vec![table(
931 "orders",
932 vec![col_enum_with_default(
933 "status",
934 "order_status",
935 vec!["pending", "shipped"],
936 "'pending'",
937 )],
938 vec![],
939 )];
940
941 let to = vec![table(
942 "orders",
943 vec![col_enum_with_default(
944 "status",
945 "order_status",
946 vec!["pending", "shipped", "delivered"],
947 "'delivered'", )],
949 vec![],
950 )];
951
952 let plan = diff_schemas(&from, &to).unwrap();
953
954 assert_eq!(
956 plan.actions.len(),
957 2,
958 "Expected 2 actions, got: {:?}",
959 plan.actions
960 );
961
962 assert!(
964 matches!(&plan.actions[0], MigrationAction::ModifyColumnType { table, column, .. }
965 if table == "orders" && column == "status"),
966 "First action should be ModifyColumnType, got: {:?}",
967 plan.actions[0]
968 );
969
970 assert!(
972 matches!(&plan.actions[1], MigrationAction::ModifyColumnDefault { table, column, .. }
973 if table == "orders" && column == "status"),
974 "Second action should be ModifyColumnDefault, got: {:?}",
975 plan.actions[1]
976 );
977 }
978
979 #[test]
980 fn enum_remove_value_that_was_default() {
981 let from = vec![table(
985 "orders",
986 vec![col_enum_with_default(
987 "status",
988 "order_status",
989 vec!["pending", "shipped", "cancelled"],
990 "'cancelled'", )],
992 vec![],
993 )];
994
995 let to = vec![table(
996 "orders",
997 vec![col_enum_with_default(
998 "status",
999 "order_status",
1000 vec!["pending", "shipped"], "'pending'", )],
1003 vec![],
1004 )];
1005
1006 let plan = diff_schemas(&from, &to).unwrap();
1007
1008 assert_eq!(
1010 plan.actions.len(),
1011 2,
1012 "Expected 2 actions, got: {:?}",
1013 plan.actions
1014 );
1015
1016 assert!(
1018 matches!(&plan.actions[0], MigrationAction::ModifyColumnDefault { table, column, .. }
1019 if table == "orders" && column == "status"),
1020 "First action should be ModifyColumnDefault, got: {:?}",
1021 plan.actions[0]
1022 );
1023
1024 assert!(
1026 matches!(&plan.actions[1], MigrationAction::ModifyColumnType { table, column, .. }
1027 if table == "orders" && column == "status"),
1028 "Second action should be ModifyColumnType, got: {:?}",
1029 plan.actions[1]
1030 );
1031 }
1032
1033 #[test]
1034 fn enum_remove_value_default_unchanged() {
1035 let from = vec![table(
1037 "orders",
1038 vec![col_enum_with_default(
1039 "status",
1040 "order_status",
1041 vec!["pending", "shipped", "cancelled"],
1042 "'pending'", )],
1044 vec![],
1045 )];
1046
1047 let to = vec![table(
1048 "orders",
1049 vec![col_enum_with_default(
1050 "status",
1051 "order_status",
1052 vec!["pending", "shipped"], "'pending'", )],
1055 vec![],
1056 )];
1057
1058 let plan = diff_schemas(&from, &to).unwrap();
1059
1060 assert_eq!(
1062 plan.actions.len(),
1063 1,
1064 "Expected 1 action, got: {:?}",
1065 plan.actions
1066 );
1067 assert!(
1068 matches!(&plan.actions[0], MigrationAction::ModifyColumnType { table, column, .. }
1069 if table == "orders" && column == "status"),
1070 "Action should be ModifyColumnType, got: {:?}",
1071 plan.actions[0]
1072 );
1073 }
1074
1075 #[test]
1076 fn enum_remove_value_with_default_change_to_remaining() {
1077 let from = vec![table(
1079 "orders",
1080 vec![col_enum_with_default(
1081 "status",
1082 "order_status",
1083 vec!["draft", "pending", "shipped", "delivered", "cancelled"],
1084 "'cancelled'",
1085 )],
1086 vec![],
1087 )];
1088
1089 let to = vec![table(
1090 "orders",
1091 vec![col_enum_with_default(
1092 "status",
1093 "order_status",
1094 vec!["pending", "shipped", "delivered"], "'pending'",
1096 )],
1097 vec![],
1098 )];
1099
1100 let plan = diff_schemas(&from, &to).unwrap();
1101
1102 assert_eq!(
1103 plan.actions.len(),
1104 2,
1105 "Expected 2 actions, got: {:?}",
1106 plan.actions
1107 );
1108
1109 assert!(
1111 matches!(
1112 &plan.actions[0],
1113 MigrationAction::ModifyColumnDefault { .. }
1114 ),
1115 "First action should be ModifyColumnDefault, got: {:?}",
1116 plan.actions[0]
1117 );
1118 assert!(
1119 matches!(&plan.actions[1], MigrationAction::ModifyColumnType { .. }),
1120 "Second action should be ModifyColumnType, got: {:?}",
1121 plan.actions[1]
1122 );
1123 }
1124
1125 #[test]
1126 fn enum_remove_value_with_unquoted_default() {
1127 let from = vec![table(
1130 "orders",
1131 vec![col_enum_with_default(
1132 "status",
1133 "order_status",
1134 vec!["pending", "shipped", "cancelled"],
1135 "cancelled", )],
1137 vec![],
1138 )];
1139
1140 let to = vec![table(
1141 "orders",
1142 vec![col_enum_with_default(
1143 "status",
1144 "order_status",
1145 vec!["pending", "shipped"], "pending", )],
1148 vec![],
1149 )];
1150
1151 let plan = diff_schemas(&from, &to).unwrap();
1152
1153 assert_eq!(
1155 plan.actions.len(),
1156 2,
1157 "Expected 2 actions, got: {:?}",
1158 plan.actions
1159 );
1160
1161 assert!(
1163 matches!(
1164 &plan.actions[0],
1165 MigrationAction::ModifyColumnDefault { .. }
1166 ),
1167 "First action should be ModifyColumnDefault, got: {:?}",
1168 plan.actions[0]
1169 );
1170 assert!(
1171 matches!(&plan.actions[1], MigrationAction::ModifyColumnType { .. }),
1172 "Second action should be ModifyColumnType, got: {:?}",
1173 plan.actions[1]
1174 );
1175 }
1176 }
1177
1178 mod inline_constraints {
1180 use super::*;
1181 use vespertide_core::schema::foreign_key::ForeignKeyDef;
1182 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1183 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1184 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
1185
1186 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
1187 ColumnDef {
1188 name: name.to_string(),
1189 r#type: ty,
1190 nullable: false,
1191 default: None,
1192 comment: None,
1193 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1194 unique: None,
1195 index: None,
1196 foreign_key: None,
1197 }
1198 }
1199
1200 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
1201 ColumnDef {
1202 name: name.to_string(),
1203 r#type: ty,
1204 nullable: true,
1205 default: None,
1206 comment: None,
1207 primary_key: None,
1208 unique: Some(StrOrBoolOrArray::Bool(true)),
1209 index: None,
1210 foreign_key: None,
1211 }
1212 }
1213
1214 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
1215 ColumnDef {
1216 name: name.to_string(),
1217 r#type: ty,
1218 nullable: true,
1219 default: None,
1220 comment: None,
1221 primary_key: None,
1222 unique: None,
1223 index: Some(StrOrBoolOrArray::Bool(true)),
1224 foreign_key: None,
1225 }
1226 }
1227
1228 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
1229 ColumnDef {
1230 name: name.to_string(),
1231 r#type: ty,
1232 nullable: true,
1233 default: None,
1234 comment: None,
1235 primary_key: None,
1236 unique: None,
1237 index: None,
1238 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
1239 ref_table: ref_table.to_string(),
1240 ref_columns: vec![ref_col.to_string()],
1241 on_delete: None,
1242 on_update: None,
1243 })),
1244 }
1245 }
1246
1247 #[test]
1248 fn create_table_with_inline_pk() {
1249 let plan = diff_schemas(
1250 &[],
1251 &[table(
1252 "users",
1253 vec![
1254 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
1255 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1256 ],
1257 vec![],
1258 )],
1259 )
1260 .unwrap();
1261
1262 assert_eq!(plan.actions.len(), 1);
1264 if let MigrationAction::CreateTable {
1265 columns,
1266 constraints,
1267 ..
1268 } = &plan.actions[0]
1269 {
1270 assert_eq!(constraints.len(), 0);
1272 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
1274 assert!(id_col.primary_key.is_some());
1275 } else {
1276 panic!("Expected CreateTable action");
1277 }
1278 }
1279
1280 #[test]
1281 fn create_table_with_inline_unique() {
1282 let plan = diff_schemas(
1283 &[],
1284 &[table(
1285 "users",
1286 vec![
1287 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1288 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
1289 ],
1290 vec![],
1291 )],
1292 )
1293 .unwrap();
1294
1295 assert_eq!(plan.actions.len(), 1);
1297 if let MigrationAction::CreateTable {
1298 columns,
1299 constraints,
1300 ..
1301 } = &plan.actions[0]
1302 {
1303 assert_eq!(constraints.len(), 0);
1305 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
1307 assert!(matches!(
1308 email_col.unique,
1309 Some(StrOrBoolOrArray::Bool(true))
1310 ));
1311 } else {
1312 panic!("Expected CreateTable action");
1313 }
1314 }
1315
1316 #[test]
1317 fn create_table_with_inline_index() {
1318 let plan = diff_schemas(
1319 &[],
1320 &[table(
1321 "users",
1322 vec![
1323 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1324 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
1325 ],
1326 vec![],
1327 )],
1328 )
1329 .unwrap();
1330
1331 assert_eq!(plan.actions.len(), 1);
1333 if let MigrationAction::CreateTable {
1334 columns,
1335 constraints,
1336 ..
1337 } = &plan.actions[0]
1338 {
1339 assert_eq!(constraints.len(), 0);
1341 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
1343 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
1344 } else {
1345 panic!("Expected CreateTable action");
1346 }
1347 }
1348
1349 #[test]
1350 fn create_table_with_inline_fk() {
1351 let plan = diff_schemas(
1352 &[],
1353 &[table(
1354 "posts",
1355 vec![
1356 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1357 col_with_fk(
1358 "user_id",
1359 ColumnType::Simple(SimpleColumnType::Integer),
1360 "users",
1361 "id",
1362 ),
1363 ],
1364 vec![],
1365 )],
1366 )
1367 .unwrap();
1368
1369 assert_eq!(plan.actions.len(), 1);
1371 if let MigrationAction::CreateTable {
1372 columns,
1373 constraints,
1374 ..
1375 } = &plan.actions[0]
1376 {
1377 assert_eq!(constraints.len(), 0);
1379 let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
1381 assert!(user_id_col.foreign_key.is_some());
1382 } else {
1383 panic!("Expected CreateTable action");
1384 }
1385 }
1386
1387 #[test]
1388 fn add_index_via_inline_constraint() {
1389 let plan = diff_schemas(
1392 &[table(
1393 "users",
1394 vec![
1395 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1396 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1397 ],
1398 vec![],
1399 )],
1400 &[table(
1401 "users",
1402 vec![
1403 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1404 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
1405 ],
1406 vec![],
1407 )],
1408 )
1409 .unwrap();
1410
1411 assert_eq!(plan.actions.len(), 1);
1413 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1414 assert_eq!(table, "users");
1415 if let TableConstraint::Index { name, columns } = constraint {
1416 assert_eq!(name, &None); assert_eq!(columns, &vec!["name".to_string()]);
1418 } else {
1419 panic!("Expected Index constraint, got {:?}", constraint);
1420 }
1421 } else {
1422 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1423 }
1424 }
1425
1426 #[test]
1427 fn create_table_with_all_inline_constraints() {
1428 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
1429 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
1430 id_col.nullable = false;
1431
1432 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
1433 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
1434
1435 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
1436 name_col.index = Some(StrOrBoolOrArray::Bool(true));
1437
1438 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
1439 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
1440 ref_table: "orgs".into(),
1441 ref_columns: vec!["id".into()],
1442 on_delete: None,
1443 on_update: None,
1444 }));
1445
1446 let plan = diff_schemas(
1447 &[],
1448 &[table(
1449 "users",
1450 vec![id_col, email_col, name_col, org_id_col],
1451 vec![],
1452 )],
1453 )
1454 .unwrap();
1455
1456 assert_eq!(plan.actions.len(), 1);
1458
1459 if let MigrationAction::CreateTable {
1460 columns,
1461 constraints,
1462 ..
1463 } = &plan.actions[0]
1464 {
1465 assert_eq!(constraints.len(), 0);
1467
1468 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
1470 assert!(id_col.primary_key.is_some());
1471
1472 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
1473 assert!(matches!(
1474 email_col.unique,
1475 Some(StrOrBoolOrArray::Bool(true))
1476 ));
1477
1478 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
1479 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
1480
1481 let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
1482 assert!(org_id_col.foreign_key.is_some());
1483 } else {
1484 panic!("Expected CreateTable action");
1485 }
1486 }
1487
1488 #[test]
1489 fn add_constraint_to_existing_table() {
1490 let from_schema = vec![table(
1492 "users",
1493 vec![
1494 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1495 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1496 ],
1497 vec![],
1498 )];
1499
1500 let to_schema = vec![table(
1501 "users",
1502 vec![
1503 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1504 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1505 ],
1506 vec![vespertide_core::TableConstraint::Unique {
1507 name: Some("uq_users_email".into()),
1508 columns: vec!["email".into()],
1509 }],
1510 )];
1511
1512 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1513 assert_eq!(plan.actions.len(), 1);
1514 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1515 assert_eq!(table, "users");
1516 assert!(matches!(
1517 constraint,
1518 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1519 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1520 ));
1521 } else {
1522 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1523 }
1524 }
1525
1526 #[test]
1527 fn remove_constraint_from_existing_table() {
1528 let from_schema = vec![table(
1530 "users",
1531 vec![
1532 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1533 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1534 ],
1535 vec![vespertide_core::TableConstraint::Unique {
1536 name: Some("uq_users_email".into()),
1537 columns: vec!["email".into()],
1538 }],
1539 )];
1540
1541 let to_schema = vec![table(
1542 "users",
1543 vec![
1544 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1545 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1546 ],
1547 vec![],
1548 )];
1549
1550 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1551 assert_eq!(plan.actions.len(), 1);
1552 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1553 assert_eq!(table, "users");
1554 assert!(matches!(
1555 constraint,
1556 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1557 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1558 ));
1559 } else {
1560 panic!(
1561 "Expected RemoveConstraint action, got {:?}",
1562 plan.actions[0]
1563 );
1564 }
1565 }
1566
1567 #[test]
1568 fn diff_schemas_with_normalize_error() {
1569 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1571 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1572
1573 let table = TableDef {
1574 name: "test".into(),
1575 description: None,
1576 columns: vec![
1577 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1578 col1.clone(),
1579 {
1580 let mut c = col1.clone();
1582 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1583 c
1584 },
1585 ],
1586 constraints: vec![],
1587 };
1588
1589 let result = diff_schemas(&[], &[table]);
1590 assert!(result.is_err());
1591 if let Err(PlannerError::TableValidation(msg)) = result {
1592 assert!(msg.contains("Failed to normalize table"));
1593 assert!(msg.contains("Duplicate index"));
1594 } else {
1595 panic!("Expected TableValidation error, got {:?}", result);
1596 }
1597 }
1598
1599 #[test]
1600 fn diff_schemas_with_normalize_error_in_from_schema() {
1601 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1603 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1604
1605 let table = TableDef {
1606 name: "test".into(),
1607 description: None,
1608 columns: vec![
1609 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1610 col1.clone(),
1611 {
1612 let mut c = col1.clone();
1614 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1615 c
1616 },
1617 ],
1618 constraints: vec![],
1619 };
1620
1621 let result = diff_schemas(&[table], &[]);
1623 assert!(result.is_err());
1624 if let Err(PlannerError::TableValidation(msg)) = result {
1625 assert!(msg.contains("Failed to normalize table"));
1626 assert!(msg.contains("Duplicate index"));
1627 } else {
1628 panic!("Expected TableValidation error, got {:?}", result);
1629 }
1630 }
1631 }
1632
1633 mod sort_create_before_add_constraint_tests {
1635 use super::*;
1636 use crate::diff::{compare_actions_for_create_order, sort_create_before_add_constraint};
1637 use std::cmp::Ordering;
1638
1639 fn make_add_column(table: &str, col: &str) -> MigrationAction {
1640 MigrationAction::AddColumn {
1641 table: table.into(),
1642 column: Box::new(ColumnDef {
1643 name: col.into(),
1644 r#type: ColumnType::Simple(SimpleColumnType::Text),
1645 nullable: true,
1646 default: None,
1647 comment: None,
1648 primary_key: None,
1649 unique: None,
1650 index: None,
1651 foreign_key: None,
1652 }),
1653 fill_with: None,
1654 }
1655 }
1656
1657 fn make_create_table(name: &str) -> MigrationAction {
1658 MigrationAction::CreateTable {
1659 table: name.into(),
1660 columns: vec![],
1661 constraints: vec![],
1662 }
1663 }
1664
1665 fn make_add_fk(table: &str, ref_table: &str) -> MigrationAction {
1666 MigrationAction::AddConstraint {
1667 table: table.into(),
1668 constraint: TableConstraint::ForeignKey {
1669 name: None,
1670 columns: vec!["fk_col".into()],
1671 ref_table: ref_table.into(),
1672 ref_columns: vec!["id".into()],
1673 on_delete: None,
1674 on_update: None,
1675 },
1676 }
1677 }
1678
1679 #[test]
1682 fn test_compare_non_create_vs_create() {
1683 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1684
1685 let add_col = make_add_column("users", "name");
1686 let create_table = make_create_table("roles");
1687
1688 let result = compare_actions_for_create_order(&add_col, &create_table, &created_tables);
1690 assert_eq!(
1691 result,
1692 Ordering::Greater,
1693 "Non-CreateTable vs CreateTable should return Greater"
1694 );
1695 }
1696
1697 #[test]
1699 fn test_compare_create_vs_non_create() {
1700 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1701
1702 let create_table = make_create_table("roles");
1703 let add_col = make_add_column("users", "name");
1704
1705 let result = compare_actions_for_create_order(&create_table, &add_col, &created_tables);
1707 assert_eq!(
1708 result,
1709 Ordering::Less,
1710 "CreateTable vs Non-CreateTable should return Less"
1711 );
1712 }
1713
1714 #[test]
1716 fn test_compare_create_vs_create() {
1717 let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1718 .into_iter()
1719 .collect();
1720
1721 let create1 = make_create_table("roles");
1722 let create2 = make_create_table("categories");
1723
1724 let result = compare_actions_for_create_order(&create1, &create2, &created_tables);
1726 assert_eq!(
1727 result,
1728 Ordering::Equal,
1729 "CreateTable vs CreateTable should return Equal"
1730 );
1731 }
1732
1733 #[test]
1735 fn test_compare_refs_vs_non_refs() {
1736 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1737
1738 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);
1743 assert_eq!(
1744 result,
1745 Ordering::Greater,
1746 "FK-ref vs non-ref should return Greater"
1747 );
1748 }
1749
1750 #[test]
1752 fn test_compare_non_refs_vs_refs() {
1753 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1754
1755 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);
1760 assert_eq!(
1761 result,
1762 Ordering::Less,
1763 "Non-ref vs FK-ref should return Less"
1764 );
1765 }
1766
1767 #[test]
1769 fn test_compare_non_refs_vs_non_refs() {
1770 let created_tables: BTreeSet<String> = ["roles".to_string()].into_iter().collect();
1771
1772 let add_col1 = make_add_column("users", "name");
1773 let add_col2 = make_add_column("posts", "title");
1774
1775 let result = compare_actions_for_create_order(&add_col1, &add_col2, &created_tables);
1777 assert_eq!(
1778 result,
1779 Ordering::Equal,
1780 "Non-ref vs non-ref should return Equal"
1781 );
1782 }
1783
1784 #[test]
1786 fn test_compare_refs_vs_refs() {
1787 let created_tables: BTreeSet<String> = ["roles".to_string(), "categories".to_string()]
1788 .into_iter()
1789 .collect();
1790
1791 let add_fk1 = make_add_fk("users", "roles");
1792 let add_fk2 = make_add_fk("posts", "categories");
1793
1794 let result = compare_actions_for_create_order(&add_fk1, &add_fk2, &created_tables);
1796 assert_eq!(
1797 result,
1798 Ordering::Equal,
1799 "FK-ref vs FK-ref should return Equal"
1800 );
1801 }
1802
1803 #[test]
1805 fn test_sort_integration() {
1806 let mut actions = vec![
1807 make_add_column("t1", "c1"),
1808 make_add_fk("users", "roles"),
1809 make_create_table("roles"),
1810 ];
1811
1812 sort_create_before_add_constraint(&mut actions);
1813
1814 assert!(matches!(&actions[0], MigrationAction::CreateTable { .. }));
1816 assert!(matches!(&actions[1], MigrationAction::AddColumn { .. }));
1817 assert!(matches!(&actions[2], MigrationAction::AddConstraint { .. }));
1818 }
1819 }
1820
1821 mod fk_ordering {
1823 use super::*;
1824 use vespertide_core::TableConstraint;
1825
1826 fn table_with_fk(
1827 name: &str,
1828 ref_table: &str,
1829 fk_column: &str,
1830 ref_column: &str,
1831 ) -> TableDef {
1832 TableDef {
1833 name: name.to_string(),
1834 description: None,
1835 columns: vec![
1836 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1837 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1838 ],
1839 constraints: vec![TableConstraint::ForeignKey {
1840 name: None,
1841 columns: vec![fk_column.to_string()],
1842 ref_table: ref_table.to_string(),
1843 ref_columns: vec![ref_column.to_string()],
1844 on_delete: None,
1845 on_update: None,
1846 }],
1847 }
1848 }
1849
1850 fn simple_table(name: &str) -> TableDef {
1851 TableDef {
1852 name: name.to_string(),
1853 description: None,
1854 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1855 constraints: vec![],
1856 }
1857 }
1858
1859 #[test]
1860 fn create_tables_respects_fk_order() {
1861 let users = simple_table("users");
1864 let posts = table_with_fk("posts", "users", "user_id", "id");
1865
1866 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1867
1868 let create_order: Vec<&str> = plan
1870 .actions
1871 .iter()
1872 .filter_map(|a| {
1873 if let MigrationAction::CreateTable { table, .. } = a {
1874 Some(table.as_str())
1875 } else {
1876 None
1877 }
1878 })
1879 .collect();
1880
1881 assert_eq!(create_order, vec!["users", "posts"]);
1882 }
1883
1884 #[test]
1885 fn create_tables_chain_dependency() {
1886 let users = simple_table("users");
1891 let media = table_with_fk("media", "users", "owner_id", "id");
1892 let articles = table_with_fk("articles", "media", "media_id", "id");
1893
1894 let plan =
1896 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1897
1898 let create_order: Vec<&str> = plan
1899 .actions
1900 .iter()
1901 .filter_map(|a| {
1902 if let MigrationAction::CreateTable { table, .. } = a {
1903 Some(table.as_str())
1904 } else {
1905 None
1906 }
1907 })
1908 .collect();
1909
1910 assert_eq!(create_order, vec!["users", "media", "articles"]);
1911 }
1912
1913 #[test]
1914 fn create_tables_multiple_independent_branches() {
1915 let users = simple_table("users");
1919 let posts = table_with_fk("posts", "users", "user_id", "id");
1920 let categories = simple_table("categories");
1921 let products = table_with_fk("products", "categories", "category_id", "id");
1922
1923 let plan = diff_schemas(
1924 &[],
1925 &[
1926 products.clone(),
1927 posts.clone(),
1928 categories.clone(),
1929 users.clone(),
1930 ],
1931 )
1932 .unwrap();
1933
1934 let create_order: Vec<&str> = plan
1935 .actions
1936 .iter()
1937 .filter_map(|a| {
1938 if let MigrationAction::CreateTable { table, .. } = a {
1939 Some(table.as_str())
1940 } else {
1941 None
1942 }
1943 })
1944 .collect();
1945
1946 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1948 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1949 assert!(
1950 users_pos < posts_pos,
1951 "users should be created before posts"
1952 );
1953
1954 let categories_pos = create_order
1956 .iter()
1957 .position(|&t| t == "categories")
1958 .unwrap();
1959 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1960 assert!(
1961 categories_pos < products_pos,
1962 "categories should be created before products"
1963 );
1964 }
1965
1966 #[test]
1967 fn delete_tables_respects_fk_order() {
1968 let users = simple_table("users");
1971 let posts = table_with_fk("posts", "users", "user_id", "id");
1972
1973 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1974
1975 let delete_order: Vec<&str> = plan
1976 .actions
1977 .iter()
1978 .filter_map(|a| {
1979 if let MigrationAction::DeleteTable { table } = a {
1980 Some(table.as_str())
1981 } else {
1982 None
1983 }
1984 })
1985 .collect();
1986
1987 assert_eq!(delete_order, vec!["posts", "users"]);
1988 }
1989
1990 #[test]
1991 fn delete_tables_chain_dependency() {
1992 let users = simple_table("users");
1995 let media = table_with_fk("media", "users", "owner_id", "id");
1996 let articles = table_with_fk("articles", "media", "media_id", "id");
1997
1998 let plan =
1999 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
2000
2001 let delete_order: Vec<&str> = plan
2002 .actions
2003 .iter()
2004 .filter_map(|a| {
2005 if let MigrationAction::DeleteTable { table } = a {
2006 Some(table.as_str())
2007 } else {
2008 None
2009 }
2010 })
2011 .collect();
2012
2013 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
2015 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
2016 assert!(
2017 articles_pos < media_pos,
2018 "articles should be deleted before media"
2019 );
2020
2021 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
2023 assert!(
2024 media_pos < users_pos,
2025 "media should be deleted before users"
2026 );
2027 }
2028
2029 #[test]
2030 fn circular_fk_dependency_returns_error() {
2031 let table_a = TableDef {
2033 name: "table_a".to_string(),
2034 description: None,
2035 columns: vec![
2036 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2037 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
2038 ],
2039 constraints: vec![TableConstraint::ForeignKey {
2040 name: None,
2041 columns: vec!["b_id".to_string()],
2042 ref_table: "table_b".to_string(),
2043 ref_columns: vec!["id".to_string()],
2044 on_delete: None,
2045 on_update: None,
2046 }],
2047 };
2048
2049 let table_b = TableDef {
2050 name: "table_b".to_string(),
2051 description: None,
2052 columns: vec![
2053 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2054 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
2055 ],
2056 constraints: vec![TableConstraint::ForeignKey {
2057 name: None,
2058 columns: vec!["a_id".to_string()],
2059 ref_table: "table_a".to_string(),
2060 ref_columns: vec!["id".to_string()],
2061 on_delete: None,
2062 on_update: None,
2063 }],
2064 };
2065
2066 let result = diff_schemas(&[], &[table_a, table_b]);
2067 assert!(result.is_err());
2068 if let Err(PlannerError::TableValidation(msg)) = result {
2069 assert!(
2070 msg.contains("Circular foreign key dependency"),
2071 "Expected circular dependency error, got: {}",
2072 msg
2073 );
2074 } else {
2075 panic!("Expected TableValidation error, got {:?}", result);
2076 }
2077 }
2078
2079 #[test]
2080 fn fk_to_external_table_is_ignored() {
2081 let posts = table_with_fk("posts", "users", "user_id", "id");
2083 let comments = table_with_fk("comments", "posts", "post_id", "id");
2084
2085 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
2087
2088 let create_order: Vec<&str> = plan
2089 .actions
2090 .iter()
2091 .filter_map(|a| {
2092 if let MigrationAction::CreateTable { table, .. } = a {
2093 Some(table.as_str())
2094 } else {
2095 None
2096 }
2097 })
2098 .collect();
2099
2100 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
2102 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
2103 assert!(
2104 posts_pos < comments_pos,
2105 "posts should be created before comments"
2106 );
2107 }
2108
2109 #[test]
2110 fn delete_tables_mixed_with_other_actions() {
2111 use crate::diff::diff_schemas;
2114
2115 let from_schema = vec![
2116 table(
2117 "users",
2118 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2119 vec![],
2120 ),
2121 table(
2122 "posts",
2123 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2124 vec![],
2125 ),
2126 ];
2127
2128 let to_schema = vec![
2129 table(
2131 "users",
2132 vec![
2133 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2134 col("name", ColumnType::Simple(SimpleColumnType::Text)),
2135 ],
2136 vec![],
2137 ),
2138 ];
2139
2140 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2141
2142 assert!(
2144 plan.actions
2145 .iter()
2146 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
2147 );
2148 assert!(
2149 plan.actions
2150 .iter()
2151 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
2152 );
2153
2154 }
2157
2158 #[test]
2159 #[should_panic(expected = "Expected DeleteTable action")]
2160 fn test_extract_delete_table_name_panics_on_non_delete_action() {
2161 use super::extract_delete_table_name;
2163
2164 let action = MigrationAction::AddColumn {
2165 table: "users".into(),
2166 column: Box::new(ColumnDef {
2167 name: "email".into(),
2168 r#type: ColumnType::Simple(SimpleColumnType::Text),
2169 nullable: true,
2170 default: None,
2171 comment: None,
2172 primary_key: None,
2173 unique: None,
2174 index: None,
2175 foreign_key: None,
2176 }),
2177 fill_with: None,
2178 };
2179
2180 extract_delete_table_name(&action);
2182 }
2183
2184 #[test]
2186 fn create_tables_with_inline_fk_chain() {
2187 use super::*;
2188 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
2189 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
2190
2191 fn col_pk(name: &str) -> ColumnDef {
2192 ColumnDef {
2193 name: name.to_string(),
2194 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2195 nullable: false,
2196 default: None,
2197 comment: None,
2198 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2199 unique: None,
2200 index: None,
2201 foreign_key: None,
2202 }
2203 }
2204
2205 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
2206 ColumnDef {
2207 name: name.to_string(),
2208 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2209 nullable: true,
2210 default: None,
2211 comment: None,
2212 primary_key: None,
2213 unique: None,
2214 index: None,
2215 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
2216 }
2217 }
2218
2219 let user = TableDef {
2228 name: "user".to_string(),
2229 description: None,
2230 columns: vec![col_pk("id")],
2231 constraints: vec![],
2232 };
2233
2234 let product = TableDef {
2235 name: "product".to_string(),
2236 description: None,
2237 columns: vec![col_pk("id")],
2238 constraints: vec![],
2239 };
2240
2241 let project = TableDef {
2242 name: "project".to_string(),
2243 description: None,
2244 columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
2245 constraints: vec![],
2246 };
2247
2248 let code = TableDef {
2249 name: "code".to_string(),
2250 description: None,
2251 columns: vec![
2252 col_pk("id"),
2253 col_inline_fk("product_id", "product"),
2254 col_inline_fk("creator_user_id", "user"),
2255 col_inline_fk("project_id", "project"),
2256 ],
2257 constraints: vec![],
2258 };
2259
2260 let order = TableDef {
2261 name: "order".to_string(),
2262 description: None,
2263 columns: vec![
2264 col_pk("id"),
2265 col_inline_fk("user_id", "user"),
2266 col_inline_fk("project_id", "project"),
2267 col_inline_fk("product_id", "product"),
2268 col_inline_fk("code_id", "code"),
2269 ],
2270 constraints: vec![],
2271 };
2272
2273 let payment = TableDef {
2274 name: "payment".to_string(),
2275 description: None,
2276 columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
2277 constraints: vec![],
2278 };
2279
2280 let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
2282 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
2283
2284 let plan = result.unwrap();
2285 let create_order: Vec<&str> = plan
2286 .actions
2287 .iter()
2288 .filter_map(|a| {
2289 if let MigrationAction::CreateTable { table, .. } = a {
2290 Some(table.as_str())
2291 } else {
2292 None
2293 }
2294 })
2295 .collect();
2296
2297 let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
2299
2300 assert!(
2303 get_pos("user") < get_pos("project"),
2304 "user must come before project"
2305 );
2306 assert!(
2308 get_pos("product") < get_pos("code"),
2309 "product must come before code"
2310 );
2311 assert!(
2312 get_pos("user") < get_pos("code"),
2313 "user must come before code"
2314 );
2315 assert!(
2316 get_pos("project") < get_pos("code"),
2317 "project must come before code"
2318 );
2319 assert!(
2321 get_pos("code") < get_pos("order"),
2322 "code must come before order"
2323 );
2324 assert!(
2326 get_pos("order") < get_pos("payment"),
2327 "order must come before payment"
2328 );
2329 }
2330
2331 #[test]
2333 fn add_constraint_fk_to_new_table_comes_after_create_table() {
2334 use super::*;
2335
2336 let notification_from = table(
2338 "notification",
2339 vec![
2340 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2341 col(
2342 "broadcast_id",
2343 ColumnType::Simple(SimpleColumnType::Integer),
2344 ),
2345 ],
2346 vec![],
2347 );
2348
2349 let notification_broadcast = table(
2351 "notification_broadcast",
2352 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2353 vec![],
2354 );
2355
2356 let notification_to = table(
2358 "notification",
2359 vec![
2360 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2361 col(
2362 "broadcast_id",
2363 ColumnType::Simple(SimpleColumnType::Integer),
2364 ),
2365 ],
2366 vec![TableConstraint::ForeignKey {
2367 name: None,
2368 columns: vec!["broadcast_id".into()],
2369 ref_table: "notification_broadcast".into(),
2370 ref_columns: vec!["id".into()],
2371 on_delete: None,
2372 on_update: None,
2373 }],
2374 );
2375
2376 let from_schema = vec![notification_from];
2377 let to_schema = vec![notification_to, notification_broadcast];
2378
2379 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2380
2381 let create_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "notification_broadcast"));
2383 let add_constraint_pos = plan.actions.iter().position(|a| {
2384 matches!(a, MigrationAction::AddConstraint {
2385 constraint: TableConstraint::ForeignKey { ref_table, .. }, ..
2386 } if ref_table == "notification_broadcast")
2387 });
2388
2389 assert!(
2390 create_pos.is_some(),
2391 "Should have CreateTable for notification_broadcast"
2392 );
2393 assert!(
2394 add_constraint_pos.is_some(),
2395 "Should have AddConstraint for FK to notification_broadcast"
2396 );
2397 assert!(
2398 create_pos.unwrap() < add_constraint_pos.unwrap(),
2399 "CreateTable must come BEFORE AddConstraint FK that references it. Got CreateTable at {}, AddConstraint at {}",
2400 create_pos.unwrap(),
2401 add_constraint_pos.unwrap()
2402 );
2403 }
2404
2405 #[test]
2408 fn sort_create_before_add_constraint_all_branches() {
2409 use super::*;
2410
2411 let users_from = table(
2420 "users",
2421 vec![
2422 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2423 {
2424 let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2425 c.comment = Some("Old comment".into());
2426 c
2427 },
2428 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2429 ],
2430 vec![],
2431 );
2432
2433 let posts_from = table(
2434 "posts",
2435 vec![
2436 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2437 col("title", ColumnType::Simple(SimpleColumnType::Text)),
2438 ],
2439 vec![],
2440 );
2441
2442 let roles = table(
2444 "roles",
2445 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2446 vec![],
2447 );
2448
2449 let users_to = table(
2451 "users",
2452 vec![
2453 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2454 {
2455 let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text));
2456 c.comment = Some("New comment".into());
2457 c
2458 },
2459 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2460 ],
2461 vec![TableConstraint::ForeignKey {
2462 name: None,
2463 columns: vec!["role_id".into()],
2464 ref_table: "roles".into(),
2465 ref_columns: vec!["id".into()],
2466 on_delete: None,
2467 on_update: None,
2468 }],
2469 );
2470
2471 let posts_to = table(
2473 "posts",
2474 vec![
2475 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2476 col("title", ColumnType::Simple(SimpleColumnType::Text)),
2477 ],
2478 vec![TableConstraint::Index {
2479 name: Some("idx_title".into()),
2480 columns: vec!["title".into()],
2481 }],
2482 );
2483
2484 let from_schema = vec![users_from, posts_from];
2485 let to_schema = vec![users_to, posts_to, roles];
2486
2487 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2488
2489 let create_pos = plan
2491 .actions
2492 .iter()
2493 .position(
2494 |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2495 )
2496 .expect("Should have CreateTable for roles");
2497
2498 let modify_pos = plan
2500 .actions
2501 .iter()
2502 .position(|a| matches!(a, MigrationAction::ModifyColumnComment { .. }))
2503 .expect("Should have ModifyColumnComment");
2504
2505 let add_index_pos = plan
2507 .actions
2508 .iter()
2509 .position(|a| {
2510 matches!(
2511 a,
2512 MigrationAction::AddConstraint {
2513 constraint: TableConstraint::Index { .. },
2514 ..
2515 }
2516 )
2517 })
2518 .expect("Should have AddConstraint Index");
2519
2520 let add_fk_pos = plan
2522 .actions
2523 .iter()
2524 .position(|a| {
2525 matches!(
2526 a,
2527 MigrationAction::AddConstraint {
2528 constraint: TableConstraint::ForeignKey { ref_table, .. },
2529 ..
2530 } if ref_table == "roles"
2531 )
2532 })
2533 .expect("Should have AddConstraint FK to roles");
2534
2535 assert!(
2536 create_pos < modify_pos,
2537 "CreateTable must come before ModifyColumnComment"
2538 );
2539 assert!(
2540 create_pos < add_index_pos,
2541 "CreateTable must come before AddConstraint Index"
2542 );
2543 assert!(
2544 create_pos < add_fk_pos,
2545 "CreateTable must come before AddConstraint FK"
2546 );
2547 assert!(
2549 add_index_pos < add_fk_pos,
2550 "AddConstraint Index (not referencing created) should come before AddConstraint FK (referencing created)"
2551 );
2552 }
2553
2554 #[test]
2557 fn sort_multiple_fks_to_created_tables() {
2558 use super::*;
2559
2560 let users_from = table(
2562 "users",
2563 vec![
2564 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2565 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2566 ],
2567 vec![],
2568 );
2569
2570 let posts_from = table(
2571 "posts",
2572 vec![
2573 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2574 col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2575 ],
2576 vec![],
2577 );
2578
2579 let roles = table(
2581 "roles",
2582 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2583 vec![],
2584 );
2585 let categories = table(
2586 "categories",
2587 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
2588 vec![],
2589 );
2590
2591 let users_to = table(
2592 "users",
2593 vec![
2594 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2595 col("role_id", ColumnType::Simple(SimpleColumnType::Integer)),
2596 ],
2597 vec![TableConstraint::ForeignKey {
2598 name: None,
2599 columns: vec!["role_id".into()],
2600 ref_table: "roles".into(),
2601 ref_columns: vec!["id".into()],
2602 on_delete: None,
2603 on_update: None,
2604 }],
2605 );
2606
2607 let posts_to = table(
2608 "posts",
2609 vec![
2610 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2611 col("category_id", ColumnType::Simple(SimpleColumnType::Integer)),
2612 ],
2613 vec![TableConstraint::ForeignKey {
2614 name: None,
2615 columns: vec!["category_id".into()],
2616 ref_table: "categories".into(),
2617 ref_columns: vec!["id".into()],
2618 on_delete: None,
2619 on_update: None,
2620 }],
2621 );
2622
2623 let from_schema = vec![users_from, posts_from];
2624 let to_schema = vec![users_to, posts_to, roles, categories];
2625
2626 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
2627
2628 let create_roles_pos = plan.actions.iter().position(
2630 |a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "roles"),
2631 );
2632 let create_categories_pos = plan.actions.iter().position(|a| matches!(a, MigrationAction::CreateTable { table, .. } if table == "categories"));
2633 let add_fk_roles_pos = plan.actions.iter().position(|a| {
2634 matches!(
2635 a,
2636 MigrationAction::AddConstraint {
2637 constraint: TableConstraint::ForeignKey { ref_table, .. },
2638 ..
2639 } if ref_table == "roles"
2640 )
2641 });
2642 let add_fk_categories_pos = plan.actions.iter().position(|a| {
2643 matches!(
2644 a,
2645 MigrationAction::AddConstraint {
2646 constraint: TableConstraint::ForeignKey { ref_table, .. },
2647 ..
2648 } if ref_table == "categories"
2649 )
2650 });
2651
2652 assert!(create_roles_pos.is_some());
2653 assert!(create_categories_pos.is_some());
2654 assert!(add_fk_roles_pos.is_some());
2655 assert!(add_fk_categories_pos.is_some());
2656
2657 let max_create = create_roles_pos
2659 .unwrap()
2660 .max(create_categories_pos.unwrap());
2661 let min_add_fk = add_fk_roles_pos
2662 .unwrap()
2663 .min(add_fk_categories_pos.unwrap());
2664 assert!(
2665 max_create < min_add_fk,
2666 "All CreateTable actions must come before all AddConstraint FK actions"
2667 );
2668 }
2669
2670 #[test]
2672 fn create_tables_with_duplicate_fk_references() {
2673 use super::*;
2674 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
2675 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
2676
2677 fn col_pk(name: &str) -> ColumnDef {
2678 ColumnDef {
2679 name: name.to_string(),
2680 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2681 nullable: false,
2682 default: None,
2683 comment: None,
2684 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2685 unique: None,
2686 index: None,
2687 foreign_key: None,
2688 }
2689 }
2690
2691 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
2692 ColumnDef {
2693 name: name.to_string(),
2694 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2695 nullable: true,
2696 default: None,
2697 comment: None,
2698 primary_key: None,
2699 unique: None,
2700 index: None,
2701 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
2702 }
2703 }
2704
2705 let user = TableDef {
2707 name: "user".to_string(),
2708 description: None,
2709 columns: vec![col_pk("id")],
2710 constraints: vec![],
2711 };
2712
2713 let code = TableDef {
2714 name: "code".to_string(),
2715 description: None,
2716 columns: vec![
2717 col_pk("id"),
2718 col_inline_fk("creator_user_id", "user"),
2719 col_inline_fk("used_by_user_id", "user"), ],
2721 constraints: vec![],
2722 };
2723
2724 let result = diff_schemas(&[], &[code, user]);
2726 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
2727
2728 let plan = result.unwrap();
2729 let create_order: Vec<&str> = plan
2730 .actions
2731 .iter()
2732 .filter_map(|a| {
2733 if let MigrationAction::CreateTable { table, .. } = a {
2734 Some(table.as_str())
2735 } else {
2736 None
2737 }
2738 })
2739 .collect();
2740
2741 let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
2743 let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
2744 assert!(user_pos < code_pos, "user must come before code");
2745 }
2746 }
2747
2748 mod primary_key_changes {
2749 use super::*;
2750
2751 fn pk(columns: Vec<&str>) -> TableConstraint {
2752 TableConstraint::PrimaryKey {
2753 auto_increment: false,
2754 columns: columns.into_iter().map(|s| s.to_string()).collect(),
2755 }
2756 }
2757
2758 #[test]
2759 fn add_column_to_composite_pk() {
2760 let from = vec![table(
2762 "users",
2763 vec![
2764 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2765 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2766 ],
2767 vec![pk(vec!["id"])],
2768 )];
2769
2770 let to = vec![table(
2771 "users",
2772 vec![
2773 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2774 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2775 ],
2776 vec![pk(vec!["id", "tenant_id"])],
2777 )];
2778
2779 let plan = diff_schemas(&from, &to).unwrap();
2780
2781 assert_eq!(plan.actions.len(), 2);
2783
2784 let has_remove = plan.actions.iter().any(|a| {
2785 matches!(
2786 a,
2787 MigrationAction::RemoveConstraint {
2788 table,
2789 constraint: TableConstraint::PrimaryKey { columns, .. }
2790 } if table == "users" && columns == &vec!["id".to_string()]
2791 )
2792 });
2793 assert!(has_remove, "Should have RemoveConstraint for old PK");
2794
2795 let has_add = plan.actions.iter().any(|a| {
2796 matches!(
2797 a,
2798 MigrationAction::AddConstraint {
2799 table,
2800 constraint: TableConstraint::PrimaryKey { columns, .. }
2801 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2802 )
2803 });
2804 assert!(has_add, "Should have AddConstraint for new composite PK");
2805 }
2806
2807 #[test]
2808 fn remove_column_from_composite_pk() {
2809 let from = vec![table(
2811 "users",
2812 vec![
2813 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2814 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2815 ],
2816 vec![pk(vec!["id", "tenant_id"])],
2817 )];
2818
2819 let to = vec![table(
2820 "users",
2821 vec![
2822 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2823 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2824 ],
2825 vec![pk(vec!["id"])],
2826 )];
2827
2828 let plan = diff_schemas(&from, &to).unwrap();
2829
2830 assert_eq!(plan.actions.len(), 2);
2832
2833 let has_remove = plan.actions.iter().any(|a| {
2834 matches!(
2835 a,
2836 MigrationAction::RemoveConstraint {
2837 table,
2838 constraint: TableConstraint::PrimaryKey { columns, .. }
2839 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2840 )
2841 });
2842 assert!(
2843 has_remove,
2844 "Should have RemoveConstraint for old composite PK"
2845 );
2846
2847 let has_add = plan.actions.iter().any(|a| {
2848 matches!(
2849 a,
2850 MigrationAction::AddConstraint {
2851 table,
2852 constraint: TableConstraint::PrimaryKey { columns, .. }
2853 } if table == "users" && columns == &vec!["id".to_string()]
2854 )
2855 });
2856 assert!(
2857 has_add,
2858 "Should have AddConstraint for new single-column PK"
2859 );
2860 }
2861
2862 #[test]
2863 fn change_pk_columns_entirely() {
2864 let from = vec![table(
2866 "users",
2867 vec![
2868 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2869 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2870 ],
2871 vec![pk(vec!["id"])],
2872 )];
2873
2874 let to = vec![table(
2875 "users",
2876 vec![
2877 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2878 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
2879 ],
2880 vec![pk(vec!["uuid"])],
2881 )];
2882
2883 let plan = diff_schemas(&from, &to).unwrap();
2884
2885 assert_eq!(plan.actions.len(), 2);
2886
2887 let has_remove = plan.actions.iter().any(|a| {
2888 matches!(
2889 a,
2890 MigrationAction::RemoveConstraint {
2891 table,
2892 constraint: TableConstraint::PrimaryKey { columns, .. }
2893 } if table == "users" && columns == &vec!["id".to_string()]
2894 )
2895 });
2896 assert!(has_remove, "Should have RemoveConstraint for old PK");
2897
2898 let has_add = plan.actions.iter().any(|a| {
2899 matches!(
2900 a,
2901 MigrationAction::AddConstraint {
2902 table,
2903 constraint: TableConstraint::PrimaryKey { columns, .. }
2904 } if table == "users" && columns == &vec!["uuid".to_string()]
2905 )
2906 });
2907 assert!(has_add, "Should have AddConstraint for new PK");
2908 }
2909
2910 #[test]
2911 fn add_multiple_columns_to_composite_pk() {
2912 let from = vec![table(
2914 "users",
2915 vec![
2916 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2917 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2918 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2919 ],
2920 vec![pk(vec!["id"])],
2921 )];
2922
2923 let to = vec![table(
2924 "users",
2925 vec![
2926 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2927 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2928 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2929 ],
2930 vec![pk(vec!["id", "tenant_id", "region_id"])],
2931 )];
2932
2933 let plan = diff_schemas(&from, &to).unwrap();
2934
2935 assert_eq!(plan.actions.len(), 2);
2936
2937 let has_remove = plan.actions.iter().any(|a| {
2938 matches!(
2939 a,
2940 MigrationAction::RemoveConstraint {
2941 table,
2942 constraint: TableConstraint::PrimaryKey { columns, .. }
2943 } if table == "users" && columns == &vec!["id".to_string()]
2944 )
2945 });
2946 assert!(
2947 has_remove,
2948 "Should have RemoveConstraint for old single-column PK"
2949 );
2950
2951 let has_add = plan.actions.iter().any(|a| {
2952 matches!(
2953 a,
2954 MigrationAction::AddConstraint {
2955 table,
2956 constraint: TableConstraint::PrimaryKey { columns, .. }
2957 } if table == "users" && columns == &vec![
2958 "id".to_string(),
2959 "tenant_id".to_string(),
2960 "region_id".to_string()
2961 ]
2962 )
2963 });
2964 assert!(
2965 has_add,
2966 "Should have AddConstraint for new 3-column composite PK"
2967 );
2968 }
2969
2970 #[test]
2971 fn remove_multiple_columns_from_composite_pk() {
2972 let from = vec![table(
2974 "users",
2975 vec![
2976 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2977 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2978 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2979 ],
2980 vec![pk(vec!["id", "tenant_id", "region_id"])],
2981 )];
2982
2983 let to = vec![table(
2984 "users",
2985 vec![
2986 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2987 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2988 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2989 ],
2990 vec![pk(vec!["id"])],
2991 )];
2992
2993 let plan = diff_schemas(&from, &to).unwrap();
2994
2995 assert_eq!(plan.actions.len(), 2);
2996
2997 let has_remove = plan.actions.iter().any(|a| {
2998 matches!(
2999 a,
3000 MigrationAction::RemoveConstraint {
3001 table,
3002 constraint: TableConstraint::PrimaryKey { columns, .. }
3003 } if table == "users" && columns == &vec![
3004 "id".to_string(),
3005 "tenant_id".to_string(),
3006 "region_id".to_string()
3007 ]
3008 )
3009 });
3010 assert!(
3011 has_remove,
3012 "Should have RemoveConstraint for old 3-column composite PK"
3013 );
3014
3015 let has_add = plan.actions.iter().any(|a| {
3016 matches!(
3017 a,
3018 MigrationAction::AddConstraint {
3019 table,
3020 constraint: TableConstraint::PrimaryKey { columns, .. }
3021 } if table == "users" && columns == &vec!["id".to_string()]
3022 )
3023 });
3024 assert!(
3025 has_add,
3026 "Should have AddConstraint for new single-column PK"
3027 );
3028 }
3029
3030 #[test]
3031 fn change_composite_pk_columns_partially() {
3032 let from = vec![table(
3035 "users",
3036 vec![
3037 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3038 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
3039 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
3040 ],
3041 vec![pk(vec!["id", "tenant_id"])],
3042 )];
3043
3044 let to = vec![table(
3045 "users",
3046 vec![
3047 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3048 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
3049 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
3050 ],
3051 vec![pk(vec!["id", "region_id"])],
3052 )];
3053
3054 let plan = diff_schemas(&from, &to).unwrap();
3055
3056 assert_eq!(plan.actions.len(), 2);
3057
3058 let has_remove = plan.actions.iter().any(|a| {
3059 matches!(
3060 a,
3061 MigrationAction::RemoveConstraint {
3062 table,
3063 constraint: TableConstraint::PrimaryKey { columns, .. }
3064 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
3065 )
3066 });
3067 assert!(
3068 has_remove,
3069 "Should have RemoveConstraint for old PK with tenant_id"
3070 );
3071
3072 let has_add = plan.actions.iter().any(|a| {
3073 matches!(
3074 a,
3075 MigrationAction::AddConstraint {
3076 table,
3077 constraint: TableConstraint::PrimaryKey { columns, .. }
3078 } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
3079 )
3080 });
3081 assert!(
3082 has_add,
3083 "Should have AddConstraint for new PK with region_id"
3084 );
3085 }
3086 }
3087
3088 mod default_changes {
3089 use super::*;
3090
3091 fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
3092 ColumnDef {
3093 name: name.to_string(),
3094 r#type: ty,
3095 nullable: true,
3096 default: default.map(|s| s.into()),
3097 comment: None,
3098 primary_key: None,
3099 unique: None,
3100 index: None,
3101 foreign_key: None,
3102 }
3103 }
3104
3105 #[test]
3106 fn add_default_value() {
3107 let from = vec![table(
3109 "users",
3110 vec![
3111 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3112 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
3113 ],
3114 vec![],
3115 )];
3116
3117 let to = vec![table(
3118 "users",
3119 vec![
3120 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3121 col_with_default(
3122 "status",
3123 ColumnType::Simple(SimpleColumnType::Text),
3124 Some("'active'"),
3125 ),
3126 ],
3127 vec![],
3128 )];
3129
3130 let plan = diff_schemas(&from, &to).unwrap();
3131
3132 assert_eq!(plan.actions.len(), 1);
3133 assert!(matches!(
3134 &plan.actions[0],
3135 MigrationAction::ModifyColumnDefault {
3136 table,
3137 column,
3138 new_default: Some(default),
3139 } if table == "users" && column == "status" && default == "'active'"
3140 ));
3141 }
3142
3143 #[test]
3144 fn remove_default_value() {
3145 let from = vec![table(
3147 "users",
3148 vec![
3149 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3150 col_with_default(
3151 "status",
3152 ColumnType::Simple(SimpleColumnType::Text),
3153 Some("'active'"),
3154 ),
3155 ],
3156 vec![],
3157 )];
3158
3159 let to = vec![table(
3160 "users",
3161 vec![
3162 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3163 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
3164 ],
3165 vec![],
3166 )];
3167
3168 let plan = diff_schemas(&from, &to).unwrap();
3169
3170 assert_eq!(plan.actions.len(), 1);
3171 assert!(matches!(
3172 &plan.actions[0],
3173 MigrationAction::ModifyColumnDefault {
3174 table,
3175 column,
3176 new_default: None,
3177 } if table == "users" && column == "status"
3178 ));
3179 }
3180
3181 #[test]
3182 fn change_default_value() {
3183 let from = vec![table(
3185 "users",
3186 vec![
3187 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3188 col_with_default(
3189 "status",
3190 ColumnType::Simple(SimpleColumnType::Text),
3191 Some("'active'"),
3192 ),
3193 ],
3194 vec![],
3195 )];
3196
3197 let to = vec![table(
3198 "users",
3199 vec![
3200 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3201 col_with_default(
3202 "status",
3203 ColumnType::Simple(SimpleColumnType::Text),
3204 Some("'pending'"),
3205 ),
3206 ],
3207 vec![],
3208 )];
3209
3210 let plan = diff_schemas(&from, &to).unwrap();
3211
3212 assert_eq!(plan.actions.len(), 1);
3213 assert!(matches!(
3214 &plan.actions[0],
3215 MigrationAction::ModifyColumnDefault {
3216 table,
3217 column,
3218 new_default: Some(default),
3219 } if table == "users" && column == "status" && default == "'pending'"
3220 ));
3221 }
3222
3223 #[test]
3224 fn no_change_same_default() {
3225 let from = vec![table(
3227 "users",
3228 vec![
3229 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3230 col_with_default(
3231 "status",
3232 ColumnType::Simple(SimpleColumnType::Text),
3233 Some("'active'"),
3234 ),
3235 ],
3236 vec![],
3237 )];
3238
3239 let to = vec![table(
3240 "users",
3241 vec![
3242 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3243 col_with_default(
3244 "status",
3245 ColumnType::Simple(SimpleColumnType::Text),
3246 Some("'active'"),
3247 ),
3248 ],
3249 vec![],
3250 )];
3251
3252 let plan = diff_schemas(&from, &to).unwrap();
3253
3254 assert!(plan.actions.is_empty());
3255 }
3256
3257 #[test]
3258 fn multiple_columns_default_changes() {
3259 let from = vec![table(
3261 "users",
3262 vec![
3263 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3264 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
3265 col_with_default(
3266 "role",
3267 ColumnType::Simple(SimpleColumnType::Text),
3268 Some("'user'"),
3269 ),
3270 col_with_default(
3271 "active",
3272 ColumnType::Simple(SimpleColumnType::Boolean),
3273 Some("true"),
3274 ),
3275 ],
3276 vec![],
3277 )];
3278
3279 let to = vec![table(
3280 "users",
3281 vec![
3282 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3283 col_with_default(
3284 "status",
3285 ColumnType::Simple(SimpleColumnType::Text),
3286 Some("'pending'"),
3287 ), col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), col_with_default(
3290 "active",
3291 ColumnType::Simple(SimpleColumnType::Boolean),
3292 Some("true"),
3293 ), ],
3295 vec![],
3296 )];
3297
3298 let plan = diff_schemas(&from, &to).unwrap();
3299
3300 assert_eq!(plan.actions.len(), 2);
3301
3302 let has_status_change = plan.actions.iter().any(|a| {
3303 matches!(
3304 a,
3305 MigrationAction::ModifyColumnDefault {
3306 table,
3307 column,
3308 new_default: Some(default),
3309 } if table == "users" && column == "status" && default == "'pending'"
3310 )
3311 });
3312 assert!(has_status_change, "Should detect status default added");
3313
3314 let has_role_change = plan.actions.iter().any(|a| {
3315 matches!(
3316 a,
3317 MigrationAction::ModifyColumnDefault {
3318 table,
3319 column,
3320 new_default: None,
3321 } if table == "users" && column == "role"
3322 )
3323 });
3324 assert!(has_role_change, "Should detect role default removed");
3325 }
3326
3327 #[test]
3328 fn default_change_with_type_change() {
3329 let from = vec![table(
3331 "users",
3332 vec![
3333 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3334 col_with_default(
3335 "count",
3336 ColumnType::Simple(SimpleColumnType::Integer),
3337 Some("0"),
3338 ),
3339 ],
3340 vec![],
3341 )];
3342
3343 let to = vec![table(
3344 "users",
3345 vec![
3346 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3347 col_with_default(
3348 "count",
3349 ColumnType::Simple(SimpleColumnType::Text),
3350 Some("'0'"),
3351 ),
3352 ],
3353 vec![],
3354 )];
3355
3356 let plan = diff_schemas(&from, &to).unwrap();
3357
3358 assert_eq!(plan.actions.len(), 2);
3360
3361 let has_type_change = plan.actions.iter().any(|a| {
3362 matches!(
3363 a,
3364 MigrationAction::ModifyColumnType { table, column, .. }
3365 if table == "users" && column == "count"
3366 )
3367 });
3368 assert!(has_type_change, "Should detect type change");
3369
3370 let has_default_change = plan.actions.iter().any(|a| {
3371 matches!(
3372 a,
3373 MigrationAction::ModifyColumnDefault {
3374 table,
3375 column,
3376 new_default: Some(default),
3377 } if table == "users" && column == "count" && default == "'0'"
3378 )
3379 });
3380 assert!(has_default_change, "Should detect default change");
3381 }
3382 }
3383
3384 mod comment_changes {
3385 use super::*;
3386
3387 fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
3388 ColumnDef {
3389 name: name.to_string(),
3390 r#type: ty,
3391 nullable: true,
3392 default: None,
3393 comment: comment.map(|s| s.to_string()),
3394 primary_key: None,
3395 unique: None,
3396 index: None,
3397 foreign_key: None,
3398 }
3399 }
3400
3401 #[test]
3402 fn add_comment() {
3403 let from = vec![table(
3405 "users",
3406 vec![
3407 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3408 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3409 ],
3410 vec![],
3411 )];
3412
3413 let to = vec![table(
3414 "users",
3415 vec![
3416 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3417 col_with_comment(
3418 "email",
3419 ColumnType::Simple(SimpleColumnType::Text),
3420 Some("User's email address"),
3421 ),
3422 ],
3423 vec![],
3424 )];
3425
3426 let plan = diff_schemas(&from, &to).unwrap();
3427
3428 assert_eq!(plan.actions.len(), 1);
3429 assert!(matches!(
3430 &plan.actions[0],
3431 MigrationAction::ModifyColumnComment {
3432 table,
3433 column,
3434 new_comment: Some(comment),
3435 } if table == "users" && column == "email" && comment == "User's email address"
3436 ));
3437 }
3438
3439 #[test]
3440 fn remove_comment() {
3441 let from = vec![table(
3443 "users",
3444 vec![
3445 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3446 col_with_comment(
3447 "email",
3448 ColumnType::Simple(SimpleColumnType::Text),
3449 Some("User's email address"),
3450 ),
3451 ],
3452 vec![],
3453 )];
3454
3455 let to = vec![table(
3456 "users",
3457 vec![
3458 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3459 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3460 ],
3461 vec![],
3462 )];
3463
3464 let plan = diff_schemas(&from, &to).unwrap();
3465
3466 assert_eq!(plan.actions.len(), 1);
3467 assert!(matches!(
3468 &plan.actions[0],
3469 MigrationAction::ModifyColumnComment {
3470 table,
3471 column,
3472 new_comment: None,
3473 } if table == "users" && column == "email"
3474 ));
3475 }
3476
3477 #[test]
3478 fn change_comment() {
3479 let from = vec![table(
3481 "users",
3482 vec![
3483 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3484 col_with_comment(
3485 "email",
3486 ColumnType::Simple(SimpleColumnType::Text),
3487 Some("Old comment"),
3488 ),
3489 ],
3490 vec![],
3491 )];
3492
3493 let to = vec![table(
3494 "users",
3495 vec![
3496 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3497 col_with_comment(
3498 "email",
3499 ColumnType::Simple(SimpleColumnType::Text),
3500 Some("New comment"),
3501 ),
3502 ],
3503 vec![],
3504 )];
3505
3506 let plan = diff_schemas(&from, &to).unwrap();
3507
3508 assert_eq!(plan.actions.len(), 1);
3509 assert!(matches!(
3510 &plan.actions[0],
3511 MigrationAction::ModifyColumnComment {
3512 table,
3513 column,
3514 new_comment: Some(comment),
3515 } if table == "users" && column == "email" && comment == "New comment"
3516 ));
3517 }
3518
3519 #[test]
3520 fn no_change_same_comment() {
3521 let from = vec![table(
3523 "users",
3524 vec![
3525 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3526 col_with_comment(
3527 "email",
3528 ColumnType::Simple(SimpleColumnType::Text),
3529 Some("Same comment"),
3530 ),
3531 ],
3532 vec![],
3533 )];
3534
3535 let to = vec![table(
3536 "users",
3537 vec![
3538 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3539 col_with_comment(
3540 "email",
3541 ColumnType::Simple(SimpleColumnType::Text),
3542 Some("Same comment"),
3543 ),
3544 ],
3545 vec![],
3546 )];
3547
3548 let plan = diff_schemas(&from, &to).unwrap();
3549
3550 assert!(plan.actions.is_empty());
3551 }
3552
3553 #[test]
3554 fn multiple_columns_comment_changes() {
3555 let from = vec![table(
3557 "users",
3558 vec![
3559 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3560 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
3561 col_with_comment(
3562 "name",
3563 ColumnType::Simple(SimpleColumnType::Text),
3564 Some("User name"),
3565 ),
3566 col_with_comment(
3567 "phone",
3568 ColumnType::Simple(SimpleColumnType::Text),
3569 Some("Phone number"),
3570 ),
3571 ],
3572 vec![],
3573 )];
3574
3575 let to = vec![table(
3576 "users",
3577 vec![
3578 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3579 col_with_comment(
3580 "email",
3581 ColumnType::Simple(SimpleColumnType::Text),
3582 Some("Email address"),
3583 ), col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), col_with_comment(
3586 "phone",
3587 ColumnType::Simple(SimpleColumnType::Text),
3588 Some("Phone number"),
3589 ), ],
3591 vec![],
3592 )];
3593
3594 let plan = diff_schemas(&from, &to).unwrap();
3595
3596 assert_eq!(plan.actions.len(), 2);
3597
3598 let has_email_change = plan.actions.iter().any(|a| {
3599 matches!(
3600 a,
3601 MigrationAction::ModifyColumnComment {
3602 table,
3603 column,
3604 new_comment: Some(comment),
3605 } if table == "users" && column == "email" && comment == "Email address"
3606 )
3607 });
3608 assert!(has_email_change, "Should detect email comment added");
3609
3610 let has_name_change = plan.actions.iter().any(|a| {
3611 matches!(
3612 a,
3613 MigrationAction::ModifyColumnComment {
3614 table,
3615 column,
3616 new_comment: None,
3617 } if table == "users" && column == "name"
3618 )
3619 });
3620 assert!(has_name_change, "Should detect name comment removed");
3621 }
3622
3623 #[test]
3624 fn comment_change_with_nullable_change() {
3625 let from = vec![table(
3627 "users",
3628 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3629 let mut c =
3630 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
3631 c.nullable = true;
3632 c
3633 }],
3634 vec![],
3635 )];
3636
3637 let to = vec![table(
3638 "users",
3639 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
3640 let mut c = col_with_comment(
3641 "email",
3642 ColumnType::Simple(SimpleColumnType::Text),
3643 Some("Required email"),
3644 );
3645 c.nullable = false;
3646 c
3647 }],
3648 vec![],
3649 )];
3650
3651 let plan = diff_schemas(&from, &to).unwrap();
3652
3653 assert_eq!(plan.actions.len(), 2);
3655
3656 let has_nullable_change = plan.actions.iter().any(|a| {
3657 matches!(
3658 a,
3659 MigrationAction::ModifyColumnNullable {
3660 table,
3661 column,
3662 nullable: false,
3663 ..
3664 } if table == "users" && column == "email"
3665 )
3666 });
3667 assert!(has_nullable_change, "Should detect nullable change");
3668
3669 let has_comment_change = plan.actions.iter().any(|a| {
3670 matches!(
3671 a,
3672 MigrationAction::ModifyColumnComment {
3673 table,
3674 column,
3675 new_comment: Some(comment),
3676 } if table == "users" && column == "email" && comment == "Required email"
3677 )
3678 });
3679 assert!(has_comment_change, "Should detect comment change");
3680 }
3681 }
3682
3683 mod nullable_changes {
3684 use super::*;
3685
3686 fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
3687 ColumnDef {
3688 name: name.to_string(),
3689 r#type: ty,
3690 nullable,
3691 default: None,
3692 comment: None,
3693 primary_key: None,
3694 unique: None,
3695 index: None,
3696 foreign_key: None,
3697 }
3698 }
3699
3700 #[test]
3701 fn column_nullable_to_non_nullable() {
3702 let from = vec![table(
3704 "users",
3705 vec![
3706 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3707 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3708 ],
3709 vec![],
3710 )];
3711
3712 let to = vec![table(
3713 "users",
3714 vec![
3715 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3716 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3717 ],
3718 vec![],
3719 )];
3720
3721 let plan = diff_schemas(&from, &to).unwrap();
3722
3723 assert_eq!(plan.actions.len(), 1);
3724 assert!(matches!(
3725 &plan.actions[0],
3726 MigrationAction::ModifyColumnNullable {
3727 table,
3728 column,
3729 nullable: false,
3730 fill_with: None,
3731 } if table == "users" && column == "email"
3732 ));
3733 }
3734
3735 #[test]
3736 fn column_non_nullable_to_nullable() {
3737 let from = vec![table(
3739 "users",
3740 vec![
3741 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3742 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
3743 ],
3744 vec![],
3745 )];
3746
3747 let to = vec![table(
3748 "users",
3749 vec![
3750 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3751 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3752 ],
3753 vec![],
3754 )];
3755
3756 let plan = diff_schemas(&from, &to).unwrap();
3757
3758 assert_eq!(plan.actions.len(), 1);
3759 assert!(matches!(
3760 &plan.actions[0],
3761 MigrationAction::ModifyColumnNullable {
3762 table,
3763 column,
3764 nullable: true,
3765 fill_with: None,
3766 } if table == "users" && column == "email"
3767 ));
3768 }
3769
3770 #[test]
3771 fn multiple_columns_nullable_changes() {
3772 let from = vec![table(
3774 "users",
3775 vec![
3776 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3777 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
3778 col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
3779 col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
3780 ],
3781 vec![],
3782 )];
3783
3784 let to = vec![table(
3785 "users",
3786 vec![
3787 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3788 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), ],
3792 vec![],
3793 )];
3794
3795 let plan = diff_schemas(&from, &to).unwrap();
3796
3797 assert_eq!(plan.actions.len(), 2);
3798
3799 let has_email_change = plan.actions.iter().any(|a| {
3800 matches!(
3801 a,
3802 MigrationAction::ModifyColumnNullable {
3803 table,
3804 column,
3805 nullable: false,
3806 ..
3807 } if table == "users" && column == "email"
3808 )
3809 });
3810 assert!(
3811 has_email_change,
3812 "Should detect email nullable -> non-nullable"
3813 );
3814
3815 let has_name_change = plan.actions.iter().any(|a| {
3816 matches!(
3817 a,
3818 MigrationAction::ModifyColumnNullable {
3819 table,
3820 column,
3821 nullable: true,
3822 ..
3823 } if table == "users" && column == "name"
3824 )
3825 });
3826 assert!(
3827 has_name_change,
3828 "Should detect name non-nullable -> nullable"
3829 );
3830 }
3831
3832 #[test]
3833 fn nullable_change_with_type_change() {
3834 let from = vec![table(
3836 "users",
3837 vec![
3838 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3839 col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
3840 ],
3841 vec![],
3842 )];
3843
3844 let to = vec![table(
3845 "users",
3846 vec![
3847 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3848 col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
3849 ],
3850 vec![],
3851 )];
3852
3853 let plan = diff_schemas(&from, &to).unwrap();
3854
3855 assert_eq!(plan.actions.len(), 2);
3857
3858 let has_type_change = plan.actions.iter().any(|a| {
3859 matches!(
3860 a,
3861 MigrationAction::ModifyColumnType { table, column, .. }
3862 if table == "users" && column == "age"
3863 )
3864 });
3865 assert!(has_type_change, "Should detect type change");
3866
3867 let has_nullable_change = plan.actions.iter().any(|a| {
3868 matches!(
3869 a,
3870 MigrationAction::ModifyColumnNullable {
3871 table,
3872 column,
3873 nullable: false,
3874 ..
3875 } if table == "users" && column == "age"
3876 )
3877 });
3878 assert!(has_nullable_change, "Should detect nullable change");
3879 }
3880 }
3881
3882 mod diff_tables {
3883 use insta::assert_debug_snapshot;
3884
3885 use super::*;
3886
3887 #[test]
3888 fn create_table_with_inline_index() {
3889 let base = [table(
3890 "users",
3891 vec![
3892 ColumnDef {
3893 name: "id".to_string(),
3894 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3895 nullable: false,
3896 default: None,
3897 comment: None,
3898 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3899 unique: None,
3900 index: Some(StrOrBoolOrArray::Bool(false)),
3901 foreign_key: None,
3902 },
3903 ColumnDef {
3904 name: "name".to_string(),
3905 r#type: ColumnType::Simple(SimpleColumnType::Text),
3906 nullable: true,
3907 default: None,
3908 comment: None,
3909 primary_key: None,
3910 unique: Some(StrOrBoolOrArray::Bool(true)),
3911 index: Some(StrOrBoolOrArray::Bool(true)),
3912 foreign_key: None,
3913 },
3914 ],
3915 vec![],
3916 )];
3917 let plan = diff_schemas(&[], &base).unwrap();
3918
3919 assert_eq!(plan.actions.len(), 1);
3920 assert_debug_snapshot!(plan.actions);
3921
3922 let plan = diff_schemas(
3923 &base,
3924 &[table(
3925 "users",
3926 vec![
3927 ColumnDef {
3928 name: "id".to_string(),
3929 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3930 nullable: false,
3931 default: None,
3932 comment: None,
3933 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3934 unique: None,
3935 index: Some(StrOrBoolOrArray::Bool(false)),
3936 foreign_key: None,
3937 },
3938 ColumnDef {
3939 name: "name".to_string(),
3940 r#type: ColumnType::Simple(SimpleColumnType::Text),
3941 nullable: true,
3942 default: None,
3943 comment: None,
3944 primary_key: None,
3945 unique: Some(StrOrBoolOrArray::Bool(true)),
3946 index: Some(StrOrBoolOrArray::Bool(false)),
3947 foreign_key: None,
3948 },
3949 ],
3950 vec![],
3951 )],
3952 )
3953 .unwrap();
3954
3955 assert_eq!(plan.actions.len(), 1);
3956 assert_debug_snapshot!(plan.actions);
3957 }
3958
3959 #[rstest]
3960 #[case(
3961 "add_index",
3962 vec![table(
3963 "users",
3964 vec![
3965 ColumnDef {
3966 name: "id".to_string(),
3967 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3968 nullable: false,
3969 default: None,
3970 comment: None,
3971 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3972 unique: None,
3973 index: None,
3974 foreign_key: None,
3975 },
3976 ],
3977 vec![],
3978 )],
3979 vec![table(
3980 "users",
3981 vec![
3982 ColumnDef {
3983 name: "id".to_string(),
3984 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3985 nullable: false,
3986 default: None,
3987 comment: None,
3988 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3989 unique: None,
3990 index: Some(StrOrBoolOrArray::Bool(true)),
3991 foreign_key: None,
3992 },
3993 ],
3994 vec![],
3995 )],
3996 )]
3997 #[case(
3998 "remove_index",
3999 vec![table(
4000 "users",
4001 vec![
4002 ColumnDef {
4003 name: "id".to_string(),
4004 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4005 nullable: false,
4006 default: None,
4007 comment: None,
4008 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4009 unique: None,
4010 index: Some(StrOrBoolOrArray::Bool(true)),
4011 foreign_key: None,
4012 },
4013 ],
4014 vec![],
4015 )],
4016 vec![table(
4017 "users",
4018 vec![
4019 ColumnDef {
4020 name: "id".to_string(),
4021 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4022 nullable: false,
4023 default: None,
4024 comment: None,
4025 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4026 unique: None,
4027 index: Some(StrOrBoolOrArray::Bool(false)),
4028 foreign_key: None,
4029 },
4030 ],
4031 vec![],
4032 )],
4033 )]
4034 #[case(
4035 "add_named_index",
4036 vec![table(
4037 "users",
4038 vec![
4039 ColumnDef {
4040 name: "id".to_string(),
4041 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4042 nullable: false,
4043 default: None,
4044 comment: None,
4045 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4046 unique: None,
4047 index: None,
4048 foreign_key: None,
4049 },
4050 ],
4051 vec![],
4052 )],
4053 vec![table(
4054 "users",
4055 vec![
4056 ColumnDef {
4057 name: "id".to_string(),
4058 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4059 nullable: false,
4060 default: None,
4061 comment: None,
4062 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4063 unique: None,
4064 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
4065 foreign_key: None,
4066 },
4067 ],
4068 vec![],
4069 )],
4070 )]
4071 #[case(
4072 "remove_named_index",
4073 vec![table(
4074 "users",
4075 vec![
4076 ColumnDef {
4077 name: "id".to_string(),
4078 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4079 nullable: false,
4080 default: None,
4081 comment: None,
4082 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4083 unique: None,
4084 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
4085 foreign_key: None,
4086 },
4087 ],
4088 vec![],
4089 )],
4090 vec![table(
4091 "users",
4092 vec![
4093 ColumnDef {
4094 name: "id".to_string(),
4095 r#type: ColumnType::Simple(SimpleColumnType::Integer),
4096 nullable: false,
4097 default: None,
4098 comment: None,
4099 primary_key: Some(PrimaryKeySyntax::Bool(true)),
4100 unique: None,
4101 index: None,
4102 foreign_key: None,
4103 },
4104 ],
4105 vec![],
4106 )],
4107 )]
4108 fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
4109 use insta::with_settings;
4110
4111 let plan = diff_schemas(&base, &to).unwrap();
4112 with_settings!({ snapshot_suffix => name }, {
4113 assert_debug_snapshot!(plan.actions);
4114 });
4115 }
4116 }
4117
4118 mod coverage_explicit {
4120 use super::*;
4121
4122 #[test]
4123 fn delete_column_explicit() {
4124 let from = vec![table(
4126 "users",
4127 vec![
4128 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4129 col("name", ColumnType::Simple(SimpleColumnType::Text)),
4130 ],
4131 vec![],
4132 )];
4133
4134 let to = vec![table(
4135 "users",
4136 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4137 vec![],
4138 )];
4139
4140 let plan = diff_schemas(&from, &to).unwrap();
4141 assert_eq!(plan.actions.len(), 1);
4142 assert!(matches!(
4143 &plan.actions[0],
4144 MigrationAction::DeleteColumn { table, column }
4145 if table == "users" && column == "name"
4146 ));
4147 }
4148
4149 #[test]
4150 fn add_column_explicit() {
4151 let from = vec![table(
4153 "users",
4154 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4155 vec![],
4156 )];
4157
4158 let to = vec![table(
4159 "users",
4160 vec![
4161 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4162 col("email", ColumnType::Simple(SimpleColumnType::Text)),
4163 ],
4164 vec![],
4165 )];
4166
4167 let plan = diff_schemas(&from, &to).unwrap();
4168 assert_eq!(plan.actions.len(), 1);
4169 assert!(matches!(
4170 &plan.actions[0],
4171 MigrationAction::AddColumn { table, column, .. }
4172 if table == "users" && column.name == "email"
4173 ));
4174 }
4175
4176 #[test]
4177 fn remove_constraint_explicit() {
4178 let from = vec![table(
4180 "users",
4181 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4182 vec![idx("idx_users_id", vec!["id"])],
4183 )];
4184
4185 let to = vec![table(
4186 "users",
4187 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4188 vec![],
4189 )];
4190
4191 let plan = diff_schemas(&from, &to).unwrap();
4192 assert_eq!(plan.actions.len(), 1);
4193 assert!(matches!(
4194 &plan.actions[0],
4195 MigrationAction::RemoveConstraint { table, constraint }
4196 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
4197 ));
4198 }
4199
4200 #[test]
4201 fn add_constraint_explicit() {
4202 let from = vec![table(
4204 "users",
4205 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4206 vec![],
4207 )];
4208
4209 let to = vec![table(
4210 "users",
4211 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4212 vec![idx("idx_users_id", vec!["id"])],
4213 )];
4214
4215 let plan = diff_schemas(&from, &to).unwrap();
4216 assert_eq!(plan.actions.len(), 1);
4217 assert!(matches!(
4218 &plan.actions[0],
4219 MigrationAction::AddConstraint { table, constraint }
4220 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
4221 ));
4222 }
4223 }
4224
4225 mod constraint_removal_on_deleted_columns {
4226 use super::*;
4227
4228 fn fk(columns: Vec<&str>, ref_table: &str, ref_columns: Vec<&str>) -> TableConstraint {
4229 TableConstraint::ForeignKey {
4230 name: None,
4231 columns: columns.into_iter().map(|s| s.to_string()).collect(),
4232 ref_table: ref_table.to_string(),
4233 ref_columns: ref_columns.into_iter().map(|s| s.to_string()).collect(),
4234 on_delete: None,
4235 on_update: None,
4236 }
4237 }
4238
4239 #[test]
4240 fn skip_remove_constraint_when_all_columns_deleted() {
4241 let from = vec![table(
4244 "project",
4245 vec![
4246 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4247 col("template_id", ColumnType::Simple(SimpleColumnType::Integer)),
4248 ],
4249 vec![
4250 fk(vec!["template_id"], "book_template", vec!["id"]),
4251 idx("ix_project__template_id", vec!["template_id"]),
4252 ],
4253 )];
4254
4255 let to = vec![table(
4256 "project",
4257 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4258 vec![],
4259 )];
4260
4261 let plan = diff_schemas(&from, &to).unwrap();
4262
4263 assert_eq!(plan.actions.len(), 1);
4265 assert!(matches!(
4266 &plan.actions[0],
4267 MigrationAction::DeleteColumn { table, column }
4268 if table == "project" && column == "template_id"
4269 ));
4270
4271 let has_remove_constraint = plan
4273 .actions
4274 .iter()
4275 .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
4276 assert!(
4277 !has_remove_constraint,
4278 "Should NOT have RemoveConstraint when column is deleted"
4279 );
4280 }
4281
4282 #[test]
4283 fn keep_remove_constraint_when_only_some_columns_deleted() {
4284 let from = vec![table(
4286 "orders",
4287 vec![
4288 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4289 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
4290 col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
4291 ],
4292 vec![idx(
4293 "ix_orders__user_product",
4294 vec!["user_id", "product_id"],
4295 )],
4296 )];
4297
4298 let to = vec![table(
4299 "orders",
4300 vec![
4301 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4302 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
4303 ],
4305 vec![],
4306 )];
4307
4308 let plan = diff_schemas(&from, &to).unwrap();
4309
4310 let has_delete_column = plan.actions.iter().any(|a| {
4313 matches!(
4314 a,
4315 MigrationAction::DeleteColumn { table, column }
4316 if table == "orders" && column == "product_id"
4317 )
4318 });
4319 assert!(has_delete_column, "Should have DeleteColumn for product_id");
4320
4321 let has_remove_constraint = plan.actions.iter().any(|a| {
4322 matches!(
4323 a,
4324 MigrationAction::RemoveConstraint { table, .. }
4325 if table == "orders"
4326 )
4327 });
4328 assert!(
4329 has_remove_constraint,
4330 "Should have RemoveConstraint for composite index when only some columns deleted"
4331 );
4332 }
4333
4334 #[test]
4335 fn skip_remove_constraint_when_all_composite_columns_deleted() {
4336 let from = vec![table(
4338 "orders",
4339 vec![
4340 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4341 col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
4342 col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
4343 ],
4344 vec![idx(
4345 "ix_orders__user_product",
4346 vec!["user_id", "product_id"],
4347 )],
4348 )];
4349
4350 let to = vec![table(
4351 "orders",
4352 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
4353 vec![],
4354 )];
4355
4356 let plan = diff_schemas(&from, &to).unwrap();
4357
4358 let delete_columns: Vec<_> = plan
4360 .actions
4361 .iter()
4362 .filter(|a| matches!(a, MigrationAction::DeleteColumn { .. }))
4363 .collect();
4364 assert_eq!(
4365 delete_columns.len(),
4366 2,
4367 "Should have 2 DeleteColumn actions"
4368 );
4369
4370 let has_remove_constraint = plan
4371 .actions
4372 .iter()
4373 .any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
4374 assert!(
4375 !has_remove_constraint,
4376 "Should NOT have RemoveConstraint when all composite columns deleted"
4377 );
4378 }
4379
4380 #[test]
4381 fn keep_remove_constraint_when_no_columns_deleted() {
4382 let from = vec![table(
4384 "users",
4385 vec![
4386 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4387 col("email", ColumnType::Simple(SimpleColumnType::Text)),
4388 ],
4389 vec![idx("ix_users__email", vec!["email"])],
4390 )];
4391
4392 let to = vec![table(
4393 "users",
4394 vec![
4395 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
4396 col("email", ColumnType::Simple(SimpleColumnType::Text)),
4397 ],
4398 vec![], )];
4400
4401 let plan = diff_schemas(&from, &to).unwrap();
4402
4403 assert_eq!(plan.actions.len(), 1);
4404 assert!(matches!(
4405 &plan.actions[0],
4406 MigrationAction::RemoveConstraint { table, .. }
4407 if table == "users"
4408 ));
4409 }
4410 }
4411}