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