Skip to main content

vespertide_core/action/
narrowing_strategy.rs

1//! Strategy applied to existing rows whose value would violate a narrowed
2//! column type, so the migration succeeds on every supported backend.
3//!
4//! Attached to `MigrationAction::ModifyColumnType` as
5//! `narrowing_strategy: Option<NarrowingStrategy>`. When `None`, the SQL
6//! generator emits a plain `ALTER COLUMN TYPE` — which is safe *only if*
7//! the user has independently verified no row violates the new type
8//! (typically caught by the `vespertide diff` / `vespertide revision`
9//! warnings).
10//!
11//! When `Some`, the SQL generator emits a pre-processing statement
12//! (`UPDATE` / `DELETE`) immediately before the `ALTER`, transforming
13//! violating rows so the type change cannot fail.
14
15use serde::{Deserialize, Serialize};
16
17/// How an existing row that violates the new (narrowed) column type should
18/// be transformed *before* the `ALTER COLUMN TYPE` statement runs.
19///
20/// The wire format uses an internally tagged JSON representation
21/// (`{"kind": "..."}`) so future variants stay backwards compatible. The
22/// enum is `#[non_exhaustive]` — downstream `match` expressions must
23/// include a wildcard arm.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[serde(rename_all = "snake_case", tag = "kind")]
27#[non_exhaustive]
28pub enum NarrowingStrategy {
29    /// Trim the violating value to fit the new type. The row is preserved;
30    /// only the column value loses its overflowing tail.
31    ///
32    /// Applicable to *string-like* narrowings (`varchar(N)`, `char(N)`,
33    /// `text -> varchar(N)`) via `LEFT(col, N)` / `substr(col, 1, N)`, and
34    /// to `NUMERIC` scale narrowing via `ROUND(col, new_scale)`.
35    ///
36    /// **Not** applicable to integer / float / timezone narrowings —
37    /// truncation has no natural definition there. The CLI revision UI
38    /// hides this option for those cases.
39    Truncate,
40
41    /// Delete the entire row containing a violating value. Other columns of
42    /// that row are lost along with it.
43    ///
44    /// Universally applicable across every narrowing kind. Watch for FK
45    /// cascade behaviour — deleting a parent row with `ON DELETE CASCADE`
46    /// can propagate into child tables.
47    Delete,
48
49    /// Replace the violating value with a fixed sentinel that fits the new
50    /// type. The row is preserved; only the violating column is rewritten.
51    ///
52    /// The `value` field is emitted verbatim into the generated SQL
53    /// (`UPDATE ... SET col = <value>`), so callers must quote string
54    /// literals themselves (e.g. `"'TRUNCATED'"`, not `"TRUNCATED"`) and
55    /// must ensure the value itself fits the new type — otherwise the
56    /// migration will fail in a different way.
57    ///
58    /// Universally applicable across every narrowing kind, including
59    /// integer overflow (e.g. `value: "0"`) and timezone loss
60    /// (e.g. `value: "(now() AT TIME ZONE 'UTC')"`).
61    SetToValue {
62        /// SQL fragment substituted for violating values. Strings must be
63        /// pre-quoted by the caller. The CLI revision prompt wraps user
64        /// input with single quotes automatically when the new type is a
65        /// string-like type.
66        value: String,
67    },
68}
69
70impl NarrowingStrategy {
71    /// Tag name used in JSON wire format and CLI output (`truncate`,
72    /// `delete`, `set_to_value`). `#[non_exhaustive]` is enforced at
73    /// downstream-crate boundaries; this in-crate match is exhaustive.
74    #[must_use]
75    pub fn kind_label(&self) -> &'static str {
76        match self {
77            NarrowingStrategy::Truncate => "truncate",
78            NarrowingStrategy::Delete => "delete",
79            NarrowingStrategy::SetToValue { .. } => "set_to_value",
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn truncate_roundtrips_through_json_with_kind_tag() {
90        let s = NarrowingStrategy::Truncate;
91        let json = serde_json::to_string(&s).unwrap();
92        assert_eq!(json, r#"{"kind":"truncate"}"#);
93        let back: NarrowingStrategy = serde_json::from_str(&json).unwrap();
94        assert_eq!(back, s);
95    }
96
97    #[test]
98    fn delete_roundtrips_through_json_with_kind_tag() {
99        let s = NarrowingStrategy::Delete;
100        let json = serde_json::to_string(&s).unwrap();
101        assert_eq!(json, r#"{"kind":"delete"}"#);
102        let back: NarrowingStrategy = serde_json::from_str(&json).unwrap();
103        assert_eq!(back, s);
104    }
105
106    #[test]
107    fn set_to_value_roundtrips_with_value_field() {
108        let s = NarrowingStrategy::SetToValue {
109            value: "'TRUNCATED'".into(),
110        };
111        let json = serde_json::to_string(&s).unwrap();
112        assert_eq!(json, r#"{"kind":"set_to_value","value":"'TRUNCATED'"}"#);
113        let back: NarrowingStrategy = serde_json::from_str(&json).unwrap();
114        assert_eq!(back, s);
115    }
116
117    #[test]
118    fn kind_label_returns_snake_case_tag() {
119        assert_eq!(NarrowingStrategy::Truncate.kind_label(), "truncate");
120        assert_eq!(NarrowingStrategy::Delete.kind_label(), "delete");
121        assert_eq!(
122            NarrowingStrategy::SetToValue { value: "0".into() }.kind_label(),
123            "set_to_value"
124        );
125    }
126}