Skip to main content

vespertide_core/
action.rs

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