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!(
1819 has_remove,
1820 "Should have RemoveConstraint for old composite PK"
1821 );
1822
1823 let has_add = plan.actions.iter().any(|a| {
1824 matches!(
1825 a,
1826 MigrationAction::AddConstraint {
1827 table,
1828 constraint: TableConstraint::PrimaryKey { columns, .. }
1829 } if table == "users" && columns == &vec!["id".to_string()]
1830 )
1831 });
1832 assert!(
1833 has_add,
1834 "Should have AddConstraint for new single-column PK"
1835 );
1836 }
1837
1838 #[test]
1839 fn change_pk_columns_entirely() {
1840 let from = vec![table(
1842 "users",
1843 vec![
1844 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1845 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1846 ],
1847 vec![pk(vec!["id"])],
1848 )];
1849
1850 let to = vec![table(
1851 "users",
1852 vec![
1853 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1854 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1855 ],
1856 vec![pk(vec!["uuid"])],
1857 )];
1858
1859 let plan = diff_schemas(&from, &to).unwrap();
1860
1861 assert_eq!(plan.actions.len(), 2);
1862
1863 let has_remove = plan.actions.iter().any(|a| {
1864 matches!(
1865 a,
1866 MigrationAction::RemoveConstraint {
1867 table,
1868 constraint: TableConstraint::PrimaryKey { columns, .. }
1869 } if table == "users" && columns == &vec!["id".to_string()]
1870 )
1871 });
1872 assert!(has_remove, "Should have RemoveConstraint for old PK");
1873
1874 let has_add = plan.actions.iter().any(|a| {
1875 matches!(
1876 a,
1877 MigrationAction::AddConstraint {
1878 table,
1879 constraint: TableConstraint::PrimaryKey { columns, .. }
1880 } if table == "users" && columns == &vec!["uuid".to_string()]
1881 )
1882 });
1883 assert!(has_add, "Should have AddConstraint for new PK");
1884 }
1885
1886 #[test]
1887 fn add_multiple_columns_to_composite_pk() {
1888 let from = vec![table(
1890 "users",
1891 vec![
1892 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1893 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1894 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1895 ],
1896 vec![pk(vec!["id"])],
1897 )];
1898
1899 let to = vec![table(
1900 "users",
1901 vec![
1902 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1903 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1904 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1905 ],
1906 vec![pk(vec!["id", "tenant_id", "region_id"])],
1907 )];
1908
1909 let plan = diff_schemas(&from, &to).unwrap();
1910
1911 assert_eq!(plan.actions.len(), 2);
1912
1913 let has_remove = plan.actions.iter().any(|a| {
1914 matches!(
1915 a,
1916 MigrationAction::RemoveConstraint {
1917 table,
1918 constraint: TableConstraint::PrimaryKey { columns, .. }
1919 } if table == "users" && columns == &vec!["id".to_string()]
1920 )
1921 });
1922 assert!(
1923 has_remove,
1924 "Should have RemoveConstraint for old single-column PK"
1925 );
1926
1927 let has_add = plan.actions.iter().any(|a| {
1928 matches!(
1929 a,
1930 MigrationAction::AddConstraint {
1931 table,
1932 constraint: TableConstraint::PrimaryKey { columns, .. }
1933 } if table == "users" && columns == &vec![
1934 "id".to_string(),
1935 "tenant_id".to_string(),
1936 "region_id".to_string()
1937 ]
1938 )
1939 });
1940 assert!(
1941 has_add,
1942 "Should have AddConstraint for new 3-column composite PK"
1943 );
1944 }
1945
1946 #[test]
1947 fn remove_multiple_columns_from_composite_pk() {
1948 let from = vec![table(
1950 "users",
1951 vec![
1952 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1953 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1954 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1955 ],
1956 vec![pk(vec!["id", "tenant_id", "region_id"])],
1957 )];
1958
1959 let to = vec![table(
1960 "users",
1961 vec![
1962 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1963 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1964 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1965 ],
1966 vec![pk(vec!["id"])],
1967 )];
1968
1969 let plan = diff_schemas(&from, &to).unwrap();
1970
1971 assert_eq!(plan.actions.len(), 2);
1972
1973 let has_remove = plan.actions.iter().any(|a| {
1974 matches!(
1975 a,
1976 MigrationAction::RemoveConstraint {
1977 table,
1978 constraint: TableConstraint::PrimaryKey { columns, .. }
1979 } if table == "users" && columns == &vec![
1980 "id".to_string(),
1981 "tenant_id".to_string(),
1982 "region_id".to_string()
1983 ]
1984 )
1985 });
1986 assert!(
1987 has_remove,
1988 "Should have RemoveConstraint for old 3-column composite PK"
1989 );
1990
1991 let has_add = plan.actions.iter().any(|a| {
1992 matches!(
1993 a,
1994 MigrationAction::AddConstraint {
1995 table,
1996 constraint: TableConstraint::PrimaryKey { columns, .. }
1997 } if table == "users" && columns == &vec!["id".to_string()]
1998 )
1999 });
2000 assert!(
2001 has_add,
2002 "Should have AddConstraint for new single-column PK"
2003 );
2004 }
2005
2006 #[test]
2007 fn change_composite_pk_columns_partially() {
2008 let from = vec![table(
2011 "users",
2012 vec![
2013 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2014 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2015 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2016 ],
2017 vec![pk(vec!["id", "tenant_id"])],
2018 )];
2019
2020 let to = vec![table(
2021 "users",
2022 vec![
2023 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2024 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2025 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2026 ],
2027 vec![pk(vec!["id", "region_id"])],
2028 )];
2029
2030 let plan = diff_schemas(&from, &to).unwrap();
2031
2032 assert_eq!(plan.actions.len(), 2);
2033
2034 let has_remove = plan.actions.iter().any(|a| {
2035 matches!(
2036 a,
2037 MigrationAction::RemoveConstraint {
2038 table,
2039 constraint: TableConstraint::PrimaryKey { columns, .. }
2040 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2041 )
2042 });
2043 assert!(
2044 has_remove,
2045 "Should have RemoveConstraint for old PK with tenant_id"
2046 );
2047
2048 let has_add = plan.actions.iter().any(|a| {
2049 matches!(
2050 a,
2051 MigrationAction::AddConstraint {
2052 table,
2053 constraint: TableConstraint::PrimaryKey { columns, .. }
2054 } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2055 )
2056 });
2057 assert!(
2058 has_add,
2059 "Should have AddConstraint for new PK with region_id"
2060 );
2061 }
2062 }
2063
2064 mod default_changes {
2065 use super::*;
2066
2067 fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2068 ColumnDef {
2069 name: name.to_string(),
2070 r#type: ty,
2071 nullable: true,
2072 default: default.map(|s| s.into()),
2073 comment: None,
2074 primary_key: None,
2075 unique: None,
2076 index: None,
2077 foreign_key: None,
2078 }
2079 }
2080
2081 #[test]
2082 fn add_default_value() {
2083 let from = vec![table(
2085 "users",
2086 vec![
2087 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2088 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2089 ],
2090 vec![],
2091 )];
2092
2093 let to = vec![table(
2094 "users",
2095 vec![
2096 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2097 col_with_default(
2098 "status",
2099 ColumnType::Simple(SimpleColumnType::Text),
2100 Some("'active'"),
2101 ),
2102 ],
2103 vec![],
2104 )];
2105
2106 let plan = diff_schemas(&from, &to).unwrap();
2107
2108 assert_eq!(plan.actions.len(), 1);
2109 assert!(matches!(
2110 &plan.actions[0],
2111 MigrationAction::ModifyColumnDefault {
2112 table,
2113 column,
2114 new_default: Some(default),
2115 } if table == "users" && column == "status" && default == "'active'"
2116 ));
2117 }
2118
2119 #[test]
2120 fn remove_default_value() {
2121 let from = vec![table(
2123 "users",
2124 vec![
2125 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2126 col_with_default(
2127 "status",
2128 ColumnType::Simple(SimpleColumnType::Text),
2129 Some("'active'"),
2130 ),
2131 ],
2132 vec![],
2133 )];
2134
2135 let to = vec![table(
2136 "users",
2137 vec![
2138 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2139 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2140 ],
2141 vec![],
2142 )];
2143
2144 let plan = diff_schemas(&from, &to).unwrap();
2145
2146 assert_eq!(plan.actions.len(), 1);
2147 assert!(matches!(
2148 &plan.actions[0],
2149 MigrationAction::ModifyColumnDefault {
2150 table,
2151 column,
2152 new_default: None,
2153 } if table == "users" && column == "status"
2154 ));
2155 }
2156
2157 #[test]
2158 fn change_default_value() {
2159 let from = vec![table(
2161 "users",
2162 vec![
2163 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2164 col_with_default(
2165 "status",
2166 ColumnType::Simple(SimpleColumnType::Text),
2167 Some("'active'"),
2168 ),
2169 ],
2170 vec![],
2171 )];
2172
2173 let to = vec![table(
2174 "users",
2175 vec![
2176 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2177 col_with_default(
2178 "status",
2179 ColumnType::Simple(SimpleColumnType::Text),
2180 Some("'pending'"),
2181 ),
2182 ],
2183 vec![],
2184 )];
2185
2186 let plan = diff_schemas(&from, &to).unwrap();
2187
2188 assert_eq!(plan.actions.len(), 1);
2189 assert!(matches!(
2190 &plan.actions[0],
2191 MigrationAction::ModifyColumnDefault {
2192 table,
2193 column,
2194 new_default: Some(default),
2195 } if table == "users" && column == "status" && default == "'pending'"
2196 ));
2197 }
2198
2199 #[test]
2200 fn no_change_same_default() {
2201 let from = vec![table(
2203 "users",
2204 vec![
2205 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2206 col_with_default(
2207 "status",
2208 ColumnType::Simple(SimpleColumnType::Text),
2209 Some("'active'"),
2210 ),
2211 ],
2212 vec![],
2213 )];
2214
2215 let to = vec![table(
2216 "users",
2217 vec![
2218 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2219 col_with_default(
2220 "status",
2221 ColumnType::Simple(SimpleColumnType::Text),
2222 Some("'active'"),
2223 ),
2224 ],
2225 vec![],
2226 )];
2227
2228 let plan = diff_schemas(&from, &to).unwrap();
2229
2230 assert!(plan.actions.is_empty());
2231 }
2232
2233 #[test]
2234 fn multiple_columns_default_changes() {
2235 let from = vec![table(
2237 "users",
2238 vec![
2239 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2240 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2241 col_with_default(
2242 "role",
2243 ColumnType::Simple(SimpleColumnType::Text),
2244 Some("'user'"),
2245 ),
2246 col_with_default(
2247 "active",
2248 ColumnType::Simple(SimpleColumnType::Boolean),
2249 Some("true"),
2250 ),
2251 ],
2252 vec![],
2253 )];
2254
2255 let to = vec![table(
2256 "users",
2257 vec![
2258 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2259 col_with_default(
2260 "status",
2261 ColumnType::Simple(SimpleColumnType::Text),
2262 Some("'pending'"),
2263 ), col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), col_with_default(
2266 "active",
2267 ColumnType::Simple(SimpleColumnType::Boolean),
2268 Some("true"),
2269 ), ],
2271 vec![],
2272 )];
2273
2274 let plan = diff_schemas(&from, &to).unwrap();
2275
2276 assert_eq!(plan.actions.len(), 2);
2277
2278 let has_status_change = plan.actions.iter().any(|a| {
2279 matches!(
2280 a,
2281 MigrationAction::ModifyColumnDefault {
2282 table,
2283 column,
2284 new_default: Some(default),
2285 } if table == "users" && column == "status" && default == "'pending'"
2286 )
2287 });
2288 assert!(has_status_change, "Should detect status default added");
2289
2290 let has_role_change = plan.actions.iter().any(|a| {
2291 matches!(
2292 a,
2293 MigrationAction::ModifyColumnDefault {
2294 table,
2295 column,
2296 new_default: None,
2297 } if table == "users" && column == "role"
2298 )
2299 });
2300 assert!(has_role_change, "Should detect role default removed");
2301 }
2302
2303 #[test]
2304 fn default_change_with_type_change() {
2305 let from = vec![table(
2307 "users",
2308 vec![
2309 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2310 col_with_default(
2311 "count",
2312 ColumnType::Simple(SimpleColumnType::Integer),
2313 Some("0"),
2314 ),
2315 ],
2316 vec![],
2317 )];
2318
2319 let to = vec![table(
2320 "users",
2321 vec![
2322 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2323 col_with_default(
2324 "count",
2325 ColumnType::Simple(SimpleColumnType::Text),
2326 Some("'0'"),
2327 ),
2328 ],
2329 vec![],
2330 )];
2331
2332 let plan = diff_schemas(&from, &to).unwrap();
2333
2334 assert_eq!(plan.actions.len(), 2);
2336
2337 let has_type_change = plan.actions.iter().any(|a| {
2338 matches!(
2339 a,
2340 MigrationAction::ModifyColumnType { table, column, .. }
2341 if table == "users" && column == "count"
2342 )
2343 });
2344 assert!(has_type_change, "Should detect type change");
2345
2346 let has_default_change = plan.actions.iter().any(|a| {
2347 matches!(
2348 a,
2349 MigrationAction::ModifyColumnDefault {
2350 table,
2351 column,
2352 new_default: Some(default),
2353 } if table == "users" && column == "count" && default == "'0'"
2354 )
2355 });
2356 assert!(has_default_change, "Should detect default change");
2357 }
2358 }
2359
2360 mod comment_changes {
2361 use super::*;
2362
2363 fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2364 ColumnDef {
2365 name: name.to_string(),
2366 r#type: ty,
2367 nullable: true,
2368 default: None,
2369 comment: comment.map(|s| s.to_string()),
2370 primary_key: None,
2371 unique: None,
2372 index: None,
2373 foreign_key: None,
2374 }
2375 }
2376
2377 #[test]
2378 fn add_comment() {
2379 let from = vec![table(
2381 "users",
2382 vec![
2383 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2384 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2385 ],
2386 vec![],
2387 )];
2388
2389 let to = vec![table(
2390 "users",
2391 vec![
2392 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2393 col_with_comment(
2394 "email",
2395 ColumnType::Simple(SimpleColumnType::Text),
2396 Some("User's email address"),
2397 ),
2398 ],
2399 vec![],
2400 )];
2401
2402 let plan = diff_schemas(&from, &to).unwrap();
2403
2404 assert_eq!(plan.actions.len(), 1);
2405 assert!(matches!(
2406 &plan.actions[0],
2407 MigrationAction::ModifyColumnComment {
2408 table,
2409 column,
2410 new_comment: Some(comment),
2411 } if table == "users" && column == "email" && comment == "User's email address"
2412 ));
2413 }
2414
2415 #[test]
2416 fn remove_comment() {
2417 let from = vec![table(
2419 "users",
2420 vec![
2421 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2422 col_with_comment(
2423 "email",
2424 ColumnType::Simple(SimpleColumnType::Text),
2425 Some("User's email address"),
2426 ),
2427 ],
2428 vec![],
2429 )];
2430
2431 let to = vec![table(
2432 "users",
2433 vec![
2434 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2435 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2436 ],
2437 vec![],
2438 )];
2439
2440 let plan = diff_schemas(&from, &to).unwrap();
2441
2442 assert_eq!(plan.actions.len(), 1);
2443 assert!(matches!(
2444 &plan.actions[0],
2445 MigrationAction::ModifyColumnComment {
2446 table,
2447 column,
2448 new_comment: None,
2449 } if table == "users" && column == "email"
2450 ));
2451 }
2452
2453 #[test]
2454 fn change_comment() {
2455 let from = vec![table(
2457 "users",
2458 vec![
2459 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2460 col_with_comment(
2461 "email",
2462 ColumnType::Simple(SimpleColumnType::Text),
2463 Some("Old comment"),
2464 ),
2465 ],
2466 vec![],
2467 )];
2468
2469 let to = vec![table(
2470 "users",
2471 vec![
2472 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2473 col_with_comment(
2474 "email",
2475 ColumnType::Simple(SimpleColumnType::Text),
2476 Some("New comment"),
2477 ),
2478 ],
2479 vec![],
2480 )];
2481
2482 let plan = diff_schemas(&from, &to).unwrap();
2483
2484 assert_eq!(plan.actions.len(), 1);
2485 assert!(matches!(
2486 &plan.actions[0],
2487 MigrationAction::ModifyColumnComment {
2488 table,
2489 column,
2490 new_comment: Some(comment),
2491 } if table == "users" && column == "email" && comment == "New comment"
2492 ));
2493 }
2494
2495 #[test]
2496 fn no_change_same_comment() {
2497 let from = vec![table(
2499 "users",
2500 vec![
2501 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2502 col_with_comment(
2503 "email",
2504 ColumnType::Simple(SimpleColumnType::Text),
2505 Some("Same comment"),
2506 ),
2507 ],
2508 vec![],
2509 )];
2510
2511 let to = vec![table(
2512 "users",
2513 vec![
2514 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2515 col_with_comment(
2516 "email",
2517 ColumnType::Simple(SimpleColumnType::Text),
2518 Some("Same comment"),
2519 ),
2520 ],
2521 vec![],
2522 )];
2523
2524 let plan = diff_schemas(&from, &to).unwrap();
2525
2526 assert!(plan.actions.is_empty());
2527 }
2528
2529 #[test]
2530 fn multiple_columns_comment_changes() {
2531 let from = vec![table(
2533 "users",
2534 vec![
2535 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2536 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2537 col_with_comment(
2538 "name",
2539 ColumnType::Simple(SimpleColumnType::Text),
2540 Some("User name"),
2541 ),
2542 col_with_comment(
2543 "phone",
2544 ColumnType::Simple(SimpleColumnType::Text),
2545 Some("Phone number"),
2546 ),
2547 ],
2548 vec![],
2549 )];
2550
2551 let to = vec![table(
2552 "users",
2553 vec![
2554 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2555 col_with_comment(
2556 "email",
2557 ColumnType::Simple(SimpleColumnType::Text),
2558 Some("Email address"),
2559 ), col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), col_with_comment(
2562 "phone",
2563 ColumnType::Simple(SimpleColumnType::Text),
2564 Some("Phone number"),
2565 ), ],
2567 vec![],
2568 )];
2569
2570 let plan = diff_schemas(&from, &to).unwrap();
2571
2572 assert_eq!(plan.actions.len(), 2);
2573
2574 let has_email_change = plan.actions.iter().any(|a| {
2575 matches!(
2576 a,
2577 MigrationAction::ModifyColumnComment {
2578 table,
2579 column,
2580 new_comment: Some(comment),
2581 } if table == "users" && column == "email" && comment == "Email address"
2582 )
2583 });
2584 assert!(has_email_change, "Should detect email comment added");
2585
2586 let has_name_change = plan.actions.iter().any(|a| {
2587 matches!(
2588 a,
2589 MigrationAction::ModifyColumnComment {
2590 table,
2591 column,
2592 new_comment: None,
2593 } if table == "users" && column == "name"
2594 )
2595 });
2596 assert!(has_name_change, "Should detect name comment removed");
2597 }
2598
2599 #[test]
2600 fn comment_change_with_nullable_change() {
2601 let from = vec![table(
2603 "users",
2604 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2605 let mut c =
2606 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
2607 c.nullable = true;
2608 c
2609 }],
2610 vec![],
2611 )];
2612
2613 let to = vec![table(
2614 "users",
2615 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2616 let mut c = col_with_comment(
2617 "email",
2618 ColumnType::Simple(SimpleColumnType::Text),
2619 Some("Required email"),
2620 );
2621 c.nullable = false;
2622 c
2623 }],
2624 vec![],
2625 )];
2626
2627 let plan = diff_schemas(&from, &to).unwrap();
2628
2629 assert_eq!(plan.actions.len(), 2);
2631
2632 let has_nullable_change = plan.actions.iter().any(|a| {
2633 matches!(
2634 a,
2635 MigrationAction::ModifyColumnNullable {
2636 table,
2637 column,
2638 nullable: false,
2639 ..
2640 } if table == "users" && column == "email"
2641 )
2642 });
2643 assert!(has_nullable_change, "Should detect nullable change");
2644
2645 let has_comment_change = plan.actions.iter().any(|a| {
2646 matches!(
2647 a,
2648 MigrationAction::ModifyColumnComment {
2649 table,
2650 column,
2651 new_comment: Some(comment),
2652 } if table == "users" && column == "email" && comment == "Required email"
2653 )
2654 });
2655 assert!(has_comment_change, "Should detect comment change");
2656 }
2657 }
2658
2659 mod nullable_changes {
2660 use super::*;
2661
2662 fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
2663 ColumnDef {
2664 name: name.to_string(),
2665 r#type: ty,
2666 nullable,
2667 default: None,
2668 comment: None,
2669 primary_key: None,
2670 unique: None,
2671 index: None,
2672 foreign_key: None,
2673 }
2674 }
2675
2676 #[test]
2677 fn column_nullable_to_non_nullable() {
2678 let from = vec![table(
2680 "users",
2681 vec![
2682 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2683 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2684 ],
2685 vec![],
2686 )];
2687
2688 let to = vec![table(
2689 "users",
2690 vec![
2691 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2692 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2693 ],
2694 vec![],
2695 )];
2696
2697 let plan = diff_schemas(&from, &to).unwrap();
2698
2699 assert_eq!(plan.actions.len(), 1);
2700 assert!(matches!(
2701 &plan.actions[0],
2702 MigrationAction::ModifyColumnNullable {
2703 table,
2704 column,
2705 nullable: false,
2706 fill_with: None,
2707 } if table == "users" && column == "email"
2708 ));
2709 }
2710
2711 #[test]
2712 fn column_non_nullable_to_nullable() {
2713 let from = vec![table(
2715 "users",
2716 vec![
2717 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2718 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2719 ],
2720 vec![],
2721 )];
2722
2723 let to = vec![table(
2724 "users",
2725 vec![
2726 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2727 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2728 ],
2729 vec![],
2730 )];
2731
2732 let plan = diff_schemas(&from, &to).unwrap();
2733
2734 assert_eq!(plan.actions.len(), 1);
2735 assert!(matches!(
2736 &plan.actions[0],
2737 MigrationAction::ModifyColumnNullable {
2738 table,
2739 column,
2740 nullable: true,
2741 fill_with: None,
2742 } if table == "users" && column == "email"
2743 ));
2744 }
2745
2746 #[test]
2747 fn multiple_columns_nullable_changes() {
2748 let from = vec![table(
2750 "users",
2751 vec![
2752 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2753 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2754 col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
2755 col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
2756 ],
2757 vec![],
2758 )];
2759
2760 let to = vec![table(
2761 "users",
2762 vec![
2763 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2764 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), ],
2768 vec![],
2769 )];
2770
2771 let plan = diff_schemas(&from, &to).unwrap();
2772
2773 assert_eq!(plan.actions.len(), 2);
2774
2775 let has_email_change = plan.actions.iter().any(|a| {
2776 matches!(
2777 a,
2778 MigrationAction::ModifyColumnNullable {
2779 table,
2780 column,
2781 nullable: false,
2782 ..
2783 } if table == "users" && column == "email"
2784 )
2785 });
2786 assert!(
2787 has_email_change,
2788 "Should detect email nullable -> non-nullable"
2789 );
2790
2791 let has_name_change = plan.actions.iter().any(|a| {
2792 matches!(
2793 a,
2794 MigrationAction::ModifyColumnNullable {
2795 table,
2796 column,
2797 nullable: true,
2798 ..
2799 } if table == "users" && column == "name"
2800 )
2801 });
2802 assert!(
2803 has_name_change,
2804 "Should detect name non-nullable -> nullable"
2805 );
2806 }
2807
2808 #[test]
2809 fn nullable_change_with_type_change() {
2810 let from = vec![table(
2812 "users",
2813 vec![
2814 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2815 col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
2816 ],
2817 vec![],
2818 )];
2819
2820 let to = vec![table(
2821 "users",
2822 vec![
2823 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2824 col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
2825 ],
2826 vec![],
2827 )];
2828
2829 let plan = diff_schemas(&from, &to).unwrap();
2830
2831 assert_eq!(plan.actions.len(), 2);
2833
2834 let has_type_change = plan.actions.iter().any(|a| {
2835 matches!(
2836 a,
2837 MigrationAction::ModifyColumnType { table, column, .. }
2838 if table == "users" && column == "age"
2839 )
2840 });
2841 assert!(has_type_change, "Should detect type change");
2842
2843 let has_nullable_change = plan.actions.iter().any(|a| {
2844 matches!(
2845 a,
2846 MigrationAction::ModifyColumnNullable {
2847 table,
2848 column,
2849 nullable: false,
2850 ..
2851 } if table == "users" && column == "age"
2852 )
2853 });
2854 assert!(has_nullable_change, "Should detect nullable change");
2855 }
2856 }
2857
2858 mod diff_tables {
2859 use insta::assert_debug_snapshot;
2860
2861 use super::*;
2862
2863 #[test]
2864 fn create_table_with_inline_index() {
2865 let base = [table(
2866 "users",
2867 vec![
2868 ColumnDef {
2869 name: "id".to_string(),
2870 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2871 nullable: false,
2872 default: None,
2873 comment: None,
2874 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2875 unique: None,
2876 index: Some(StrOrBoolOrArray::Bool(false)),
2877 foreign_key: None,
2878 },
2879 ColumnDef {
2880 name: "name".to_string(),
2881 r#type: ColumnType::Simple(SimpleColumnType::Text),
2882 nullable: true,
2883 default: None,
2884 comment: None,
2885 primary_key: None,
2886 unique: Some(StrOrBoolOrArray::Bool(true)),
2887 index: Some(StrOrBoolOrArray::Bool(true)),
2888 foreign_key: None,
2889 },
2890 ],
2891 vec![],
2892 )];
2893 let plan = diff_schemas(&[], &base).unwrap();
2894
2895 assert_eq!(plan.actions.len(), 1);
2896 assert_debug_snapshot!(plan.actions);
2897
2898 let plan = diff_schemas(
2899 &base,
2900 &[table(
2901 "users",
2902 vec![
2903 ColumnDef {
2904 name: "id".to_string(),
2905 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2906 nullable: false,
2907 default: None,
2908 comment: None,
2909 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2910 unique: None,
2911 index: Some(StrOrBoolOrArray::Bool(false)),
2912 foreign_key: None,
2913 },
2914 ColumnDef {
2915 name: "name".to_string(),
2916 r#type: ColumnType::Simple(SimpleColumnType::Text),
2917 nullable: true,
2918 default: None,
2919 comment: None,
2920 primary_key: None,
2921 unique: Some(StrOrBoolOrArray::Bool(true)),
2922 index: Some(StrOrBoolOrArray::Bool(false)),
2923 foreign_key: None,
2924 },
2925 ],
2926 vec![],
2927 )],
2928 )
2929 .unwrap();
2930
2931 assert_eq!(plan.actions.len(), 1);
2932 assert_debug_snapshot!(plan.actions);
2933 }
2934
2935 #[rstest]
2936 #[case(
2937 "add_index",
2938 vec![table(
2939 "users",
2940 vec![
2941 ColumnDef {
2942 name: "id".to_string(),
2943 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2944 nullable: false,
2945 default: None,
2946 comment: None,
2947 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2948 unique: None,
2949 index: None,
2950 foreign_key: None,
2951 },
2952 ],
2953 vec![],
2954 )],
2955 vec![table(
2956 "users",
2957 vec![
2958 ColumnDef {
2959 name: "id".to_string(),
2960 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2961 nullable: false,
2962 default: None,
2963 comment: None,
2964 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2965 unique: None,
2966 index: Some(StrOrBoolOrArray::Bool(true)),
2967 foreign_key: None,
2968 },
2969 ],
2970 vec![],
2971 )],
2972 )]
2973 #[case(
2974 "remove_index",
2975 vec![table(
2976 "users",
2977 vec![
2978 ColumnDef {
2979 name: "id".to_string(),
2980 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2981 nullable: false,
2982 default: None,
2983 comment: None,
2984 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2985 unique: None,
2986 index: Some(StrOrBoolOrArray::Bool(true)),
2987 foreign_key: None,
2988 },
2989 ],
2990 vec![],
2991 )],
2992 vec![table(
2993 "users",
2994 vec![
2995 ColumnDef {
2996 name: "id".to_string(),
2997 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2998 nullable: false,
2999 default: None,
3000 comment: None,
3001 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3002 unique: None,
3003 index: Some(StrOrBoolOrArray::Bool(false)),
3004 foreign_key: None,
3005 },
3006 ],
3007 vec![],
3008 )],
3009 )]
3010 #[case(
3011 "add_named_index",
3012 vec![table(
3013 "users",
3014 vec![
3015 ColumnDef {
3016 name: "id".to_string(),
3017 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3018 nullable: false,
3019 default: None,
3020 comment: None,
3021 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3022 unique: None,
3023 index: None,
3024 foreign_key: None,
3025 },
3026 ],
3027 vec![],
3028 )],
3029 vec![table(
3030 "users",
3031 vec![
3032 ColumnDef {
3033 name: "id".to_string(),
3034 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3035 nullable: false,
3036 default: None,
3037 comment: None,
3038 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3039 unique: None,
3040 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3041 foreign_key: None,
3042 },
3043 ],
3044 vec![],
3045 )],
3046 )]
3047 #[case(
3048 "remove_named_index",
3049 vec![table(
3050 "users",
3051 vec![
3052 ColumnDef {
3053 name: "id".to_string(),
3054 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3055 nullable: false,
3056 default: None,
3057 comment: None,
3058 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3059 unique: None,
3060 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3061 foreign_key: None,
3062 },
3063 ],
3064 vec![],
3065 )],
3066 vec![table(
3067 "users",
3068 vec![
3069 ColumnDef {
3070 name: "id".to_string(),
3071 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3072 nullable: false,
3073 default: None,
3074 comment: None,
3075 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3076 unique: None,
3077 index: None,
3078 foreign_key: None,
3079 },
3080 ],
3081 vec![],
3082 )],
3083 )]
3084 fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3085 use insta::with_settings;
3086
3087 let plan = diff_schemas(&base, &to).unwrap();
3088 with_settings!({ snapshot_suffix => name }, {
3089 assert_debug_snapshot!(plan.actions);
3090 });
3091 }
3092 }
3093
3094 mod coverage_explicit {
3096 use super::*;
3097
3098 #[test]
3099 fn delete_column_explicit() {
3100 let from = vec![table(
3102 "users",
3103 vec![
3104 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3105 col("name", ColumnType::Simple(SimpleColumnType::Text)),
3106 ],
3107 vec![],
3108 )];
3109
3110 let to = vec![table(
3111 "users",
3112 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3113 vec![],
3114 )];
3115
3116 let plan = diff_schemas(&from, &to).unwrap();
3117 assert_eq!(plan.actions.len(), 1);
3118 assert!(matches!(
3119 &plan.actions[0],
3120 MigrationAction::DeleteColumn { table, column }
3121 if table == "users" && column == "name"
3122 ));
3123 }
3124
3125 #[test]
3126 fn add_column_explicit() {
3127 let from = vec![table(
3129 "users",
3130 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3131 vec![],
3132 )];
3133
3134 let to = vec![table(
3135 "users",
3136 vec![
3137 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3138 col("email", ColumnType::Simple(SimpleColumnType::Text)),
3139 ],
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::AddColumn { table, column, .. }
3148 if table == "users" && column.name == "email"
3149 ));
3150 }
3151
3152 #[test]
3153 fn remove_constraint_explicit() {
3154 let from = vec![table(
3156 "users",
3157 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3158 vec![idx("idx_users_id", vec!["id"])],
3159 )];
3160
3161 let to = vec![table(
3162 "users",
3163 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3164 vec![],
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::RemoveConstraint { table, constraint }
3172 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3173 ));
3174 }
3175
3176 #[test]
3177 fn add_constraint_explicit() {
3178 let from = vec![table(
3180 "users",
3181 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3182 vec![],
3183 )];
3184
3185 let to = vec![table(
3186 "users",
3187 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3188 vec![idx("idx_users_id", vec!["id"])],
3189 )];
3190
3191 let plan = diff_schemas(&from, &to).unwrap();
3192 assert_eq!(plan.actions.len(), 1);
3193 assert!(matches!(
3194 &plan.actions[0],
3195 MigrationAction::AddConstraint { table, constraint }
3196 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3197 ));
3198 }
3199 }
3200}