Skip to main content

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}