1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef};
4
5use crate::error::PlannerError;
6
7fn topological_sort_tables<'a>(tables: &[&'a TableDef]) -> Result<Vec<&'a TableDef>, PlannerError> {
11 if tables.is_empty() {
12 return Ok(vec![]);
13 }
14
15 let table_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect();
17
18 let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
22 for table in tables {
23 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
24 for constraint in &table.constraints {
25 if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
26 if table_names.contains(ref_table.as_str()) && ref_table != &table.name {
28 deps_set.insert(ref_table.as_str());
29 }
30 }
31 }
32 dependencies.insert(table.name.as_str(), deps_set.into_iter().collect());
33 }
34
35 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
39 for table in tables {
40 in_degree.entry(table.name.as_str()).or_insert(0);
41 }
42
43 for (table_name, deps) in &dependencies {
45 for _dep in deps {
46 }
49 *in_degree.entry(table_name).or_insert(0) += deps.len();
52 }
53
54 let mut queue: VecDeque<&str> = in_degree
57 .iter()
58 .filter(|(_, deg)| **deg == 0)
59 .map(|(name, _)| *name)
60 .collect();
61
62 let mut result: Vec<&TableDef> = Vec::new();
63 let table_map: BTreeMap<&str, &TableDef> =
64 tables.iter().map(|t| (t.name.as_str(), *t)).collect();
65
66 while let Some(table_name) = queue.pop_front() {
67 if let Some(&table) = table_map.get(table_name) {
68 result.push(table);
69 }
70
71 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
74 for (dependent, deps) in &dependencies {
75 if deps.contains(&table_name)
76 && let Some(degree) = in_degree.get_mut(dependent)
77 {
78 *degree -= 1;
79 if *degree == 0 {
80 ready_tables.insert(dependent);
81 }
82 }
83 }
84 for t in ready_tables {
85 queue.push_back(t);
86 }
87 }
88
89 if result.len() != tables.len() {
91 let remaining: Vec<&str> = tables
92 .iter()
93 .map(|t| t.name.as_str())
94 .filter(|name| !result.iter().any(|t| t.name.as_str() == *name))
95 .collect();
96 return Err(PlannerError::TableValidation(format!(
97 "Circular foreign key dependency detected among tables: {:?}",
98 remaining
99 )));
100 }
101
102 Ok(result)
103}
104
105fn extract_delete_table_name(action: &MigrationAction) -> &str {
110 match action {
111 MigrationAction::DeleteTable { table } => table.as_str(),
112 _ => panic!("Expected DeleteTable action"),
113 }
114}
115
116fn sort_delete_tables(actions: &mut [MigrationAction], all_tables: &BTreeMap<&str, &TableDef>) {
117 let delete_indices: Vec<usize> = actions
119 .iter()
120 .enumerate()
121 .filter_map(|(i, a)| {
122 if matches!(a, MigrationAction::DeleteTable { .. }) {
123 Some(i)
124 } else {
125 None
126 }
127 })
128 .collect();
129
130 if delete_indices.len() <= 1 {
131 return;
132 }
133
134 let delete_table_names: BTreeSet<&str> = delete_indices
137 .iter()
138 .map(|&i| extract_delete_table_name(&actions[i]))
139 .collect();
140
141 let mut dependencies: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
146 for &table_name in &delete_table_names {
147 let mut deps_set: BTreeSet<&str> = BTreeSet::new();
148 if let Some(table_def) = all_tables.get(table_name) {
149 for constraint in &table_def.constraints {
150 if let TableConstraint::ForeignKey { ref_table, .. } = constraint
151 && delete_table_names.contains(ref_table.as_str())
152 && ref_table != table_name
153 {
154 deps_set.insert(ref_table.as_str());
155 }
156 }
157 }
158 dependencies.insert(table_name, deps_set.into_iter().collect());
159 }
160
161 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
165 for &table_name in &delete_table_names {
166 in_degree.insert(
167 table_name,
168 dependencies.get(table_name).map_or(0, |d| d.len()),
169 );
170 }
171
172 let mut queue: VecDeque<&str> = in_degree
175 .iter()
176 .filter(|(_, deg)| **deg == 0)
177 .map(|(name, _)| *name)
178 .collect();
179
180 let mut sorted_tables: Vec<&str> = Vec::new();
181 while let Some(table_name) = queue.pop_front() {
182 sorted_tables.push(table_name);
183
184 let mut ready_tables: BTreeSet<&str> = BTreeSet::new();
187 for (&dependent, deps) in &dependencies {
188 if deps.contains(&table_name)
189 && let Some(degree) = in_degree.get_mut(dependent)
190 {
191 *degree -= 1;
192 if *degree == 0 {
193 ready_tables.insert(dependent);
194 }
195 }
196 }
197 for t in ready_tables {
198 queue.push_back(t);
199 }
200 }
201
202 sorted_tables.reverse();
204
205 let mut delete_actions: Vec<MigrationAction> =
207 delete_indices.iter().map(|&i| actions[i].clone()).collect();
208
209 delete_actions.sort_by(|a, b| {
210 let a_name = extract_delete_table_name(a);
211 let b_name = extract_delete_table_name(b);
212
213 let a_pos = sorted_tables.iter().position(|&t| t == a_name).unwrap_or(0);
214 let b_pos = sorted_tables.iter().position(|&t| t == b_name).unwrap_or(0);
215 a_pos.cmp(&b_pos)
216 });
217
218 for (i, idx) in delete_indices.iter().enumerate() {
220 actions[*idx] = delete_actions[i].clone();
221 }
222}
223
224pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
228 let mut actions: Vec<MigrationAction> = Vec::new();
229
230 let from_normalized: Vec<TableDef> = from
232 .iter()
233 .map(|t| {
234 t.normalize().map_err(|e| {
235 PlannerError::TableValidation(format!(
236 "Failed to normalize table '{}': {}",
237 t.name, e
238 ))
239 })
240 })
241 .collect::<Result<Vec<_>, _>>()?;
242 let to_normalized: Vec<TableDef> = to
243 .iter()
244 .map(|t| {
245 t.normalize().map_err(|e| {
246 PlannerError::TableValidation(format!(
247 "Failed to normalize table '{}': {}",
248 t.name, e
249 ))
250 })
251 })
252 .collect::<Result<Vec<_>, _>>()?;
253
254 let from_map: BTreeMap<_, _> = from_normalized
257 .iter()
258 .map(|t| (t.name.as_str(), t))
259 .collect();
260 let to_map: BTreeMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
261
262 let to_original_map: BTreeMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
264
265 for name in from_map.keys() {
267 if !to_map.contains_key(name) {
268 actions.push(MigrationAction::DeleteTable {
269 table: name.to_string(),
270 });
271 }
272 }
273
274 for (name, to_tbl) in &to_map {
276 if let Some(from_tbl) = from_map.get(name) {
277 let from_cols: BTreeMap<_, _> = from_tbl
279 .columns
280 .iter()
281 .map(|c| (c.name.as_str(), c))
282 .collect();
283 let to_cols: BTreeMap<_, _> = to_tbl
284 .columns
285 .iter()
286 .map(|c| (c.name.as_str(), c))
287 .collect();
288
289 for col in from_cols.keys() {
291 if !to_cols.contains_key(col) {
292 actions.push(MigrationAction::DeleteColumn {
293 table: name.to_string(),
294 column: col.to_string(),
295 });
296 }
297 }
298
299 for (col, to_def) in &to_cols {
301 if let Some(from_def) = from_cols.get(col)
302 && from_def.r#type.requires_migration(&to_def.r#type)
303 {
304 actions.push(MigrationAction::ModifyColumnType {
305 table: name.to_string(),
306 column: col.to_string(),
307 new_type: to_def.r#type.clone(),
308 });
309 }
310 }
311
312 for (col, to_def) in &to_cols {
314 if let Some(from_def) = from_cols.get(col)
315 && from_def.nullable != to_def.nullable
316 {
317 actions.push(MigrationAction::ModifyColumnNullable {
318 table: name.to_string(),
319 column: col.to_string(),
320 nullable: to_def.nullable,
321 fill_with: None,
322 });
323 }
324 }
325
326 for (col, to_def) in &to_cols {
328 if let Some(from_def) = from_cols.get(col) {
329 let from_default = from_def.default.as_ref().map(|d| d.to_sql());
330 let to_default = to_def.default.as_ref().map(|d| d.to_sql());
331 if from_default != to_default {
332 actions.push(MigrationAction::ModifyColumnDefault {
333 table: name.to_string(),
334 column: col.to_string(),
335 new_default: to_default,
336 });
337 }
338 }
339 }
340
341 for (col, to_def) in &to_cols {
343 if let Some(from_def) = from_cols.get(col)
344 && from_def.comment != to_def.comment
345 {
346 actions.push(MigrationAction::ModifyColumnComment {
347 table: name.to_string(),
348 column: col.to_string(),
349 new_comment: to_def.comment.clone(),
350 });
351 }
352 }
353
354 for (col, def) in &to_cols {
358 if !from_cols.contains_key(col) {
359 actions.push(MigrationAction::AddColumn {
360 table: name.to_string(),
361 column: Box::new((*def).clone()),
362 fill_with: None,
363 });
364 }
365 }
366
367 for from_constraint in &from_tbl.constraints {
369 if !to_tbl.constraints.contains(from_constraint) {
370 actions.push(MigrationAction::RemoveConstraint {
371 table: name.to_string(),
372 constraint: from_constraint.clone(),
373 });
374 }
375 }
376 for to_constraint in &to_tbl.constraints {
377 if !from_tbl.constraints.contains(to_constraint) {
378 actions.push(MigrationAction::AddConstraint {
379 table: name.to_string(),
380 constraint: to_constraint.clone(),
381 });
382 }
383 }
384 }
385 }
386
387 let new_tables: Vec<&TableDef> = to_map
391 .iter()
392 .filter(|(name, _)| !from_map.contains_key(*name))
393 .map(|(_, tbl)| *tbl)
394 .collect();
395
396 let sorted_new_tables = topological_sort_tables(&new_tables)?;
397
398 for tbl in sorted_new_tables {
399 let original_tbl = to_original_map.get(tbl.name.as_str()).unwrap();
401 actions.push(MigrationAction::CreateTable {
402 table: original_tbl.name.clone(),
403 columns: original_tbl.columns.clone(),
404 constraints: original_tbl.constraints.clone(),
405 });
406 }
407
408 sort_delete_tables(&mut actions, &from_map);
410
411 Ok(MigrationPlan {
412 comment: None,
413 created_at: None,
414 version: 0,
415 actions,
416 })
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use rstest::rstest;
423 use vespertide_core::{
424 ColumnDef, ColumnType, MigrationAction, SimpleColumnType,
425 schema::{primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray},
426 };
427
428 fn col(name: &str, ty: ColumnType) -> ColumnDef {
429 ColumnDef {
430 name: name.to_string(),
431 r#type: ty,
432 nullable: true,
433 default: None,
434 comment: None,
435 primary_key: None,
436 unique: None,
437 index: None,
438 foreign_key: None,
439 }
440 }
441
442 fn table(
443 name: &str,
444 columns: Vec<ColumnDef>,
445 constraints: Vec<vespertide_core::TableConstraint>,
446 ) -> TableDef {
447 TableDef {
448 name: name.to_string(),
449 columns,
450 constraints,
451 }
452 }
453
454 fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
455 TableConstraint::Index {
456 name: Some(name.to_string()),
457 columns: columns.into_iter().map(|s| s.to_string()).collect(),
458 }
459 }
460
461 #[rstest]
462 #[case::add_column_and_index(
463 vec![table(
464 "users",
465 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
466 vec![],
467 )],
468 vec![table(
469 "users",
470 vec![
471 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
472 col("name", ColumnType::Simple(SimpleColumnType::Text)),
473 ],
474 vec![idx("ix_users__name", vec!["name"])],
475 )],
476 vec![
477 MigrationAction::AddColumn {
478 table: "users".into(),
479 column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
480 fill_with: None,
481 },
482 MigrationAction::AddConstraint {
483 table: "users".into(),
484 constraint: idx("ix_users__name", vec!["name"]),
485 },
486 ]
487 )]
488 #[case::drop_table(
489 vec![table(
490 "users",
491 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
492 vec![],
493 )],
494 vec![],
495 vec![MigrationAction::DeleteTable {
496 table: "users".into()
497 }]
498 )]
499 #[case::add_table_with_index(
500 vec![],
501 vec![table(
502 "users",
503 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
504 vec![idx("idx_users_id", vec!["id"])],
505 )],
506 vec![
507 MigrationAction::CreateTable {
508 table: "users".into(),
509 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
510 constraints: vec![idx("idx_users_id", vec!["id"])],
511 },
512 ]
513 )]
514 #[case::delete_column(
515 vec![table(
516 "users",
517 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
518 vec![],
519 )],
520 vec![table(
521 "users",
522 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
523 vec![],
524 )],
525 vec![MigrationAction::DeleteColumn {
526 table: "users".into(),
527 column: "name".into(),
528 }]
529 )]
530 #[case::modify_column_type(
531 vec![table(
532 "users",
533 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
534 vec![],
535 )],
536 vec![table(
537 "users",
538 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
539 vec![],
540 )],
541 vec![MigrationAction::ModifyColumnType {
542 table: "users".into(),
543 column: "id".into(),
544 new_type: ColumnType::Simple(SimpleColumnType::Text),
545 }]
546 )]
547 #[case::remove_index(
548 vec![table(
549 "users",
550 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
551 vec![idx("idx_users_id", vec!["id"])],
552 )],
553 vec![table(
554 "users",
555 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
556 vec![],
557 )],
558 vec![MigrationAction::RemoveConstraint {
559 table: "users".into(),
560 constraint: idx("idx_users_id", vec!["id"]),
561 }]
562 )]
563 #[case::add_index_existing_table(
564 vec![table(
565 "users",
566 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
567 vec![],
568 )],
569 vec![table(
570 "users",
571 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
572 vec![idx("idx_users_id", vec!["id"])],
573 )],
574 vec![MigrationAction::AddConstraint {
575 table: "users".into(),
576 constraint: idx("idx_users_id", vec!["id"]),
577 }]
578 )]
579 fn diff_schemas_detects_additions(
580 #[case] from_schema: Vec<TableDef>,
581 #[case] to_schema: Vec<TableDef>,
582 #[case] expected_actions: Vec<MigrationAction>,
583 ) {
584 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
585 assert_eq!(plan.actions, expected_actions);
586 }
587
588 mod integer_enum {
590 use super::*;
591 use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
592
593 #[test]
594 fn integer_enum_values_changed_no_migration() {
595 let from = vec![table(
597 "orders",
598 vec![col(
599 "status",
600 ColumnType::Complex(ComplexColumnType::Enum {
601 name: "order_status".into(),
602 values: EnumValues::Integer(vec![
603 NumValue {
604 name: "Pending".into(),
605 value: 0,
606 },
607 NumValue {
608 name: "Shipped".into(),
609 value: 1,
610 },
611 ]),
612 }),
613 )],
614 vec![],
615 )];
616
617 let to = vec![table(
618 "orders",
619 vec![col(
620 "status",
621 ColumnType::Complex(ComplexColumnType::Enum {
622 name: "order_status".into(),
623 values: EnumValues::Integer(vec![
624 NumValue {
625 name: "Pending".into(),
626 value: 0,
627 },
628 NumValue {
629 name: "Shipped".into(),
630 value: 1,
631 },
632 NumValue {
633 name: "Delivered".into(),
634 value: 2,
635 },
636 NumValue {
637 name: "Cancelled".into(),
638 value: 100,
639 },
640 ]),
641 }),
642 )],
643 vec![],
644 )];
645
646 let plan = diff_schemas(&from, &to).unwrap();
647 assert!(
648 plan.actions.is_empty(),
649 "Expected no actions, got: {:?}",
650 plan.actions
651 );
652 }
653
654 #[test]
655 fn string_enum_values_changed_requires_migration() {
656 let from = vec![table(
658 "orders",
659 vec![col(
660 "status",
661 ColumnType::Complex(ComplexColumnType::Enum {
662 name: "order_status".into(),
663 values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
664 }),
665 )],
666 vec![],
667 )];
668
669 let to = vec![table(
670 "orders",
671 vec![col(
672 "status",
673 ColumnType::Complex(ComplexColumnType::Enum {
674 name: "order_status".into(),
675 values: EnumValues::String(vec![
676 "pending".into(),
677 "shipped".into(),
678 "delivered".into(),
679 ]),
680 }),
681 )],
682 vec![],
683 )];
684
685 let plan = diff_schemas(&from, &to).unwrap();
686 assert_eq!(plan.actions.len(), 1);
687 assert!(matches!(
688 &plan.actions[0],
689 MigrationAction::ModifyColumnType { table, column, .. }
690 if table == "orders" && column == "status"
691 ));
692 }
693 }
694
695 mod inline_constraints {
697 use super::*;
698 use vespertide_core::schema::foreign_key::ForeignKeyDef;
699 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
700 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
701 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
702
703 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
704 ColumnDef {
705 name: name.to_string(),
706 r#type: ty,
707 nullable: false,
708 default: None,
709 comment: None,
710 primary_key: Some(PrimaryKeySyntax::Bool(true)),
711 unique: None,
712 index: None,
713 foreign_key: None,
714 }
715 }
716
717 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
718 ColumnDef {
719 name: name.to_string(),
720 r#type: ty,
721 nullable: true,
722 default: None,
723 comment: None,
724 primary_key: None,
725 unique: Some(StrOrBoolOrArray::Bool(true)),
726 index: None,
727 foreign_key: None,
728 }
729 }
730
731 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
732 ColumnDef {
733 name: name.to_string(),
734 r#type: ty,
735 nullable: true,
736 default: None,
737 comment: None,
738 primary_key: None,
739 unique: None,
740 index: Some(StrOrBoolOrArray::Bool(true)),
741 foreign_key: None,
742 }
743 }
744
745 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
746 ColumnDef {
747 name: name.to_string(),
748 r#type: ty,
749 nullable: true,
750 default: None,
751 comment: None,
752 primary_key: None,
753 unique: None,
754 index: None,
755 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
756 ref_table: ref_table.to_string(),
757 ref_columns: vec![ref_col.to_string()],
758 on_delete: None,
759 on_update: None,
760 })),
761 }
762 }
763
764 #[test]
765 fn create_table_with_inline_pk() {
766 let plan = diff_schemas(
767 &[],
768 &[table(
769 "users",
770 vec![
771 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
772 col("name", ColumnType::Simple(SimpleColumnType::Text)),
773 ],
774 vec![],
775 )],
776 )
777 .unwrap();
778
779 assert_eq!(plan.actions.len(), 1);
781 if let MigrationAction::CreateTable {
782 columns,
783 constraints,
784 ..
785 } = &plan.actions[0]
786 {
787 assert_eq!(constraints.len(), 0);
789 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
791 assert!(id_col.primary_key.is_some());
792 } else {
793 panic!("Expected CreateTable action");
794 }
795 }
796
797 #[test]
798 fn create_table_with_inline_unique() {
799 let plan = diff_schemas(
800 &[],
801 &[table(
802 "users",
803 vec![
804 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
805 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
806 ],
807 vec![],
808 )],
809 )
810 .unwrap();
811
812 assert_eq!(plan.actions.len(), 1);
814 if let MigrationAction::CreateTable {
815 columns,
816 constraints,
817 ..
818 } = &plan.actions[0]
819 {
820 assert_eq!(constraints.len(), 0);
822 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
824 assert!(matches!(
825 email_col.unique,
826 Some(StrOrBoolOrArray::Bool(true))
827 ));
828 } else {
829 panic!("Expected CreateTable action");
830 }
831 }
832
833 #[test]
834 fn create_table_with_inline_index() {
835 let plan = diff_schemas(
836 &[],
837 &[table(
838 "users",
839 vec![
840 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
841 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
842 ],
843 vec![],
844 )],
845 )
846 .unwrap();
847
848 assert_eq!(plan.actions.len(), 1);
850 if let MigrationAction::CreateTable {
851 columns,
852 constraints,
853 ..
854 } = &plan.actions[0]
855 {
856 assert_eq!(constraints.len(), 0);
858 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
860 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
861 } else {
862 panic!("Expected CreateTable action");
863 }
864 }
865
866 #[test]
867 fn create_table_with_inline_fk() {
868 let plan = diff_schemas(
869 &[],
870 &[table(
871 "posts",
872 vec![
873 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
874 col_with_fk(
875 "user_id",
876 ColumnType::Simple(SimpleColumnType::Integer),
877 "users",
878 "id",
879 ),
880 ],
881 vec![],
882 )],
883 )
884 .unwrap();
885
886 assert_eq!(plan.actions.len(), 1);
888 if let MigrationAction::CreateTable {
889 columns,
890 constraints,
891 ..
892 } = &plan.actions[0]
893 {
894 assert_eq!(constraints.len(), 0);
896 let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
898 assert!(user_id_col.foreign_key.is_some());
899 } else {
900 panic!("Expected CreateTable action");
901 }
902 }
903
904 #[test]
905 fn add_index_via_inline_constraint() {
906 let plan = diff_schemas(
909 &[table(
910 "users",
911 vec![
912 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
913 col("name", ColumnType::Simple(SimpleColumnType::Text)),
914 ],
915 vec![],
916 )],
917 &[table(
918 "users",
919 vec![
920 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
921 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
922 ],
923 vec![],
924 )],
925 )
926 .unwrap();
927
928 assert_eq!(plan.actions.len(), 1);
930 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
931 assert_eq!(table, "users");
932 if let TableConstraint::Index { name, columns } = constraint {
933 assert_eq!(name, &None); assert_eq!(columns, &vec!["name".to_string()]);
935 } else {
936 panic!("Expected Index constraint, got {:?}", constraint);
937 }
938 } else {
939 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
940 }
941 }
942
943 #[test]
944 fn create_table_with_all_inline_constraints() {
945 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
946 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
947 id_col.nullable = false;
948
949 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
950 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
951
952 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
953 name_col.index = Some(StrOrBoolOrArray::Bool(true));
954
955 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
956 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
957 ref_table: "orgs".into(),
958 ref_columns: vec!["id".into()],
959 on_delete: None,
960 on_update: None,
961 }));
962
963 let plan = diff_schemas(
964 &[],
965 &[table(
966 "users",
967 vec![id_col, email_col, name_col, org_id_col],
968 vec![],
969 )],
970 )
971 .unwrap();
972
973 assert_eq!(plan.actions.len(), 1);
975
976 if let MigrationAction::CreateTable {
977 columns,
978 constraints,
979 ..
980 } = &plan.actions[0]
981 {
982 assert_eq!(constraints.len(), 0);
984
985 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
987 assert!(id_col.primary_key.is_some());
988
989 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
990 assert!(matches!(
991 email_col.unique,
992 Some(StrOrBoolOrArray::Bool(true))
993 ));
994
995 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
996 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
997
998 let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
999 assert!(org_id_col.foreign_key.is_some());
1000 } else {
1001 panic!("Expected CreateTable action");
1002 }
1003 }
1004
1005 #[test]
1006 fn add_constraint_to_existing_table() {
1007 let from_schema = vec![table(
1009 "users",
1010 vec![
1011 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1012 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1013 ],
1014 vec![],
1015 )];
1016
1017 let to_schema = vec![table(
1018 "users",
1019 vec![
1020 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1021 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1022 ],
1023 vec![vespertide_core::TableConstraint::Unique {
1024 name: Some("uq_users_email".into()),
1025 columns: vec!["email".into()],
1026 }],
1027 )];
1028
1029 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1030 assert_eq!(plan.actions.len(), 1);
1031 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1032 assert_eq!(table, "users");
1033 assert!(matches!(
1034 constraint,
1035 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1036 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1037 ));
1038 } else {
1039 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1040 }
1041 }
1042
1043 #[test]
1044 fn remove_constraint_from_existing_table() {
1045 let from_schema = vec![table(
1047 "users",
1048 vec![
1049 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1050 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1051 ],
1052 vec![vespertide_core::TableConstraint::Unique {
1053 name: Some("uq_users_email".into()),
1054 columns: vec!["email".into()],
1055 }],
1056 )];
1057
1058 let to_schema = vec![table(
1059 "users",
1060 vec![
1061 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1062 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1063 ],
1064 vec![],
1065 )];
1066
1067 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1068 assert_eq!(plan.actions.len(), 1);
1069 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1070 assert_eq!(table, "users");
1071 assert!(matches!(
1072 constraint,
1073 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1074 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1075 ));
1076 } else {
1077 panic!(
1078 "Expected RemoveConstraint action, got {:?}",
1079 plan.actions[0]
1080 );
1081 }
1082 }
1083
1084 #[test]
1085 fn diff_schemas_with_normalize_error() {
1086 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1088 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1089
1090 let table = TableDef {
1091 name: "test".into(),
1092 columns: vec![
1093 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1094 col1.clone(),
1095 {
1096 let mut c = col1.clone();
1098 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1099 c
1100 },
1101 ],
1102 constraints: vec![],
1103 };
1104
1105 let result = diff_schemas(&[], &[table]);
1106 assert!(result.is_err());
1107 if let Err(PlannerError::TableValidation(msg)) = result {
1108 assert!(msg.contains("Failed to normalize table"));
1109 assert!(msg.contains("Duplicate index"));
1110 } else {
1111 panic!("Expected TableValidation error, got {:?}", result);
1112 }
1113 }
1114
1115 #[test]
1116 fn diff_schemas_with_normalize_error_in_from_schema() {
1117 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1119 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1120
1121 let table = TableDef {
1122 name: "test".into(),
1123 columns: vec![
1124 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1125 col1.clone(),
1126 {
1127 let mut c = col1.clone();
1129 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1130 c
1131 },
1132 ],
1133 constraints: vec![],
1134 };
1135
1136 let result = diff_schemas(&[table], &[]);
1138 assert!(result.is_err());
1139 if let Err(PlannerError::TableValidation(msg)) = result {
1140 assert!(msg.contains("Failed to normalize table"));
1141 assert!(msg.contains("Duplicate index"));
1142 } else {
1143 panic!("Expected TableValidation error, got {:?}", result);
1144 }
1145 }
1146 }
1147
1148 mod fk_ordering {
1150 use super::*;
1151 use vespertide_core::TableConstraint;
1152
1153 fn table_with_fk(
1154 name: &str,
1155 ref_table: &str,
1156 fk_column: &str,
1157 ref_column: &str,
1158 ) -> TableDef {
1159 TableDef {
1160 name: name.to_string(),
1161 columns: vec![
1162 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1163 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1164 ],
1165 constraints: vec![TableConstraint::ForeignKey {
1166 name: None,
1167 columns: vec![fk_column.to_string()],
1168 ref_table: ref_table.to_string(),
1169 ref_columns: vec![ref_column.to_string()],
1170 on_delete: None,
1171 on_update: None,
1172 }],
1173 }
1174 }
1175
1176 fn simple_table(name: &str) -> TableDef {
1177 TableDef {
1178 name: name.to_string(),
1179 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1180 constraints: vec![],
1181 }
1182 }
1183
1184 #[test]
1185 fn create_tables_respects_fk_order() {
1186 let users = simple_table("users");
1189 let posts = table_with_fk("posts", "users", "user_id", "id");
1190
1191 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1192
1193 let create_order: Vec<&str> = plan
1195 .actions
1196 .iter()
1197 .filter_map(|a| {
1198 if let MigrationAction::CreateTable { table, .. } = a {
1199 Some(table.as_str())
1200 } else {
1201 None
1202 }
1203 })
1204 .collect();
1205
1206 assert_eq!(create_order, vec!["users", "posts"]);
1207 }
1208
1209 #[test]
1210 fn create_tables_chain_dependency() {
1211 let users = simple_table("users");
1216 let media = table_with_fk("media", "users", "owner_id", "id");
1217 let articles = table_with_fk("articles", "media", "media_id", "id");
1218
1219 let plan =
1221 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1222
1223 let create_order: Vec<&str> = plan
1224 .actions
1225 .iter()
1226 .filter_map(|a| {
1227 if let MigrationAction::CreateTable { table, .. } = a {
1228 Some(table.as_str())
1229 } else {
1230 None
1231 }
1232 })
1233 .collect();
1234
1235 assert_eq!(create_order, vec!["users", "media", "articles"]);
1236 }
1237
1238 #[test]
1239 fn create_tables_multiple_independent_branches() {
1240 let users = simple_table("users");
1244 let posts = table_with_fk("posts", "users", "user_id", "id");
1245 let categories = simple_table("categories");
1246 let products = table_with_fk("products", "categories", "category_id", "id");
1247
1248 let plan = diff_schemas(
1249 &[],
1250 &[
1251 products.clone(),
1252 posts.clone(),
1253 categories.clone(),
1254 users.clone(),
1255 ],
1256 )
1257 .unwrap();
1258
1259 let create_order: Vec<&str> = plan
1260 .actions
1261 .iter()
1262 .filter_map(|a| {
1263 if let MigrationAction::CreateTable { table, .. } = a {
1264 Some(table.as_str())
1265 } else {
1266 None
1267 }
1268 })
1269 .collect();
1270
1271 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1273 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1274 assert!(
1275 users_pos < posts_pos,
1276 "users should be created before posts"
1277 );
1278
1279 let categories_pos = create_order
1281 .iter()
1282 .position(|&t| t == "categories")
1283 .unwrap();
1284 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1285 assert!(
1286 categories_pos < products_pos,
1287 "categories should be created before products"
1288 );
1289 }
1290
1291 #[test]
1292 fn delete_tables_respects_fk_order() {
1293 let users = simple_table("users");
1296 let posts = table_with_fk("posts", "users", "user_id", "id");
1297
1298 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1299
1300 let delete_order: Vec<&str> = plan
1301 .actions
1302 .iter()
1303 .filter_map(|a| {
1304 if let MigrationAction::DeleteTable { table } = a {
1305 Some(table.as_str())
1306 } else {
1307 None
1308 }
1309 })
1310 .collect();
1311
1312 assert_eq!(delete_order, vec!["posts", "users"]);
1313 }
1314
1315 #[test]
1316 fn delete_tables_chain_dependency() {
1317 let users = simple_table("users");
1320 let media = table_with_fk("media", "users", "owner_id", "id");
1321 let articles = table_with_fk("articles", "media", "media_id", "id");
1322
1323 let plan =
1324 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1325
1326 let delete_order: Vec<&str> = plan
1327 .actions
1328 .iter()
1329 .filter_map(|a| {
1330 if let MigrationAction::DeleteTable { table } = a {
1331 Some(table.as_str())
1332 } else {
1333 None
1334 }
1335 })
1336 .collect();
1337
1338 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1340 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1341 assert!(
1342 articles_pos < media_pos,
1343 "articles should be deleted before media"
1344 );
1345
1346 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1348 assert!(
1349 media_pos < users_pos,
1350 "media should be deleted before users"
1351 );
1352 }
1353
1354 #[test]
1355 fn circular_fk_dependency_returns_error() {
1356 let table_a = TableDef {
1358 name: "table_a".to_string(),
1359 columns: vec![
1360 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1361 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1362 ],
1363 constraints: vec![TableConstraint::ForeignKey {
1364 name: None,
1365 columns: vec!["b_id".to_string()],
1366 ref_table: "table_b".to_string(),
1367 ref_columns: vec!["id".to_string()],
1368 on_delete: None,
1369 on_update: None,
1370 }],
1371 };
1372
1373 let table_b = TableDef {
1374 name: "table_b".to_string(),
1375 columns: vec![
1376 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1377 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1378 ],
1379 constraints: vec![TableConstraint::ForeignKey {
1380 name: None,
1381 columns: vec!["a_id".to_string()],
1382 ref_table: "table_a".to_string(),
1383 ref_columns: vec!["id".to_string()],
1384 on_delete: None,
1385 on_update: None,
1386 }],
1387 };
1388
1389 let result = diff_schemas(&[], &[table_a, table_b]);
1390 assert!(result.is_err());
1391 if let Err(PlannerError::TableValidation(msg)) = result {
1392 assert!(
1393 msg.contains("Circular foreign key dependency"),
1394 "Expected circular dependency error, got: {}",
1395 msg
1396 );
1397 } else {
1398 panic!("Expected TableValidation error, got {:?}", result);
1399 }
1400 }
1401
1402 #[test]
1403 fn fk_to_external_table_is_ignored() {
1404 let posts = table_with_fk("posts", "users", "user_id", "id");
1406 let comments = table_with_fk("comments", "posts", "post_id", "id");
1407
1408 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1410
1411 let create_order: Vec<&str> = plan
1412 .actions
1413 .iter()
1414 .filter_map(|a| {
1415 if let MigrationAction::CreateTable { table, .. } = a {
1416 Some(table.as_str())
1417 } else {
1418 None
1419 }
1420 })
1421 .collect();
1422
1423 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1425 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1426 assert!(
1427 posts_pos < comments_pos,
1428 "posts should be created before comments"
1429 );
1430 }
1431
1432 #[test]
1433 fn delete_tables_mixed_with_other_actions() {
1434 use crate::diff::diff_schemas;
1437
1438 let from_schema = vec![
1439 table(
1440 "users",
1441 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1442 vec![],
1443 ),
1444 table(
1445 "posts",
1446 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1447 vec![],
1448 ),
1449 ];
1450
1451 let to_schema = vec![
1452 table(
1454 "users",
1455 vec![
1456 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1457 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1458 ],
1459 vec![],
1460 ),
1461 ];
1462
1463 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1464
1465 assert!(
1467 plan.actions
1468 .iter()
1469 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1470 );
1471 assert!(
1472 plan.actions
1473 .iter()
1474 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1475 );
1476
1477 }
1480
1481 #[test]
1482 #[should_panic(expected = "Expected DeleteTable action")]
1483 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1484 use super::extract_delete_table_name;
1486
1487 let action = MigrationAction::AddColumn {
1488 table: "users".into(),
1489 column: Box::new(ColumnDef {
1490 name: "email".into(),
1491 r#type: ColumnType::Simple(SimpleColumnType::Text),
1492 nullable: true,
1493 default: None,
1494 comment: None,
1495 primary_key: None,
1496 unique: None,
1497 index: None,
1498 foreign_key: None,
1499 }),
1500 fill_with: None,
1501 };
1502
1503 extract_delete_table_name(&action);
1505 }
1506
1507 #[test]
1509 fn create_tables_with_inline_fk_chain() {
1510 use super::*;
1511 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1512 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1513
1514 fn col_pk(name: &str) -> ColumnDef {
1515 ColumnDef {
1516 name: name.to_string(),
1517 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1518 nullable: false,
1519 default: None,
1520 comment: None,
1521 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1522 unique: None,
1523 index: None,
1524 foreign_key: None,
1525 }
1526 }
1527
1528 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1529 ColumnDef {
1530 name: name.to_string(),
1531 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1532 nullable: true,
1533 default: None,
1534 comment: None,
1535 primary_key: None,
1536 unique: None,
1537 index: None,
1538 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1539 }
1540 }
1541
1542 let user = TableDef {
1551 name: "user".to_string(),
1552 columns: vec![col_pk("id")],
1553 constraints: vec![],
1554 };
1555
1556 let product = TableDef {
1557 name: "product".to_string(),
1558 columns: vec![col_pk("id")],
1559 constraints: vec![],
1560 };
1561
1562 let project = TableDef {
1563 name: "project".to_string(),
1564 columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1565 constraints: vec![],
1566 };
1567
1568 let code = TableDef {
1569 name: "code".to_string(),
1570 columns: vec![
1571 col_pk("id"),
1572 col_inline_fk("product_id", "product"),
1573 col_inline_fk("creator_user_id", "user"),
1574 col_inline_fk("project_id", "project"),
1575 ],
1576 constraints: vec![],
1577 };
1578
1579 let order = TableDef {
1580 name: "order".to_string(),
1581 columns: vec![
1582 col_pk("id"),
1583 col_inline_fk("user_id", "user"),
1584 col_inline_fk("project_id", "project"),
1585 col_inline_fk("product_id", "product"),
1586 col_inline_fk("code_id", "code"),
1587 ],
1588 constraints: vec![],
1589 };
1590
1591 let payment = TableDef {
1592 name: "payment".to_string(),
1593 columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1594 constraints: vec![],
1595 };
1596
1597 let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1599 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1600
1601 let plan = result.unwrap();
1602 let create_order: Vec<&str> = plan
1603 .actions
1604 .iter()
1605 .filter_map(|a| {
1606 if let MigrationAction::CreateTable { table, .. } = a {
1607 Some(table.as_str())
1608 } else {
1609 None
1610 }
1611 })
1612 .collect();
1613
1614 let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1616
1617 assert!(
1620 get_pos("user") < get_pos("project"),
1621 "user must come before project"
1622 );
1623 assert!(
1625 get_pos("product") < get_pos("code"),
1626 "product must come before code"
1627 );
1628 assert!(
1629 get_pos("user") < get_pos("code"),
1630 "user must come before code"
1631 );
1632 assert!(
1633 get_pos("project") < get_pos("code"),
1634 "project must come before code"
1635 );
1636 assert!(
1638 get_pos("code") < get_pos("order"),
1639 "code must come before order"
1640 );
1641 assert!(
1643 get_pos("order") < get_pos("payment"),
1644 "order must come before payment"
1645 );
1646 }
1647
1648 #[test]
1650 fn create_tables_with_duplicate_fk_references() {
1651 use super::*;
1652 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1653 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1654
1655 fn col_pk(name: &str) -> ColumnDef {
1656 ColumnDef {
1657 name: name.to_string(),
1658 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1659 nullable: false,
1660 default: None,
1661 comment: None,
1662 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1663 unique: None,
1664 index: None,
1665 foreign_key: None,
1666 }
1667 }
1668
1669 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1670 ColumnDef {
1671 name: name.to_string(),
1672 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1673 nullable: true,
1674 default: None,
1675 comment: None,
1676 primary_key: None,
1677 unique: None,
1678 index: None,
1679 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1680 }
1681 }
1682
1683 let user = TableDef {
1685 name: "user".to_string(),
1686 columns: vec![col_pk("id")],
1687 constraints: vec![],
1688 };
1689
1690 let code = TableDef {
1691 name: "code".to_string(),
1692 columns: vec![
1693 col_pk("id"),
1694 col_inline_fk("creator_user_id", "user"),
1695 col_inline_fk("used_by_user_id", "user"), ],
1697 constraints: vec![],
1698 };
1699
1700 let result = diff_schemas(&[], &[code, user]);
1702 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1703
1704 let plan = result.unwrap();
1705 let create_order: Vec<&str> = plan
1706 .actions
1707 .iter()
1708 .filter_map(|a| {
1709 if let MigrationAction::CreateTable { table, .. } = a {
1710 Some(table.as_str())
1711 } else {
1712 None
1713 }
1714 })
1715 .collect();
1716
1717 let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1719 let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1720 assert!(user_pos < code_pos, "user must come before code");
1721 }
1722 }
1723
1724 mod primary_key_changes {
1725 use super::*;
1726
1727 fn pk(columns: Vec<&str>) -> TableConstraint {
1728 TableConstraint::PrimaryKey {
1729 auto_increment: false,
1730 columns: columns.into_iter().map(|s| s.to_string()).collect(),
1731 }
1732 }
1733
1734 #[test]
1735 fn add_column_to_composite_pk() {
1736 let from = vec![table(
1738 "users",
1739 vec![
1740 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1741 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1742 ],
1743 vec![pk(vec!["id"])],
1744 )];
1745
1746 let to = vec![table(
1747 "users",
1748 vec![
1749 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1750 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1751 ],
1752 vec![pk(vec!["id", "tenant_id"])],
1753 )];
1754
1755 let plan = diff_schemas(&from, &to).unwrap();
1756
1757 assert_eq!(plan.actions.len(), 2);
1759
1760 let has_remove = plan.actions.iter().any(|a| {
1761 matches!(
1762 a,
1763 MigrationAction::RemoveConstraint {
1764 table,
1765 constraint: TableConstraint::PrimaryKey { columns, .. }
1766 } if table == "users" && columns == &vec!["id".to_string()]
1767 )
1768 });
1769 assert!(has_remove, "Should have RemoveConstraint for old PK");
1770
1771 let has_add = plan.actions.iter().any(|a| {
1772 matches!(
1773 a,
1774 MigrationAction::AddConstraint {
1775 table,
1776 constraint: TableConstraint::PrimaryKey { columns, .. }
1777 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1778 )
1779 });
1780 assert!(has_add, "Should have AddConstraint for new composite PK");
1781 }
1782
1783 #[test]
1784 fn remove_column_from_composite_pk() {
1785 let from = vec![table(
1787 "users",
1788 vec![
1789 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1790 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1791 ],
1792 vec![pk(vec!["id", "tenant_id"])],
1793 )];
1794
1795 let to = vec![table(
1796 "users",
1797 vec![
1798 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1799 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1800 ],
1801 vec![pk(vec!["id"])],
1802 )];
1803
1804 let plan = diff_schemas(&from, &to).unwrap();
1805
1806 assert_eq!(plan.actions.len(), 2);
1808
1809 let has_remove = plan.actions.iter().any(|a| {
1810 matches!(
1811 a,
1812 MigrationAction::RemoveConstraint {
1813 table,
1814 constraint: TableConstraint::PrimaryKey { columns, .. }
1815 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1816 )
1817 });
1818 assert!(has_remove, "Should have RemoveConstraint for old composite PK");
1819
1820 let has_add = plan.actions.iter().any(|a| {
1821 matches!(
1822 a,
1823 MigrationAction::AddConstraint {
1824 table,
1825 constraint: TableConstraint::PrimaryKey { columns, .. }
1826 } if table == "users" && columns == &vec!["id".to_string()]
1827 )
1828 });
1829 assert!(has_add, "Should have AddConstraint for new single-column PK");
1830 }
1831
1832 #[test]
1833 fn change_pk_columns_entirely() {
1834 let from = vec![table(
1836 "users",
1837 vec![
1838 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1839 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1840 ],
1841 vec![pk(vec!["id"])],
1842 )];
1843
1844 let to = vec![table(
1845 "users",
1846 vec![
1847 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1848 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1849 ],
1850 vec![pk(vec!["uuid"])],
1851 )];
1852
1853 let plan = diff_schemas(&from, &to).unwrap();
1854
1855 assert_eq!(plan.actions.len(), 2);
1856
1857 let has_remove = plan.actions.iter().any(|a| {
1858 matches!(
1859 a,
1860 MigrationAction::RemoveConstraint {
1861 table,
1862 constraint: TableConstraint::PrimaryKey { columns, .. }
1863 } if table == "users" && columns == &vec!["id".to_string()]
1864 )
1865 });
1866 assert!(has_remove, "Should have RemoveConstraint for old PK");
1867
1868 let has_add = plan.actions.iter().any(|a| {
1869 matches!(
1870 a,
1871 MigrationAction::AddConstraint {
1872 table,
1873 constraint: TableConstraint::PrimaryKey { columns, .. }
1874 } if table == "users" && columns == &vec!["uuid".to_string()]
1875 )
1876 });
1877 assert!(has_add, "Should have AddConstraint for new PK");
1878 }
1879
1880 #[test]
1881 fn add_multiple_columns_to_composite_pk() {
1882 let from = vec![table(
1884 "users",
1885 vec![
1886 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1887 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1888 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1889 ],
1890 vec![pk(vec!["id"])],
1891 )];
1892
1893 let to = vec![table(
1894 "users",
1895 vec![
1896 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1897 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1898 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1899 ],
1900 vec![pk(vec!["id", "tenant_id", "region_id"])],
1901 )];
1902
1903 let plan = diff_schemas(&from, &to).unwrap();
1904
1905 assert_eq!(plan.actions.len(), 2);
1906
1907 let has_remove = plan.actions.iter().any(|a| {
1908 matches!(
1909 a,
1910 MigrationAction::RemoveConstraint {
1911 table,
1912 constraint: TableConstraint::PrimaryKey { columns, .. }
1913 } if table == "users" && columns == &vec!["id".to_string()]
1914 )
1915 });
1916 assert!(has_remove, "Should have RemoveConstraint for old single-column PK");
1917
1918 let has_add = plan.actions.iter().any(|a| {
1919 matches!(
1920 a,
1921 MigrationAction::AddConstraint {
1922 table,
1923 constraint: TableConstraint::PrimaryKey { columns, .. }
1924 } if table == "users" && columns == &vec![
1925 "id".to_string(),
1926 "tenant_id".to_string(),
1927 "region_id".to_string()
1928 ]
1929 )
1930 });
1931 assert!(has_add, "Should have AddConstraint for new 3-column composite PK");
1932 }
1933
1934 #[test]
1935 fn remove_multiple_columns_from_composite_pk() {
1936 let from = vec![table(
1938 "users",
1939 vec![
1940 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1941 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1942 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1943 ],
1944 vec![pk(vec!["id", "tenant_id", "region_id"])],
1945 )];
1946
1947 let to = vec![table(
1948 "users",
1949 vec![
1950 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1951 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1952 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1953 ],
1954 vec![pk(vec!["id"])],
1955 )];
1956
1957 let plan = diff_schemas(&from, &to).unwrap();
1958
1959 assert_eq!(plan.actions.len(), 2);
1960
1961 let has_remove = plan.actions.iter().any(|a| {
1962 matches!(
1963 a,
1964 MigrationAction::RemoveConstraint {
1965 table,
1966 constraint: TableConstraint::PrimaryKey { columns, .. }
1967 } if table == "users" && columns == &vec![
1968 "id".to_string(),
1969 "tenant_id".to_string(),
1970 "region_id".to_string()
1971 ]
1972 )
1973 });
1974 assert!(has_remove, "Should have RemoveConstraint for old 3-column composite PK");
1975
1976 let has_add = plan.actions.iter().any(|a| {
1977 matches!(
1978 a,
1979 MigrationAction::AddConstraint {
1980 table,
1981 constraint: TableConstraint::PrimaryKey { columns, .. }
1982 } if table == "users" && columns == &vec!["id".to_string()]
1983 )
1984 });
1985 assert!(has_add, "Should have AddConstraint for new single-column PK");
1986 }
1987
1988 #[test]
1989 fn change_composite_pk_columns_partially() {
1990 let from = vec![table(
1993 "users",
1994 vec![
1995 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1996 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1997 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1998 ],
1999 vec![pk(vec!["id", "tenant_id"])],
2000 )];
2001
2002 let to = vec![table(
2003 "users",
2004 vec![
2005 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2006 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2007 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2008 ],
2009 vec![pk(vec!["id", "region_id"])],
2010 )];
2011
2012 let plan = diff_schemas(&from, &to).unwrap();
2013
2014 assert_eq!(plan.actions.len(), 2);
2015
2016 let has_remove = plan.actions.iter().any(|a| {
2017 matches!(
2018 a,
2019 MigrationAction::RemoveConstraint {
2020 table,
2021 constraint: TableConstraint::PrimaryKey { columns, .. }
2022 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2023 )
2024 });
2025 assert!(has_remove, "Should have RemoveConstraint for old PK with tenant_id");
2026
2027 let has_add = plan.actions.iter().any(|a| {
2028 matches!(
2029 a,
2030 MigrationAction::AddConstraint {
2031 table,
2032 constraint: TableConstraint::PrimaryKey { columns, .. }
2033 } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2034 )
2035 });
2036 assert!(has_add, "Should have AddConstraint for new PK with region_id");
2037 }
2038 }
2039
2040 mod default_changes {
2041 use super::*;
2042
2043 fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2044 ColumnDef {
2045 name: name.to_string(),
2046 r#type: ty,
2047 nullable: true,
2048 default: default.map(|s| s.into()),
2049 comment: None,
2050 primary_key: None,
2051 unique: None,
2052 index: None,
2053 foreign_key: None,
2054 }
2055 }
2056
2057 #[test]
2058 fn add_default_value() {
2059 let from = vec![table(
2061 "users",
2062 vec![
2063 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2064 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2065 ],
2066 vec![],
2067 )];
2068
2069 let to = vec![table(
2070 "users",
2071 vec![
2072 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2073 col_with_default(
2074 "status",
2075 ColumnType::Simple(SimpleColumnType::Text),
2076 Some("'active'"),
2077 ),
2078 ],
2079 vec![],
2080 )];
2081
2082 let plan = diff_schemas(&from, &to).unwrap();
2083
2084 assert_eq!(plan.actions.len(), 1);
2085 assert!(matches!(
2086 &plan.actions[0],
2087 MigrationAction::ModifyColumnDefault {
2088 table,
2089 column,
2090 new_default: Some(default),
2091 } if table == "users" && column == "status" && default == "'active'"
2092 ));
2093 }
2094
2095 #[test]
2096 fn remove_default_value() {
2097 let from = vec![table(
2099 "users",
2100 vec![
2101 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2102 col_with_default(
2103 "status",
2104 ColumnType::Simple(SimpleColumnType::Text),
2105 Some("'active'"),
2106 ),
2107 ],
2108 vec![],
2109 )];
2110
2111 let to = vec![table(
2112 "users",
2113 vec![
2114 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2115 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2116 ],
2117 vec![],
2118 )];
2119
2120 let plan = diff_schemas(&from, &to).unwrap();
2121
2122 assert_eq!(plan.actions.len(), 1);
2123 assert!(matches!(
2124 &plan.actions[0],
2125 MigrationAction::ModifyColumnDefault {
2126 table,
2127 column,
2128 new_default: None,
2129 } if table == "users" && column == "status"
2130 ));
2131 }
2132
2133 #[test]
2134 fn change_default_value() {
2135 let from = vec![table(
2137 "users",
2138 vec![
2139 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2140 col_with_default(
2141 "status",
2142 ColumnType::Simple(SimpleColumnType::Text),
2143 Some("'active'"),
2144 ),
2145 ],
2146 vec![],
2147 )];
2148
2149 let to = vec![table(
2150 "users",
2151 vec![
2152 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2153 col_with_default(
2154 "status",
2155 ColumnType::Simple(SimpleColumnType::Text),
2156 Some("'pending'"),
2157 ),
2158 ],
2159 vec![],
2160 )];
2161
2162 let plan = diff_schemas(&from, &to).unwrap();
2163
2164 assert_eq!(plan.actions.len(), 1);
2165 assert!(matches!(
2166 &plan.actions[0],
2167 MigrationAction::ModifyColumnDefault {
2168 table,
2169 column,
2170 new_default: Some(default),
2171 } if table == "users" && column == "status" && default == "'pending'"
2172 ));
2173 }
2174
2175 #[test]
2176 fn no_change_same_default() {
2177 let from = vec![table(
2179 "users",
2180 vec![
2181 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2182 col_with_default(
2183 "status",
2184 ColumnType::Simple(SimpleColumnType::Text),
2185 Some("'active'"),
2186 ),
2187 ],
2188 vec![],
2189 )];
2190
2191 let to = vec![table(
2192 "users",
2193 vec![
2194 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2195 col_with_default(
2196 "status",
2197 ColumnType::Simple(SimpleColumnType::Text),
2198 Some("'active'"),
2199 ),
2200 ],
2201 vec![],
2202 )];
2203
2204 let plan = diff_schemas(&from, &to).unwrap();
2205
2206 assert!(plan.actions.is_empty());
2207 }
2208
2209 #[test]
2210 fn multiple_columns_default_changes() {
2211 let from = vec![table(
2213 "users",
2214 vec![
2215 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2216 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2217 col_with_default(
2218 "role",
2219 ColumnType::Simple(SimpleColumnType::Text),
2220 Some("'user'"),
2221 ),
2222 col_with_default(
2223 "active",
2224 ColumnType::Simple(SimpleColumnType::Boolean),
2225 Some("true"),
2226 ),
2227 ],
2228 vec![],
2229 )];
2230
2231 let to = vec![table(
2232 "users",
2233 vec![
2234 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2235 col_with_default(
2236 "status",
2237 ColumnType::Simple(SimpleColumnType::Text),
2238 Some("'pending'"),
2239 ), col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), col_with_default(
2242 "active",
2243 ColumnType::Simple(SimpleColumnType::Boolean),
2244 Some("true"),
2245 ), ],
2247 vec![],
2248 )];
2249
2250 let plan = diff_schemas(&from, &to).unwrap();
2251
2252 assert_eq!(plan.actions.len(), 2);
2253
2254 let has_status_change = plan.actions.iter().any(|a| {
2255 matches!(
2256 a,
2257 MigrationAction::ModifyColumnDefault {
2258 table,
2259 column,
2260 new_default: Some(default),
2261 } if table == "users" && column == "status" && default == "'pending'"
2262 )
2263 });
2264 assert!(has_status_change, "Should detect status default added");
2265
2266 let has_role_change = plan.actions.iter().any(|a| {
2267 matches!(
2268 a,
2269 MigrationAction::ModifyColumnDefault {
2270 table,
2271 column,
2272 new_default: None,
2273 } if table == "users" && column == "role"
2274 )
2275 });
2276 assert!(has_role_change, "Should detect role default removed");
2277 }
2278
2279 #[test]
2280 fn default_change_with_type_change() {
2281 let from = vec![table(
2283 "users",
2284 vec![
2285 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2286 col_with_default(
2287 "count",
2288 ColumnType::Simple(SimpleColumnType::Integer),
2289 Some("0"),
2290 ),
2291 ],
2292 vec![],
2293 )];
2294
2295 let to = vec![table(
2296 "users",
2297 vec![
2298 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2299 col_with_default(
2300 "count",
2301 ColumnType::Simple(SimpleColumnType::Text),
2302 Some("'0'"),
2303 ),
2304 ],
2305 vec![],
2306 )];
2307
2308 let plan = diff_schemas(&from, &to).unwrap();
2309
2310 assert_eq!(plan.actions.len(), 2);
2312
2313 let has_type_change = plan.actions.iter().any(|a| {
2314 matches!(
2315 a,
2316 MigrationAction::ModifyColumnType { table, column, .. }
2317 if table == "users" && column == "count"
2318 )
2319 });
2320 assert!(has_type_change, "Should detect type change");
2321
2322 let has_default_change = plan.actions.iter().any(|a| {
2323 matches!(
2324 a,
2325 MigrationAction::ModifyColumnDefault {
2326 table,
2327 column,
2328 new_default: Some(default),
2329 } if table == "users" && column == "count" && default == "'0'"
2330 )
2331 });
2332 assert!(has_default_change, "Should detect default change");
2333 }
2334 }
2335
2336 mod comment_changes {
2337 use super::*;
2338
2339 fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2340 ColumnDef {
2341 name: name.to_string(),
2342 r#type: ty,
2343 nullable: true,
2344 default: None,
2345 comment: comment.map(|s| s.to_string()),
2346 primary_key: None,
2347 unique: None,
2348 index: None,
2349 foreign_key: None,
2350 }
2351 }
2352
2353 #[test]
2354 fn add_comment() {
2355 let from = vec![table(
2357 "users",
2358 vec![
2359 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2360 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2361 ],
2362 vec![],
2363 )];
2364
2365 let to = vec![table(
2366 "users",
2367 vec![
2368 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2369 col_with_comment(
2370 "email",
2371 ColumnType::Simple(SimpleColumnType::Text),
2372 Some("User's email address"),
2373 ),
2374 ],
2375 vec![],
2376 )];
2377
2378 let plan = diff_schemas(&from, &to).unwrap();
2379
2380 assert_eq!(plan.actions.len(), 1);
2381 assert!(matches!(
2382 &plan.actions[0],
2383 MigrationAction::ModifyColumnComment {
2384 table,
2385 column,
2386 new_comment: Some(comment),
2387 } if table == "users" && column == "email" && comment == "User's email address"
2388 ));
2389 }
2390
2391 #[test]
2392 fn remove_comment() {
2393 let from = vec![table(
2395 "users",
2396 vec![
2397 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2398 col_with_comment(
2399 "email",
2400 ColumnType::Simple(SimpleColumnType::Text),
2401 Some("User's email address"),
2402 ),
2403 ],
2404 vec![],
2405 )];
2406
2407 let to = vec![table(
2408 "users",
2409 vec![
2410 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2411 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2412 ],
2413 vec![],
2414 )];
2415
2416 let plan = diff_schemas(&from, &to).unwrap();
2417
2418 assert_eq!(plan.actions.len(), 1);
2419 assert!(matches!(
2420 &plan.actions[0],
2421 MigrationAction::ModifyColumnComment {
2422 table,
2423 column,
2424 new_comment: None,
2425 } if table == "users" && column == "email"
2426 ));
2427 }
2428
2429 #[test]
2430 fn change_comment() {
2431 let from = vec![table(
2433 "users",
2434 vec![
2435 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2436 col_with_comment(
2437 "email",
2438 ColumnType::Simple(SimpleColumnType::Text),
2439 Some("Old comment"),
2440 ),
2441 ],
2442 vec![],
2443 )];
2444
2445 let to = vec![table(
2446 "users",
2447 vec![
2448 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2449 col_with_comment(
2450 "email",
2451 ColumnType::Simple(SimpleColumnType::Text),
2452 Some("New comment"),
2453 ),
2454 ],
2455 vec![],
2456 )];
2457
2458 let plan = diff_schemas(&from, &to).unwrap();
2459
2460 assert_eq!(plan.actions.len(), 1);
2461 assert!(matches!(
2462 &plan.actions[0],
2463 MigrationAction::ModifyColumnComment {
2464 table,
2465 column,
2466 new_comment: Some(comment),
2467 } if table == "users" && column == "email" && comment == "New comment"
2468 ));
2469 }
2470
2471 #[test]
2472 fn no_change_same_comment() {
2473 let from = vec![table(
2475 "users",
2476 vec![
2477 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2478 col_with_comment(
2479 "email",
2480 ColumnType::Simple(SimpleColumnType::Text),
2481 Some("Same comment"),
2482 ),
2483 ],
2484 vec![],
2485 )];
2486
2487 let to = vec![table(
2488 "users",
2489 vec![
2490 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2491 col_with_comment(
2492 "email",
2493 ColumnType::Simple(SimpleColumnType::Text),
2494 Some("Same comment"),
2495 ),
2496 ],
2497 vec![],
2498 )];
2499
2500 let plan = diff_schemas(&from, &to).unwrap();
2501
2502 assert!(plan.actions.is_empty());
2503 }
2504
2505 #[test]
2506 fn multiple_columns_comment_changes() {
2507 let from = vec![table(
2509 "users",
2510 vec![
2511 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2512 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2513 col_with_comment(
2514 "name",
2515 ColumnType::Simple(SimpleColumnType::Text),
2516 Some("User name"),
2517 ),
2518 col_with_comment(
2519 "phone",
2520 ColumnType::Simple(SimpleColumnType::Text),
2521 Some("Phone number"),
2522 ),
2523 ],
2524 vec![],
2525 )];
2526
2527 let to = vec![table(
2528 "users",
2529 vec![
2530 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2531 col_with_comment(
2532 "email",
2533 ColumnType::Simple(SimpleColumnType::Text),
2534 Some("Email address"),
2535 ), col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), col_with_comment(
2538 "phone",
2539 ColumnType::Simple(SimpleColumnType::Text),
2540 Some("Phone number"),
2541 ), ],
2543 vec![],
2544 )];
2545
2546 let plan = diff_schemas(&from, &to).unwrap();
2547
2548 assert_eq!(plan.actions.len(), 2);
2549
2550 let has_email_change = plan.actions.iter().any(|a| {
2551 matches!(
2552 a,
2553 MigrationAction::ModifyColumnComment {
2554 table,
2555 column,
2556 new_comment: Some(comment),
2557 } if table == "users" && column == "email" && comment == "Email address"
2558 )
2559 });
2560 assert!(has_email_change, "Should detect email comment added");
2561
2562 let has_name_change = plan.actions.iter().any(|a| {
2563 matches!(
2564 a,
2565 MigrationAction::ModifyColumnComment {
2566 table,
2567 column,
2568 new_comment: None,
2569 } if table == "users" && column == "name"
2570 )
2571 });
2572 assert!(has_name_change, "Should detect name comment removed");
2573 }
2574
2575 #[test]
2576 fn comment_change_with_nullable_change() {
2577 let from = vec![table(
2579 "users",
2580 vec![
2581 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2582 {
2583 let mut c =
2584 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
2585 c.nullable = true;
2586 c
2587 },
2588 ],
2589 vec![],
2590 )];
2591
2592 let to = vec![table(
2593 "users",
2594 vec![
2595 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2596 {
2597 let mut c = col_with_comment(
2598 "email",
2599 ColumnType::Simple(SimpleColumnType::Text),
2600 Some("Required email"),
2601 );
2602 c.nullable = false;
2603 c
2604 },
2605 ],
2606 vec![],
2607 )];
2608
2609 let plan = diff_schemas(&from, &to).unwrap();
2610
2611 assert_eq!(plan.actions.len(), 2);
2613
2614 let has_nullable_change = plan.actions.iter().any(|a| {
2615 matches!(
2616 a,
2617 MigrationAction::ModifyColumnNullable {
2618 table,
2619 column,
2620 nullable: false,
2621 ..
2622 } if table == "users" && column == "email"
2623 )
2624 });
2625 assert!(has_nullable_change, "Should detect nullable change");
2626
2627 let has_comment_change = plan.actions.iter().any(|a| {
2628 matches!(
2629 a,
2630 MigrationAction::ModifyColumnComment {
2631 table,
2632 column,
2633 new_comment: Some(comment),
2634 } if table == "users" && column == "email" && comment == "Required email"
2635 )
2636 });
2637 assert!(has_comment_change, "Should detect comment change");
2638 }
2639 }
2640
2641 mod nullable_changes {
2642 use super::*;
2643
2644 fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
2645 ColumnDef {
2646 name: name.to_string(),
2647 r#type: ty,
2648 nullable,
2649 default: None,
2650 comment: None,
2651 primary_key: None,
2652 unique: None,
2653 index: None,
2654 foreign_key: None,
2655 }
2656 }
2657
2658 #[test]
2659 fn column_nullable_to_non_nullable() {
2660 let from = vec![table(
2662 "users",
2663 vec![
2664 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2665 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2666 ],
2667 vec![],
2668 )];
2669
2670 let to = vec![table(
2671 "users",
2672 vec![
2673 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2674 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2675 ],
2676 vec![],
2677 )];
2678
2679 let plan = diff_schemas(&from, &to).unwrap();
2680
2681 assert_eq!(plan.actions.len(), 1);
2682 assert!(matches!(
2683 &plan.actions[0],
2684 MigrationAction::ModifyColumnNullable {
2685 table,
2686 column,
2687 nullable: false,
2688 fill_with: None,
2689 } if table == "users" && column == "email"
2690 ));
2691 }
2692
2693 #[test]
2694 fn column_non_nullable_to_nullable() {
2695 let from = vec![table(
2697 "users",
2698 vec![
2699 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2700 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2701 ],
2702 vec![],
2703 )];
2704
2705 let to = vec![table(
2706 "users",
2707 vec![
2708 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2709 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2710 ],
2711 vec![],
2712 )];
2713
2714 let plan = diff_schemas(&from, &to).unwrap();
2715
2716 assert_eq!(plan.actions.len(), 1);
2717 assert!(matches!(
2718 &plan.actions[0],
2719 MigrationAction::ModifyColumnNullable {
2720 table,
2721 column,
2722 nullable: true,
2723 fill_with: None,
2724 } if table == "users" && column == "email"
2725 ));
2726 }
2727
2728 #[test]
2729 fn multiple_columns_nullable_changes() {
2730 let from = vec![table(
2732 "users",
2733 vec![
2734 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2735 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2736 col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
2737 col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
2738 ],
2739 vec![],
2740 )];
2741
2742 let to = vec![table(
2743 "users",
2744 vec![
2745 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2746 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), ],
2750 vec![],
2751 )];
2752
2753 let plan = diff_schemas(&from, &to).unwrap();
2754
2755 assert_eq!(plan.actions.len(), 2);
2756
2757 let has_email_change = plan.actions.iter().any(|a| {
2758 matches!(
2759 a,
2760 MigrationAction::ModifyColumnNullable {
2761 table,
2762 column,
2763 nullable: false,
2764 ..
2765 } if table == "users" && column == "email"
2766 )
2767 });
2768 assert!(has_email_change, "Should detect email nullable -> non-nullable");
2769
2770 let has_name_change = plan.actions.iter().any(|a| {
2771 matches!(
2772 a,
2773 MigrationAction::ModifyColumnNullable {
2774 table,
2775 column,
2776 nullable: true,
2777 ..
2778 } if table == "users" && column == "name"
2779 )
2780 });
2781 assert!(has_name_change, "Should detect name non-nullable -> nullable");
2782 }
2783
2784 #[test]
2785 fn nullable_change_with_type_change() {
2786 let from = vec![table(
2788 "users",
2789 vec![
2790 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2791 col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
2792 ],
2793 vec![],
2794 )];
2795
2796 let to = vec![table(
2797 "users",
2798 vec![
2799 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2800 col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
2801 ],
2802 vec![],
2803 )];
2804
2805 let plan = diff_schemas(&from, &to).unwrap();
2806
2807 assert_eq!(plan.actions.len(), 2);
2809
2810 let has_type_change = plan.actions.iter().any(|a| {
2811 matches!(
2812 a,
2813 MigrationAction::ModifyColumnType { table, column, .. }
2814 if table == "users" && column == "age"
2815 )
2816 });
2817 assert!(has_type_change, "Should detect type change");
2818
2819 let has_nullable_change = plan.actions.iter().any(|a| {
2820 matches!(
2821 a,
2822 MigrationAction::ModifyColumnNullable {
2823 table,
2824 column,
2825 nullable: false,
2826 ..
2827 } if table == "users" && column == "age"
2828 )
2829 });
2830 assert!(has_nullable_change, "Should detect nullable change");
2831 }
2832 }
2833
2834 mod diff_tables {
2835 use insta::assert_debug_snapshot;
2836
2837 use super::*;
2838
2839 #[test]
2840 fn create_table_with_inline_index() {
2841 let base = [table(
2842 "users",
2843 vec![
2844 ColumnDef {
2845 name: "id".to_string(),
2846 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2847 nullable: false,
2848 default: None,
2849 comment: None,
2850 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2851 unique: None,
2852 index: Some(StrOrBoolOrArray::Bool(false)),
2853 foreign_key: None,
2854 },
2855 ColumnDef {
2856 name: "name".to_string(),
2857 r#type: ColumnType::Simple(SimpleColumnType::Text),
2858 nullable: true,
2859 default: None,
2860 comment: None,
2861 primary_key: None,
2862 unique: Some(StrOrBoolOrArray::Bool(true)),
2863 index: Some(StrOrBoolOrArray::Bool(true)),
2864 foreign_key: None,
2865 },
2866 ],
2867 vec![],
2868 )];
2869 let plan = diff_schemas(&[], &base).unwrap();
2870
2871 assert_eq!(plan.actions.len(), 1);
2872 assert_debug_snapshot!(plan.actions);
2873
2874 let plan = diff_schemas(
2875 &base,
2876 &[table(
2877 "users",
2878 vec![
2879 ColumnDef {
2880 name: "id".to_string(),
2881 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2882 nullable: false,
2883 default: None,
2884 comment: None,
2885 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2886 unique: None,
2887 index: Some(StrOrBoolOrArray::Bool(false)),
2888 foreign_key: None,
2889 },
2890 ColumnDef {
2891 name: "name".to_string(),
2892 r#type: ColumnType::Simple(SimpleColumnType::Text),
2893 nullable: true,
2894 default: None,
2895 comment: None,
2896 primary_key: None,
2897 unique: Some(StrOrBoolOrArray::Bool(true)),
2898 index: Some(StrOrBoolOrArray::Bool(false)),
2899 foreign_key: None,
2900 },
2901 ],
2902 vec![],
2903 )],
2904 )
2905 .unwrap();
2906
2907 assert_eq!(plan.actions.len(), 1);
2908 assert_debug_snapshot!(plan.actions);
2909 }
2910
2911 #[rstest]
2912 #[case(
2913 "add_index",
2914 vec![table(
2915 "users",
2916 vec![
2917 ColumnDef {
2918 name: "id".to_string(),
2919 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2920 nullable: false,
2921 default: None,
2922 comment: None,
2923 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2924 unique: None,
2925 index: None,
2926 foreign_key: None,
2927 },
2928 ],
2929 vec![],
2930 )],
2931 vec![table(
2932 "users",
2933 vec![
2934 ColumnDef {
2935 name: "id".to_string(),
2936 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2937 nullable: false,
2938 default: None,
2939 comment: None,
2940 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2941 unique: None,
2942 index: Some(StrOrBoolOrArray::Bool(true)),
2943 foreign_key: None,
2944 },
2945 ],
2946 vec![],
2947 )],
2948 )]
2949 #[case(
2950 "remove_index",
2951 vec![table(
2952 "users",
2953 vec![
2954 ColumnDef {
2955 name: "id".to_string(),
2956 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2957 nullable: false,
2958 default: None,
2959 comment: None,
2960 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2961 unique: None,
2962 index: Some(StrOrBoolOrArray::Bool(true)),
2963 foreign_key: None,
2964 },
2965 ],
2966 vec![],
2967 )],
2968 vec![table(
2969 "users",
2970 vec![
2971 ColumnDef {
2972 name: "id".to_string(),
2973 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2974 nullable: false,
2975 default: None,
2976 comment: None,
2977 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2978 unique: None,
2979 index: Some(StrOrBoolOrArray::Bool(false)),
2980 foreign_key: None,
2981 },
2982 ],
2983 vec![],
2984 )],
2985 )]
2986 #[case(
2987 "add_named_index",
2988 vec![table(
2989 "users",
2990 vec![
2991 ColumnDef {
2992 name: "id".to_string(),
2993 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2994 nullable: false,
2995 default: None,
2996 comment: None,
2997 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2998 unique: None,
2999 index: None,
3000 foreign_key: None,
3001 },
3002 ],
3003 vec![],
3004 )],
3005 vec![table(
3006 "users",
3007 vec![
3008 ColumnDef {
3009 name: "id".to_string(),
3010 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3011 nullable: false,
3012 default: None,
3013 comment: None,
3014 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3015 unique: None,
3016 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3017 foreign_key: None,
3018 },
3019 ],
3020 vec![],
3021 )],
3022 )]
3023 #[case(
3024 "remove_named_index",
3025 vec![table(
3026 "users",
3027 vec![
3028 ColumnDef {
3029 name: "id".to_string(),
3030 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3031 nullable: false,
3032 default: None,
3033 comment: None,
3034 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3035 unique: None,
3036 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3037 foreign_key: None,
3038 },
3039 ],
3040 vec![],
3041 )],
3042 vec![table(
3043 "users",
3044 vec![
3045 ColumnDef {
3046 name: "id".to_string(),
3047 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3048 nullable: false,
3049 default: None,
3050 comment: None,
3051 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3052 unique: None,
3053 index: None,
3054 foreign_key: None,
3055 },
3056 ],
3057 vec![],
3058 )],
3059 )]
3060 fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3061 use insta::with_settings;
3062
3063 let plan = diff_schemas(&base, &to).unwrap();
3064 with_settings!({ snapshot_suffix => name }, {
3065 assert_debug_snapshot!(plan.actions);
3066 });
3067 }
3068 }
3069
3070 mod coverage_explicit {
3072 use super::*;
3073
3074 #[test]
3075 fn delete_column_explicit() {
3076 let from = vec![table(
3078 "users",
3079 vec![
3080 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3081 col("name", ColumnType::Simple(SimpleColumnType::Text)),
3082 ],
3083 vec![],
3084 )];
3085
3086 let to = vec![table(
3087 "users",
3088 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3089 vec![],
3090 )];
3091
3092 let plan = diff_schemas(&from, &to).unwrap();
3093 assert_eq!(plan.actions.len(), 1);
3094 assert!(matches!(
3095 &plan.actions[0],
3096 MigrationAction::DeleteColumn { table, column }
3097 if table == "users" && column == "name"
3098 ));
3099 }
3100
3101 #[test]
3102 fn add_column_explicit() {
3103 let from = vec![table(
3105 "users",
3106 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3107 vec![],
3108 )];
3109
3110 let to = vec![table(
3111 "users",
3112 vec![
3113 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3114 col("email", ColumnType::Simple(SimpleColumnType::Text)),
3115 ],
3116 vec![],
3117 )];
3118
3119 let plan = diff_schemas(&from, &to).unwrap();
3120 assert_eq!(plan.actions.len(), 1);
3121 assert!(matches!(
3122 &plan.actions[0],
3123 MigrationAction::AddColumn { table, column, .. }
3124 if table == "users" && column.name == "email"
3125 ));
3126 }
3127
3128 #[test]
3129 fn remove_constraint_explicit() {
3130 let from = vec![table(
3132 "users",
3133 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3134 vec![idx("idx_users_id", vec!["id"])],
3135 )];
3136
3137 let to = vec![table(
3138 "users",
3139 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3140 vec![],
3141 )];
3142
3143 let plan = diff_schemas(&from, &to).unwrap();
3144 assert_eq!(plan.actions.len(), 1);
3145 assert!(matches!(
3146 &plan.actions[0],
3147 MigrationAction::RemoveConstraint { table, constraint }
3148 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3149 ));
3150 }
3151
3152 #[test]
3153 fn add_constraint_explicit() {
3154 let from = vec![table(
3156 "users",
3157 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3158 vec![],
3159 )];
3160
3161 let to = vec![table(
3162 "users",
3163 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3164 vec![idx("idx_users_id", vec!["id"])],
3165 )];
3166
3167 let plan = diff_schemas(&from, &to).unwrap();
3168 assert_eq!(plan.actions.len(), 1);
3169 assert!(matches!(
3170 &plan.actions[0],
3171 MigrationAction::AddConstraint { table, constraint }
3172 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3173 ));
3174 }
3175 }
3176}