Skip to main content

vespertide_core/action/
mod.rs

1mod display;
2mod narrowing_strategy;
3mod prefix;
4mod remap_mapping_serde;
5
6use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName};
7pub use narrowing_strategy::NarrowingStrategy;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// A single versioned migration, grouping a set of [`MigrationAction`]s under a version number.
12///
13/// Migration plans are auto-generated by `vespertide revision` and stored as JSON or YAML files
14/// in the `migrations/` directory. **Never create or edit these files manually.**
15///
16/// The `version` field is a monotonically increasing integer. The `id` is a UUID that guards
17/// against accidental plan substitution when the same version number appears in two different
18/// migration histories.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub struct MigrationPlan {
23    /// Unique identifier for this migration (UUID format).
24    /// Defaults to empty string for backward compatibility with old migration files.
25    #[serde(default)]
26    pub id: String,
27    /// Human-readable description of what this migration does (from `-m "message"`).
28    pub comment: Option<String>,
29    /// ISO 8601 timestamp of when the migration file was generated.
30    #[serde(default)]
31    pub created_at: Option<String>,
32    /// Monotonically increasing version number, starting at 1.
33    pub version: u32,
34    /// Ordered list of schema changes to apply in this migration.
35    pub actions: Vec<MigrationAction>,
36}
37
38/// A single schema change produced by the planner and consumed by the SQL generator.
39///
40/// The planner emits a `Vec<MigrationAction>` when diffing two schemas. The SQL generator
41/// (`vespertide-query`) translates each action into backend-specific DDL statements.
42///
43/// Prefer typed actions over [`MigrationAction::RawSql`]. Raw SQL is an emergency escape hatch:
44/// it is not portable across backends and is skipped during baseline replay, which means the
45/// planner cannot reason about it.
46///
47/// This enum is `#[non_exhaustive]`: new variants may be added in future releases.
48/// Downstream `match` expressions should include a wildcard arm.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(tag = "type", rename_all = "snake_case")]
52#[non_exhaustive]
53pub enum MigrationAction {
54    /// Create a new table with the given columns and constraints (`CREATE TABLE`).
55    CreateTable {
56        table: TableName,
57        columns: Vec<ColumnDef>,
58        constraints: Vec<TableConstraint>,
59    },
60    /// Drop an existing table and all its data (`DROP TABLE`).
61    DeleteTable { table: TableName },
62    /// Add a new column to an existing table (`ALTER TABLE ... ADD COLUMN`).
63    AddColumn {
64        table: TableName,
65        column: Box<ColumnDef>,
66        /// Optional fill value to backfill existing rows when adding NOT NULL without default.
67        fill_with: Option<String>,
68    },
69    /// Rename a column in an existing table (`ALTER TABLE ... RENAME COLUMN`).
70    RenameColumn {
71        table: TableName,
72        from: ColumnName,
73        to: ColumnName,
74    },
75    /// Remove a column from an existing table (`ALTER TABLE ... DROP COLUMN`).
76    DeleteColumn {
77        table: TableName,
78        column: ColumnName,
79    },
80    /// Change the SQL type of an existing column (`ALTER TABLE ... ALTER COLUMN ... TYPE`).
81    ModifyColumnType {
82        table: TableName,
83        column: ColumnName,
84        new_type: ColumnType,
85        /// Mapping of removed enum values to replacement values for safe enum value removal.
86        /// e.g., `{"cancelled": "'pending'"}` generates an `UPDATE` before the type change.
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        fill_with: Option<BTreeMap<String, String>>,
89        /// Strategy for transforming existing rows that would violate a *narrowed* new type
90        /// (smaller VARCHAR length, lower NUMERIC scale, smaller integer size, etc.) so the
91        /// `ALTER COLUMN TYPE` cannot fail. When `None`, the SQL generator emits a plain ALTER —
92        /// safe only when the user has independently verified no row violates the new type
93        /// (typically prompted by the `vespertide revision` type-narrowing select UI).
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        narrowing_strategy: Option<NarrowingStrategy>,
96        /// `IANA` timezone name (e.g. `"Asia/Seoul"`) or numeric UTC offset (e.g. `"+09:00"`)
97        /// used when converting between `timestamp` and `timestamptz`. Required for safe
98        /// migration on `PostgreSQL` where the conversion is non-trivial; ignored on `MySQL`
99        /// and `SQLite` where vespertide maps both `timestamp` and `timestamptz` to the same
100        /// underlying type. Validated by the CLI `revision` prompt against a 30-name
101        /// whitelist plus numeric offset format `±HH:MM`.
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        timezone: Option<String>,
104    },
105    /// Change whether a column accepts `NULL` values.
106    ModifyColumnNullable {
107        table: TableName,
108        column: ColumnName,
109        nullable: bool,
110        /// Required when changing from nullable to non-nullable to backfill existing NULL values.
111        fill_with: Option<String>,
112        /// When true, rows with NULL values in the column are deleted instead of backfilled.
113        /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value
114        /// may not exist.
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        delete_null_rows: Option<bool>,
117    },
118    /// Change or remove the default value of a column.
119    ModifyColumnDefault {
120        table: TableName,
121        column: ColumnName,
122        /// The new default value, or `None` to remove the default.
123        new_default: Option<String>,
124        /// **F15 fault gate — backfill existing rows.**
125        ///
126        /// `ALTER TABLE ... SET DEFAULT ...` only affects *new* rows. When
127        /// the user wants every existing row updated to the new default in
128        /// the same migration (the common "I want consistency right now"
129        /// intent), the CLI's `revision` prompt captures that choice and
130        /// stores the desired value here. The SQL generator then emits an
131        /// `UPDATE table SET col = <value>` immediately after the ALTER.
132        ///
133        /// `None` (default, wire-format identical to v0.2.0) → skip the
134        /// backfill: existing rows keep their current values. The action
135        /// behaves exactly as before this field existed.
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        backfill: Option<String>,
138    },
139    /// Change or remove the comment on a column.
140    ModifyColumnComment {
141        table: TableName,
142        column: ColumnName,
143        /// The new comment, or `None` to remove the comment.
144        new_comment: Option<String>,
145    },
146    /// Add a constraint (primary key, unique, foreign key, check, or index) to a table.
147    ///
148    /// F2 duplicate-handling strategy lives on the `Unique` variant of
149    /// [`TableConstraint`] itself (see `TableConstraint::Unique.strategy`),
150    /// not here — the strategy is intrinsic to a particular unique
151    /// constraint, regardless of whether it is added, replaced, or shipped
152    /// inside a `CreateTable`.
153    AddConstraint {
154        table: TableName,
155        constraint: TableConstraint,
156    },
157    /// Remove a constraint from a table.
158    RemoveConstraint {
159        table: TableName,
160        constraint: TableConstraint,
161    },
162    /// Atomically replace one constraint with another (e.g. when columns in a composite key change).
163    ReplaceConstraint {
164        table: TableName,
165        from: TableConstraint,
166        to: TableConstraint,
167    },
168    /// Remap stored integer values of an integer-backed enum column.
169    ///
170    /// Emitted when the user changes the `value` of an existing integer
171    /// enum variant in the model (e.g. `medium: 5 → 10`). Because integer
172    /// enums are stored as plain `INTEGER` in the database, the DB itself
173    /// cannot detect the drift; if Vespertide stayed silent the ORM mapping
174    /// would silently re-interpret existing rows. The SQL generator turns
175    /// this action into a single atomic `UPDATE table SET col = CASE WHEN
176    /// col = old THEN new ... END WHERE col IN (...)` that re-stamps every
177    /// affected row before the new ORM mapping takes effect.
178    ///
179    /// `mapping` is a `BTreeMap<i64, i64>` keyed on the *old* value, so the
180    /// type system guarantees a single replacement per source value.
181    /// Canonical JSON wire format is `{"5": 10, "100": 20}` (string keys
182    /// are how JSON represents map keys; serde transparently parses them
183    /// back to `i64`). For backward compatibility the legacy array form
184    /// `[[5, 10], [100, 20]]` is still accepted on read — see
185    /// `remap_mapping_serde` for the details. The map iterates in
186    /// `old_value` order at emit time so snapshots stay deterministic.
187    /// Variants whose name AND value are unchanged are absent; variants
188    /// added or removed (no name overlap on either side) are also absent
189    /// — those need a separate migration action.
190    RemapEnumValues {
191        table: TableName,
192        column: ColumnName,
193        #[serde(with = "remap_mapping_serde")]
194        #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, i64>"))]
195        mapping: BTreeMap<i64, i64>,
196    },
197    /// Rename a table (`ALTER TABLE ... RENAME TO`).
198    RenameTable { from: TableName, to: TableName },
199    /// Execute a raw SQL statement verbatim.
200    ///
201    /// **Emergency escape hatch only.** Raw SQL is not portable across backends and is invisible
202    /// to baseline replay, so the planner cannot reason about schema state after this action.
203    /// Use typed actions whenever possible.
204    RawSql { sql: String },
205}
206
207impl MigrationAction {
208    /// Returns the primary table this action affects, if any.
209    /// Returns None for actions that don't bind to a single table (e.g. `RawSql`).
210    #[must_use]
211    pub fn table_name(&self) -> Option<&str> {
212        match self {
213            Self::CreateTable { table, .. }
214            | Self::DeleteTable { table }
215            | Self::AddColumn { table, .. }
216            | Self::DeleteColumn { table, .. }
217            | Self::RenameColumn { table, .. }
218            | Self::ModifyColumnType { table, .. }
219            | Self::ModifyColumnNullable { table, .. }
220            | Self::ModifyColumnDefault { table, .. }
221            | Self::ModifyColumnComment { table, .. }
222            | Self::AddConstraint { table, .. }
223            | Self::RemoveConstraint { table, .. }
224            | Self::ReplaceConstraint { table, .. }
225            | Self::RemapEnumValues { table, .. } => Some(table.as_str()),
226            Self::RenameTable { from, .. } => Some(from.as_str()),
227            Self::RawSql { .. } => None,
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::schema::{ReferenceAction, SimpleColumnType};
236    use rstest::rstest;
237
238    fn default_column() -> ColumnDef {
239        ColumnDef::new("email", ColumnType::Simple(SimpleColumnType::Text), true)
240    }
241
242    fn idx(name: Option<&str>, cols: &[&str]) -> TableConstraint {
243        TableConstraint::Index {
244            name: name.map(Into::into),
245            columns: cols.iter().map(|c| (*c).into()).collect(),
246        }
247    }
248    fn pk_id() -> TableConstraint {
249        TableConstraint::PrimaryKey {
250            auto_increment: false,
251            columns: vec!["id".into()],
252            strategy: crate::PrimaryKeyAdditionStrategy::default(),
253        }
254    }
255    fn pk_id_auto() -> TableConstraint {
256        TableConstraint::PrimaryKey {
257            auto_increment: true,
258            columns: vec!["id".into()],
259            strategy: crate::PrimaryKeyAdditionStrategy::default(),
260        }
261    }
262    fn uq_email(name: Option<&str>) -> TableConstraint {
263        TableConstraint::Unique {
264            name: name.map(Into::into),
265            columns: vec!["email".into()],
266            strategy: crate::schema::UniqueConstraintStrategy::DeleteDuplicates {
267                keep: crate::schema::KeepPolicy::First,
268            },
269        }
270    }
271    fn fk_user(name: Option<&str>, on_delete: Option<ReferenceAction>) -> TableConstraint {
272        TableConstraint::ForeignKey {
273            name: name.map(Into::into),
274            columns: vec!["user_id".into()],
275            ref_table: "users".into(),
276            ref_columns: vec!["id".into()],
277            on_delete,
278            on_update: None,
279            orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
280        }
281    }
282    fn chk(name: &str, expr: &str) -> TableConstraint {
283        TableConstraint::Check {
284            name: name.into(),
285            expr: expr.into(),
286            strategy: crate::CheckViolationStrategy::default(),
287        }
288    }
289    fn idx_email(name: Option<&str>) -> TableConstraint {
290        idx(name, &["email"])
291    }
292
293    #[test]
294    fn migration_action_wire_format_round_trip() {
295        let canonical = r#"{"type":"create_table","table":"user","columns":[],"constraints":[]}"#;
296        let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
297        let reserialized = serde_json::to_string(&parsed).expect("serialize");
298
299        assert_eq!(
300            reserialized, canonical,
301            "wire format MUST be byte-identical"
302        );
303    }
304
305    #[test]
306    fn migration_action_rename_column_wire_format() {
307        let canonical = r#"{"type":"rename_column","table":"orders","from":"old","to":"new"}"#;
308        let parsed: MigrationAction = serde_json::from_str(canonical).expect("parse");
309        let reserialized = serde_json::to_string(&parsed).expect("serialize");
310
311        assert_eq!(reserialized, canonical);
312    }
313
314    #[test]
315    fn migration_plan_real_example_round_trip() {
316        let plan_json =
317            include_str!("../../../../examples/app/migrations/0001_init.vespertide.json");
318        let parsed: MigrationPlan =
319            serde_json::from_str(plan_json).expect("real migration plan parses");
320        let reserialized = serde_json::to_string(&parsed).expect("serialize");
321        let reparsed: MigrationPlan = serde_json::from_str(&reserialized).expect("round-trip");
322
323        assert_eq!(
324            parsed, reparsed,
325            "semantic content preserved across round-trip"
326        );
327    }
328
329    #[rstest]
330    #[case::create_table(
331        MigrationAction::CreateTable {
332            table: "users".into(),
333            columns: vec![],
334            constraints: vec![],
335        },
336        "CreateTable: users"
337    )]
338    #[case::delete_table(
339        MigrationAction::DeleteTable {
340            table: "users".into(),
341        },
342        "DeleteTable: users"
343    )]
344    #[case::add_column(
345        MigrationAction::AddColumn {
346            table: "users".into(),
347            column: Box::new(default_column()),
348            fill_with: None,
349        },
350        "AddColumn: users.email"
351    )]
352    #[case::rename_column(
353        MigrationAction::RenameColumn {
354            table: "users".into(),
355            from: "old_name".into(),
356            to: "new_name".into(),
357        },
358        "RenameColumn: users.old_name -> new_name"
359    )]
360    #[case::delete_column(
361        MigrationAction::DeleteColumn {
362            table: "users".into(),
363            column: "email".into(),
364        },
365        "DeleteColumn: users.email"
366    )]
367    #[case::modify_column_type(
368        MigrationAction::ModifyColumnType {
369            table: "users".into(),
370            column: "age".into(),
371            new_type: ColumnType::Simple(SimpleColumnType::Integer),
372            fill_with: None,
373            narrowing_strategy: None,
374            timezone: None,
375        },
376        "ModifyColumnType: users.age"
377    )]
378    #[case::add_constraint_index_with_name(
379        MigrationAction::AddConstraint { table: "users".into(), constraint: idx_email(Some("ix_users__email")) },
380        "AddConstraint: users.ix_users__email (INDEX)"
381    )]
382    #[case::add_constraint_index_without_name(
383        MigrationAction::AddConstraint { table: "users".into(), constraint: idx_email(None) },
384        "AddConstraint: users.INDEX"
385    )]
386    #[case::remove_constraint_index_with_name(
387        MigrationAction::RemoveConstraint { table: "users".into(), constraint: idx_email(Some("ix_users__email")) },
388        "RemoveConstraint: users.ix_users__email (INDEX)"
389    )]
390    #[case::remove_constraint_index_without_name(
391        MigrationAction::RemoveConstraint { table: "users".into(), constraint: idx_email(None) },
392        "RemoveConstraint: users.INDEX"
393    )]
394    #[case::rename_table(
395        MigrationAction::RenameTable {
396            from: "old_table".into(),
397            to: "new_table".into(),
398        },
399        "RenameTable: old_table -> new_table"
400    )]
401    fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) {
402        assert_eq!(action.to_string(), expected);
403    }
404
405    #[test]
406    fn test_display_raw_sql_truncates_unicode_without_panicking() {
407        let sql = "COMMENT ON COLUMN 한국어테이블.이름 IS '日本語 café 📊';".repeat(3);
408        let action = MigrationAction::RawSql { sql };
409
410        let display = action.to_string();
411
412        assert!(display.starts_with("RawSql: COMMENT ON COLUMN 한국어테이블"));
413        assert!(display.ends_with("..."));
414    }
415
416    #[rstest]
417    #[case::create_table(
418        MigrationAction::CreateTable {
419            table: "users".into(),
420            columns: vec![],
421            constraints: vec![],
422        },
423        Some("users")
424    )]
425    #[case::rename_table(
426        MigrationAction::RenameTable {
427            from: "old_users".into(),
428            to: "users".into(),
429        },
430        Some("old_users")
431    )]
432    #[case::raw_sql(MigrationAction::RawSql { sql: "SELECT 1".into() }, None)]
433    fn test_table_name(#[case] action: MigrationAction, #[case] expected: Option<&str>) {
434        assert_eq!(action.table_name(), expected);
435    }
436
437    #[rstest]
438    #[case::add_constraint_primary_key(
439        MigrationAction::AddConstraint { table: "users".into(), constraint: pk_id() },
440        "AddConstraint: users.PRIMARY KEY"
441    )]
442    #[case::add_constraint_unique_with_name(
443        MigrationAction::AddConstraint { table: "users".into(), constraint: uq_email(Some("uq_email")) },
444        "AddConstraint: users.uq_email (UNIQUE)"
445    )]
446    #[case::add_constraint_unique_without_name(
447        MigrationAction::AddConstraint { table: "users".into(), constraint: uq_email(None) },
448        "AddConstraint: users.UNIQUE"
449    )]
450    #[case::add_constraint_foreign_key_with_name(
451        MigrationAction::AddConstraint { table: "posts".into(), constraint: fk_user(Some("fk_user"), Some(ReferenceAction::Cascade)) },
452        "AddConstraint: posts.fk_user (FOREIGN KEY)"
453    )]
454    #[case::add_constraint_foreign_key_without_name(
455        MigrationAction::AddConstraint { table: "posts".into(), constraint: fk_user(None, None) },
456        "AddConstraint: posts.FOREIGN KEY"
457    )]
458    #[case::add_constraint_check(
459        MigrationAction::AddConstraint { table: "users".into(), constraint: chk("chk_age", "age > 0") },
460        "AddConstraint: users.chk_age (CHECK)"
461    )]
462    fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
463        assert_eq!(action.to_string(), expected);
464    }
465
466    #[rstest]
467    #[case::remove_constraint_primary_key(
468        MigrationAction::RemoveConstraint { table: "users".into(), constraint: pk_id() },
469        "RemoveConstraint: users.PRIMARY KEY"
470    )]
471    #[case::remove_constraint_unique_with_name(
472        MigrationAction::RemoveConstraint { table: "users".into(), constraint: uq_email(Some("uq_email")) },
473        "RemoveConstraint: users.uq_email (UNIQUE)"
474    )]
475    #[case::remove_constraint_unique_without_name(
476        MigrationAction::RemoveConstraint { table: "users".into(), constraint: uq_email(None) },
477        "RemoveConstraint: users.UNIQUE"
478    )]
479    #[case::remove_constraint_foreign_key_with_name(
480        MigrationAction::RemoveConstraint { table: "posts".into(), constraint: fk_user(Some("fk_user"), None) },
481        "RemoveConstraint: posts.fk_user (FOREIGN KEY)"
482    )]
483    #[case::remove_constraint_foreign_key_without_name(
484        MigrationAction::RemoveConstraint { table: "posts".into(), constraint: fk_user(None, None) },
485        "RemoveConstraint: posts.FOREIGN KEY"
486    )]
487    #[case::remove_constraint_check(
488        MigrationAction::RemoveConstraint { table: "users".into(), constraint: chk("chk_age", "age > 0") },
489        "RemoveConstraint: users.chk_age (CHECK)"
490    )]
491    fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
492        assert_eq!(action.to_string(), expected);
493    }
494
495    #[rstest]
496    #[case::raw_sql_short(
497        MigrationAction::RawSql {
498            sql: "SELECT 1".into(),
499        },
500        "RawSql: SELECT 1"
501    )]
502    fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) {
503        assert_eq!(action.to_string(), expected);
504    }
505
506    #[test]
507    fn test_display_raw_sql_long() {
508        let action = MigrationAction::RawSql {
509            sql:
510                "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'"
511                    .into(),
512        };
513        let result = action.to_string();
514        assert!(result.starts_with("RawSql: "));
515        assert!(result.ends_with("..."));
516        assert!(result.len() > 10);
517    }
518
519    #[rstest]
520    #[case::modify_column_nullable_to_not_null(
521        MigrationAction::ModifyColumnNullable {
522            table: "users".into(),
523            column: "email".into(),
524            nullable: false,
525            fill_with: None,
526            delete_null_rows: None,
527        },
528        "ModifyColumnNullable: users.email -> NOT NULL"
529    )]
530    #[case::modify_column_nullable_to_null(
531        MigrationAction::ModifyColumnNullable {
532            table: "users".into(),
533            column: "email".into(),
534            nullable: true,
535            fill_with: None,
536            delete_null_rows: None,
537        },
538        "ModifyColumnNullable: users.email -> NULL"
539    )]
540    fn test_display_modify_column_nullable(
541        #[case] action: MigrationAction,
542        #[case] expected: &str,
543    ) {
544        assert_eq!(action.to_string(), expected);
545    }
546
547    #[rstest]
548    #[case::modify_column_default_set(
549        MigrationAction::ModifyColumnDefault {
550            table: "users".into(),
551            column: "status".into(),
552            new_default: Some("'active'".into()),
553            backfill: None,
554        },
555        "ModifyColumnDefault: users.status -> 'active'"
556    )]
557    #[case::modify_column_default_drop(
558        MigrationAction::ModifyColumnDefault {
559            table: "users".into(),
560            column: "status".into(),
561            new_default: None,
562            backfill: None,
563        },
564        "ModifyColumnDefault: users.status -> (none)"
565    )]
566    fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) {
567        assert_eq!(action.to_string(), expected);
568    }
569
570    #[rstest]
571    #[case::modify_column_comment_set(
572        MigrationAction::ModifyColumnComment {
573            table: "users".into(),
574            column: "email".into(),
575            new_comment: Some("User email address".into()),
576        },
577        "ModifyColumnComment: users.email -> 'User email address'"
578    )]
579    #[case::modify_column_comment_drop(
580        MigrationAction::ModifyColumnComment {
581            table: "users".into(),
582            column: "email".into(),
583            new_comment: None,
584        },
585        "ModifyColumnComment: users.email -> (none)"
586    )]
587    fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) {
588        assert_eq!(action.to_string(), expected);
589    }
590
591    #[test]
592    fn test_display_modify_column_comment_long() {
593        // Test truncation for long comments (> 30 chars)
594        let action = MigrationAction::ModifyColumnComment {
595            table: "users".into(),
596            column: "email".into(),
597            new_comment: Some(
598                "This is a very long comment that should be truncated in display".into(),
599            ),
600        };
601        let result = action.to_string();
602        assert!(result.contains("..."));
603        assert!(result.contains("This is a very long comment"));
604        // Should be truncated at 27 chars + "..."
605        assert!(!result.contains("truncated in display"));
606    }
607
608    // Tests for with_prefix
609    #[test]
610    fn test_action_with_prefix_create_table() {
611        let action = MigrationAction::CreateTable {
612            table: "users".into(),
613            columns: vec![default_column()],
614            constraints: vec![TableConstraint::ForeignKey {
615                name: Some("fk_org".into()),
616                columns: vec!["org_id".into()],
617                ref_table: "organizations".into(),
618                ref_columns: vec!["id".into()],
619                on_delete: None,
620                on_update: None,
621                orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
622            }],
623        };
624        let prefixed = action.with_prefix("myapp_");
625        if let MigrationAction::CreateTable {
626            table, constraints, ..
627        } = prefixed
628        {
629            assert_eq!(table.as_str(), "myapp_users");
630            if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
631                assert_eq!(ref_table.as_str(), "myapp_organizations");
632            }
633        } else {
634            panic!("Expected CreateTable");
635        }
636    }
637
638    #[test]
639    fn test_action_with_prefix_delete_table() {
640        let action = MigrationAction::DeleteTable {
641            table: "users".into(),
642        };
643        let prefixed = action.with_prefix("myapp_");
644        if let MigrationAction::DeleteTable { table } = prefixed {
645            assert_eq!(table.as_str(), "myapp_users");
646        } else {
647            panic!("Expected DeleteTable");
648        }
649    }
650
651    #[test]
652    fn test_action_with_prefix_add_column() {
653        let action = MigrationAction::AddColumn {
654            table: "users".into(),
655            column: Box::new(default_column()),
656            fill_with: None,
657        };
658        let prefixed = action.with_prefix("myapp_");
659        if let MigrationAction::AddColumn { table, .. } = prefixed {
660            assert_eq!(table.as_str(), "myapp_users");
661        } else {
662            panic!("Expected AddColumn");
663        }
664    }
665
666    #[test]
667    fn test_action_with_prefix_rename_table() {
668        let action = MigrationAction::RenameTable {
669            from: "old_table".into(),
670            to: "new_table".into(),
671        };
672        let prefixed = action.with_prefix("myapp_");
673        if let MigrationAction::RenameTable { from, to } = prefixed {
674            assert_eq!(from.as_str(), "myapp_old_table");
675            assert_eq!(to.as_str(), "myapp_new_table");
676        } else {
677            panic!("Expected RenameTable");
678        }
679    }
680
681    #[test]
682    fn test_action_with_prefix_raw_sql_unchanged() {
683        let action = MigrationAction::RawSql {
684            sql: "SELECT * FROM users".into(),
685        };
686        let prefixed = action.with_prefix("myapp_");
687        if let MigrationAction::RawSql { sql } = prefixed {
688            // RawSql is not modified - user is responsible for table names
689            assert_eq!(sql, "SELECT * FROM users");
690        } else {
691            panic!("Expected RawSql");
692        }
693    }
694
695    #[test]
696    fn test_action_with_prefix_empty_prefix() {
697        let action = MigrationAction::CreateTable {
698            table: "users".into(),
699            columns: vec![],
700            constraints: vec![],
701        };
702        let prefixed = action.clone().with_prefix("");
703        if let MigrationAction::CreateTable { table, .. } = prefixed {
704            assert_eq!(table.as_str(), "users");
705        }
706    }
707
708    #[test]
709    fn test_migration_plan_with_prefix() {
710        let plan = MigrationPlan {
711            id: String::new(),
712            comment: Some("test".into()),
713            created_at: None,
714            version: 1,
715            actions: vec![
716                MigrationAction::CreateTable {
717                    table: "users".into(),
718                    columns: vec![],
719                    constraints: vec![],
720                },
721                MigrationAction::CreateTable {
722                    table: "posts".into(),
723                    columns: vec![],
724                    constraints: vec![fk_user(Some("fk_user"), None)],
725                },
726            ],
727        };
728        let prefixed = plan.with_prefix("myapp_");
729        assert_eq!(prefixed.actions.len(), 2);
730
731        if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] {
732            assert_eq!(table.as_str(), "myapp_users");
733        }
734        if let MigrationAction::CreateTable {
735            table, constraints, ..
736        } = &prefixed.actions[1]
737        {
738            assert_eq!(table.as_str(), "myapp_posts");
739            if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] {
740                assert_eq!(ref_table.as_str(), "myapp_users");
741            }
742        }
743    }
744
745    #[test]
746    fn test_action_with_prefix_rename_column() {
747        let action = MigrationAction::RenameColumn {
748            table: "users".into(),
749            from: "name".into(),
750            to: "full_name".into(),
751        };
752        let prefixed = action.with_prefix("myapp_");
753        if let MigrationAction::RenameColumn { table, from, to } = prefixed {
754            assert_eq!(table.as_str(), "myapp_users");
755            assert_eq!(from.as_str(), "name");
756            assert_eq!(to.as_str(), "full_name");
757        } else {
758            panic!("Expected RenameColumn");
759        }
760    }
761
762    #[test]
763    fn test_action_with_prefix_delete_column() {
764        let action = MigrationAction::DeleteColumn {
765            table: "users".into(),
766            column: "old_field".into(),
767        };
768        let prefixed = action.with_prefix("myapp_");
769        if let MigrationAction::DeleteColumn { table, column } = prefixed {
770            assert_eq!(table.as_str(), "myapp_users");
771            assert_eq!(column.as_str(), "old_field");
772        } else {
773            panic!("Expected DeleteColumn");
774        }
775    }
776
777    #[test]
778    fn test_action_with_prefix_modify_column_type() {
779        let action = MigrationAction::ModifyColumnType {
780            table: "users".into(),
781            column: "age".into(),
782            new_type: ColumnType::Simple(SimpleColumnType::BigInt),
783            fill_with: None,
784            narrowing_strategy: None,
785            timezone: None,
786        };
787        let prefixed = action.with_prefix("myapp_");
788        if let MigrationAction::ModifyColumnType {
789            table,
790            column,
791            new_type,
792            fill_with,
793            ..
794        } = prefixed
795        {
796            assert_eq!(table.as_str(), "myapp_users");
797            assert_eq!(column.as_str(), "age");
798            assert!(matches!(
799                new_type,
800                ColumnType::Simple(SimpleColumnType::BigInt)
801            ));
802            assert_eq!(fill_with, None);
803        } else {
804            panic!("Expected ModifyColumnType");
805        }
806    }
807
808    #[test]
809    fn test_action_with_prefix_modify_column_nullable() {
810        let action = MigrationAction::ModifyColumnNullable {
811            table: "users".into(),
812            column: "email".into(),
813            nullable: false,
814            fill_with: Some("default@example.com".into()),
815            delete_null_rows: None,
816        };
817        let prefixed = action.with_prefix("myapp_");
818        if let MigrationAction::ModifyColumnNullable {
819            table,
820            column,
821            nullable,
822            fill_with,
823            delete_null_rows,
824        } = prefixed
825        {
826            assert_eq!(table.as_str(), "myapp_users");
827            assert_eq!(column.as_str(), "email");
828            assert!(!nullable);
829            assert_eq!(fill_with, Some("default@example.com".into()));
830            assert_eq!(delete_null_rows, None);
831        } else {
832            panic!("Expected ModifyColumnNullable");
833        }
834    }
835
836    #[test]
837    fn test_action_with_prefix_modify_column_default() {
838        let action = MigrationAction::ModifyColumnDefault {
839            table: "users".into(),
840            column: "status".into(),
841            new_default: Some("active".into()),
842            backfill: None,
843        };
844        let prefixed = action.with_prefix("myapp_");
845        if let MigrationAction::ModifyColumnDefault {
846            table,
847            column,
848            new_default,
849            ..
850        } = prefixed
851        {
852            assert_eq!(table.as_str(), "myapp_users");
853            assert_eq!(column.as_str(), "status");
854            assert_eq!(new_default, Some("active".into()));
855        } else {
856            panic!("Expected ModifyColumnDefault");
857        }
858    }
859
860    #[test]
861    fn test_action_with_prefix_modify_column_comment() {
862        let action = MigrationAction::ModifyColumnComment {
863            table: "users".into(),
864            column: "bio".into(),
865            new_comment: Some("User biography".into()),
866        };
867        let prefixed = action.with_prefix("myapp_");
868        if let MigrationAction::ModifyColumnComment {
869            table,
870            column,
871            new_comment,
872        } = prefixed
873        {
874            assert_eq!(table.as_str(), "myapp_users");
875            assert_eq!(column.as_str(), "bio");
876            assert_eq!(new_comment, Some("User biography".into()));
877        } else {
878            panic!("Expected ModifyColumnComment");
879        }
880    }
881
882    #[test]
883    fn test_action_with_prefix_add_constraint() {
884        let action = MigrationAction::AddConstraint {
885            table: "posts".into(),
886            constraint: fk_user(Some("fk_user"), None),
887        };
888        let prefixed = action.with_prefix("myapp_");
889        if let MigrationAction::AddConstraint { table, constraint } = prefixed {
890            assert_eq!(table.as_str(), "myapp_posts");
891            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
892                assert_eq!(ref_table.as_str(), "myapp_users");
893            } else {
894                panic!("Expected ForeignKey constraint");
895            }
896        } else {
897            panic!("Expected AddConstraint");
898        }
899    }
900
901    #[test]
902    fn test_action_with_prefix_remove_constraint() {
903        let action = MigrationAction::RemoveConstraint {
904            table: "posts".into(),
905            constraint: fk_user(Some("fk_user"), None),
906        };
907        let prefixed = action.with_prefix("myapp_");
908        if let MigrationAction::RemoveConstraint { table, constraint } = prefixed {
909            assert_eq!(table.as_str(), "myapp_posts");
910            if let TableConstraint::ForeignKey { ref_table, .. } = constraint {
911                assert_eq!(ref_table.as_str(), "myapp_users");
912            } else {
913                panic!("Expected ForeignKey constraint");
914            }
915        } else {
916            panic!("Expected RemoveConstraint");
917        }
918    }
919
920    #[rstest]
921    #[case::replace_constraint_primary_key(
922        MigrationAction::ReplaceConstraint { table: "users".into(), from: pk_id(), to: pk_id_auto() },
923        "ReplaceConstraint: users.PRIMARY KEY"
924    )]
925    #[case::replace_constraint_unique_with_name(
926        MigrationAction::ReplaceConstraint { table: "users".into(), from: uq_email(None), to: uq_email(Some("uq_email")) },
927        "ReplaceConstraint: users.uq_email (UNIQUE)"
928    )]
929    #[case::replace_constraint_unique_without_name(
930        MigrationAction::ReplaceConstraint { table: "users".into(), from: uq_email(Some("uq_email")), to: uq_email(None) },
931        "ReplaceConstraint: users.UNIQUE"
932    )]
933    #[case::replace_constraint_foreign_key_with_name(
934        MigrationAction::ReplaceConstraint { table: "posts".into(), from: fk_user(None, None), to: fk_user(Some("fk_user"), None) },
935        "ReplaceConstraint: posts.fk_user (FOREIGN KEY)"
936    )]
937    #[case::replace_constraint_foreign_key_without_name(
938        MigrationAction::ReplaceConstraint { table: "posts".into(), from: fk_user(Some("fk_user"), None), to: fk_user(None, None) },
939        "ReplaceConstraint: posts.FOREIGN KEY"
940    )]
941    #[case::replace_constraint_check(
942        MigrationAction::ReplaceConstraint { table: "users".into(), from: chk("chk_age", "age > 0"), to: chk("chk_age", "age >= 0") },
943        "ReplaceConstraint: users.chk_age (CHECK)"
944    )]
945    #[case::replace_constraint_index_with_name(
946        MigrationAction::ReplaceConstraint { table: "users".into(), from: idx_email(None), to: idx_email(Some("ix_users__email")) },
947        "ReplaceConstraint: users.ix_users__email (INDEX)"
948    )]
949    #[case::replace_constraint_index_without_name(
950        MigrationAction::ReplaceConstraint { table: "users".into(), from: idx_email(Some("ix_users__email")), to: idx_email(None) },
951        "ReplaceConstraint: users.INDEX"
952    )]
953    fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
954        assert_eq!(action.to_string(), expected);
955    }
956
957    #[test]
958    fn test_action_with_prefix_replace_constraint() {
959        let action = MigrationAction::ReplaceConstraint {
960            table: "posts".into(),
961            from: fk_user(Some("fk_user"), Some(ReferenceAction::Cascade)),
962            to: fk_user(Some("fk_user"), Some(ReferenceAction::SetNull)),
963        };
964        let prefixed = action.with_prefix("myapp_");
965        if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed {
966            assert_eq!(table.as_str(), "myapp_posts");
967            if let TableConstraint::ForeignKey { ref_table, .. } = from {
968                assert_eq!(ref_table.as_str(), "myapp_users");
969            } else {
970                panic!("Expected ForeignKey constraint in from");
971            }
972            if let TableConstraint::ForeignKey { ref_table, .. } = to {
973                assert_eq!(ref_table.as_str(), "myapp_users");
974            } else {
975                panic!("Expected ForeignKey constraint in to");
976            }
977        } else {
978            panic!("Expected ReplaceConstraint");
979        }
980    }
981}