Skip to main content

vespertide_core/
action.rs

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