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}