Skip to main content

vespertide_core/action/
mod.rs

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