Skip to main content

nodedb_crdt/
constraint.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! SQL constraint definitions for CRDT collections.
4//!
5//! Constraints are checked at commit time against the leader's state.
6//! They define invariants that must hold globally, even though individual
7//! agents operate optimistically without them.
8
9use serde::{Deserialize, Serialize};
10
11/// The kind of SQL constraint to enforce.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub enum ConstraintKind {
14    /// No two rows may have the same value for this key.
15    /// Analogous to SQL `UNIQUE(column)`.
16    Unique,
17
18    /// The value must reference an existing key in another collection.
19    /// Analogous to SQL `FOREIGN KEY(column) REFERENCES other(key)`.
20    ForeignKey {
21        /// The referenced collection name.
22        ref_collection: String,
23        /// The referenced key field.
24        ref_key: String,
25    },
26
27    /// Bitemporal foreign key. Write-side semantics match `ForeignKey`
28    /// (referent must exist live), but on referent delete the referrer
29    /// row/edge is *closed* by appending a new version with
30    /// `valid_until_ms = now` rather than being cascade-deleted. This
31    /// preserves the historical truth that the relationship existed.
32    BiTemporalFK {
33        ref_collection: String,
34        ref_key: String,
35    },
36
37    /// The value must not be null/empty.
38    /// Analogous to SQL `NOT NULL`.
39    NotNull,
40
41    /// Custom predicate — evaluated as a boolean expression on the row.
42    /// Analogous to SQL `CHECK(expression)`.
43    Check {
44        /// Human-readable description of the check.
45        description: String,
46    },
47}
48
49/// A constraint bound to a specific collection and field.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Constraint {
52    /// Unique name for this constraint (e.g., "users_email_unique").
53    pub name: String,
54    /// The collection (table) this constraint applies to.
55    pub collection: String,
56    /// The field (column) this constraint applies to.
57    pub field: String,
58    /// The kind of constraint.
59    pub kind: ConstraintKind,
60}
61
62/// A set of constraints for a schema.
63#[derive(Debug, Clone, Default)]
64pub struct ConstraintSet {
65    constraints: Vec<Constraint>,
66}
67
68impl ConstraintSet {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Add a constraint.
74    pub fn add(&mut self, constraint: Constraint) {
75        self.constraints.push(constraint);
76    }
77
78    /// Add a UNIQUE constraint.
79    pub fn add_unique(&mut self, name: &str, collection: &str, field: &str) {
80        self.add(Constraint {
81            name: name.to_string(),
82            collection: collection.to_string(),
83            field: field.to_string(),
84            kind: ConstraintKind::Unique,
85        });
86    }
87
88    /// Add a FOREIGN KEY constraint.
89    pub fn add_foreign_key(
90        &mut self,
91        name: &str,
92        collection: &str,
93        field: &str,
94        ref_collection: &str,
95        ref_key: &str,
96    ) {
97        self.add(Constraint {
98            name: name.to_string(),
99            collection: collection.to_string(),
100            field: field.to_string(),
101            kind: ConstraintKind::ForeignKey {
102                ref_collection: ref_collection.to_string(),
103                ref_key: ref_key.to_string(),
104            },
105        });
106    }
107
108    /// Add a BITEMPORAL FOREIGN KEY constraint.
109    pub fn add_bitemporal_fk(
110        &mut self,
111        name: &str,
112        collection: &str,
113        field: &str,
114        ref_collection: &str,
115        ref_key: &str,
116    ) {
117        self.add(Constraint {
118            name: name.to_string(),
119            collection: collection.to_string(),
120            field: field.to_string(),
121            kind: ConstraintKind::BiTemporalFK {
122                ref_collection: ref_collection.to_string(),
123                ref_key: ref_key.to_string(),
124            },
125        });
126    }
127
128    /// Add a NOT NULL constraint.
129    pub fn add_not_null(&mut self, name: &str, collection: &str, field: &str) {
130        self.add(Constraint {
131            name: name.to_string(),
132            collection: collection.to_string(),
133            field: field.to_string(),
134            kind: ConstraintKind::NotNull,
135        });
136    }
137
138    /// Get all constraints for a given collection.
139    pub fn for_collection(&self, collection: &str) -> Vec<&Constraint> {
140        self.constraints
141            .iter()
142            .filter(|c| c.collection == collection)
143            .collect()
144    }
145
146    /// Get all constraints.
147    pub fn all(&self) -> &[Constraint] {
148        &self.constraints
149    }
150
151    /// Number of constraints.
152    pub fn len(&self) -> usize {
153        self.constraints.len()
154    }
155
156    pub fn is_empty(&self) -> bool {
157        self.constraints.is_empty()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn constraint_set_operations() {
167        let mut cs = ConstraintSet::new();
168        cs.add_unique("users_email_unique", "users", "email");
169        cs.add_not_null("users_name_nn", "users", "name");
170        cs.add_foreign_key("posts_author_fk", "posts", "author_id", "users", "id");
171        cs.add_bitemporal_fk("orders_user_btfk", "orders", "user_id", "users", "id");
172
173        assert_eq!(cs.len(), 4);
174        assert_eq!(cs.for_collection("users").len(), 2);
175        assert_eq!(cs.for_collection("posts").len(), 1);
176        assert_eq!(cs.for_collection("orders").len(), 1);
177        assert_eq!(cs.for_collection("missing").len(), 0);
178
179        let btfk = cs.for_collection("orders")[0];
180        assert!(matches!(btfk.kind, ConstraintKind::BiTemporalFK { .. }));
181    }
182}