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