vespertide_core/schema/check_violation_strategy.rs
1//! Strategy for pre-existing rows that violate a `CHECK` constraint being
2//! added to an existing table.
3//!
4//! `AddConstraint(Check)` against a populated column would fail at apply
5//! time when at least one row violates the predicate. Vespertide treats
6//! this as a fault the user must resolve explicitly: every revision that
7//! adds a `CHECK` whose expression matches the narrow shape
8//! `<col> <op> <literal>` or `<col> IN (...)` (see
9//! [`crate::schema::TableConstraint::Check`] doc) emits a pre-cleanup SQL
10//! statement ahead of the `ADD CONSTRAINT`, either NULL-ing the offending
11//! column ([`NullifyViolatingColumn`]) or deleting the offending row
12//! ([`DeleteViolatingRows`]).
13//!
14//! There is no "skip cleanup" / "fail" option ? letting the database
15//! reject the migration is incompatible with vespertide's safety
16//! promise. Users who *know* their data is clean still get the same
17//! SQL: the pre-cleanup statement is a no-op on a clean table.
18//!
19//! `#[non_exhaustive]` so additional strategies can be added in a
20//! future minor release without breaking downstream `match`es.
21//!
22//! [`NullifyViolatingColumn`]: CheckViolationStrategy::NullifyViolatingColumn
23//! [`DeleteViolatingRows`]: CheckViolationStrategy::DeleteViolatingRows
24
25use serde::{Deserialize, Serialize};
26
27use crate::schema::names::ColumnName;
28
29/// How `AddConstraint(Check)` should handle pre-existing rows that
30/// violate the CHECK predicate.
31///
32/// "Violation" is computed statically by the narrow-shape parser in
33/// `vespertide-planner::validate::check_default` and the cleanup SQL is
34/// emitted by `vespertide-query::sql::add_constraint::check`. CHECK
35/// expressions that exceed the narrow shape (function calls, AND/OR
36/// composition, subqueries) are *not* covered by either side ? the
37/// detector skips them and no pre-cleanup is emitted.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40#[serde(tag = "kind", rename_all = "snake_case")]
41#[non_exhaustive]
42pub enum CheckViolationStrategy {
43 /// Set the named column of every violating row to `NULL` ahead of
44 /// the `ADD CONSTRAINT`. The row is preserved; only the failing
45 /// value is cleared.
46 ///
47 /// Only applicable when `column` is nullable in the baseline; the
48 /// SQL generator emits a runtime SQL error otherwise (UPDATE ...
49 /// SET col = NULL violates the existing NOT NULL). The revision
50 /// CLI's narrow-shape parser identifies the target column
51 /// automatically; the user rarely sets this directly in the model
52 /// JSON.
53 NullifyViolatingColumn {
54 /// Which column receives `SET = NULL`. Narrow-shape CHECKs
55 /// always reduce to a single column, so this is the only
56 /// column the cleanup affects.
57 column: ColumnName,
58 },
59 /// Delete the violating rows outright ahead of the
60 /// `ADD CONSTRAINT`. Use when the violating rows are themselves
61 /// invalid (logically dangling records).
62 ///
63 /// This is also the canonical default for the wire format: v0.1.x
64 /// models carry no `strategy` field and so deserialize to
65 /// `DeleteViolatingRows`. The revision CLI re-prompts for an
66 /// explicit choice, and the prompt offers
67 /// `NullifyViolatingColumn { column }` when the narrow-shape
68 /// parser can identify a nullable target column.
69 DeleteViolatingRows,
70}
71
72impl Default for CheckViolationStrategy {
73 /// Default strategy is `DeleteViolatingRows`. Unlike F3
74 /// (`NullifyOrphans`), F4 cannot default to the less destructive
75 /// `NullifyViolatingColumn` because that variant requires a
76 /// `column` argument the wire-format default cannot supply
77 /// (CHECK expressions are free-form text and the column name has
78 /// to be parsed out by `vespertide-planner`).
79 ///
80 /// **Wire-format note.** v0.1.x emitted no pre-cleanup at all and
81 /// let the database reject the migration. v0.2 emits
82 /// `DELETE FROM table WHERE NOT (<expr>)` automatically. Existing
83 /// migrations that *already applied* under v0.1.x are unaffected
84 /// (apply happens once); v0.1.x migrations *re-run* against a
85 /// fresh DB will now drop violating rows instead of failing. The
86 /// revision CLI prompts for an explicit choice on every new
87 /// `AddConstraint(Check)`, so production usage rarely hits the
88 /// default - it exists for v0.1.x compatibility only.
89 fn default() -> Self {
90 Self::DeleteViolatingRows
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn default_is_delete_violating_rows() {
100 assert_eq!(
101 CheckViolationStrategy::default(),
102 CheckViolationStrategy::DeleteViolatingRows
103 );
104 }
105
106 #[test]
107 fn serde_roundtrip_nullify() {
108 let s = CheckViolationStrategy::NullifyViolatingColumn {
109 column: "price".into(),
110 };
111 let j = serde_json::to_string(&s).unwrap();
112 assert_eq!(j, r#"{"kind":"nullify_violating_column","column":"price"}"#);
113 let back: CheckViolationStrategy = serde_json::from_str(&j).unwrap();
114 assert_eq!(back, s);
115 }
116
117 #[test]
118 fn serde_roundtrip_delete() {
119 let s = CheckViolationStrategy::DeleteViolatingRows;
120 let j = serde_json::to_string(&s).unwrap();
121 assert_eq!(j, r#"{"kind":"delete_violating_rows"}"#);
122 let back: CheckViolationStrategy = serde_json::from_str(&j).unwrap();
123 assert_eq!(back, s);
124 }
125}