Skip to main content

vespertide_core/schema/
constraint.rs

1use serde::{Deserialize, Serialize};
2
3use crate::schema::{
4    ReferenceAction,
5    check_violation_strategy::CheckViolationStrategy,
6    fk_orphan_strategy::ForeignKeyOrphanStrategy,
7    names::{ColumnName, TableName},
8    pk_addition_strategy::PrimaryKeyAdditionStrategy,
9    unique_strategy::{KeepPolicy, UniqueConstraintStrategy},
10};
11
12/// `serde(skip_serializing_if)` helper ? true when `strategy` is the
13/// canonical default (`DeleteDuplicates { keep: First }`). Lets the
14/// common case omit the field from the JSON wire format.
15fn is_default_unique_strategy(s: &UniqueConstraintStrategy) -> bool {
16    matches!(
17        s,
18        UniqueConstraintStrategy::DeleteDuplicates {
19            keep: KeepPolicy::First
20        }
21    )
22}
23
24/// `serde(skip_serializing_if)` helper ? true when `orphan_strategy` is
25/// the canonical default (`NullifyOrphans`). Lets the common case omit
26/// the field from the JSON wire format.
27#[expect(
28    clippy::trivially_copy_pass_by_ref,
29    reason = "serde `skip_serializing_if` callbacks must have signature `fn(&T) -> bool`"
30)]
31fn is_default_fk_orphan_strategy(s: &ForeignKeyOrphanStrategy) -> bool {
32    matches!(s, ForeignKeyOrphanStrategy::NullifyOrphans)
33}
34
35/// `serde(skip_serializing_if)` helper ? true when CHECK `strategy` is the
36/// canonical default (`DeleteViolatingRows`). Lets the common case
37/// omit the field from the JSON wire format.
38fn is_default_check_violation_strategy(s: &CheckViolationStrategy) -> bool {
39    matches!(s, CheckViolationStrategy::DeleteViolatingRows)
40}
41
42/// `serde(skip_serializing_if)` helper ? true when PK `strategy` is
43/// the canonical default (`DeleteDuplicates { keep: First }`). Lets
44/// the common case omit the field from the JSON wire format.
45fn is_default_pk_addition_strategy(s: &PrimaryKeyAdditionStrategy) -> bool {
46    matches!(
47        s,
48        PrimaryKeyAdditionStrategy::DeleteDuplicates {
49            keep: KeepPolicy::First
50        }
51    )
52}
53
54/// A table-level constraint produced by [`TableDef::normalize`].
55///
56/// Inline column constraints (`primary_key`, `unique`, `index`, `foreign_key`) declared in model
57/// JSON files are converted into `TableConstraint` variants during normalization. You rarely
58/// construct these directly; the planner and SQL generator consume them.
59///
60/// This enum is `#[non_exhaustive]`: new variants may be added in future releases.
61/// Downstream `match` expressions should include a wildcard arm.
62///
63/// [`TableDef::normalize`]: crate::schema::TableDef::normalize
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "snake_case", tag = "type")]
67#[non_exhaustive]
68pub enum TableConstraint {
69    /// Primary key constraint, optionally with auto-increment (serial / identity) semantics.
70    ///
71    /// `strategy` controls how pre-existing duplicate rows in the
72    /// chosen column set are handled when this constraint is added to
73    /// an already-populated table. The canonical default is
74    /// [`PrimaryKeyAdditionStrategy::DeleteDuplicates { keep: KeepPolicy::First }`]
75    /// (omitted from the JSON wire format). NULL violations on PK
76    /// columns are handled separately by the F1 `fill_with` mechanism;
77    /// the revision CLI prompts for fill values on every nullable PK
78    /// column.
79    ///
80    /// `strategy` is **stripped from `model.schema.json`** but
81    /// **preserved in `migration.schema.json`**.
82    PrimaryKey {
83        #[serde(default)]
84        auto_increment: bool,
85        columns: Vec<ColumnName>,
86        #[serde(default, skip_serializing_if = "is_default_pk_addition_strategy")]
87        strategy: PrimaryKeyAdditionStrategy,
88    },
89    /// Unique constraint ensuring no two rows share the same value(s) in the listed columns.
90    ///
91    /// `strategy` controls how pre-existing duplicate rows are handled when
92    /// this constraint is added to an already-populated table. The
93    /// canonical default is [`UniqueConstraintStrategy::DeleteDuplicates { keep: KeepPolicy::First }`],
94    /// which matches v0.1.x behaviour and is omitted from the JSON wire
95    /// format. Other strategies (e.g. `DeleteDuplicates`) emit a pre-cleanup
96    /// step ahead of `ADD CONSTRAINT` so the migration succeeds even when
97    /// production data has duplicates.
98    ///
99    /// `strategy` is **stripped from `model.schema.json`** by the
100    /// schema generator (`vespertide-schema-gen`), but **preserved in
101    /// `migration.schema.json`** since migration files carry the
102    /// revision CLI's stamped choice.
103    Unique {
104        #[serde(skip_serializing_if = "Option::is_none")]
105        name: Option<String>,
106        columns: Vec<ColumnName>,
107        #[serde(default, skip_serializing_if = "is_default_unique_strategy")]
108        strategy: UniqueConstraintStrategy,
109    },
110    /// Foreign key constraint linking columns in this table to columns in another table.
111    ///
112    /// `orphan_strategy` controls how pre-existing orphan rows are
113    /// handled when this constraint is added to an already-populated
114    /// table. The canonical default is
115    /// [`ForeignKeyOrphanStrategy::NullifyOrphans`] (omitted from the
116    /// JSON wire format). The revision CLI re-prompts the user for an
117    /// explicit choice; the default exists only so v0.1.x model files
118    /// continue to deserialize.
119    ///
120    /// `orphan_strategy` is **stripped from `model.schema.json`** by
121    /// the schema generator but **preserved in `migration.schema.json`**.
122    ForeignKey {
123        #[serde(skip_serializing_if = "Option::is_none")]
124        name: Option<String>,
125        columns: Vec<ColumnName>,
126        ref_table: TableName,
127        ref_columns: Vec<ColumnName>,
128        on_delete: Option<ReferenceAction>,
129        on_update: Option<ReferenceAction>,
130        #[serde(default, skip_serializing_if = "is_default_fk_orphan_strategy")]
131        orphan_strategy: ForeignKeyOrphanStrategy,
132    },
133    /// Arbitrary SQL CHECK expression enforced by the database on every write.
134    ///
135    /// `strategy` controls how pre-existing violating rows are handled when
136    /// this constraint is added to an already-populated table. The
137    /// canonical default is [`CheckViolationStrategy::NullifyViolatingColumn`]
138    /// (omitted from the JSON wire format). The revision CLI re-prompts
139    /// the user for an explicit choice; the default exists only so v0.1.x
140    /// model files continue to deserialize.
141    ///
142    /// Cleanup SQL is emitted only when the expression matches a narrow
143    /// recognisable shape (`<col> <op> <literal>` or `<col> IN (...)`);
144    /// more complex expressions skip pre-cleanup and rely on the database
145    /// to validate at apply time.
146    ///
147    /// `strategy` is **stripped from `model.schema.json`** but
148    /// **preserved in `migration.schema.json`**.
149    Check {
150        name: String,
151        expr: String,
152        #[serde(default, skip_serializing_if = "is_default_check_violation_strategy")]
153        strategy: CheckViolationStrategy,
154    },
155    /// Non-unique index to speed up queries on the listed columns.
156    Index {
157        #[serde(skip_serializing_if = "Option::is_none")]
158        name: Option<String>,
159        columns: Vec<ColumnName>,
160    },
161}
162
163/// Lightweight tag identifying the kind of a [`TableConstraint`] without carrying its data.
164///
165/// Returned by [`TableConstraint::kind`] and useful for filtering or grouping constraints without
166/// pattern-matching on the full enum.
167///
168/// This enum is `#[non_exhaustive]`: new variants may be added in future releases.
169/// Downstream `match` expressions should include a wildcard arm.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "snake_case")]
173#[non_exhaustive]
174pub enum ConstraintKind {
175    /// Identifies a [`TableConstraint::PrimaryKey`] constraint.
176    PrimaryKey,
177    /// Identifies a [`TableConstraint::ForeignKey`] constraint.
178    ForeignKey,
179    /// Identifies a [`TableConstraint::Unique`] constraint.
180    Unique,
181    /// Identifies a [`TableConstraint::Check`] constraint.
182    Check,
183    /// Identifies a [`TableConstraint::Index`] constraint.
184    Index,
185}
186
187impl TableConstraint {
188    /// Returns the high-level kind of this constraint.
189    #[must_use]
190    pub fn kind(&self) -> ConstraintKind {
191        match self {
192            TableConstraint::PrimaryKey { .. } => ConstraintKind::PrimaryKey,
193            TableConstraint::ForeignKey { .. } => ConstraintKind::ForeignKey,
194            TableConstraint::Unique { .. } => ConstraintKind::Unique,
195            TableConstraint::Check { .. } => ConstraintKind::Check,
196            TableConstraint::Index { .. } => ConstraintKind::Index,
197        }
198    }
199
200    /// Returns the columns referenced by this constraint.
201    /// For Check constraints, returns an empty slice (expression-based, not column-based).
202    pub fn columns(&self) -> &[ColumnName] {
203        match self {
204            TableConstraint::PrimaryKey { columns, .. }
205            | TableConstraint::Unique { columns, .. }
206            | TableConstraint::ForeignKey { columns, .. }
207            | TableConstraint::Index { columns, .. } => columns,
208            TableConstraint::Check { .. } => &[],
209        }
210    }
211
212    /// Apply a prefix to referenced table names in this constraint.
213    /// Only affects `ForeignKey` constraints (which reference other tables).
214    pub fn with_prefix(self, prefix: &str) -> Self {
215        if prefix.is_empty() {
216            return self;
217        }
218        match self {
219            TableConstraint::ForeignKey {
220                name,
221                columns,
222                ref_table,
223                ref_columns,
224                on_delete,
225                on_update,
226                orphan_strategy,
227            } => TableConstraint::ForeignKey {
228                name,
229                columns,
230                ref_table: format!("{prefix}{ref_table}").into(),
231                ref_columns,
232                on_delete,
233                on_update,
234                orphan_strategy,
235            },
236            // Other constraints don't reference external tables
237            other => other,
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_columns_primary_key() {
248        let pk = TableConstraint::PrimaryKey {
249            auto_increment: false,
250            columns: vec!["id".into(), "tenant_id".into()],
251            strategy: PrimaryKeyAdditionStrategy::default(),
252        };
253        assert_eq!(pk.columns().len(), 2);
254        assert_eq!(pk.columns()[0], "id");
255        assert_eq!(pk.columns()[1], "tenant_id");
256    }
257
258    #[test]
259    fn test_columns_unique() {
260        let unique = TableConstraint::Unique {
261            name: Some("uq_email".into()),
262            columns: vec!["email".into()],
263            strategy: crate::schema::UniqueConstraintStrategy::DeleteDuplicates {
264                keep: crate::schema::KeepPolicy::First,
265            },
266        };
267        assert_eq!(unique.columns().len(), 1);
268        assert_eq!(unique.columns()[0], "email");
269    }
270
271    #[test]
272    fn test_columns_foreign_key() {
273        let fk = TableConstraint::ForeignKey {
274            name: Some("fk_user".into()),
275            columns: vec!["user_id".into()],
276            ref_table: "users".into(),
277            ref_columns: vec!["id".into()],
278            on_delete: None,
279            on_update: None,
280            orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
281        };
282        assert_eq!(fk.columns().len(), 1);
283        assert_eq!(fk.columns()[0], "user_id");
284    }
285
286    #[test]
287    fn test_columns_index() {
288        let idx = TableConstraint::Index {
289            name: Some("ix_created_at".into()),
290            columns: vec!["created_at".into()],
291        };
292        assert_eq!(idx.columns().len(), 1);
293        assert_eq!(idx.columns()[0], "created_at");
294    }
295
296    #[test]
297    fn test_columns_check_returns_empty() {
298        let check = TableConstraint::Check {
299            name: "check_positive".into(),
300            expr: "amount > 0".into(),
301            strategy: crate::CheckViolationStrategy::default(),
302        };
303        assert!(check.columns().is_empty());
304    }
305
306    #[test]
307    fn test_kind() {
308        let constraints = [
309            (
310                TableConstraint::PrimaryKey {
311                    auto_increment: false,
312                    columns: vec!["id".into()],
313                    strategy: PrimaryKeyAdditionStrategy::default(),
314                },
315                ConstraintKind::PrimaryKey,
316            ),
317            (
318                TableConstraint::ForeignKey {
319                    name: None,
320                    columns: vec!["user_id".into()],
321                    ref_table: "user".into(),
322                    ref_columns: vec!["id".into()],
323                    on_delete: None,
324                    on_update: None,
325                    orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
326                },
327                ConstraintKind::ForeignKey,
328            ),
329            (
330                TableConstraint::Unique {
331                    name: None,
332                    columns: vec!["email".into()],
333                    strategy: crate::schema::UniqueConstraintStrategy::DeleteDuplicates {
334                        keep: crate::schema::KeepPolicy::First,
335                    },
336                },
337                ConstraintKind::Unique,
338            ),
339            (
340                TableConstraint::Check {
341                    name: "check_positive".into(),
342                    expr: "amount > 0".into(),
343                    strategy: crate::CheckViolationStrategy::default(),
344                },
345                ConstraintKind::Check,
346            ),
347            (
348                TableConstraint::Index {
349                    name: None,
350                    columns: vec!["email".into()],
351                },
352                ConstraintKind::Index,
353            ),
354        ];
355
356        for (constraint, expected) in constraints {
357            assert_eq!(constraint.kind(), expected);
358        }
359    }
360
361    #[test]
362    fn test_with_prefix_foreign_key() {
363        let fk = TableConstraint::ForeignKey {
364            name: Some("fk_user".into()),
365            columns: vec!["user_id".into()],
366            ref_table: "users".into(),
367            ref_columns: vec!["id".into()],
368            on_delete: None,
369            on_update: None,
370            orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
371        };
372        let prefixed = fk.with_prefix("myapp_");
373        if let TableConstraint::ForeignKey { ref_table, .. } = prefixed {
374            assert_eq!(ref_table.as_str(), "myapp_users");
375        } else {
376            panic!("Expected ForeignKey");
377        }
378    }
379
380    #[test]
381    fn test_with_prefix_non_fk_unchanged() {
382        let pk = TableConstraint::PrimaryKey {
383            auto_increment: false,
384            columns: vec!["id".into()],
385            strategy: PrimaryKeyAdditionStrategy::default(),
386        };
387        let prefixed = pk.clone().with_prefix("myapp_");
388        assert_eq!(pk, prefixed);
389
390        let unique = TableConstraint::Unique {
391            name: Some("uq_email".into()),
392            columns: vec!["email".into()],
393            strategy: crate::schema::UniqueConstraintStrategy::DeleteDuplicates {
394                keep: crate::schema::KeepPolicy::First,
395            },
396        };
397        let prefixed = unique.clone().with_prefix("myapp_");
398        assert_eq!(unique, prefixed);
399
400        let idx = TableConstraint::Index {
401            name: Some("ix_created_at".into()),
402            columns: vec!["created_at".into()],
403        };
404        let prefixed = idx.clone().with_prefix("myapp_");
405        assert_eq!(idx, prefixed);
406
407        let check = TableConstraint::Check {
408            name: "check_positive".into(),
409            expr: "amount > 0".into(),
410            strategy: crate::CheckViolationStrategy::default(),
411        };
412        let prefixed = check.clone().with_prefix("myapp_");
413        assert_eq!(check, prefixed);
414    }
415
416    #[test]
417    fn test_with_prefix_empty_prefix() {
418        let fk = TableConstraint::ForeignKey {
419            name: Some("fk_user".into()),
420            columns: vec!["user_id".into()],
421            ref_table: "users".into(),
422            ref_columns: vec!["id".into()],
423            on_delete: None,
424            on_update: None,
425            orphan_strategy: crate::ForeignKeyOrphanStrategy::default(),
426        };
427        let prefixed = fk.clone().with_prefix("");
428        assert_eq!(fk, prefixed);
429    }
430}