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