1mod data_migration;
2mod display;
3mod narrowing_strategy;
4mod prefix;
5mod remap_mapping_serde;
6
7use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName};
8pub use data_migration::{DataMigrationSql, leading_ddl_keyword, sql_preview};
9pub use display::truncate_comment;
10pub use narrowing_strategy::NarrowingStrategy;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[serde(rename_all = "snake_case")]
25pub struct MigrationPlan {
26 #[serde(default)]
29 pub id: String,
30 pub comment: Option<String>,
32 #[serde(default)]
34 pub created_at: Option<String>,
35 pub version: u32,
37 pub actions: Vec<MigrationAction>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(tag = "type", rename_all = "snake_case")]
57#[non_exhaustive]
58pub enum MigrationAction {
59 CreateTable {
61 table: TableName,
62 columns: Vec<ColumnDef>,
63 constraints: Vec<TableConstraint>,
64 },
65 DeleteTable { table: TableName },
67 AddColumn {
69 table: TableName,
70 column: Box<ColumnDef>,
71 fill_with: Option<String>,
73 },
74 RenameColumn {
76 table: TableName,
77 from: ColumnName,
78 to: ColumnName,
79 },
80 DeleteColumn {
82 table: TableName,
83 column: ColumnName,
84 },
85 ModifyColumnType {
87 table: TableName,
88 column: ColumnName,
89 new_type: ColumnType,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
98 fill_with: Option<BTreeMap<String, String>>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
105 narrowing_strategy: Option<NarrowingStrategy>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
113 timezone: Option<String>,
114 },
115 ModifyColumnNullable {
117 table: TableName,
118 column: ColumnName,
119 nullable: bool,
120 fill_with: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
126 delete_null_rows: Option<bool>,
127 },
128 ModifyColumnDefault {
130 table: TableName,
131 column: ColumnName,
132 new_default: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
147 backfill: Option<String>,
148 },
149 ModifyColumnComment {
151 table: TableName,
152 column: ColumnName,
153 new_comment: Option<String>,
155 },
156 AddConstraint {
164 table: TableName,
165 constraint: TableConstraint,
166 },
167 RemoveConstraint {
169 table: TableName,
170 constraint: TableConstraint,
171 },
172 ReplaceConstraint {
174 table: TableName,
175 from: TableConstraint,
176 to: TableConstraint,
177 },
178 RemapEnumValues {
201 table: TableName,
202 column: ColumnName,
203 #[serde(with = "remap_mapping_serde")]
204 #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, i64>"))]
205 mapping: BTreeMap<i64, i64>,
206 },
207 RenameTable { from: TableName, to: TableName },
209 RawSql { sql: String },
221 DataMigration {
240 sql: DataMigrationSql,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 description: Option<String>,
245 },
246}
247
248impl MigrationAction {
249 #[must_use]
253 pub fn table_name(&self) -> Option<&str> {
254 match self {
255 Self::CreateTable { table, .. }
256 | Self::DeleteTable { table }
257 | Self::AddColumn { table, .. }
258 | Self::DeleteColumn { table, .. }
259 | Self::RenameColumn { table, .. }
260 | Self::ModifyColumnType { table, .. }
261 | Self::ModifyColumnNullable { table, .. }
262 | Self::ModifyColumnDefault { table, .. }
263 | Self::ModifyColumnComment { table, .. }
264 | Self::AddConstraint { table, .. }
265 | Self::RemoveConstraint { table, .. }
266 | Self::ReplaceConstraint { table, .. }
267 | Self::RemapEnumValues { table, .. } => Some(table.as_str()),
268 Self::RenameTable { from, .. } => Some(from.as_str()),
269 Self::RawSql { .. } | Self::DataMigration { .. } => None,
270 }
271 }
272
273 #[must_use]
279 pub fn data_migration_ddl_violation(&self) -> Option<(&'static str, &str)> {
280 let Self::DataMigration { sql, .. } = self else {
281 return None;
282 };
283 sql.statements()
284 .find_map(|stmt| leading_ddl_keyword(stmt).map(|keyword| (keyword, stmt)))
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::schema::{ReferenceAction, SimpleColumnType};
292 use rstest::rstest;
293
294 fn default_column() -> ColumnDef {
295 ColumnDef::new("email", ColumnType::Simple(SimpleColumnType::Text), true)
296 }
297
298 fn idx(name: Option<&str>, cols: &[&str]) -> TableConstraint {
299 TableConstraint::Index {
300 name: name.map(Into::into),
301 columns: cols.iter().map(|c| (*c).into()).collect(),
302 }
303 }
304 fn pk_id() -> TableConstraint {
305 TableConstraint::PrimaryKey {
306 auto_increment: false,
307 columns: vec!["id".into()],
308 strategy: crate::PrimaryKeyAdditionStrategy::default(),
309 }
310 }
311 fn pk_id_auto() -> TableConstraint {
312 TableConstraint::PrimaryKey {
313 auto_increment: true,
314 columns: vec!["id".into()],
315 strategy: crate::PrimaryKeyAdditionStrategy::default(),
316 }
317 }
318 fn uq_email(name: Option<&str>) -> TableConstraint {
319 TableConstraint::Unique {
320 name: name.map(Into::into),
321 columns: vec!["email".into()],
322 strategy: crate::schema::UniqueConstraintStrategy::DeleteDuplicates {
323 keep: crate::schema::KeepPolicy::First,
324 },
325 }
326 }
327 fn fk_user(name: Option<&str>, on_delete: Option<ReferenceAction>) -> TableConstraint {
328 TableConstraint::ForeignKey {
329 name: name.map(Into::into),
330 columns: vec!["user_id".into()],
331 ref_table: "users".into(),
332 ref_columns: vec!["id".into()],
333 on_delete,
334 on_update: None,
335 orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
336 }
337 }
338 fn chk(name: &str, expr: &str) -> TableConstraint {
339 TableConstraint::Check {
340 name: name.into(),
341 expr: expr.into(),
342 strategy: crate::CheckViolationStrategy::default(),
343 }
344 }
345 fn idx_email(name: Option<&str>) -> TableConstraint {
346 idx(name, &["email"])
347 }
348
349 #[test]
350 fn migration_action_wire_format_round_trip() {
351 let canonical = r#"{"type":"create_table","table":"user","columns":[],"constraints":[]}"#;
352 let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
353 let reserialized = serde_json::to_string(&parsed).expect("serialize");
354
355 assert_eq!(
356 reserialized, canonical,
357 "wire format MUST be byte-identical"
358 );
359 }
360
361 #[test]
362 fn migration_action_rename_column_wire_format() {
363 let canonical = r#"{"type":"rename_column","table":"orders","from":"old","to":"new"}"#;
364 let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
365 let reserialized = serde_json::to_string(&parsed).expect("serialize");
366
367 assert_eq!(reserialized, canonical);
368 }
369
370 #[test]
371 fn migration_plan_real_example_round_trip() {
372 let plan_json =
373 include_str!("../../../../examples/app/migrations/0001_init.vespertide.json");
374 let parsed: MigrationPlan =
375 serde_json::from_str(plan_json).expect("real migration plan parses");
376 let reserialized = serde_json::to_string(&parsed).expect("serialize");
377 let reparsed: MigrationPlan = serde_json::from_str(&reserialized).expect("round-trip");
378
379 assert_eq!(
380 parsed, reparsed,
381 "semantic content preserved across round-trip"
382 );
383 }
384
385 #[rstest]
386 #[case::create_table(
387 MigrationAction::CreateTable {
388 table: "users".into(),
389 columns: vec![],
390 constraints: vec![],
391 },
392 "CreateTable: users"
393 )]
394 #[case::delete_table(
395 MigrationAction::DeleteTable {
396 table: "users".into(),
397 },
398 "DeleteTable: users"
399 )]
400 #[case::add_column(
401 MigrationAction::AddColumn {
402 table: "users".into(),
403 column: Box::new(default_column()),
404 fill_with: None,
405 },
406 "AddColumn: users.email"
407 )]
408 #[case::rename_column(
409 MigrationAction::RenameColumn {
410 table: "users".into(),
411 from: "old_name".into(),
412 to: "new_name".into(),
413 },
414 "RenameColumn: users.old_name -> new_name"
415 )]
416 #[case::delete_column(
417 MigrationAction::DeleteColumn {
418 table: "users".into(),
419 column: "email".into(),
420 },
421 "DeleteColumn: users.email"
422 )]
423 #[case::modify_column_type(
424 MigrationAction::ModifyColumnType {
425 table: "users".into(),
426 column: "age".into(),
427 new_type: ColumnType::Simple(SimpleColumnType::Integer),
428 fill_with: None,
429 narrowing_strategy: None,
430 timezone: None,
431 },
432 "ModifyColumnType: users.age"
433 )]
434 #[case::add_constraint_index_with_name(
435 MigrationAction::AddConstraint { table: "users".into(), constraint: idx_email(Some("ix_users__email")) },
436 "AddConstraint: users.ix_users__email (INDEX)"
437 )]
438 #[case::add_constraint_index_without_name(
439 MigrationAction::AddConstraint { table: "users".into(), constraint: idx_email(None) },
440 "AddConstraint: users.INDEX"
441 )]
442 #[case::remove_constraint_index_with_name(
443 MigrationAction::RemoveConstraint { table: "users".into(), constraint: idx_email(Some("ix_users__email")) },
444 "RemoveConstraint: users.ix_users__email (INDEX)"
445 )]
446 #[case::remove_constraint_index_without_name(
447 MigrationAction::RemoveConstraint { table: "users".into(), constraint: idx_email(None) },
448 "RemoveConstraint: users.INDEX"
449 )]
450 #[case::rename_table(
451 MigrationAction::RenameTable {
452 from: "old_table".into(),
453 to: "new_table".into(),
454 },
455 "RenameTable: old_table -> new_table"
456 )]
457 fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) {
458 assert_eq!(action.to_string(), expected);
459 }
460
461 #[test]
462 fn test_display_raw_sql_truncates_unicode_without_panicking() {
463 let sql = "COMMENT ON COLUMN 한국어테이블.이름 IS '日本語 café 📊';".repeat(3);
464 let action = MigrationAction::RawSql { sql };
465
466 let display = action.to_string();
467
468 assert!(display.starts_with("RawSql: COMMENT ON COLUMN 한국어테이블"));
469 assert!(display.ends_with("..."));
470 }
471
472 #[rstest]
473 #[case::create_table(
474 MigrationAction::CreateTable {
475 table: "users".into(),
476 columns: vec![],
477 constraints: vec![],
478 },
479 Some("users")
480 )]
481 #[case::rename_table(
482 MigrationAction::RenameTable {
483 from: "old_users".into(),
484 to: "users".into(),
485 },
486 Some("old_users")
487 )]
488 #[case::raw_sql(MigrationAction::RawSql { sql: "SELECT 1".into() }, None)]
489 #[case::data_migration(
490 MigrationAction::DataMigration { sql: "UPDATE t SET x = 1".into(), description: None },
491 None
492 )]
493 fn test_table_name(#[case] action: MigrationAction, #[case] expected: Option<&str>) {
494 assert_eq!(action.table_name(), expected);
495 }
496
497 #[test]
498 fn data_migration_wire_format_round_trip_without_description() {
499 let canonical = r#"{"type":"data_migration","sql":"UPDATE user SET active = true"}"#;
500 let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
501 assert_eq!(
502 parsed,
503 MigrationAction::DataMigration {
504 sql: "UPDATE user SET active = true".into(),
505 description: None,
506 }
507 );
508 assert_eq!(
509 serde_json::to_string(&parsed).expect("serialize"),
510 canonical,
511 "wire format MUST be byte-identical"
512 );
513 }
514
515 #[test]
516 fn data_migration_wire_format_round_trip_with_description() {
517 let canonical = concat!(
518 r#"{"type":"data_migration","sql":"UPDATE user SET active = true","#,
519 r#""description":"activate legacy accounts"}"#
520 );
521 let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
522 assert_eq!(
523 serde_json::to_string(&parsed).expect("serialize"),
524 canonical
525 );
526 }
527
528 #[test]
529 fn data_migration_per_backend_wire_format_round_trip() {
530 let canonical = concat!(
531 r#"{"type":"data_migration","sql":{"postgres":"UPDATE a","#,
532 r#""mysql":"UPDATE b","sqlite":"UPDATE c"}}"#
533 );
534 let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
535 assert_eq!(
536 parsed,
537 MigrationAction::DataMigration {
538 sql: DataMigrationSql::PerBackend {
539 postgres: "UPDATE a".into(),
540 mysql: "UPDATE b".into(),
541 sqlite: "UPDATE c".into(),
542 },
543 description: None,
544 }
545 );
546 assert_eq!(
547 serde_json::to_string(&parsed).expect("serialize"),
548 canonical
549 );
550 }
551
552 #[rstest]
553 #[case::uniform_dml("UPDATE user SET active = true".into(), None)]
554 #[case::uniform_ddl("DROP TABLE user".into(), Some(("DROP", "DROP TABLE user")))]
555 #[case::commented_ddl(
556 "-- tidy up\n truncate table audit".into(),
557 Some(("TRUNCATE", "-- tidy up\n truncate table audit"))
558 )]
559 fn data_migration_ddl_violation_detects_uniform_sql(
560 #[case] sql: DataMigrationSql,
561 #[case] expected: Option<(&'static str, &str)>,
562 ) {
563 let action = MigrationAction::DataMigration {
564 sql,
565 description: None,
566 };
567 assert_eq!(action.data_migration_ddl_violation(), expected);
568 }
569
570 #[test]
571 fn data_migration_ddl_violation_scans_every_backend_statement() {
572 let action = MigrationAction::DataMigration {
573 sql: DataMigrationSql::PerBackend {
574 postgres: "UPDATE t SET x = 1".into(),
575 mysql: "UPDATE t SET x = 1".into(),
576 sqlite: "ALTER TABLE t RENAME TO t2".into(),
577 },
578 description: None,
579 };
580 assert_eq!(
581 action.data_migration_ddl_violation(),
582 Some(("ALTER", "ALTER TABLE t RENAME TO t2")),
583 "a DDL statement hidden in a non-default backend branch must still be caught"
584 );
585 }
586
587 #[test]
588 fn ddl_violation_is_none_for_every_non_data_migration_action() {
589 let action = MigrationAction::RawSql {
590 sql: "DROP TABLE user".to_string(),
591 };
592 assert_eq!(
593 action.data_migration_ddl_violation(),
594 None,
595 "raw_sql keeps its escape-hatch freedom; only data_migration is constrained"
596 );
597 }
598
599 #[rstest]
600 #[case::with_description(
601 MigrationAction::DataMigration {
602 sql: "UPDATE user SET active = true".into(),
603 description: Some("activate legacy accounts".into()),
604 },
605 "DataMigration: activate legacy accounts"
606 )]
607 #[case::without_description(
608 MigrationAction::DataMigration {
609 sql: "UPDATE user SET active = true".into(),
610 description: None,
611 },
612 "DataMigration: UPDATE user SET active = true"
613 )]
614 #[case::per_backend_without_description(
615 MigrationAction::DataMigration {
616 sql: DataMigrationSql::PerBackend {
617 postgres: "UPDATE pg".into(),
618 mysql: "UPDATE my".into(),
619 sqlite: "UPDATE lite".into(),
620 },
621 description: None,
622 },
623 "DataMigration: UPDATE pg"
624 )]
625 fn test_display_data_migration(#[case] action: MigrationAction, #[case] expected: &str) {
626 assert_eq!(action.to_string(), expected);
627 }
628
629 #[rstest]
630 #[case::add_constraint_primary_key(
631 MigrationAction::AddConstraint { table: "users".into(), constraint: pk_id() },
632 "AddConstraint: users.PRIMARY KEY"
633 )]
634 #[case::add_constraint_unique_with_name(
635 MigrationAction::AddConstraint { table: "users".into(), constraint: uq_email(Some("uq_email")) },
636 "AddConstraint: users.uq_email (UNIQUE)"
637 )]
638 #[case::add_constraint_unique_without_name(
639 MigrationAction::AddConstraint { table: "users".into(), constraint: uq_email(None) },
640 "AddConstraint: users.UNIQUE"
641 )]
642 #[case::add_constraint_foreign_key_with_name(
643 MigrationAction::AddConstraint { table: "posts".into(), constraint: fk_user(Some("fk_user"), Some(ReferenceAction::Cascade)) },
644 "AddConstraint: posts.fk_user (FOREIGN KEY)"
645 )]
646 #[case::add_constraint_foreign_key_without_name(
647 MigrationAction::AddConstraint { table: "posts".into(), constraint: fk_user(None, None) },
648 "AddConstraint: posts.FOREIGN KEY"
649 )]
650 #[case::add_constraint_check(
651 MigrationAction::AddConstraint { table: "users".into(), constraint: chk("chk_age", "age > 0") },
652 "AddConstraint: users.chk_age (CHECK)"
653 )]
654 fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
655 assert_eq!(action.to_string(), expected);
656 }
657
658 #[rstest]
659 #[case::remove_constraint_primary_key(
660 MigrationAction::RemoveConstraint { table: "users".into(), constraint: pk_id() },
661 "RemoveConstraint: users.PRIMARY KEY"
662 )]
663 #[case::remove_constraint_unique_with_name(
664 MigrationAction::RemoveConstraint { table: "users".into(), constraint: uq_email(Some("uq_email")) },
665 "RemoveConstraint: users.uq_email (UNIQUE)"
666 )]
667 #[case::remove_constraint_unique_without_name(
668 MigrationAction::RemoveConstraint { table: "users".into(), constraint: uq_email(None) },
669 "RemoveConstraint: users.UNIQUE"
670 )]
671 #[case::remove_constraint_foreign_key_with_name(
672 MigrationAction::RemoveConstraint { table: "posts".into(), constraint: fk_user(Some("fk_user"), None) },
673 "RemoveConstraint: posts.fk_user (FOREIGN KEY)"
674 )]
675 #[case::remove_constraint_foreign_key_without_name(
676 MigrationAction::RemoveConstraint { table: "posts".into(), constraint: fk_user(None, None) },
677 "RemoveConstraint: posts.FOREIGN KEY"
678 )]
679 #[case::remove_constraint_check(
680 MigrationAction::RemoveConstraint { table: "users".into(), constraint: chk("chk_age", "age > 0") },
681 "RemoveConstraint: users.chk_age (CHECK)"
682 )]
683 fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
684 assert_eq!(action.to_string(), expected);
685 }
686
687 #[rstest]
688 #[case::raw_sql_short(
689 MigrationAction::RawSql {
690 sql: "SELECT 1".into(),
691 },
692 "RawSql: SELECT 1"
693 )]
694 fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) {
695 assert_eq!(action.to_string(), expected);
696 }
697
698 #[test]
699 fn test_display_raw_sql_long() {
700 let action = MigrationAction::RawSql {
701 sql:
702 "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'"
703 .into(),
704 };
705 let result = action.to_string();
706 assert!(result.starts_with("RawSql: "));
707 assert!(result.ends_with("..."));
708 assert!(result.len() > 10);
709 }
710
711 #[rstest]
712 #[case::modify_column_nullable_to_not_null(
713 MigrationAction::ModifyColumnNullable {
714 table: "users".into(),
715 column: "email".into(),
716 nullable: false,
717 fill_with: None,
718 delete_null_rows: None,
719 },
720 "ModifyColumnNullable: users.email -> NOT NULL"
721 )]
722 #[case::modify_column_nullable_to_null(
723 MigrationAction::ModifyColumnNullable {
724 table: "users".into(),
725 column: "email".into(),
726 nullable: true,
727 fill_with: None,
728 delete_null_rows: None,
729 },
730 "ModifyColumnNullable: users.email -> NULL"
731 )]
732 fn test_display_modify_column_nullable(
733 #[case] action: MigrationAction,
734 #[case] expected: &str,
735 ) {
736 assert_eq!(action.to_string(), expected);
737 }
738
739 #[rstest]
740 #[case::modify_column_default_set(
741 MigrationAction::ModifyColumnDefault {
742 table: "users".into(),
743 column: "status".into(),
744 new_default: Some("'active'".into()),
745 backfill: None,
746 },
747 "ModifyColumnDefault: users.status -> 'active'"
748 )]
749 #[case::modify_column_default_drop(
750 MigrationAction::ModifyColumnDefault {
751 table: "users".into(),
752 column: "status".into(),
753 new_default: None,
754 backfill: None,
755 },
756 "ModifyColumnDefault: users.status -> (none)"
757 )]
758 fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) {
759 assert_eq!(action.to_string(), expected);
760 }
761
762 #[rstest]
763 #[case::modify_column_comment_set(
764 MigrationAction::ModifyColumnComment {
765 table: "users".into(),
766 column: "email".into(),
767 new_comment: Some("User email address".into()),
768 },
769 "ModifyColumnComment: users.email -> 'User email address'"
770 )]
771 #[case::modify_column_comment_drop(
772 MigrationAction::ModifyColumnComment {
773 table: "users".into(),
774 column: "email".into(),
775 new_comment: None,
776 },
777 "ModifyColumnComment: users.email -> (none)"
778 )]
779 fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) {
780 assert_eq!(action.to_string(), expected);
781 }
782
783 #[test]
784 fn test_display_modify_column_comment_long() {
785 let action = MigrationAction::ModifyColumnComment {
787 table: "users".into(),
788 column: "email".into(),
789 new_comment: Some(
790 "This is a very long comment that should be truncated in display".into(),
791 ),
792 };
793 let result = action.to_string();
794 assert!(result.contains("..."));
795 assert!(result.contains("This is a very long comment"));
796 assert!(!result.contains("truncated in display"));
798 }
799
800 #[test]
802 fn test_action_with_prefix_create_table() {
803 let action = MigrationAction::CreateTable {
804 table: "users".into(),
805 columns: vec![default_column()],
806 constraints: vec![TableConstraint::ForeignKey {
807 name: Some("fk_org".into()),
808 columns: vec!["org_id".into()],
809 ref_table: "organizations".into(),
810 ref_columns: vec!["id".into()],
811 on_delete: None,
812 on_update: None,
813 orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
814 }],
815 };
816 let prefixed = action.with_prefix("myapp_");
817 if let MigrationAction::CreateTable {
818 table, constraints, ..
819 } = prefixed
820 {
821 assert_eq!(table.as_str(), "myapp_users");
822 if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
823 assert_eq!(ref_table.as_str(), "myapp_organizations");
824 }
825 } else {
826 panic!("Expected CreateTable");
827 }
828 }
829
830 #[test]
831 fn test_action_with_prefix_delete_table() {
832 let action = MigrationAction::DeleteTable {
833 table: "users".into(),
834 };
835 let prefixed = action.with_prefix("myapp_");
836 if let MigrationAction::DeleteTable { table } = prefixed {
837 assert_eq!(table.as_str(), "myapp_users");
838 } else {
839 panic!("Expected DeleteTable");
840 }
841 }
842
843 #[test]
844 fn test_action_with_prefix_add_column() {
845 let action = MigrationAction::AddColumn {
846 table: "users".into(),
847 column: Box::new(default_column()),
848 fill_with: None,
849 };
850 let prefixed = action.with_prefix("myapp_");
851 if let MigrationAction::AddColumn { table, .. } = prefixed {
852 assert_eq!(table.as_str(), "myapp_users");
853 } else {
854 panic!("Expected AddColumn");
855 }
856 }
857
858 #[test]
859 fn test_action_with_prefix_rename_table() {
860 let action = MigrationAction::RenameTable {
861 from: "old_table".into(),
862 to: "new_table".into(),
863 };
864 let prefixed = action.with_prefix("myapp_");
865 if let MigrationAction::RenameTable { from, to } = prefixed {
866 assert_eq!(from.as_str(), "myapp_old_table");
867 assert_eq!(to.as_str(), "myapp_new_table");
868 } else {
869 panic!("Expected RenameTable");
870 }
871 }
872
873 #[test]
874 fn test_action_with_prefix_raw_sql_unchanged() {
875 let action = MigrationAction::RawSql {
876 sql: "SELECT * FROM users".into(),
877 };
878 let prefixed = action.with_prefix("myapp_");
879 if let MigrationAction::RawSql { sql } = prefixed {
880 assert_eq!(sql, "SELECT * FROM users");
882 } else {
883 panic!("Expected RawSql");
884 }
885 }
886
887 #[test]
888 fn test_action_with_prefix_empty_prefix() {
889 let action = MigrationAction::CreateTable {
890 table: "users".into(),
891 columns: vec![],
892 constraints: vec![],
893 };
894 let prefixed = action.clone().with_prefix("");
895 if let MigrationAction::CreateTable { table, .. } = prefixed {
896 assert_eq!(table.as_str(), "users");
897 }
898 }
899
900 #[test]
901 fn test_migration_plan_with_prefix() {
902 let plan = MigrationPlan {
903 id: String::new(),
904 comment: Some("test".into()),
905 created_at: None,
906 version: 1,
907 actions: vec![
908 MigrationAction::CreateTable {
909 table: "users".into(),
910 columns: vec![],
911 constraints: vec![],
912 },
913 MigrationAction::CreateTable {
914 table: "posts".into(),
915 columns: vec![],
916 constraints: vec![fk_user(Some("fk_user"), None)],
917 },
918 ],
919 };
920 let prefixed = plan.with_prefix("myapp_");
921 assert_eq!(prefixed.actions.len(), 2);
922
923 if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] {
924 assert_eq!(table.as_str(), "myapp_users");
925 }
926 if let MigrationAction::CreateTable {
927 table, constraints, ..
928 } = &prefixed.actions[1]
929 {
930 assert_eq!(table.as_str(), "myapp_posts");
931 if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
932 assert_eq!(ref_table.as_str(), "myapp_users");
933 }
934 }
935 }
936
937 #[test]
938 fn test_action_with_prefix_rename_column() {
939 let action = MigrationAction::RenameColumn {
940 table: "users".into(),
941 from: "name".into(),
942 to: "full_name".into(),
943 };
944 let prefixed = action.with_prefix("myapp_");
945 if let MigrationAction::RenameColumn { table, from, to } = prefixed {
946 assert_eq!(table.as_str(), "myapp_users");
947 assert_eq!(from.as_str(), "name");
948 assert_eq!(to.as_str(), "full_name");
949 } else {
950 panic!("Expected RenameColumn");
951 }
952 }
953
954 #[test]
955 fn test_action_with_prefix_delete_column() {
956 let action = MigrationAction::DeleteColumn {
957 table: "users".into(),
958 column: "old_field".into(),
959 };
960 let prefixed = action.with_prefix("myapp_");
961 if let MigrationAction::DeleteColumn { table, column } = prefixed {
962 assert_eq!(table.as_str(), "myapp_users");
963 assert_eq!(column.as_str(), "old_field");
964 } else {
965 panic!("Expected DeleteColumn");
966 }
967 }
968
969 #[test]
970 fn test_action_with_prefix_modify_column_type() {
971 let action = MigrationAction::ModifyColumnType {
972 table: "users".into(),
973 column: "age".into(),
974 new_type: ColumnType::Simple(SimpleColumnType::BigInt),
975 fill_with: None,
976 narrowing_strategy: None,
977 timezone: None,
978 };
979 let prefixed = action.with_prefix("myapp_");
980 if let MigrationAction::ModifyColumnType {
981 table,
982 column,
983 new_type,
984 fill_with,
985 ..
986 } = prefixed
987 {
988 assert_eq!(table.as_str(), "myapp_users");
989 assert_eq!(column.as_str(), "age");
990 assert!(matches!(
991 new_type,
992 ColumnType::Simple(SimpleColumnType::BigInt)
993 ));
994 assert_eq!(fill_with, None);
995 } else {
996 panic!("Expected ModifyColumnType");
997 }
998 }
999
1000 #[test]
1001 fn test_action_with_prefix_modify_column_nullable() {
1002 let action = MigrationAction::ModifyColumnNullable {
1003 table: "users".into(),
1004 column: "email".into(),
1005 nullable: false,
1006 fill_with: Some("default@example.com".into()),
1007 delete_null_rows: None,
1008 };
1009 let prefixed = action.with_prefix("myapp_");
1010 if let MigrationAction::ModifyColumnNullable {
1011 table,
1012 column,
1013 nullable,
1014 fill_with,
1015 delete_null_rows,
1016 } = prefixed
1017 {
1018 assert_eq!(table.as_str(), "myapp_users");
1019 assert_eq!(column.as_str(), "email");
1020 assert!(!nullable);
1021 assert_eq!(fill_with, Some("default@example.com".into()));
1022 assert_eq!(delete_null_rows, None);
1023 } else {
1024 panic!("Expected ModifyColumnNullable");
1025 }
1026 }
1027
1028 #[test]
1029 fn test_action_with_prefix_modify_column_default() {
1030 let action = MigrationAction::ModifyColumnDefault {
1031 table: "users".into(),
1032 column: "status".into(),
1033 new_default: Some("active".into()),
1034 backfill: None,
1035 };
1036 let prefixed = action.with_prefix("myapp_");
1037 if let MigrationAction::ModifyColumnDefault {
1038 table,
1039 column,
1040 new_default,
1041 ..
1042 } = prefixed
1043 {
1044 assert_eq!(table.as_str(), "myapp_users");
1045 assert_eq!(column.as_str(), "status");
1046 assert_eq!(new_default, Some("active".into()));
1047 } else {
1048 panic!("Expected ModifyColumnDefault");
1049 }
1050 }
1051
1052 #[test]
1053 fn test_action_with_prefix_modify_column_comment() {
1054 let action = MigrationAction::ModifyColumnComment {
1055 table: "users".into(),
1056 column: "bio".into(),
1057 new_comment: Some("User biography".into()),
1058 };
1059 let prefixed = action.with_prefix("myapp_");
1060 if let MigrationAction::ModifyColumnComment {
1061 table,
1062 column,
1063 new_comment,
1064 } = prefixed
1065 {
1066 assert_eq!(table.as_str(), "myapp_users");
1067 assert_eq!(column.as_str(), "bio");
1068 assert_eq!(new_comment, Some("User biography".into()));
1069 } else {
1070 panic!("Expected ModifyColumnComment");
1071 }
1072 }
1073
1074 #[test]
1075 fn test_action_with_prefix_add_constraint() {
1076 let action = MigrationAction::AddConstraint {
1077 table: "posts".into(),
1078 constraint: fk_user(Some("fk_user"), None),
1079 };
1080 let prefixed = action.with_prefix("myapp_");
1081 if let MigrationAction::AddConstraint { table, constraint } = prefixed {
1082 assert_eq!(table.as_str(), "myapp_posts");
1083 if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
1084 assert_eq!(ref_table.as_str(), "myapp_users");
1085 } else {
1086 panic!("Expected ForeignKey constraint");
1087 }
1088 } else {
1089 panic!("Expected AddConstraint");
1090 }
1091 }
1092
1093 #[test]
1094 fn test_action_with_prefix_remove_constraint() {
1095 let action = MigrationAction::RemoveConstraint {
1096 table: "posts".into(),
1097 constraint: fk_user(Some("fk_user"), None),
1098 };
1099 let prefixed = action.with_prefix("myapp_");
1100 if let MigrationAction::RemoveConstraint { table, constraint } = prefixed {
1101 assert_eq!(table.as_str(), "myapp_posts");
1102 if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
1103 assert_eq!(ref_table.as_str(), "myapp_users");
1104 } else {
1105 panic!("Expected ForeignKey constraint");
1106 }
1107 } else {
1108 panic!("Expected RemoveConstraint");
1109 }
1110 }
1111
1112 #[rstest]
1113 #[case::replace_constraint_primary_key(
1114 MigrationAction::ReplaceConstraint { table: "users".into(), from: pk_id(), to: pk_id_auto() },
1115 "ReplaceConstraint: users.PRIMARY KEY"
1116 )]
1117 #[case::replace_constraint_unique_with_name(
1118 MigrationAction::ReplaceConstraint { table: "users".into(), from: uq_email(None), to: uq_email(Some("uq_email")) },
1119 "ReplaceConstraint: users.uq_email (UNIQUE)"
1120 )]
1121 #[case::replace_constraint_unique_without_name(
1122 MigrationAction::ReplaceConstraint { table: "users".into(), from: uq_email(Some("uq_email")), to: uq_email(None) },
1123 "ReplaceConstraint: users.UNIQUE"
1124 )]
1125 #[case::replace_constraint_foreign_key_with_name(
1126 MigrationAction::ReplaceConstraint { table: "posts".into(), from: fk_user(None, None), to: fk_user(Some("fk_user"), None) },
1127 "ReplaceConstraint: posts.fk_user (FOREIGN KEY)"
1128 )]
1129 #[case::replace_constraint_foreign_key_without_name(
1130 MigrationAction::ReplaceConstraint { table: "posts".into(), from: fk_user(Some("fk_user"), None), to: fk_user(None, None) },
1131 "ReplaceConstraint: posts.FOREIGN KEY"
1132 )]
1133 #[case::replace_constraint_check(
1134 MigrationAction::ReplaceConstraint { table: "users".into(), from: chk("chk_age", "age > 0"), to: chk("chk_age", "age >= 0") },
1135 "ReplaceConstraint: users.chk_age (CHECK)"
1136 )]
1137 #[case::replace_constraint_index_with_name(
1138 MigrationAction::ReplaceConstraint { table: "users".into(), from: idx_email(None), to: idx_email(Some("ix_users__email")) },
1139 "ReplaceConstraint: users.ix_users__email (INDEX)"
1140 )]
1141 #[case::replace_constraint_index_without_name(
1142 MigrationAction::ReplaceConstraint { table: "users".into(), from: idx_email(Some("ix_users__email")), to: idx_email(None) },
1143 "ReplaceConstraint: users.INDEX"
1144 )]
1145 fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
1146 assert_eq!(action.to_string(), expected);
1147 }
1148
1149 #[test]
1150 fn test_action_with_prefix_replace_constraint() {
1151 let action = MigrationAction::ReplaceConstraint {
1152 table: "posts".into(),
1153 from: fk_user(Some("fk_user"), Some(ReferenceAction::Cascade)),
1154 to: fk_user(Some("fk_user"), Some(ReferenceAction::SetNull)),
1155 };
1156 let prefixed = action.with_prefix("myapp_");
1157 if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed {
1158 assert_eq!(table.as_str(), "myapp_posts");
1159 if let TableConstraint::ForeignKey { ref_table, .. } = from {
1160 assert_eq!(ref_table.as_str(), "myapp_users");
1161 } else {
1162 panic!("Expected ForeignKey constraint in from");
1163 }
1164 if let TableConstraint::ForeignKey { ref_table, .. } = to {
1165 assert_eq!(ref_table.as_str(), "myapp_users");
1166 } else {
1167 panic!("Expected ForeignKey constraint in to");
1168 }
1169 } else {
1170 panic!("Expected ReplaceConstraint");
1171 }
1172 }
1173}