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 description: None,
450 columns,
451 constraints,
452 }
453 }
454
455 fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
456 TableConstraint::Index {
457 name: Some(name.to_string()),
458 columns: columns.into_iter().map(|s| s.to_string()).collect(),
459 }
460 }
461
462 #[rstest]
463 #[case::add_column_and_index(
464 vec![table(
465 "users",
466 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
467 vec![],
468 )],
469 vec![table(
470 "users",
471 vec![
472 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
473 col("name", ColumnType::Simple(SimpleColumnType::Text)),
474 ],
475 vec![idx("ix_users__name", vec!["name"])],
476 )],
477 vec![
478 MigrationAction::AddColumn {
479 table: "users".into(),
480 column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
481 fill_with: None,
482 },
483 MigrationAction::AddConstraint {
484 table: "users".into(),
485 constraint: idx("ix_users__name", vec!["name"]),
486 },
487 ]
488 )]
489 #[case::drop_table(
490 vec![table(
491 "users",
492 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
493 vec![],
494 )],
495 vec![],
496 vec![MigrationAction::DeleteTable {
497 table: "users".into()
498 }]
499 )]
500 #[case::add_table_with_index(
501 vec![],
502 vec![table(
503 "users",
504 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
505 vec![idx("idx_users_id", vec!["id"])],
506 )],
507 vec![
508 MigrationAction::CreateTable {
509 table: "users".into(),
510 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
511 constraints: vec![idx("idx_users_id", vec!["id"])],
512 },
513 ]
514 )]
515 #[case::delete_column(
516 vec![table(
517 "users",
518 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
519 vec![],
520 )],
521 vec![table(
522 "users",
523 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
524 vec![],
525 )],
526 vec![MigrationAction::DeleteColumn {
527 table: "users".into(),
528 column: "name".into(),
529 }]
530 )]
531 #[case::modify_column_type(
532 vec![table(
533 "users",
534 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
535 vec![],
536 )],
537 vec![table(
538 "users",
539 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
540 vec![],
541 )],
542 vec![MigrationAction::ModifyColumnType {
543 table: "users".into(),
544 column: "id".into(),
545 new_type: ColumnType::Simple(SimpleColumnType::Text),
546 }]
547 )]
548 #[case::remove_index(
549 vec![table(
550 "users",
551 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
552 vec![idx("idx_users_id", vec!["id"])],
553 )],
554 vec![table(
555 "users",
556 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
557 vec![],
558 )],
559 vec![MigrationAction::RemoveConstraint {
560 table: "users".into(),
561 constraint: idx("idx_users_id", vec!["id"]),
562 }]
563 )]
564 #[case::add_index_existing_table(
565 vec![table(
566 "users",
567 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
568 vec![],
569 )],
570 vec![table(
571 "users",
572 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
573 vec![idx("idx_users_id", vec!["id"])],
574 )],
575 vec![MigrationAction::AddConstraint {
576 table: "users".into(),
577 constraint: idx("idx_users_id", vec!["id"]),
578 }]
579 )]
580 fn diff_schemas_detects_additions(
581 #[case] from_schema: Vec<TableDef>,
582 #[case] to_schema: Vec<TableDef>,
583 #[case] expected_actions: Vec<MigrationAction>,
584 ) {
585 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
586 assert_eq!(plan.actions, expected_actions);
587 }
588
589 mod integer_enum {
591 use super::*;
592 use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
593
594 #[test]
595 fn integer_enum_values_changed_no_migration() {
596 let from = vec![table(
598 "orders",
599 vec![col(
600 "status",
601 ColumnType::Complex(ComplexColumnType::Enum {
602 name: "order_status".into(),
603 values: EnumValues::Integer(vec![
604 NumValue {
605 name: "Pending".into(),
606 value: 0,
607 },
608 NumValue {
609 name: "Shipped".into(),
610 value: 1,
611 },
612 ]),
613 }),
614 )],
615 vec![],
616 )];
617
618 let to = vec![table(
619 "orders",
620 vec![col(
621 "status",
622 ColumnType::Complex(ComplexColumnType::Enum {
623 name: "order_status".into(),
624 values: EnumValues::Integer(vec![
625 NumValue {
626 name: "Pending".into(),
627 value: 0,
628 },
629 NumValue {
630 name: "Shipped".into(),
631 value: 1,
632 },
633 NumValue {
634 name: "Delivered".into(),
635 value: 2,
636 },
637 NumValue {
638 name: "Cancelled".into(),
639 value: 100,
640 },
641 ]),
642 }),
643 )],
644 vec![],
645 )];
646
647 let plan = diff_schemas(&from, &to).unwrap();
648 assert!(
649 plan.actions.is_empty(),
650 "Expected no actions, got: {:?}",
651 plan.actions
652 );
653 }
654
655 #[test]
656 fn string_enum_values_changed_requires_migration() {
657 let from = vec![table(
659 "orders",
660 vec![col(
661 "status",
662 ColumnType::Complex(ComplexColumnType::Enum {
663 name: "order_status".into(),
664 values: EnumValues::String(vec!["pending".into(), "shipped".into()]),
665 }),
666 )],
667 vec![],
668 )];
669
670 let to = vec![table(
671 "orders",
672 vec![col(
673 "status",
674 ColumnType::Complex(ComplexColumnType::Enum {
675 name: "order_status".into(),
676 values: EnumValues::String(vec![
677 "pending".into(),
678 "shipped".into(),
679 "delivered".into(),
680 ]),
681 }),
682 )],
683 vec![],
684 )];
685
686 let plan = diff_schemas(&from, &to).unwrap();
687 assert_eq!(plan.actions.len(), 1);
688 assert!(matches!(
689 &plan.actions[0],
690 MigrationAction::ModifyColumnType { table, column, .. }
691 if table == "orders" && column == "status"
692 ));
693 }
694 }
695
696 mod inline_constraints {
698 use super::*;
699 use vespertide_core::schema::foreign_key::ForeignKeyDef;
700 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
701 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
702 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
703
704 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
705 ColumnDef {
706 name: name.to_string(),
707 r#type: ty,
708 nullable: false,
709 default: None,
710 comment: None,
711 primary_key: Some(PrimaryKeySyntax::Bool(true)),
712 unique: None,
713 index: None,
714 foreign_key: None,
715 }
716 }
717
718 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
719 ColumnDef {
720 name: name.to_string(),
721 r#type: ty,
722 nullable: true,
723 default: None,
724 comment: None,
725 primary_key: None,
726 unique: Some(StrOrBoolOrArray::Bool(true)),
727 index: None,
728 foreign_key: None,
729 }
730 }
731
732 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
733 ColumnDef {
734 name: name.to_string(),
735 r#type: ty,
736 nullable: true,
737 default: None,
738 comment: None,
739 primary_key: None,
740 unique: None,
741 index: Some(StrOrBoolOrArray::Bool(true)),
742 foreign_key: None,
743 }
744 }
745
746 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
747 ColumnDef {
748 name: name.to_string(),
749 r#type: ty,
750 nullable: true,
751 default: None,
752 comment: None,
753 primary_key: None,
754 unique: None,
755 index: None,
756 foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
757 ref_table: ref_table.to_string(),
758 ref_columns: vec![ref_col.to_string()],
759 on_delete: None,
760 on_update: None,
761 })),
762 }
763 }
764
765 #[test]
766 fn create_table_with_inline_pk() {
767 let plan = diff_schemas(
768 &[],
769 &[table(
770 "users",
771 vec![
772 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
773 col("name", ColumnType::Simple(SimpleColumnType::Text)),
774 ],
775 vec![],
776 )],
777 )
778 .unwrap();
779
780 assert_eq!(plan.actions.len(), 1);
782 if let MigrationAction::CreateTable {
783 columns,
784 constraints,
785 ..
786 } = &plan.actions[0]
787 {
788 assert_eq!(constraints.len(), 0);
790 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
792 assert!(id_col.primary_key.is_some());
793 } else {
794 panic!("Expected CreateTable action");
795 }
796 }
797
798 #[test]
799 fn create_table_with_inline_unique() {
800 let plan = diff_schemas(
801 &[],
802 &[table(
803 "users",
804 vec![
805 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
806 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
807 ],
808 vec![],
809 )],
810 )
811 .unwrap();
812
813 assert_eq!(plan.actions.len(), 1);
815 if let MigrationAction::CreateTable {
816 columns,
817 constraints,
818 ..
819 } = &plan.actions[0]
820 {
821 assert_eq!(constraints.len(), 0);
823 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
825 assert!(matches!(
826 email_col.unique,
827 Some(StrOrBoolOrArray::Bool(true))
828 ));
829 } else {
830 panic!("Expected CreateTable action");
831 }
832 }
833
834 #[test]
835 fn create_table_with_inline_index() {
836 let plan = diff_schemas(
837 &[],
838 &[table(
839 "users",
840 vec![
841 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
842 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
843 ],
844 vec![],
845 )],
846 )
847 .unwrap();
848
849 assert_eq!(plan.actions.len(), 1);
851 if let MigrationAction::CreateTable {
852 columns,
853 constraints,
854 ..
855 } = &plan.actions[0]
856 {
857 assert_eq!(constraints.len(), 0);
859 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
861 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
862 } else {
863 panic!("Expected CreateTable action");
864 }
865 }
866
867 #[test]
868 fn create_table_with_inline_fk() {
869 let plan = diff_schemas(
870 &[],
871 &[table(
872 "posts",
873 vec![
874 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
875 col_with_fk(
876 "user_id",
877 ColumnType::Simple(SimpleColumnType::Integer),
878 "users",
879 "id",
880 ),
881 ],
882 vec![],
883 )],
884 )
885 .unwrap();
886
887 assert_eq!(plan.actions.len(), 1);
889 if let MigrationAction::CreateTable {
890 columns,
891 constraints,
892 ..
893 } = &plan.actions[0]
894 {
895 assert_eq!(constraints.len(), 0);
897 let user_id_col = columns.iter().find(|c| c.name == "user_id").unwrap();
899 assert!(user_id_col.foreign_key.is_some());
900 } else {
901 panic!("Expected CreateTable action");
902 }
903 }
904
905 #[test]
906 fn add_index_via_inline_constraint() {
907 let plan = diff_schemas(
910 &[table(
911 "users",
912 vec![
913 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
914 col("name", ColumnType::Simple(SimpleColumnType::Text)),
915 ],
916 vec![],
917 )],
918 &[table(
919 "users",
920 vec![
921 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
922 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
923 ],
924 vec![],
925 )],
926 )
927 .unwrap();
928
929 assert_eq!(plan.actions.len(), 1);
931 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
932 assert_eq!(table, "users");
933 if let TableConstraint::Index { name, columns } = constraint {
934 assert_eq!(name, &None); assert_eq!(columns, &vec!["name".to_string()]);
936 } else {
937 panic!("Expected Index constraint, got {:?}", constraint);
938 }
939 } else {
940 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
941 }
942 }
943
944 #[test]
945 fn create_table_with_all_inline_constraints() {
946 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
947 id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
948 id_col.nullable = false;
949
950 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
951 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
952
953 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
954 name_col.index = Some(StrOrBoolOrArray::Bool(true));
955
956 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
957 org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
958 ref_table: "orgs".into(),
959 ref_columns: vec!["id".into()],
960 on_delete: None,
961 on_update: None,
962 }));
963
964 let plan = diff_schemas(
965 &[],
966 &[table(
967 "users",
968 vec![id_col, email_col, name_col, org_id_col],
969 vec![],
970 )],
971 )
972 .unwrap();
973
974 assert_eq!(plan.actions.len(), 1);
976
977 if let MigrationAction::CreateTable {
978 columns,
979 constraints,
980 ..
981 } = &plan.actions[0]
982 {
983 assert_eq!(constraints.len(), 0);
985
986 let id_col = columns.iter().find(|c| c.name == "id").unwrap();
988 assert!(id_col.primary_key.is_some());
989
990 let email_col = columns.iter().find(|c| c.name == "email").unwrap();
991 assert!(matches!(
992 email_col.unique,
993 Some(StrOrBoolOrArray::Bool(true))
994 ));
995
996 let name_col = columns.iter().find(|c| c.name == "name").unwrap();
997 assert!(matches!(name_col.index, Some(StrOrBoolOrArray::Bool(true))));
998
999 let org_id_col = columns.iter().find(|c| c.name == "org_id").unwrap();
1000 assert!(org_id_col.foreign_key.is_some());
1001 } else {
1002 panic!("Expected CreateTable action");
1003 }
1004 }
1005
1006 #[test]
1007 fn add_constraint_to_existing_table() {
1008 let from_schema = vec![table(
1010 "users",
1011 vec![
1012 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1013 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1014 ],
1015 vec![],
1016 )];
1017
1018 let to_schema = vec![table(
1019 "users",
1020 vec![
1021 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1022 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1023 ],
1024 vec![vespertide_core::TableConstraint::Unique {
1025 name: Some("uq_users_email".into()),
1026 columns: vec!["email".into()],
1027 }],
1028 )];
1029
1030 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1031 assert_eq!(plan.actions.len(), 1);
1032 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
1033 assert_eq!(table, "users");
1034 assert!(matches!(
1035 constraint,
1036 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1037 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1038 ));
1039 } else {
1040 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
1041 }
1042 }
1043
1044 #[test]
1045 fn remove_constraint_from_existing_table() {
1046 let from_schema = vec![table(
1048 "users",
1049 vec![
1050 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1051 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1052 ],
1053 vec![vespertide_core::TableConstraint::Unique {
1054 name: Some("uq_users_email".into()),
1055 columns: vec!["email".into()],
1056 }],
1057 )];
1058
1059 let to_schema = vec![table(
1060 "users",
1061 vec![
1062 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1063 col("email", ColumnType::Simple(SimpleColumnType::Text)),
1064 ],
1065 vec![],
1066 )];
1067
1068 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1069 assert_eq!(plan.actions.len(), 1);
1070 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
1071 assert_eq!(table, "users");
1072 assert!(matches!(
1073 constraint,
1074 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
1075 if n == "uq_users_email" && columns == &vec!["email".to_string()]
1076 ));
1077 } else {
1078 panic!(
1079 "Expected RemoveConstraint action, got {:?}",
1080 plan.actions[0]
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn diff_schemas_with_normalize_error() {
1087 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1089 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1090
1091 let table = TableDef {
1092 name: "test".into(),
1093 description: None,
1094 columns: vec![
1095 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1096 col1.clone(),
1097 {
1098 let mut c = col1.clone();
1100 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1101 c
1102 },
1103 ],
1104 constraints: vec![],
1105 };
1106
1107 let result = diff_schemas(&[], &[table]);
1108 assert!(result.is_err());
1109 if let Err(PlannerError::TableValidation(msg)) = result {
1110 assert!(msg.contains("Failed to normalize table"));
1111 assert!(msg.contains("Duplicate index"));
1112 } else {
1113 panic!("Expected TableValidation error, got {:?}", result);
1114 }
1115 }
1116
1117 #[test]
1118 fn diff_schemas_with_normalize_error_in_from_schema() {
1119 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
1121 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1122
1123 let table = TableDef {
1124 name: "test".into(),
1125 description: None,
1126 columns: vec![
1127 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1128 col1.clone(),
1129 {
1130 let mut c = col1.clone();
1132 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
1133 c
1134 },
1135 ],
1136 constraints: vec![],
1137 };
1138
1139 let result = diff_schemas(&[table], &[]);
1141 assert!(result.is_err());
1142 if let Err(PlannerError::TableValidation(msg)) = result {
1143 assert!(msg.contains("Failed to normalize table"));
1144 assert!(msg.contains("Duplicate index"));
1145 } else {
1146 panic!("Expected TableValidation error, got {:?}", result);
1147 }
1148 }
1149 }
1150
1151 mod fk_ordering {
1153 use super::*;
1154 use vespertide_core::TableConstraint;
1155
1156 fn table_with_fk(
1157 name: &str,
1158 ref_table: &str,
1159 fk_column: &str,
1160 ref_column: &str,
1161 ) -> TableDef {
1162 TableDef {
1163 name: name.to_string(),
1164 description: None,
1165 columns: vec![
1166 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1167 col(fk_column, ColumnType::Simple(SimpleColumnType::Integer)),
1168 ],
1169 constraints: vec![TableConstraint::ForeignKey {
1170 name: None,
1171 columns: vec![fk_column.to_string()],
1172 ref_table: ref_table.to_string(),
1173 ref_columns: vec![ref_column.to_string()],
1174 on_delete: None,
1175 on_update: None,
1176 }],
1177 }
1178 }
1179
1180 fn simple_table(name: &str) -> TableDef {
1181 TableDef {
1182 name: name.to_string(),
1183 description: None,
1184 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1185 constraints: vec![],
1186 }
1187 }
1188
1189 #[test]
1190 fn create_tables_respects_fk_order() {
1191 let users = simple_table("users");
1194 let posts = table_with_fk("posts", "users", "user_id", "id");
1195
1196 let plan = diff_schemas(&[], &[posts.clone(), users.clone()]).unwrap();
1197
1198 let create_order: Vec<&str> = plan
1200 .actions
1201 .iter()
1202 .filter_map(|a| {
1203 if let MigrationAction::CreateTable { table, .. } = a {
1204 Some(table.as_str())
1205 } else {
1206 None
1207 }
1208 })
1209 .collect();
1210
1211 assert_eq!(create_order, vec!["users", "posts"]);
1212 }
1213
1214 #[test]
1215 fn create_tables_chain_dependency() {
1216 let users = simple_table("users");
1221 let media = table_with_fk("media", "users", "owner_id", "id");
1222 let articles = table_with_fk("articles", "media", "media_id", "id");
1223
1224 let plan =
1226 diff_schemas(&[], &[articles.clone(), media.clone(), users.clone()]).unwrap();
1227
1228 let create_order: Vec<&str> = plan
1229 .actions
1230 .iter()
1231 .filter_map(|a| {
1232 if let MigrationAction::CreateTable { table, .. } = a {
1233 Some(table.as_str())
1234 } else {
1235 None
1236 }
1237 })
1238 .collect();
1239
1240 assert_eq!(create_order, vec!["users", "media", "articles"]);
1241 }
1242
1243 #[test]
1244 fn create_tables_multiple_independent_branches() {
1245 let users = simple_table("users");
1249 let posts = table_with_fk("posts", "users", "user_id", "id");
1250 let categories = simple_table("categories");
1251 let products = table_with_fk("products", "categories", "category_id", "id");
1252
1253 let plan = diff_schemas(
1254 &[],
1255 &[
1256 products.clone(),
1257 posts.clone(),
1258 categories.clone(),
1259 users.clone(),
1260 ],
1261 )
1262 .unwrap();
1263
1264 let create_order: Vec<&str> = plan
1265 .actions
1266 .iter()
1267 .filter_map(|a| {
1268 if let MigrationAction::CreateTable { table, .. } = a {
1269 Some(table.as_str())
1270 } else {
1271 None
1272 }
1273 })
1274 .collect();
1275
1276 let users_pos = create_order.iter().position(|&t| t == "users").unwrap();
1278 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1279 assert!(
1280 users_pos < posts_pos,
1281 "users should be created before posts"
1282 );
1283
1284 let categories_pos = create_order
1286 .iter()
1287 .position(|&t| t == "categories")
1288 .unwrap();
1289 let products_pos = create_order.iter().position(|&t| t == "products").unwrap();
1290 assert!(
1291 categories_pos < products_pos,
1292 "categories should be created before products"
1293 );
1294 }
1295
1296 #[test]
1297 fn delete_tables_respects_fk_order() {
1298 let users = simple_table("users");
1301 let posts = table_with_fk("posts", "users", "user_id", "id");
1302
1303 let plan = diff_schemas(&[users.clone(), posts.clone()], &[]).unwrap();
1304
1305 let delete_order: Vec<&str> = plan
1306 .actions
1307 .iter()
1308 .filter_map(|a| {
1309 if let MigrationAction::DeleteTable { table } = a {
1310 Some(table.as_str())
1311 } else {
1312 None
1313 }
1314 })
1315 .collect();
1316
1317 assert_eq!(delete_order, vec!["posts", "users"]);
1318 }
1319
1320 #[test]
1321 fn delete_tables_chain_dependency() {
1322 let users = simple_table("users");
1325 let media = table_with_fk("media", "users", "owner_id", "id");
1326 let articles = table_with_fk("articles", "media", "media_id", "id");
1327
1328 let plan =
1329 diff_schemas(&[users.clone(), media.clone(), articles.clone()], &[]).unwrap();
1330
1331 let delete_order: Vec<&str> = plan
1332 .actions
1333 .iter()
1334 .filter_map(|a| {
1335 if let MigrationAction::DeleteTable { table } = a {
1336 Some(table.as_str())
1337 } else {
1338 None
1339 }
1340 })
1341 .collect();
1342
1343 let articles_pos = delete_order.iter().position(|&t| t == "articles").unwrap();
1345 let media_pos = delete_order.iter().position(|&t| t == "media").unwrap();
1346 assert!(
1347 articles_pos < media_pos,
1348 "articles should be deleted before media"
1349 );
1350
1351 let users_pos = delete_order.iter().position(|&t| t == "users").unwrap();
1353 assert!(
1354 media_pos < users_pos,
1355 "media should be deleted before users"
1356 );
1357 }
1358
1359 #[test]
1360 fn circular_fk_dependency_returns_error() {
1361 let table_a = TableDef {
1363 name: "table_a".to_string(),
1364 description: None,
1365 columns: vec![
1366 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1367 col("b_id", ColumnType::Simple(SimpleColumnType::Integer)),
1368 ],
1369 constraints: vec![TableConstraint::ForeignKey {
1370 name: None,
1371 columns: vec!["b_id".to_string()],
1372 ref_table: "table_b".to_string(),
1373 ref_columns: vec!["id".to_string()],
1374 on_delete: None,
1375 on_update: None,
1376 }],
1377 };
1378
1379 let table_b = TableDef {
1380 name: "table_b".to_string(),
1381 description: None,
1382 columns: vec![
1383 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1384 col("a_id", ColumnType::Simple(SimpleColumnType::Integer)),
1385 ],
1386 constraints: vec![TableConstraint::ForeignKey {
1387 name: None,
1388 columns: vec!["a_id".to_string()],
1389 ref_table: "table_a".to_string(),
1390 ref_columns: vec!["id".to_string()],
1391 on_delete: None,
1392 on_update: None,
1393 }],
1394 };
1395
1396 let result = diff_schemas(&[], &[table_a, table_b]);
1397 assert!(result.is_err());
1398 if let Err(PlannerError::TableValidation(msg)) = result {
1399 assert!(
1400 msg.contains("Circular foreign key dependency"),
1401 "Expected circular dependency error, got: {}",
1402 msg
1403 );
1404 } else {
1405 panic!("Expected TableValidation error, got {:?}", result);
1406 }
1407 }
1408
1409 #[test]
1410 fn fk_to_external_table_is_ignored() {
1411 let posts = table_with_fk("posts", "users", "user_id", "id");
1413 let comments = table_with_fk("comments", "posts", "post_id", "id");
1414
1415 let plan = diff_schemas(&[], &[comments.clone(), posts.clone()]).unwrap();
1417
1418 let create_order: Vec<&str> = plan
1419 .actions
1420 .iter()
1421 .filter_map(|a| {
1422 if let MigrationAction::CreateTable { table, .. } = a {
1423 Some(table.as_str())
1424 } else {
1425 None
1426 }
1427 })
1428 .collect();
1429
1430 let posts_pos = create_order.iter().position(|&t| t == "posts").unwrap();
1432 let comments_pos = create_order.iter().position(|&t| t == "comments").unwrap();
1433 assert!(
1434 posts_pos < comments_pos,
1435 "posts should be created before comments"
1436 );
1437 }
1438
1439 #[test]
1440 fn delete_tables_mixed_with_other_actions() {
1441 use crate::diff::diff_schemas;
1444
1445 let from_schema = vec![
1446 table(
1447 "users",
1448 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1449 vec![],
1450 ),
1451 table(
1452 "posts",
1453 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1454 vec![],
1455 ),
1456 ];
1457
1458 let to_schema = vec![
1459 table(
1461 "users",
1462 vec![
1463 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1464 col("name", ColumnType::Simple(SimpleColumnType::Text)),
1465 ],
1466 vec![],
1467 ),
1468 ];
1469
1470 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
1471
1472 assert!(
1474 plan.actions
1475 .iter()
1476 .any(|a| matches!(a, MigrationAction::AddColumn { .. }))
1477 );
1478 assert!(
1479 plan.actions
1480 .iter()
1481 .any(|a| matches!(a, MigrationAction::DeleteTable { .. }))
1482 );
1483
1484 }
1487
1488 #[test]
1489 #[should_panic(expected = "Expected DeleteTable action")]
1490 fn test_extract_delete_table_name_panics_on_non_delete_action() {
1491 use super::extract_delete_table_name;
1493
1494 let action = MigrationAction::AddColumn {
1495 table: "users".into(),
1496 column: Box::new(ColumnDef {
1497 name: "email".into(),
1498 r#type: ColumnType::Simple(SimpleColumnType::Text),
1499 nullable: true,
1500 default: None,
1501 comment: None,
1502 primary_key: None,
1503 unique: None,
1504 index: None,
1505 foreign_key: None,
1506 }),
1507 fill_with: None,
1508 };
1509
1510 extract_delete_table_name(&action);
1512 }
1513
1514 #[test]
1516 fn create_tables_with_inline_fk_chain() {
1517 use super::*;
1518 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1519 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1520
1521 fn col_pk(name: &str) -> ColumnDef {
1522 ColumnDef {
1523 name: name.to_string(),
1524 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1525 nullable: false,
1526 default: None,
1527 comment: None,
1528 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1529 unique: None,
1530 index: None,
1531 foreign_key: None,
1532 }
1533 }
1534
1535 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1536 ColumnDef {
1537 name: name.to_string(),
1538 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1539 nullable: true,
1540 default: None,
1541 comment: None,
1542 primary_key: None,
1543 unique: None,
1544 index: None,
1545 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1546 }
1547 }
1548
1549 let user = TableDef {
1558 name: "user".to_string(),
1559 description: None,
1560 columns: vec![col_pk("id")],
1561 constraints: vec![],
1562 };
1563
1564 let product = TableDef {
1565 name: "product".to_string(),
1566 description: None,
1567 columns: vec![col_pk("id")],
1568 constraints: vec![],
1569 };
1570
1571 let project = TableDef {
1572 name: "project".to_string(),
1573 description: None,
1574 columns: vec![col_pk("id"), col_inline_fk("user_id", "user")],
1575 constraints: vec![],
1576 };
1577
1578 let code = TableDef {
1579 name: "code".to_string(),
1580 description: None,
1581 columns: vec![
1582 col_pk("id"),
1583 col_inline_fk("product_id", "product"),
1584 col_inline_fk("creator_user_id", "user"),
1585 col_inline_fk("project_id", "project"),
1586 ],
1587 constraints: vec![],
1588 };
1589
1590 let order = TableDef {
1591 name: "order".to_string(),
1592 description: None,
1593 columns: vec![
1594 col_pk("id"),
1595 col_inline_fk("user_id", "user"),
1596 col_inline_fk("project_id", "project"),
1597 col_inline_fk("product_id", "product"),
1598 col_inline_fk("code_id", "code"),
1599 ],
1600 constraints: vec![],
1601 };
1602
1603 let payment = TableDef {
1604 name: "payment".to_string(),
1605 description: None,
1606 columns: vec![col_pk("id"), col_inline_fk("order_id", "order")],
1607 constraints: vec![],
1608 };
1609
1610 let result = diff_schemas(&[], &[payment, order, code, project, product, user]);
1612 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1613
1614 let plan = result.unwrap();
1615 let create_order: Vec<&str> = plan
1616 .actions
1617 .iter()
1618 .filter_map(|a| {
1619 if let MigrationAction::CreateTable { table, .. } = a {
1620 Some(table.as_str())
1621 } else {
1622 None
1623 }
1624 })
1625 .collect();
1626
1627 let get_pos = |name: &str| create_order.iter().position(|&t| t == name).unwrap();
1629
1630 assert!(
1633 get_pos("user") < get_pos("project"),
1634 "user must come before project"
1635 );
1636 assert!(
1638 get_pos("product") < get_pos("code"),
1639 "product must come before code"
1640 );
1641 assert!(
1642 get_pos("user") < get_pos("code"),
1643 "user must come before code"
1644 );
1645 assert!(
1646 get_pos("project") < get_pos("code"),
1647 "project must come before code"
1648 );
1649 assert!(
1651 get_pos("code") < get_pos("order"),
1652 "code must come before order"
1653 );
1654 assert!(
1656 get_pos("order") < get_pos("payment"),
1657 "order must come before payment"
1658 );
1659 }
1660
1661 #[test]
1663 fn create_tables_with_duplicate_fk_references() {
1664 use super::*;
1665 use vespertide_core::schema::foreign_key::ForeignKeySyntax;
1666 use vespertide_core::schema::primary_key::PrimaryKeySyntax;
1667
1668 fn col_pk(name: &str) -> ColumnDef {
1669 ColumnDef {
1670 name: name.to_string(),
1671 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1672 nullable: false,
1673 default: None,
1674 comment: None,
1675 primary_key: Some(PrimaryKeySyntax::Bool(true)),
1676 unique: None,
1677 index: None,
1678 foreign_key: None,
1679 }
1680 }
1681
1682 fn col_inline_fk(name: &str, ref_table: &str) -> ColumnDef {
1683 ColumnDef {
1684 name: name.to_string(),
1685 r#type: ColumnType::Simple(SimpleColumnType::Integer),
1686 nullable: true,
1687 default: None,
1688 comment: None,
1689 primary_key: None,
1690 unique: None,
1691 index: None,
1692 foreign_key: Some(ForeignKeySyntax::String(format!("{}.id", ref_table))),
1693 }
1694 }
1695
1696 let user = TableDef {
1698 name: "user".to_string(),
1699 description: None,
1700 columns: vec![col_pk("id")],
1701 constraints: vec![],
1702 };
1703
1704 let code = TableDef {
1705 name: "code".to_string(),
1706 description: None,
1707 columns: vec![
1708 col_pk("id"),
1709 col_inline_fk("creator_user_id", "user"),
1710 col_inline_fk("used_by_user_id", "user"), ],
1712 constraints: vec![],
1713 };
1714
1715 let result = diff_schemas(&[], &[code, user]);
1717 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
1718
1719 let plan = result.unwrap();
1720 let create_order: Vec<&str> = plan
1721 .actions
1722 .iter()
1723 .filter_map(|a| {
1724 if let MigrationAction::CreateTable { table, .. } = a {
1725 Some(table.as_str())
1726 } else {
1727 None
1728 }
1729 })
1730 .collect();
1731
1732 let user_pos = create_order.iter().position(|&t| t == "user").unwrap();
1734 let code_pos = create_order.iter().position(|&t| t == "code").unwrap();
1735 assert!(user_pos < code_pos, "user must come before code");
1736 }
1737 }
1738
1739 mod primary_key_changes {
1740 use super::*;
1741
1742 fn pk(columns: Vec<&str>) -> TableConstraint {
1743 TableConstraint::PrimaryKey {
1744 auto_increment: false,
1745 columns: columns.into_iter().map(|s| s.to_string()).collect(),
1746 }
1747 }
1748
1749 #[test]
1750 fn add_column_to_composite_pk() {
1751 let from = vec![table(
1753 "users",
1754 vec![
1755 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1756 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1757 ],
1758 vec![pk(vec!["id"])],
1759 )];
1760
1761 let to = vec![table(
1762 "users",
1763 vec![
1764 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1765 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1766 ],
1767 vec![pk(vec!["id", "tenant_id"])],
1768 )];
1769
1770 let plan = diff_schemas(&from, &to).unwrap();
1771
1772 assert_eq!(plan.actions.len(), 2);
1774
1775 let has_remove = plan.actions.iter().any(|a| {
1776 matches!(
1777 a,
1778 MigrationAction::RemoveConstraint {
1779 table,
1780 constraint: TableConstraint::PrimaryKey { columns, .. }
1781 } if table == "users" && columns == &vec!["id".to_string()]
1782 )
1783 });
1784 assert!(has_remove, "Should have RemoveConstraint for old PK");
1785
1786 let has_add = plan.actions.iter().any(|a| {
1787 matches!(
1788 a,
1789 MigrationAction::AddConstraint {
1790 table,
1791 constraint: TableConstraint::PrimaryKey { columns, .. }
1792 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1793 )
1794 });
1795 assert!(has_add, "Should have AddConstraint for new composite PK");
1796 }
1797
1798 #[test]
1799 fn remove_column_from_composite_pk() {
1800 let from = vec![table(
1802 "users",
1803 vec![
1804 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1805 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1806 ],
1807 vec![pk(vec!["id", "tenant_id"])],
1808 )];
1809
1810 let to = vec![table(
1811 "users",
1812 vec![
1813 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1814 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1815 ],
1816 vec![pk(vec!["id"])],
1817 )];
1818
1819 let plan = diff_schemas(&from, &to).unwrap();
1820
1821 assert_eq!(plan.actions.len(), 2);
1823
1824 let has_remove = plan.actions.iter().any(|a| {
1825 matches!(
1826 a,
1827 MigrationAction::RemoveConstraint {
1828 table,
1829 constraint: TableConstraint::PrimaryKey { columns, .. }
1830 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
1831 )
1832 });
1833 assert!(
1834 has_remove,
1835 "Should have RemoveConstraint for old composite PK"
1836 );
1837
1838 let has_add = plan.actions.iter().any(|a| {
1839 matches!(
1840 a,
1841 MigrationAction::AddConstraint {
1842 table,
1843 constraint: TableConstraint::PrimaryKey { columns, .. }
1844 } if table == "users" && columns == &vec!["id".to_string()]
1845 )
1846 });
1847 assert!(
1848 has_add,
1849 "Should have AddConstraint for new single-column PK"
1850 );
1851 }
1852
1853 #[test]
1854 fn change_pk_columns_entirely() {
1855 let from = vec![table(
1857 "users",
1858 vec![
1859 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1860 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1861 ],
1862 vec![pk(vec!["id"])],
1863 )];
1864
1865 let to = vec![table(
1866 "users",
1867 vec![
1868 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1869 col("uuid", ColumnType::Simple(SimpleColumnType::Text)),
1870 ],
1871 vec![pk(vec!["uuid"])],
1872 )];
1873
1874 let plan = diff_schemas(&from, &to).unwrap();
1875
1876 assert_eq!(plan.actions.len(), 2);
1877
1878 let has_remove = plan.actions.iter().any(|a| {
1879 matches!(
1880 a,
1881 MigrationAction::RemoveConstraint {
1882 table,
1883 constraint: TableConstraint::PrimaryKey { columns, .. }
1884 } if table == "users" && columns == &vec!["id".to_string()]
1885 )
1886 });
1887 assert!(has_remove, "Should have RemoveConstraint for old PK");
1888
1889 let has_add = plan.actions.iter().any(|a| {
1890 matches!(
1891 a,
1892 MigrationAction::AddConstraint {
1893 table,
1894 constraint: TableConstraint::PrimaryKey { columns, .. }
1895 } if table == "users" && columns == &vec!["uuid".to_string()]
1896 )
1897 });
1898 assert!(has_add, "Should have AddConstraint for new PK");
1899 }
1900
1901 #[test]
1902 fn add_multiple_columns_to_composite_pk() {
1903 let from = vec![table(
1905 "users",
1906 vec![
1907 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1908 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1909 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1910 ],
1911 vec![pk(vec!["id"])],
1912 )];
1913
1914 let to = vec![table(
1915 "users",
1916 vec![
1917 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1918 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1919 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1920 ],
1921 vec![pk(vec!["id", "tenant_id", "region_id"])],
1922 )];
1923
1924 let plan = diff_schemas(&from, &to).unwrap();
1925
1926 assert_eq!(plan.actions.len(), 2);
1927
1928 let has_remove = plan.actions.iter().any(|a| {
1929 matches!(
1930 a,
1931 MigrationAction::RemoveConstraint {
1932 table,
1933 constraint: TableConstraint::PrimaryKey { columns, .. }
1934 } if table == "users" && columns == &vec!["id".to_string()]
1935 )
1936 });
1937 assert!(
1938 has_remove,
1939 "Should have RemoveConstraint for old single-column PK"
1940 );
1941
1942 let has_add = plan.actions.iter().any(|a| {
1943 matches!(
1944 a,
1945 MigrationAction::AddConstraint {
1946 table,
1947 constraint: TableConstraint::PrimaryKey { columns, .. }
1948 } if table == "users" && columns == &vec![
1949 "id".to_string(),
1950 "tenant_id".to_string(),
1951 "region_id".to_string()
1952 ]
1953 )
1954 });
1955 assert!(
1956 has_add,
1957 "Should have AddConstraint for new 3-column composite PK"
1958 );
1959 }
1960
1961 #[test]
1962 fn remove_multiple_columns_from_composite_pk() {
1963 let from = vec![table(
1965 "users",
1966 vec![
1967 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1968 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1969 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1970 ],
1971 vec![pk(vec!["id", "tenant_id", "region_id"])],
1972 )];
1973
1974 let to = vec![table(
1975 "users",
1976 vec![
1977 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1978 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
1979 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
1980 ],
1981 vec![pk(vec!["id"])],
1982 )];
1983
1984 let plan = diff_schemas(&from, &to).unwrap();
1985
1986 assert_eq!(plan.actions.len(), 2);
1987
1988 let has_remove = plan.actions.iter().any(|a| {
1989 matches!(
1990 a,
1991 MigrationAction::RemoveConstraint {
1992 table,
1993 constraint: TableConstraint::PrimaryKey { columns, .. }
1994 } if table == "users" && columns == &vec![
1995 "id".to_string(),
1996 "tenant_id".to_string(),
1997 "region_id".to_string()
1998 ]
1999 )
2000 });
2001 assert!(
2002 has_remove,
2003 "Should have RemoveConstraint for old 3-column composite PK"
2004 );
2005
2006 let has_add = plan.actions.iter().any(|a| {
2007 matches!(
2008 a,
2009 MigrationAction::AddConstraint {
2010 table,
2011 constraint: TableConstraint::PrimaryKey { columns, .. }
2012 } if table == "users" && columns == &vec!["id".to_string()]
2013 )
2014 });
2015 assert!(
2016 has_add,
2017 "Should have AddConstraint for new single-column PK"
2018 );
2019 }
2020
2021 #[test]
2022 fn change_composite_pk_columns_partially() {
2023 let from = vec![table(
2026 "users",
2027 vec![
2028 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2029 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2030 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2031 ],
2032 vec![pk(vec!["id", "tenant_id"])],
2033 )];
2034
2035 let to = vec![table(
2036 "users",
2037 vec![
2038 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2039 col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)),
2040 col("region_id", ColumnType::Simple(SimpleColumnType::Integer)),
2041 ],
2042 vec![pk(vec!["id", "region_id"])],
2043 )];
2044
2045 let plan = diff_schemas(&from, &to).unwrap();
2046
2047 assert_eq!(plan.actions.len(), 2);
2048
2049 let has_remove = plan.actions.iter().any(|a| {
2050 matches!(
2051 a,
2052 MigrationAction::RemoveConstraint {
2053 table,
2054 constraint: TableConstraint::PrimaryKey { columns, .. }
2055 } if table == "users" && columns == &vec!["id".to_string(), "tenant_id".to_string()]
2056 )
2057 });
2058 assert!(
2059 has_remove,
2060 "Should have RemoveConstraint for old PK with tenant_id"
2061 );
2062
2063 let has_add = plan.actions.iter().any(|a| {
2064 matches!(
2065 a,
2066 MigrationAction::AddConstraint {
2067 table,
2068 constraint: TableConstraint::PrimaryKey { columns, .. }
2069 } if table == "users" && columns == &vec!["id".to_string(), "region_id".to_string()]
2070 )
2071 });
2072 assert!(
2073 has_add,
2074 "Should have AddConstraint for new PK with region_id"
2075 );
2076 }
2077 }
2078
2079 mod default_changes {
2080 use super::*;
2081
2082 fn col_with_default(name: &str, ty: ColumnType, default: Option<&str>) -> ColumnDef {
2083 ColumnDef {
2084 name: name.to_string(),
2085 r#type: ty,
2086 nullable: true,
2087 default: default.map(|s| s.into()),
2088 comment: None,
2089 primary_key: None,
2090 unique: None,
2091 index: None,
2092 foreign_key: None,
2093 }
2094 }
2095
2096 #[test]
2097 fn add_default_value() {
2098 let from = vec![table(
2100 "users",
2101 vec![
2102 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2103 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2104 ],
2105 vec![],
2106 )];
2107
2108 let to = vec![table(
2109 "users",
2110 vec![
2111 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2112 col_with_default(
2113 "status",
2114 ColumnType::Simple(SimpleColumnType::Text),
2115 Some("'active'"),
2116 ),
2117 ],
2118 vec![],
2119 )];
2120
2121 let plan = diff_schemas(&from, &to).unwrap();
2122
2123 assert_eq!(plan.actions.len(), 1);
2124 assert!(matches!(
2125 &plan.actions[0],
2126 MigrationAction::ModifyColumnDefault {
2127 table,
2128 column,
2129 new_default: Some(default),
2130 } if table == "users" && column == "status" && default == "'active'"
2131 ));
2132 }
2133
2134 #[test]
2135 fn remove_default_value() {
2136 let from = vec![table(
2138 "users",
2139 vec![
2140 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2141 col_with_default(
2142 "status",
2143 ColumnType::Simple(SimpleColumnType::Text),
2144 Some("'active'"),
2145 ),
2146 ],
2147 vec![],
2148 )];
2149
2150 let to = vec![table(
2151 "users",
2152 vec![
2153 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2154 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2155 ],
2156 vec![],
2157 )];
2158
2159 let plan = diff_schemas(&from, &to).unwrap();
2160
2161 assert_eq!(plan.actions.len(), 1);
2162 assert!(matches!(
2163 &plan.actions[0],
2164 MigrationAction::ModifyColumnDefault {
2165 table,
2166 column,
2167 new_default: None,
2168 } if table == "users" && column == "status"
2169 ));
2170 }
2171
2172 #[test]
2173 fn change_default_value() {
2174 let from = vec![table(
2176 "users",
2177 vec![
2178 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2179 col_with_default(
2180 "status",
2181 ColumnType::Simple(SimpleColumnType::Text),
2182 Some("'active'"),
2183 ),
2184 ],
2185 vec![],
2186 )];
2187
2188 let to = vec![table(
2189 "users",
2190 vec![
2191 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2192 col_with_default(
2193 "status",
2194 ColumnType::Simple(SimpleColumnType::Text),
2195 Some("'pending'"),
2196 ),
2197 ],
2198 vec![],
2199 )];
2200
2201 let plan = diff_schemas(&from, &to).unwrap();
2202
2203 assert_eq!(plan.actions.len(), 1);
2204 assert!(matches!(
2205 &plan.actions[0],
2206 MigrationAction::ModifyColumnDefault {
2207 table,
2208 column,
2209 new_default: Some(default),
2210 } if table == "users" && column == "status" && default == "'pending'"
2211 ));
2212 }
2213
2214 #[test]
2215 fn no_change_same_default() {
2216 let from = vec![table(
2218 "users",
2219 vec![
2220 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2221 col_with_default(
2222 "status",
2223 ColumnType::Simple(SimpleColumnType::Text),
2224 Some("'active'"),
2225 ),
2226 ],
2227 vec![],
2228 )];
2229
2230 let to = vec![table(
2231 "users",
2232 vec![
2233 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2234 col_with_default(
2235 "status",
2236 ColumnType::Simple(SimpleColumnType::Text),
2237 Some("'active'"),
2238 ),
2239 ],
2240 vec![],
2241 )];
2242
2243 let plan = diff_schemas(&from, &to).unwrap();
2244
2245 assert!(plan.actions.is_empty());
2246 }
2247
2248 #[test]
2249 fn multiple_columns_default_changes() {
2250 let from = vec![table(
2252 "users",
2253 vec![
2254 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2255 col_with_default("status", ColumnType::Simple(SimpleColumnType::Text), None),
2256 col_with_default(
2257 "role",
2258 ColumnType::Simple(SimpleColumnType::Text),
2259 Some("'user'"),
2260 ),
2261 col_with_default(
2262 "active",
2263 ColumnType::Simple(SimpleColumnType::Boolean),
2264 Some("true"),
2265 ),
2266 ],
2267 vec![],
2268 )];
2269
2270 let to = vec![table(
2271 "users",
2272 vec![
2273 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2274 col_with_default(
2275 "status",
2276 ColumnType::Simple(SimpleColumnType::Text),
2277 Some("'pending'"),
2278 ), col_with_default("role", ColumnType::Simple(SimpleColumnType::Text), None), col_with_default(
2281 "active",
2282 ColumnType::Simple(SimpleColumnType::Boolean),
2283 Some("true"),
2284 ), ],
2286 vec![],
2287 )];
2288
2289 let plan = diff_schemas(&from, &to).unwrap();
2290
2291 assert_eq!(plan.actions.len(), 2);
2292
2293 let has_status_change = plan.actions.iter().any(|a| {
2294 matches!(
2295 a,
2296 MigrationAction::ModifyColumnDefault {
2297 table,
2298 column,
2299 new_default: Some(default),
2300 } if table == "users" && column == "status" && default == "'pending'"
2301 )
2302 });
2303 assert!(has_status_change, "Should detect status default added");
2304
2305 let has_role_change = plan.actions.iter().any(|a| {
2306 matches!(
2307 a,
2308 MigrationAction::ModifyColumnDefault {
2309 table,
2310 column,
2311 new_default: None,
2312 } if table == "users" && column == "role"
2313 )
2314 });
2315 assert!(has_role_change, "Should detect role default removed");
2316 }
2317
2318 #[test]
2319 fn default_change_with_type_change() {
2320 let from = vec![table(
2322 "users",
2323 vec![
2324 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2325 col_with_default(
2326 "count",
2327 ColumnType::Simple(SimpleColumnType::Integer),
2328 Some("0"),
2329 ),
2330 ],
2331 vec![],
2332 )];
2333
2334 let to = vec![table(
2335 "users",
2336 vec![
2337 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2338 col_with_default(
2339 "count",
2340 ColumnType::Simple(SimpleColumnType::Text),
2341 Some("'0'"),
2342 ),
2343 ],
2344 vec![],
2345 )];
2346
2347 let plan = diff_schemas(&from, &to).unwrap();
2348
2349 assert_eq!(plan.actions.len(), 2);
2351
2352 let has_type_change = plan.actions.iter().any(|a| {
2353 matches!(
2354 a,
2355 MigrationAction::ModifyColumnType { table, column, .. }
2356 if table == "users" && column == "count"
2357 )
2358 });
2359 assert!(has_type_change, "Should detect type change");
2360
2361 let has_default_change = plan.actions.iter().any(|a| {
2362 matches!(
2363 a,
2364 MigrationAction::ModifyColumnDefault {
2365 table,
2366 column,
2367 new_default: Some(default),
2368 } if table == "users" && column == "count" && default == "'0'"
2369 )
2370 });
2371 assert!(has_default_change, "Should detect default change");
2372 }
2373 }
2374
2375 mod comment_changes {
2376 use super::*;
2377
2378 fn col_with_comment(name: &str, ty: ColumnType, comment: Option<&str>) -> ColumnDef {
2379 ColumnDef {
2380 name: name.to_string(),
2381 r#type: ty,
2382 nullable: true,
2383 default: None,
2384 comment: comment.map(|s| s.to_string()),
2385 primary_key: None,
2386 unique: None,
2387 index: None,
2388 foreign_key: None,
2389 }
2390 }
2391
2392 #[test]
2393 fn add_comment() {
2394 let from = vec![table(
2396 "users",
2397 vec![
2398 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2399 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2400 ],
2401 vec![],
2402 )];
2403
2404 let to = vec![table(
2405 "users",
2406 vec![
2407 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2408 col_with_comment(
2409 "email",
2410 ColumnType::Simple(SimpleColumnType::Text),
2411 Some("User's email address"),
2412 ),
2413 ],
2414 vec![],
2415 )];
2416
2417 let plan = diff_schemas(&from, &to).unwrap();
2418
2419 assert_eq!(plan.actions.len(), 1);
2420 assert!(matches!(
2421 &plan.actions[0],
2422 MigrationAction::ModifyColumnComment {
2423 table,
2424 column,
2425 new_comment: Some(comment),
2426 } if table == "users" && column == "email" && comment == "User's email address"
2427 ));
2428 }
2429
2430 #[test]
2431 fn remove_comment() {
2432 let from = vec![table(
2434 "users",
2435 vec![
2436 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2437 col_with_comment(
2438 "email",
2439 ColumnType::Simple(SimpleColumnType::Text),
2440 Some("User's email address"),
2441 ),
2442 ],
2443 vec![],
2444 )];
2445
2446 let to = vec![table(
2447 "users",
2448 vec![
2449 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2450 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2451 ],
2452 vec![],
2453 )];
2454
2455 let plan = diff_schemas(&from, &to).unwrap();
2456
2457 assert_eq!(plan.actions.len(), 1);
2458 assert!(matches!(
2459 &plan.actions[0],
2460 MigrationAction::ModifyColumnComment {
2461 table,
2462 column,
2463 new_comment: None,
2464 } if table == "users" && column == "email"
2465 ));
2466 }
2467
2468 #[test]
2469 fn change_comment() {
2470 let from = vec![table(
2472 "users",
2473 vec![
2474 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2475 col_with_comment(
2476 "email",
2477 ColumnType::Simple(SimpleColumnType::Text),
2478 Some("Old comment"),
2479 ),
2480 ],
2481 vec![],
2482 )];
2483
2484 let to = vec![table(
2485 "users",
2486 vec![
2487 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2488 col_with_comment(
2489 "email",
2490 ColumnType::Simple(SimpleColumnType::Text),
2491 Some("New comment"),
2492 ),
2493 ],
2494 vec![],
2495 )];
2496
2497 let plan = diff_schemas(&from, &to).unwrap();
2498
2499 assert_eq!(plan.actions.len(), 1);
2500 assert!(matches!(
2501 &plan.actions[0],
2502 MigrationAction::ModifyColumnComment {
2503 table,
2504 column,
2505 new_comment: Some(comment),
2506 } if table == "users" && column == "email" && comment == "New comment"
2507 ));
2508 }
2509
2510 #[test]
2511 fn no_change_same_comment() {
2512 let from = vec![table(
2514 "users",
2515 vec![
2516 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2517 col_with_comment(
2518 "email",
2519 ColumnType::Simple(SimpleColumnType::Text),
2520 Some("Same comment"),
2521 ),
2522 ],
2523 vec![],
2524 )];
2525
2526 let to = vec![table(
2527 "users",
2528 vec![
2529 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2530 col_with_comment(
2531 "email",
2532 ColumnType::Simple(SimpleColumnType::Text),
2533 Some("Same comment"),
2534 ),
2535 ],
2536 vec![],
2537 )];
2538
2539 let plan = diff_schemas(&from, &to).unwrap();
2540
2541 assert!(plan.actions.is_empty());
2542 }
2543
2544 #[test]
2545 fn multiple_columns_comment_changes() {
2546 let from = vec![table(
2548 "users",
2549 vec![
2550 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2551 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None),
2552 col_with_comment(
2553 "name",
2554 ColumnType::Simple(SimpleColumnType::Text),
2555 Some("User name"),
2556 ),
2557 col_with_comment(
2558 "phone",
2559 ColumnType::Simple(SimpleColumnType::Text),
2560 Some("Phone number"),
2561 ),
2562 ],
2563 vec![],
2564 )];
2565
2566 let to = vec![table(
2567 "users",
2568 vec![
2569 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2570 col_with_comment(
2571 "email",
2572 ColumnType::Simple(SimpleColumnType::Text),
2573 Some("Email address"),
2574 ), col_with_comment("name", ColumnType::Simple(SimpleColumnType::Text), None), col_with_comment(
2577 "phone",
2578 ColumnType::Simple(SimpleColumnType::Text),
2579 Some("Phone number"),
2580 ), ],
2582 vec![],
2583 )];
2584
2585 let plan = diff_schemas(&from, &to).unwrap();
2586
2587 assert_eq!(plan.actions.len(), 2);
2588
2589 let has_email_change = plan.actions.iter().any(|a| {
2590 matches!(
2591 a,
2592 MigrationAction::ModifyColumnComment {
2593 table,
2594 column,
2595 new_comment: Some(comment),
2596 } if table == "users" && column == "email" && comment == "Email address"
2597 )
2598 });
2599 assert!(has_email_change, "Should detect email comment added");
2600
2601 let has_name_change = plan.actions.iter().any(|a| {
2602 matches!(
2603 a,
2604 MigrationAction::ModifyColumnComment {
2605 table,
2606 column,
2607 new_comment: None,
2608 } if table == "users" && column == "name"
2609 )
2610 });
2611 assert!(has_name_change, "Should detect name comment removed");
2612 }
2613
2614 #[test]
2615 fn comment_change_with_nullable_change() {
2616 let from = vec![table(
2618 "users",
2619 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2620 let mut c =
2621 col_with_comment("email", ColumnType::Simple(SimpleColumnType::Text), None);
2622 c.nullable = true;
2623 c
2624 }],
2625 vec![],
2626 )];
2627
2628 let to = vec![table(
2629 "users",
2630 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
2631 let mut c = col_with_comment(
2632 "email",
2633 ColumnType::Simple(SimpleColumnType::Text),
2634 Some("Required email"),
2635 );
2636 c.nullable = false;
2637 c
2638 }],
2639 vec![],
2640 )];
2641
2642 let plan = diff_schemas(&from, &to).unwrap();
2643
2644 assert_eq!(plan.actions.len(), 2);
2646
2647 let has_nullable_change = plan.actions.iter().any(|a| {
2648 matches!(
2649 a,
2650 MigrationAction::ModifyColumnNullable {
2651 table,
2652 column,
2653 nullable: false,
2654 ..
2655 } if table == "users" && column == "email"
2656 )
2657 });
2658 assert!(has_nullable_change, "Should detect nullable change");
2659
2660 let has_comment_change = plan.actions.iter().any(|a| {
2661 matches!(
2662 a,
2663 MigrationAction::ModifyColumnComment {
2664 table,
2665 column,
2666 new_comment: Some(comment),
2667 } if table == "users" && column == "email" && comment == "Required email"
2668 )
2669 });
2670 assert!(has_comment_change, "Should detect comment change");
2671 }
2672 }
2673
2674 mod nullable_changes {
2675 use super::*;
2676
2677 fn col_nullable(name: &str, ty: ColumnType, nullable: bool) -> ColumnDef {
2678 ColumnDef {
2679 name: name.to_string(),
2680 r#type: ty,
2681 nullable,
2682 default: None,
2683 comment: None,
2684 primary_key: None,
2685 unique: None,
2686 index: None,
2687 foreign_key: None,
2688 }
2689 }
2690
2691 #[test]
2692 fn column_nullable_to_non_nullable() {
2693 let from = vec![table(
2695 "users",
2696 vec![
2697 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2698 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2699 ],
2700 vec![],
2701 )];
2702
2703 let to = vec![table(
2704 "users",
2705 vec![
2706 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2707 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2708 ],
2709 vec![],
2710 )];
2711
2712 let plan = diff_schemas(&from, &to).unwrap();
2713
2714 assert_eq!(plan.actions.len(), 1);
2715 assert!(matches!(
2716 &plan.actions[0],
2717 MigrationAction::ModifyColumnNullable {
2718 table,
2719 column,
2720 nullable: false,
2721 fill_with: None,
2722 } if table == "users" && column == "email"
2723 ));
2724 }
2725
2726 #[test]
2727 fn column_non_nullable_to_nullable() {
2728 let from = vec![table(
2730 "users",
2731 vec![
2732 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2733 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false),
2734 ],
2735 vec![],
2736 )];
2737
2738 let to = vec![table(
2739 "users",
2740 vec![
2741 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2742 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2743 ],
2744 vec![],
2745 )];
2746
2747 let plan = diff_schemas(&from, &to).unwrap();
2748
2749 assert_eq!(plan.actions.len(), 1);
2750 assert!(matches!(
2751 &plan.actions[0],
2752 MigrationAction::ModifyColumnNullable {
2753 table,
2754 column,
2755 nullable: true,
2756 fill_with: None,
2757 } if table == "users" && column == "email"
2758 ));
2759 }
2760
2761 #[test]
2762 fn multiple_columns_nullable_changes() {
2763 let from = vec![table(
2765 "users",
2766 vec![
2767 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2768 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), true),
2769 col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), false),
2770 col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true),
2771 ],
2772 vec![],
2773 )];
2774
2775 let to = vec![table(
2776 "users",
2777 vec![
2778 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2779 col_nullable("email", ColumnType::Simple(SimpleColumnType::Text), false), col_nullable("name", ColumnType::Simple(SimpleColumnType::Text), true), col_nullable("phone", ColumnType::Simple(SimpleColumnType::Text), true), ],
2783 vec![],
2784 )];
2785
2786 let plan = diff_schemas(&from, &to).unwrap();
2787
2788 assert_eq!(plan.actions.len(), 2);
2789
2790 let has_email_change = plan.actions.iter().any(|a| {
2791 matches!(
2792 a,
2793 MigrationAction::ModifyColumnNullable {
2794 table,
2795 column,
2796 nullable: false,
2797 ..
2798 } if table == "users" && column == "email"
2799 )
2800 });
2801 assert!(
2802 has_email_change,
2803 "Should detect email nullable -> non-nullable"
2804 );
2805
2806 let has_name_change = plan.actions.iter().any(|a| {
2807 matches!(
2808 a,
2809 MigrationAction::ModifyColumnNullable {
2810 table,
2811 column,
2812 nullable: true,
2813 ..
2814 } if table == "users" && column == "name"
2815 )
2816 });
2817 assert!(
2818 has_name_change,
2819 "Should detect name non-nullable -> nullable"
2820 );
2821 }
2822
2823 #[test]
2824 fn nullable_change_with_type_change() {
2825 let from = vec![table(
2827 "users",
2828 vec![
2829 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2830 col_nullable("age", ColumnType::Simple(SimpleColumnType::Integer), true),
2831 ],
2832 vec![],
2833 )];
2834
2835 let to = vec![table(
2836 "users",
2837 vec![
2838 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
2839 col_nullable("age", ColumnType::Simple(SimpleColumnType::Text), false),
2840 ],
2841 vec![],
2842 )];
2843
2844 let plan = diff_schemas(&from, &to).unwrap();
2845
2846 assert_eq!(plan.actions.len(), 2);
2848
2849 let has_type_change = plan.actions.iter().any(|a| {
2850 matches!(
2851 a,
2852 MigrationAction::ModifyColumnType { table, column, .. }
2853 if table == "users" && column == "age"
2854 )
2855 });
2856 assert!(has_type_change, "Should detect type change");
2857
2858 let has_nullable_change = plan.actions.iter().any(|a| {
2859 matches!(
2860 a,
2861 MigrationAction::ModifyColumnNullable {
2862 table,
2863 column,
2864 nullable: false,
2865 ..
2866 } if table == "users" && column == "age"
2867 )
2868 });
2869 assert!(has_nullable_change, "Should detect nullable change");
2870 }
2871 }
2872
2873 mod diff_tables {
2874 use insta::assert_debug_snapshot;
2875
2876 use super::*;
2877
2878 #[test]
2879 fn create_table_with_inline_index() {
2880 let base = [table(
2881 "users",
2882 vec![
2883 ColumnDef {
2884 name: "id".to_string(),
2885 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2886 nullable: false,
2887 default: None,
2888 comment: None,
2889 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2890 unique: None,
2891 index: Some(StrOrBoolOrArray::Bool(false)),
2892 foreign_key: None,
2893 },
2894 ColumnDef {
2895 name: "name".to_string(),
2896 r#type: ColumnType::Simple(SimpleColumnType::Text),
2897 nullable: true,
2898 default: None,
2899 comment: None,
2900 primary_key: None,
2901 unique: Some(StrOrBoolOrArray::Bool(true)),
2902 index: Some(StrOrBoolOrArray::Bool(true)),
2903 foreign_key: None,
2904 },
2905 ],
2906 vec![],
2907 )];
2908 let plan = diff_schemas(&[], &base).unwrap();
2909
2910 assert_eq!(plan.actions.len(), 1);
2911 assert_debug_snapshot!(plan.actions);
2912
2913 let plan = diff_schemas(
2914 &base,
2915 &[table(
2916 "users",
2917 vec![
2918 ColumnDef {
2919 name: "id".to_string(),
2920 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2921 nullable: false,
2922 default: None,
2923 comment: None,
2924 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2925 unique: None,
2926 index: Some(StrOrBoolOrArray::Bool(false)),
2927 foreign_key: None,
2928 },
2929 ColumnDef {
2930 name: "name".to_string(),
2931 r#type: ColumnType::Simple(SimpleColumnType::Text),
2932 nullable: true,
2933 default: None,
2934 comment: None,
2935 primary_key: None,
2936 unique: Some(StrOrBoolOrArray::Bool(true)),
2937 index: Some(StrOrBoolOrArray::Bool(false)),
2938 foreign_key: None,
2939 },
2940 ],
2941 vec![],
2942 )],
2943 )
2944 .unwrap();
2945
2946 assert_eq!(plan.actions.len(), 1);
2947 assert_debug_snapshot!(plan.actions);
2948 }
2949
2950 #[rstest]
2951 #[case(
2952 "add_index",
2953 vec![table(
2954 "users",
2955 vec![
2956 ColumnDef {
2957 name: "id".to_string(),
2958 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2959 nullable: false,
2960 default: None,
2961 comment: None,
2962 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2963 unique: None,
2964 index: None,
2965 foreign_key: None,
2966 },
2967 ],
2968 vec![],
2969 )],
2970 vec![table(
2971 "users",
2972 vec![
2973 ColumnDef {
2974 name: "id".to_string(),
2975 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2976 nullable: false,
2977 default: None,
2978 comment: None,
2979 primary_key: Some(PrimaryKeySyntax::Bool(true)),
2980 unique: None,
2981 index: Some(StrOrBoolOrArray::Bool(true)),
2982 foreign_key: None,
2983 },
2984 ],
2985 vec![],
2986 )],
2987 )]
2988 #[case(
2989 "remove_index",
2990 vec![table(
2991 "users",
2992 vec![
2993 ColumnDef {
2994 name: "id".to_string(),
2995 r#type: ColumnType::Simple(SimpleColumnType::Integer),
2996 nullable: false,
2997 default: None,
2998 comment: None,
2999 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3000 unique: None,
3001 index: Some(StrOrBoolOrArray::Bool(true)),
3002 foreign_key: None,
3003 },
3004 ],
3005 vec![],
3006 )],
3007 vec![table(
3008 "users",
3009 vec![
3010 ColumnDef {
3011 name: "id".to_string(),
3012 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3013 nullable: false,
3014 default: None,
3015 comment: None,
3016 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3017 unique: None,
3018 index: Some(StrOrBoolOrArray::Bool(false)),
3019 foreign_key: None,
3020 },
3021 ],
3022 vec![],
3023 )],
3024 )]
3025 #[case(
3026 "add_named_index",
3027 vec![table(
3028 "users",
3029 vec![
3030 ColumnDef {
3031 name: "id".to_string(),
3032 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3033 nullable: false,
3034 default: None,
3035 comment: None,
3036 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3037 unique: None,
3038 index: None,
3039 foreign_key: None,
3040 },
3041 ],
3042 vec![],
3043 )],
3044 vec![table(
3045 "users",
3046 vec![
3047 ColumnDef {
3048 name: "id".to_string(),
3049 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3050 nullable: false,
3051 default: None,
3052 comment: None,
3053 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3054 unique: None,
3055 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3056 foreign_key: None,
3057 },
3058 ],
3059 vec![],
3060 )],
3061 )]
3062 #[case(
3063 "remove_named_index",
3064 vec![table(
3065 "users",
3066 vec![
3067 ColumnDef {
3068 name: "id".to_string(),
3069 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3070 nullable: false,
3071 default: None,
3072 comment: None,
3073 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3074 unique: None,
3075 index: Some(StrOrBoolOrArray::Str("hello".to_string())),
3076 foreign_key: None,
3077 },
3078 ],
3079 vec![],
3080 )],
3081 vec![table(
3082 "users",
3083 vec![
3084 ColumnDef {
3085 name: "id".to_string(),
3086 r#type: ColumnType::Simple(SimpleColumnType::Integer),
3087 nullable: false,
3088 default: None,
3089 comment: None,
3090 primary_key: Some(PrimaryKeySyntax::Bool(true)),
3091 unique: None,
3092 index: None,
3093 foreign_key: None,
3094 },
3095 ],
3096 vec![],
3097 )],
3098 )]
3099 fn diff_tables(#[case] name: &str, #[case] base: Vec<TableDef>, #[case] to: Vec<TableDef>) {
3100 use insta::with_settings;
3101
3102 let plan = diff_schemas(&base, &to).unwrap();
3103 with_settings!({ snapshot_suffix => name }, {
3104 assert_debug_snapshot!(plan.actions);
3105 });
3106 }
3107 }
3108
3109 mod coverage_explicit {
3111 use super::*;
3112
3113 #[test]
3114 fn delete_column_explicit() {
3115 let from = vec![table(
3117 "users",
3118 vec![
3119 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3120 col("name", ColumnType::Simple(SimpleColumnType::Text)),
3121 ],
3122 vec![],
3123 )];
3124
3125 let to = vec![table(
3126 "users",
3127 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3128 vec![],
3129 )];
3130
3131 let plan = diff_schemas(&from, &to).unwrap();
3132 assert_eq!(plan.actions.len(), 1);
3133 assert!(matches!(
3134 &plan.actions[0],
3135 MigrationAction::DeleteColumn { table, column }
3136 if table == "users" && column == "name"
3137 ));
3138 }
3139
3140 #[test]
3141 fn add_column_explicit() {
3142 let from = vec![table(
3144 "users",
3145 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3146 vec![],
3147 )];
3148
3149 let to = vec![table(
3150 "users",
3151 vec![
3152 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3153 col("email", ColumnType::Simple(SimpleColumnType::Text)),
3154 ],
3155 vec![],
3156 )];
3157
3158 let plan = diff_schemas(&from, &to).unwrap();
3159 assert_eq!(plan.actions.len(), 1);
3160 assert!(matches!(
3161 &plan.actions[0],
3162 MigrationAction::AddColumn { table, column, .. }
3163 if table == "users" && column.name == "email"
3164 ));
3165 }
3166
3167 #[test]
3168 fn remove_constraint_explicit() {
3169 let from = vec![table(
3171 "users",
3172 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3173 vec![idx("idx_users_id", vec!["id"])],
3174 )];
3175
3176 let to = vec![table(
3177 "users",
3178 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3179 vec![],
3180 )];
3181
3182 let plan = diff_schemas(&from, &to).unwrap();
3183 assert_eq!(plan.actions.len(), 1);
3184 assert!(matches!(
3185 &plan.actions[0],
3186 MigrationAction::RemoveConstraint { table, constraint }
3187 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3188 ));
3189 }
3190
3191 #[test]
3192 fn add_constraint_explicit() {
3193 let from = vec![table(
3195 "users",
3196 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3197 vec![],
3198 )];
3199
3200 let to = vec![table(
3201 "users",
3202 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3203 vec![idx("idx_users_id", vec!["id"])],
3204 )];
3205
3206 let plan = diff_schemas(&from, &to).unwrap();
3207 assert_eq!(plan.actions.len(), 1);
3208 assert!(matches!(
3209 &plan.actions[0],
3210 MigrationAction::AddConstraint { table, constraint }
3211 if table == "users" && matches!(constraint, TableConstraint::Index { name: Some(n), .. } if n == "idx_users_id")
3212 ));
3213 }
3214 }
3215}