Skip to main content

vespertide_core/
action.rs

1use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8#[serde(rename_all = "snake_case")]
9pub struct MigrationPlan {
10    /// Unique identifier for this migration (UUID format).
11    /// Defaults to empty string for backward compatibility with old migration files.
12    #[serde(default)]
13    pub id: String,
14    pub comment: Option<String>,
15    #[serde(default)]
16    pub created_at: Option<String>,
17    pub version: u32,
18    pub actions: Vec<MigrationAction>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum MigrationAction {
25    CreateTable {
26        table: TableName,
27        columns: Vec<ColumnDef>,
28        constraints: Vec<TableConstraint>,
29    },
30    DeleteTable {
31        table: TableName,
32    },
33    AddColumn {
34        table: TableName,
35        column: Box<ColumnDef>,
36        /// Optional fill value to backfill existing rows when adding NOT NULL without default.
37        fill_with: Option<String>,
38    },
39    RenameColumn {
40        table: TableName,
41        from: ColumnName,
42        to: ColumnName,
43    },
44    DeleteColumn {
45        table: TableName,
46        column: ColumnName,
47    },
48    ModifyColumnType {
49        table: TableName,
50        column: ColumnName,
51        new_type: ColumnType,
52        /// Mapping of removed enum values to replacement values for safe enum value removal.
53        /// e.g., {"cancelled": "'pending'"} generates UPDATE before type change.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        fill_with: Option<BTreeMap<String, String>>,
56    },
57    ModifyColumnNullable {
58        table: TableName,
59        column: ColumnName,
60        nullable: bool,
61        /// Required when changing from nullable to non-nullable to backfill existing NULL values.
62        fill_with: Option<String>,
63        /// When true, rows with NULL values in the column are deleted instead of backfilled.
64        /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value
65        /// may not exist.
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        delete_null_rows: Option<bool>,
68    },
69    ModifyColumnDefault {
70        table: TableName,
71        column: ColumnName,
72        /// The new default value, or None to remove the default.
73        new_default: Option<String>,
74    },
75    ModifyColumnComment {
76        table: TableName,
77        column: ColumnName,
78        /// The new comment, or None to remove the comment.
79        new_comment: Option<String>,
80    },
81    AddConstraint {
82        table: TableName,
83        constraint: TableConstraint,
84    },
85    RemoveConstraint {
86        table: TableName,
87        constraint: TableConstraint,
88    },
89    RenameTable {
90        from: TableName,
91        to: TableName,
92    },
93    RawSql {
94        sql: String,
95    },
96}
97
98impl MigrationPlan {
99    /// Apply a prefix to all table names in the migration plan.
100    /// This modifies all table references in all actions.
101    pub fn with_prefix(self, prefix: &str) -> Self {
102        if prefix.is_empty() {
103            return self;
104        }
105        Self {
106            actions: self
107                .actions
108                .into_iter()
109                .map(|action| action.with_prefix(prefix))
110                .collect(),
111            ..self
112        }
113    }
114}
115
116impl MigrationAction {
117    /// Apply a prefix to all table names in this action.
118    pub fn with_prefix(self, prefix: &str) -> Self {
119        if prefix.is_empty() {
120            return self;
121        }
122        match self {
123            MigrationAction::CreateTable {
124                table,
125                columns,
126                constraints,
127            } => MigrationAction::CreateTable {
128                table: format!("{}{}", prefix, table),
129                columns,
130                constraints: constraints
131                    .into_iter()
132                    .map(|c| c.with_prefix(prefix))
133                    .collect(),
134            },
135            MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable {
136                table: format!("{}{}", prefix, table),
137            },
138            MigrationAction::AddColumn {
139                table,
140                column,
141                fill_with,
142            } => MigrationAction::AddColumn {
143                table: format!("{}{}", prefix, table),
144                column,
145                fill_with,
146            },
147            MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn {
148                table: format!("{}{}", prefix, table),
149                from,
150                to,
151            },
152            MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn {
153                table: format!("{}{}", prefix, table),
154                column,
155            },
156            MigrationAction::ModifyColumnType {
157                table,
158                column,
159                new_type,
160                fill_with,
161            } => MigrationAction::ModifyColumnType {
162                table: format!("{}{}", prefix, table),
163                column,
164                new_type,
165                fill_with,
166            },
167            MigrationAction::ModifyColumnNullable {
168                table,
169                column,
170                nullable,
171                fill_with,
172                delete_null_rows,
173            } => MigrationAction::ModifyColumnNullable {
174                table: format!("{}{}", prefix, table),
175                column,
176                nullable,
177                fill_with,
178                delete_null_rows,
179            },
180            MigrationAction::ModifyColumnDefault {
181                table,
182                column,
183                new_default,
184            } => MigrationAction::ModifyColumnDefault {
185                table: format!("{}{}", prefix, table),
186                column,
187                new_default,
188            },
189            MigrationAction::ModifyColumnComment {
190                table,
191                column,
192                new_comment,
193            } => MigrationAction::ModifyColumnComment {
194                table: format!("{}{}", prefix, table),
195                column,
196                new_comment,
197            },
198            MigrationAction::AddConstraint { table, constraint } => {
199                MigrationAction::AddConstraint {
200                    table: format!("{}{}", prefix, table),
201                    constraint: constraint.with_prefix(prefix),
202                }
203            }
204            MigrationAction::RemoveConstraint { table, constraint } => {
205                MigrationAction::RemoveConstraint {
206                    table: format!("{}{}", prefix, table),
207                    constraint: constraint.with_prefix(prefix),
208                }
209            }
210            MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable {
211                from: format!("{}{}", prefix, from),
212                to: format!("{}{}", prefix, to),
213            },
214            MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql },
215        }
216    }
217}
218
219impl fmt::Display for MigrationAction {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            MigrationAction::CreateTable { table, .. } => {
223                write!(f, "CreateTable: {}", table)
224            }
225            MigrationAction::DeleteTable { table } => {
226                write!(f, "DeleteTable: {}", table)
227            }
228            MigrationAction::AddColumn { table, column, .. } => {
229                write!(f, "AddColumn: {}.{}", table, column.name)
230            }
231            MigrationAction::RenameColumn { table, from, to } => {
232                write!(f, "RenameColumn: {}.{} -> {}", table, from, to)
233            }
234            MigrationAction::DeleteColumn { table, column } => {
235                write!(f, "DeleteColumn: {}.{}", table, column)
236            }
237            MigrationAction::ModifyColumnType { table, column, .. } => {
238                write!(f, "ModifyColumnType: {}.{}", table, column)
239            }
240            MigrationAction::ModifyColumnNullable {
241                table,
242                column,
243                nullable,
244                ..
245            } => {
246                let nullability = if *nullable { "NULL" } else { "NOT NULL" };
247                write!(
248                    f,
249                    "ModifyColumnNullable: {}.{} -> {}",
250                    table, column, nullability
251                )
252            }
253            MigrationAction::ModifyColumnDefault {
254                table,
255                column,
256                new_default,
257            } => {
258                if let Some(default) = new_default {
259                    write!(
260                        f,
261                        "ModifyColumnDefault: {}.{} -> {}",
262                        table, column, default
263                    )
264                } else {
265                    write!(f, "ModifyColumnDefault: {}.{} -> (none)", table, column)
266                }
267            }
268            MigrationAction::ModifyColumnComment {
269                table,
270                column,
271                new_comment,
272            } => {
273                if let Some(comment) = new_comment {
274                    let display = if comment.chars().count() > 30 {
275                        format!("{}...", comment.chars().take(27).collect::<String>())
276                    } else {
277                        comment.clone()
278                    };
279                    write!(
280                        f,
281                        "ModifyColumnComment: {}.{} -> '{}'",
282                        table, column, display
283                    )
284                } else {
285                    write!(f, "ModifyColumnComment: {}.{} -> (none)", table, column)
286                }
287            }
288            MigrationAction::AddConstraint { table, constraint } => {
289                let constraint_name = match constraint {
290                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
291                    TableConstraint::Unique { name, .. } => {
292                        if let Some(n) = name {
293                            return write!(f, "AddConstraint: {}.{} (UNIQUE)", table, n);
294                        }
295                        "UNIQUE"
296                    }
297                    TableConstraint::ForeignKey { name, .. } => {
298                        if let Some(n) = name {
299                            return write!(f, "AddConstraint: {}.{} (FOREIGN KEY)", table, n);
300                        }
301                        "FOREIGN KEY"
302                    }
303                    TableConstraint::Check { name, .. } => {
304                        return write!(f, "AddConstraint: {}.{} (CHECK)", table, name);
305                    }
306                    TableConstraint::Index { name, .. } => {
307                        if let Some(n) = name {
308                            return write!(f, "AddConstraint: {}.{} (INDEX)", table, n);
309                        }
310                        "INDEX"
311                    }
312                };
313                write!(f, "AddConstraint: {}.{}", table, constraint_name)
314            }
315            MigrationAction::RemoveConstraint { table, constraint } => {
316                let constraint_name = match constraint {
317                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
318                    TableConstraint::Unique { name, .. } => {
319                        if let Some(n) = name {
320                            return write!(f, "RemoveConstraint: {}.{} (UNIQUE)", table, n);
321                        }
322                        "UNIQUE"
323                    }
324                    TableConstraint::ForeignKey { name, .. } => {
325                        if let Some(n) = name {
326                            return write!(f, "RemoveConstraint: {}.{} (FOREIGN KEY)", table, n);
327                        }
328                        "FOREIGN KEY"
329                    }
330                    TableConstraint::Check { name, .. } => {
331                        return write!(f, "RemoveConstraint: {}.{} (CHECK)", table, name);
332                    }
333                    TableConstraint::Index { name, .. } => {
334                        if let Some(n) = name {
335                            return write!(f, "RemoveConstraint: {}.{} (INDEX)", table, n);
336                        }
337                        "INDEX"
338                    }
339                };
340                write!(f, "RemoveConstraint: {}.{}", table, constraint_name)
341            }
342            MigrationAction::RenameTable { from, to } => {
343                write!(f, "RenameTable: {} -> {}", from, to)
344            }
345            MigrationAction::RawSql { sql } => {
346                // Truncate SQL if too long for display
347                let display_sql = if sql.len() > 50 {
348                    format!("{}...", &sql[..47])
349                } else {
350                    sql.clone()
351                };
352                write!(f, "RawSql: {}", display_sql)
353            }
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::schema::{ReferenceAction, SimpleColumnType};
362    use rstest::rstest;
363
364    fn default_column() -> ColumnDef {
365        ColumnDef {
366            name: "email".into(),
367            r#type: ColumnType::Simple(SimpleColumnType::Text),
368            nullable: true,
369            default: None,
370            comment: None,
371            primary_key: None,
372            unique: None,
373            index: None,
374            foreign_key: None,
375        }
376    }
377
378    #[rstest]
379    #[case::create_table(
380        MigrationAction::CreateTable {
381            table: "users".into(),
382            columns: vec![],
383            constraints: vec![],
384        },
385        "CreateTable: users"
386    )]
387    #[case::delete_table(
388        MigrationAction::DeleteTable {
389            table: "users".into(),
390        },
391        "DeleteTable: users"
392    )]
393    #[case::add_column(
394        MigrationAction::AddColumn {
395            table: "users".into(),
396            column: Box::new(default_column()),
397            fill_with: None,
398        },
399        "AddColumn: users.email"
400    )]
401    #[case::rename_column(
402        MigrationAction::RenameColumn {
403            table: "users".into(),
404            from: "old_name".into(),
405            to: "new_name".into(),
406        },
407        "RenameColumn: users.old_name -> new_name"
408    )]
409    #[case::delete_column(
410        MigrationAction::DeleteColumn {
411            table: "users".into(),
412            column: "email".into(),
413        },
414        "DeleteColumn: users.email"
415    )]
416    #[case::modify_column_type(
417        MigrationAction::ModifyColumnType {
418            table: "users".into(),
419            column: "age".into(),
420            new_type: ColumnType::Simple(SimpleColumnType::Integer),
421            fill_with: None,
422        },
423        "ModifyColumnType: users.age"
424    )]
425    #[case::add_constraint_index_with_name(
426        MigrationAction::AddConstraint {
427            table: "users".into(),
428            constraint: TableConstraint::Index {
429                name: Some("ix_users__email".into()),
430                columns: vec!["email".into()],
431            },
432        },
433        "AddConstraint: users.ix_users__email (INDEX)"
434    )]
435    #[case::add_constraint_index_without_name(
436        MigrationAction::AddConstraint {
437            table: "users".into(),
438            constraint: TableConstraint::Index {
439                name: None,
440                columns: vec!["email".into()],
441            },
442        },
443        "AddConstraint: users.INDEX"
444    )]
445    #[case::remove_constraint_index_with_name(
446        MigrationAction::RemoveConstraint {
447            table: "users".into(),
448            constraint: TableConstraint::Index {
449                name: Some("ix_users__email".into()),
450                columns: vec!["email".into()],
451            },
452        },
453        "RemoveConstraint: users.ix_users__email (INDEX)"
454    )]
455    #[case::remove_constraint_index_without_name(
456        MigrationAction::RemoveConstraint {
457            table: "users".into(),
458            constraint: TableConstraint::Index {
459                name: None,
460                columns: vec!["email".into()],
461            },
462        },
463        "RemoveConstraint: users.INDEX"
464    )]
465    #[case::rename_table(
466        MigrationAction::RenameTable {
467            from: "old_table".into(),
468            to: "new_table".into(),
469        },
470        "RenameTable: old_table -> new_table"
471    )]
472    fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) {
473        assert_eq!(action.to_string(), expected);
474    }
475
476    #[rstest]
477    #[case::add_constraint_primary_key(
478        MigrationAction::AddConstraint {
479            table: "users".into(),
480            constraint: TableConstraint::PrimaryKey {
481                auto_increment: false,
482                columns: vec!["id".into()],
483            },
484        },
485        "AddConstraint: users.PRIMARY KEY"
486    )]
487    #[case::add_constraint_unique_with_name(
488        MigrationAction::AddConstraint {
489            table: "users".into(),
490            constraint: TableConstraint::Unique {
491                name: Some("uq_email".into()),
492                columns: vec!["email".into()],
493            },
494        },
495        "AddConstraint: users.uq_email (UNIQUE)"
496    )]
497    #[case::add_constraint_unique_without_name(
498        MigrationAction::AddConstraint {
499            table: "users".into(),
500            constraint: TableConstraint::Unique {
501                name: None,
502                columns: vec!["email".into()],
503            },
504        },
505        "AddConstraint: users.UNIQUE"
506    )]
507    #[case::add_constraint_foreign_key_with_name(
508        MigrationAction::AddConstraint {
509            table: "posts".into(),
510            constraint: TableConstraint::ForeignKey {
511                name: Some("fk_user".into()),
512                columns: vec!["user_id".into()],
513                ref_table: "users".into(),
514                ref_columns: vec!["id".into()],
515                on_delete: Some(ReferenceAction::Cascade),
516                on_update: None,
517            },
518        },
519        "AddConstraint: posts.fk_user (FOREIGN KEY)"
520    )]
521    #[case::add_constraint_foreign_key_without_name(
522        MigrationAction::AddConstraint {
523            table: "posts".into(),
524            constraint: TableConstraint::ForeignKey {
525                name: None,
526                columns: vec!["user_id".into()],
527                ref_table: "users".into(),
528                ref_columns: vec!["id".into()],
529                on_delete: None,
530                on_update: None,
531            },
532        },
533        "AddConstraint: posts.FOREIGN KEY"
534    )]
535    #[case::add_constraint_check(
536        MigrationAction::AddConstraint {
537            table: "users".into(),
538            constraint: TableConstraint::Check {
539                name: "chk_age".into(),
540                expr: "age > 0".into(),
541            },
542        },
543        "AddConstraint: users.chk_age (CHECK)"
544    )]
545    fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
546        assert_eq!(action.to_string(), expected);
547    }
548
549    #[rstest]
550    #[case::remove_constraint_primary_key(
551        MigrationAction::RemoveConstraint {
552            table: "users".into(),
553            constraint: TableConstraint::PrimaryKey {
554                auto_increment: false,
555                columns: vec!["id".into()],
556            },
557        },
558        "RemoveConstraint: users.PRIMARY KEY"
559    )]
560    #[case::remove_constraint_unique_with_name(
561        MigrationAction::RemoveConstraint {
562            table: "users".into(),
563            constraint: TableConstraint::Unique {
564                name: Some("uq_email".into()),
565                columns: vec!["email".into()],
566            },
567        },
568        "RemoveConstraint: users.uq_email (UNIQUE)"
569    )]
570    #[case::remove_constraint_unique_without_name(
571        MigrationAction::RemoveConstraint {
572            table: "users".into(),
573            constraint: TableConstraint::Unique {
574                name: None,
575                columns: vec!["email".into()],
576            },
577        },
578        "RemoveConstraint: users.UNIQUE"
579    )]
580    #[case::remove_constraint_foreign_key_with_name(
581        MigrationAction::RemoveConstraint {
582            table: "posts".into(),
583            constraint: TableConstraint::ForeignKey {
584                name: Some("fk_user".into()),
585                columns: vec!["user_id".into()],
586                ref_table: "users".into(),
587                ref_columns: vec!["id".into()],
588                on_delete: None,
589                on_update: None,
590            },
591        },
592        "RemoveConstraint: posts.fk_user (FOREIGN KEY)"
593    )]
594    #[case::remove_constraint_foreign_key_without_name(
595        MigrationAction::RemoveConstraint {
596            table: "posts".into(),
597            constraint: TableConstraint::ForeignKey {
598                name: None,
599                columns: vec!["user_id".into()],
600                ref_table: "users".into(),
601                ref_columns: vec!["id".into()],
602                on_delete: None,
603                on_update: None,
604            },
605        },
606        "RemoveConstraint: posts.FOREIGN KEY"
607    )]
608    #[case::remove_constraint_check(
609        MigrationAction::RemoveConstraint {
610            table: "users".into(),
611            constraint: TableConstraint::Check {
612                name: "chk_age".into(),
613                expr: "age > 0".into(),
614            },
615        },
616        "RemoveConstraint: users.chk_age (CHECK)"
617    )]
618    fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
619        assert_eq!(action.to_string(), expected);
620    }
621
622    #[rstest]
623    #[case::raw_sql_short(
624        MigrationAction::RawSql {
625            sql: "SELECT 1".into(),
626        },
627        "RawSql: SELECT 1"
628    )]
629    fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) {
630        assert_eq!(action.to_string(), expected);
631    }
632
633    #[test]
634    fn test_display_raw_sql_long() {
635        let action = MigrationAction::RawSql {
636            sql:
637                "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'"
638                    .into(),
639        };
640        let result = action.to_string();
641        assert!(result.starts_with("RawSql: "));
642        assert!(result.ends_with("..."));
643        assert!(result.len() > 10);
644    }
645
646    #[rstest]
647    #[case::modify_column_nullable_to_not_null(
648        MigrationAction::ModifyColumnNullable {
649            table: "users".into(),
650            column: "email".into(),
651            nullable: false,
652            fill_with: None,
653            delete_null_rows: None,
654        },
655        "ModifyColumnNullable: users.email -> NOT NULL"
656    )]
657    #[case::modify_column_nullable_to_null(
658        MigrationAction::ModifyColumnNullable {
659            table: "users".into(),
660            column: "email".into(),
661            nullable: true,
662            fill_with: None,
663            delete_null_rows: None,
664        },
665        "ModifyColumnNullable: users.email -> NULL"
666    )]
667    fn test_display_modify_column_nullable(
668        #[case] action: MigrationAction,
669        #[case] expected: &str,
670    ) {
671        assert_eq!(action.to_string(), expected);
672    }
673
674    #[rstest]
675    #[case::modify_column_default_set(
676        MigrationAction::ModifyColumnDefault {
677            table: "users".into(),
678            column: "status".into(),
679            new_default: Some("'active'".into()),
680        },
681        "ModifyColumnDefault: users.status -> 'active'"
682    )]
683    #[case::modify_column_default_drop(
684        MigrationAction::ModifyColumnDefault {
685            table: "users".into(),
686            column: "status".into(),
687            new_default: None,
688        },
689        "ModifyColumnDefault: users.status -> (none)"
690    )]
691    fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) {
692        assert_eq!(action.to_string(), expected);
693    }
694
695    #[rstest]
696    #[case::modify_column_comment_set(
697        MigrationAction::ModifyColumnComment {
698            table: "users".into(),
699            column: "email".into(),
700            new_comment: Some("User email address".into()),
701        },
702        "ModifyColumnComment: users.email -> 'User email address'"
703    )]
704    #[case::modify_column_comment_drop(
705        MigrationAction::ModifyColumnComment {
706            table: "users".into(),
707            column: "email".into(),
708            new_comment: None,
709        },
710        "ModifyColumnComment: users.email -> (none)"
711    )]
712    fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) {
713        assert_eq!(action.to_string(), expected);
714    }
715
716    #[test]
717    fn test_display_modify_column_comment_long() {
718        // Test truncation for long comments (> 30 chars)
719        let action = MigrationAction::ModifyColumnComment {
720            table: "users".into(),
721            column: "email".into(),
722            new_comment: Some(
723                "This is a very long comment that should be truncated in display".into(),
724            ),
725        };
726        let result = action.to_string();
727        assert!(result.contains("..."));
728        assert!(result.contains("This is a very long comment"));
729        // Should be truncated at 27 chars + "..."
730        assert!(!result.contains("truncated in display"));
731    }
732
733    // Tests for with_prefix
734    #[test]
735    fn test_action_with_prefix_create_table() {
736        let action = MigrationAction::CreateTable {
737            table: "users".into(),
738            columns: vec![default_column()],
739            constraints: vec![TableConstraint::ForeignKey {
740                name: Some("fk_org".into()),
741                columns: vec!["org_id".into()],
742                ref_table: "organizations".into(),
743                ref_columns: vec!["id".into()],
744                on_delete: None,
745                on_update: None,
746            }],
747        };
748        let prefixed = action.with_prefix("myapp_");
749        if let MigrationAction::CreateTable {
750            table, constraints, ..
751        } = prefixed
752        {
753            assert_eq!(table.as_str(), "myapp_users");
754            if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
755                assert_eq!(ref_table.as_str(), "myapp_organizations");
756            }
757        } else {
758            panic!("Expected CreateTable");
759        }
760    }
761
762    #[test]
763    fn test_action_with_prefix_delete_table() {
764        let action = MigrationAction::DeleteTable {
765            table: "users".into(),
766        };
767        let prefixed = action.with_prefix("myapp_");
768        if let MigrationAction::DeleteTable { table } = prefixed {
769            assert_eq!(table.as_str(), "myapp_users");
770        } else {
771            panic!("Expected DeleteTable");
772        }
773    }
774
775    #[test]
776    fn test_action_with_prefix_add_column() {
777        let action = MigrationAction::AddColumn {
778            table: "users".into(),
779            column: Box::new(default_column()),
780            fill_with: None,
781        };
782        let prefixed = action.with_prefix("myapp_");
783        if let MigrationAction::AddColumn { table, .. } = prefixed {
784            assert_eq!(table.as_str(), "myapp_users");
785        } else {
786            panic!("Expected AddColumn");
787        }
788    }
789
790    #[test]
791    fn test_action_with_prefix_rename_table() {
792        let action = MigrationAction::RenameTable {
793            from: "old_table".into(),
794            to: "new_table".into(),
795        };
796        let prefixed = action.with_prefix("myapp_");
797        if let MigrationAction::RenameTable { from, to } = prefixed {
798            assert_eq!(from.as_str(), "myapp_old_table");
799            assert_eq!(to.as_str(), "myapp_new_table");
800        } else {
801            panic!("Expected RenameTable");
802        }
803    }
804
805    #[test]
806    fn test_action_with_prefix_raw_sql_unchanged() {
807        let action = MigrationAction::RawSql {
808            sql: "SELECT * FROM users".into(),
809        };
810        let prefixed = action.with_prefix("myapp_");
811        if let MigrationAction::RawSql { sql } = prefixed {
812            // RawSql is not modified - user is responsible for table names
813            assert_eq!(sql, "SELECT * FROM users");
814        } else {
815            panic!("Expected RawSql");
816        }
817    }
818
819    #[test]
820    fn test_action_with_prefix_empty_prefix() {
821        let action = MigrationAction::CreateTable {
822            table: "users".into(),
823            columns: vec![],
824            constraints: vec![],
825        };
826        let prefixed = action.clone().with_prefix("");
827        if let MigrationAction::CreateTable { table, .. } = prefixed {
828            assert_eq!(table.as_str(), "users");
829        }
830    }
831
832    #[test]
833    fn test_migration_plan_with_prefix() {
834        let plan = MigrationPlan {
835            id: String::new(),
836            comment: Some("test".into()),
837            created_at: None,
838            version: 1,
839            actions: vec![
840                MigrationAction::CreateTable {
841                    table: "users".into(),
842                    columns: vec![],
843                    constraints: vec![],
844                },
845                MigrationAction::CreateTable {
846                    table: "posts".into(),
847                    columns: vec![],
848                    constraints: vec![TableConstraint::ForeignKey {
849                        name: Some("fk_user".into()),
850                        columns: vec!["user_id".into()],
851                        ref_table: "users".into(),
852                        ref_columns: vec!["id".into()],
853                        on_delete: None,
854                        on_update: None,
855                    }],
856                },
857            ],
858        };
859        let prefixed = plan.with_prefix("myapp_");
860        assert_eq!(prefixed.actions.len(), 2);
861
862        if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] {
863            assert_eq!(table.as_str(), "myapp_users");
864        }
865        if let MigrationAction::CreateTable {
866            table, constraints, ..
867        } = &prefixed.actions[1]
868        {
869            assert_eq!(table.as_str(), "myapp_posts");
870            if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
871                assert_eq!(ref_table.as_str(), "myapp_users");
872            }
873        }
874    }
875
876    #[test]
877    fn test_action_with_prefix_rename_column() {
878        let action = MigrationAction::RenameColumn {
879            table: "users".into(),
880            from: "name".into(),
881            to: "full_name".into(),
882        };
883        let prefixed = action.with_prefix("myapp_");
884        if let MigrationAction::RenameColumn { table, from, to } = prefixed {
885            assert_eq!(table.as_str(), "myapp_users");
886            assert_eq!(from.as_str(), "name");
887            assert_eq!(to.as_str(), "full_name");
888        } else {
889            panic!("Expected RenameColumn");
890        }
891    }
892
893    #[test]
894    fn test_action_with_prefix_delete_column() {
895        let action = MigrationAction::DeleteColumn {
896            table: "users".into(),
897            column: "old_field".into(),
898        };
899        let prefixed = action.with_prefix("myapp_");
900        if let MigrationAction::DeleteColumn { table, column } = prefixed {
901            assert_eq!(table.as_str(), "myapp_users");
902            assert_eq!(column.as_str(), "old_field");
903        } else {
904            panic!("Expected DeleteColumn");
905        }
906    }
907
908    #[test]
909    fn test_action_with_prefix_modify_column_type() {
910        let action = MigrationAction::ModifyColumnType {
911            table: "users".into(),
912            column: "age".into(),
913            new_type: ColumnType::Simple(SimpleColumnType::BigInt),
914            fill_with: None,
915        };
916        let prefixed = action.with_prefix("myapp_");
917        if let MigrationAction::ModifyColumnType {
918            table,
919            column,
920            new_type,
921            fill_with,
922        } = prefixed
923        {
924            assert_eq!(table.as_str(), "myapp_users");
925            assert_eq!(column.as_str(), "age");
926            assert!(matches!(
927                new_type,
928                ColumnType::Simple(SimpleColumnType::BigInt)
929            ));
930            assert_eq!(fill_with, None);
931        } else {
932            panic!("Expected ModifyColumnType");
933        }
934    }
935
936    #[test]
937    fn test_action_with_prefix_modify_column_nullable() {
938        let action = MigrationAction::ModifyColumnNullable {
939            table: "users".into(),
940            column: "email".into(),
941            nullable: false,
942            fill_with: Some("default@example.com".into()),
943            delete_null_rows: None,
944        };
945        let prefixed = action.with_prefix("myapp_");
946        if let MigrationAction::ModifyColumnNullable {
947            table,
948            column,
949            nullable,
950            fill_with,
951            delete_null_rows,
952        } = prefixed
953        {
954            assert_eq!(table.as_str(), "myapp_users");
955            assert_eq!(column.as_str(), "email");
956            assert!(!nullable);
957            assert_eq!(fill_with, Some("default@example.com".into()));
958            assert_eq!(delete_null_rows, None);
959        } else {
960            panic!("Expected ModifyColumnNullable");
961        }
962    }
963
964    #[test]
965    fn test_action_with_prefix_modify_column_default() {
966        let action = MigrationAction::ModifyColumnDefault {
967            table: "users".into(),
968            column: "status".into(),
969            new_default: Some("active".into()),
970        };
971        let prefixed = action.with_prefix("myapp_");
972        if let MigrationAction::ModifyColumnDefault {
973            table,
974            column,
975            new_default,
976        } = prefixed
977        {
978            assert_eq!(table.as_str(), "myapp_users");
979            assert_eq!(column.as_str(), "status");
980            assert_eq!(new_default, Some("active".into()));
981        } else {
982            panic!("Expected ModifyColumnDefault");
983        }
984    }
985
986    #[test]
987    fn test_action_with_prefix_modify_column_comment() {
988        let action = MigrationAction::ModifyColumnComment {
989            table: "users".into(),
990            column: "bio".into(),
991            new_comment: Some("User biography".into()),
992        };
993        let prefixed = action.with_prefix("myapp_");
994        if let MigrationAction::ModifyColumnComment {
995            table,
996            column,
997            new_comment,
998        } = prefixed
999        {
1000            assert_eq!(table.as_str(), "myapp_users");
1001            assert_eq!(column.as_str(), "bio");
1002            assert_eq!(new_comment, Some("User biography".into()));
1003        } else {
1004            panic!("Expected ModifyColumnComment");
1005        }
1006    }
1007
1008    #[test]
1009    fn test_action_with_prefix_add_constraint() {
1010        let action = MigrationAction::AddConstraint {
1011            table: "posts".into(),
1012            constraint: TableConstraint::ForeignKey {
1013                name: Some("fk_user".into()),
1014                columns: vec!["user_id".into()],
1015                ref_table: "users".into(),
1016                ref_columns: vec!["id".into()],
1017                on_delete: None,
1018                on_update: None,
1019            },
1020        };
1021        let prefixed = action.with_prefix("myapp_");
1022        if let MigrationAction::AddConstraint { table, constraint } = prefixed {
1023            assert_eq!(table.as_str(), "myapp_posts");
1024            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
1025                assert_eq!(ref_table.as_str(), "myapp_users");
1026            } else {
1027                panic!("Expected ForeignKey constraint");
1028            }
1029        } else {
1030            panic!("Expected AddConstraint");
1031        }
1032    }
1033
1034    #[test]
1035    fn test_action_with_prefix_remove_constraint() {
1036        let action = MigrationAction::RemoveConstraint {
1037            table: "posts".into(),
1038            constraint: TableConstraint::ForeignKey {
1039                name: Some("fk_user".into()),
1040                columns: vec!["user_id".into()],
1041                ref_table: "users".into(),
1042                ref_columns: vec!["id".into()],
1043                on_delete: None,
1044                on_update: None,
1045            },
1046        };
1047        let prefixed = action.with_prefix("myapp_");
1048        if let MigrationAction::RemoveConstraint { table, constraint } = prefixed {
1049            assert_eq!(table.as_str(), "myapp_posts");
1050            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
1051                assert_eq!(ref_table.as_str(), "myapp_users");
1052            } else {
1053                panic!("Expected ForeignKey constraint");
1054            }
1055        } else {
1056            panic!("Expected RemoveConstraint");
1057        }
1058    }
1059}