Skip to main content

backbone_core/
row_rule.rs

1//! The values a compiled row rule computes with, and its operators.
2//!
3//! The schema generator compiles each row rule a module declares (a condition
4//! over one row's own fields) into a Rust expression over [`Val`], built from
5//! the functions here, and evaluates it against the row the write would store,
6//! read through the row's JSON form so enums, decimals and dates arrive as the
7//! text and numbers their columns hold.
8//!
9//! The semantics are SQL's, because the same rule also compiles to a `CHECK`
10//! constraint and both must give one answer:
11//!
12//! - A missing or null field is [`Val::Null`], and an operator over a null is
13//!   null: `name.length >= 1` with no name is neither true nor false.
14//! - `&&`, `||` and `!` are three-valued: `false && null` is false,
15//!   `true || null` is true, anything else with a null is null.
16//! - A rule [`holds`] unless it is definitely false, as a `CHECK` passes on
17//!   null. `x == null` and `x != null` test for null itself and are never null.
18//! - A comparison between a number and a text parses the text, the way a
19//!   decimal column serialized as `"12.50"` compares to `0`; a comparison
20//!   between a time and a text parses the text as a timestamp or a date.
21//!
22//! A rule may also read the row before the write (`$old.x`, through [`field`]
23//! on that row), the writer ([`actor`]) and the time of the write ([`at`]).
24
25use std::collections::HashMap;
26use std::sync::{Mutex, OnceLock};
27
28use chrono::{DateTime, NaiveDate, Utc};
29use serde_json::Value;
30
31/// One value inside a rule.
32#[derive(Debug, Clone, PartialEq)]
33pub enum Val {
34    Null,
35    Bool(bool),
36    Num(f64),
37    Text(String),
38    List(Vec<Val>),
39    /// A point in time: the write's `now()`, or a stored timestamp or date read as one.
40    Time(DateTime<Utc>),
41}
42
43impl Val {
44    fn from_json(v: &Value) -> Val {
45        match v {
46            Value::Null | Value::Object(_) => Val::Null,
47            Value::Bool(b) => Val::Bool(*b),
48            Value::Number(n) => n.as_f64().map(Val::Num).unwrap_or(Val::Null),
49            Value::String(s) => Val::Text(s.clone()),
50            Value::Array(items) => Val::List(items.iter().map(Val::from_json).collect()),
51        }
52    }
53
54    fn num(&self) -> Option<f64> {
55        match self {
56            Val::Num(n) => Some(*n),
57            Val::Text(s) => s.trim().parse().ok(),
58            _ => None,
59        }
60    }
61
62    fn time(&self) -> Option<DateTime<Utc>> {
63        match self {
64            Val::Time(t) => Some(*t),
65            Val::Text(s) => DateTime::parse_from_rfc3339(s)
66                .map(|t| t.with_timezone(&Utc))
67                .ok()
68                .or_else(|| {
69                    let d = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
70                    Some(d.and_hms_opt(0, 0, 0)?.and_utc())
71                }),
72            _ => None,
73        }
74    }
75
76    fn text(&self) -> Option<&str> {
77        match self {
78            Val::Text(s) => Some(s),
79            _ => None,
80        }
81    }
82
83    fn truth(&self) -> Option<bool> {
84        match self {
85            Val::Bool(b) => Some(*b),
86            _ => None,
87        }
88    }
89}
90
91/// The row's field `name`; [`Val::Null`] when it is absent or null.
92pub fn field(row: &Value, name: &str) -> Val {
93    row.get(name).map(Val::from_json).unwrap_or(Val::Null)
94}
95
96/// The writer, as `$actor.id` reads it; null for a job, a seed or an unscoped caller.
97pub fn actor(actor: Option<&str>) -> Val {
98    actor.map(|a| Val::Text(a.to_string())).unwrap_or(Val::Null)
99}
100
101/// The time of the write, as `now()` reads it.
102pub fn at(at: DateTime<Utc>) -> Val {
103    Val::Time(at)
104}
105
106/// Whether any of `fields` differs between the row before and the row after a write. A rule
107/// about the time of the write checks an update only when it changes what the rule reads, so a
108/// later edit of a row whose deadline has passed is not a break.
109pub fn changed(before: Option<&Value>, after: &Value, fields: &[&str]) -> bool {
110    match before {
111        None => true,
112        Some(b) => fields.iter().any(|f| b.get(*f) != after.get(*f)),
113    }
114}
115
116pub fn text(s: &str) -> Val {
117    Val::Text(s.to_string())
118}
119
120/// Whether a rule's value lets the write through: everything but a definite `false`.
121pub fn holds(v: &Val) -> bool {
122    !matches!(v, Val::Bool(false))
123}
124
125// ── Null tests ───────────────────────────────────────────────────────────
126
127pub fn is_null(a: Val) -> Val {
128    Val::Bool(matches!(a, Val::Null))
129}
130
131pub fn is_not_null(a: Val) -> Val {
132    Val::Bool(!matches!(a, Val::Null))
133}
134
135// ── Logic ────────────────────────────────────────────────────────────────
136
137pub fn and(a: Val, b: Val) -> Val {
138    match (a.truth(), b.truth()) {
139        (Some(false), _) | (_, Some(false)) => Val::Bool(false),
140        (Some(true), Some(true)) => Val::Bool(true),
141        _ => Val::Null,
142    }
143}
144
145pub fn or(a: Val, b: Val) -> Val {
146    match (a.truth(), b.truth()) {
147        (Some(true), _) | (_, Some(true)) => Val::Bool(true),
148        (Some(false), Some(false)) => Val::Bool(false),
149        _ => Val::Null,
150    }
151}
152
153pub fn not(a: Val) -> Val {
154    a.truth().map(|b| Val::Bool(!b)).unwrap_or(Val::Null)
155}
156
157/// `c ? t : e`, as SQL's `CASE WHEN c THEN t ELSE e END`: a null condition takes `e`.
158pub fn cond(c: Val, t: Val, e: Val) -> Val {
159    if c.truth() == Some(true) {
160        t
161    } else {
162        e
163    }
164}
165
166// ── Comparison ───────────────────────────────────────────────────────────
167
168fn order(a: &Val, b: &Val) -> Option<std::cmp::Ordering> {
169    match (a, b) {
170        (Val::Null, _) | (_, Val::Null) => None,
171        (Val::Text(x), Val::Text(y)) => Some(x.cmp(y)),
172        (Val::Bool(x), Val::Bool(y)) => Some(x.cmp(y)),
173        (Val::Time(_), _) | (_, Val::Time(_)) => Some(a.time()?.cmp(&b.time()?)),
174        _ => a.num()?.partial_cmp(&b.num()?),
175    }
176}
177
178fn compare(a: Val, b: Val, test: fn(std::cmp::Ordering) -> bool) -> Val {
179    order(&a, &b).map(|o| Val::Bool(test(o))).unwrap_or(Val::Null)
180}
181
182pub fn eq(a: Val, b: Val) -> Val {
183    compare(a, b, |o| o.is_eq())
184}
185
186pub fn ne(a: Val, b: Val) -> Val {
187    compare(a, b, |o| o.is_ne())
188}
189
190pub fn lt(a: Val, b: Val) -> Val {
191    compare(a, b, |o| o.is_lt())
192}
193
194pub fn le(a: Val, b: Val) -> Val {
195    compare(a, b, |o| o.is_le())
196}
197
198pub fn gt(a: Val, b: Val) -> Val {
199    compare(a, b, |o| o.is_gt())
200}
201
202pub fn ge(a: Val, b: Val) -> Val {
203    compare(a, b, |o| o.is_ge())
204}
205
206/// `a in [..]`, as SQL's `IN`: true on a match, null when `a` is null or no
207/// item matched but one was null, false otherwise.
208pub fn in_list(a: Val, list: Val) -> Val {
209    let Val::List(items) = list else { return Val::Null };
210    if matches!(a, Val::Null) {
211        return Val::Null;
212    }
213    let mut unknown = false;
214    for item in items {
215        match eq(a.clone(), item) {
216            Val::Bool(true) => return Val::Bool(true),
217            Val::Null => unknown = true,
218            _ => {}
219        }
220    }
221    if unknown {
222        Val::Null
223    } else {
224        Val::Bool(false)
225    }
226}
227
228pub fn not_in(a: Val, list: Val) -> Val {
229    not(in_list(a, list))
230}
231
232/// `a matches 'pattern'`: whether the text contains a match of the pattern.
233/// The generator only emits patterns that compile.
234pub fn matches(a: Val, pattern: &str) -> Val {
235    static CACHE: OnceLock<Mutex<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
236    let Some(s) = a.text() else { return Val::Null };
237    let mut cache = CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
238    let re = cache.entry(pattern.to_string()).or_insert_with(|| regex::Regex::new(pattern).ok());
239    match re {
240        Some(re) => Val::Bool(re.is_match(s)),
241        None => Val::Null,
242    }
243}
244
245// ── Arithmetic ───────────────────────────────────────────────────────────
246
247fn arith(a: Val, b: Val, f: fn(f64, f64) -> Option<f64>) -> Val {
248    match (a.num(), b.num()) {
249        (Some(x), Some(y)) => f(x, y).map(Val::Num).unwrap_or(Val::Null),
250        _ => Val::Null,
251    }
252}
253
254pub fn add(a: Val, b: Val) -> Val {
255    arith(a, b, |x, y| Some(x + y))
256}
257
258pub fn sub(a: Val, b: Val) -> Val {
259    arith(a, b, |x, y| Some(x - y))
260}
261
262pub fn mul(a: Val, b: Val) -> Val {
263    arith(a, b, |x, y| Some(x * y))
264}
265
266/// Division by zero is null rather than an error: a rule never panics a write.
267pub fn div(a: Val, b: Val) -> Val {
268    arith(a, b, |x, y| (y != 0.0).then(|| x / y))
269}
270
271pub fn rem(a: Val, b: Val) -> Val {
272    arith(a, b, |x, y| (y != 0.0).then(|| x % y))
273}
274
275pub fn neg(a: Val) -> Val {
276    a.num().map(|n| Val::Num(-n)).unwrap_or(Val::Null)
277}
278
279fn num_fn(a: Val, f: fn(f64) -> f64) -> Val {
280    a.num().map(|n| Val::Num(f(n))).unwrap_or(Val::Null)
281}
282
283pub fn abs(a: Val) -> Val {
284    num_fn(a, f64::abs)
285}
286
287pub fn ceil(a: Val) -> Val {
288    num_fn(a, f64::ceil)
289}
290
291pub fn floor(a: Val) -> Val {
292    num_fn(a, f64::floor)
293}
294
295pub fn round(a: Val) -> Val {
296    num_fn(a, f64::round)
297}
298
299/// The first value that is not null.
300pub fn coalesce(values: Vec<Val>) -> Val {
301    values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
302}
303
304// ── Text and lists ───────────────────────────────────────────────────────
305
306/// A text's length in characters (code points, as on every target), or a list's.
307pub fn length(a: Val) -> Val {
308    match a {
309        Val::Text(s) => Val::Num(s.chars().count() as f64),
310        Val::List(items) => Val::Num(items.len() as f64),
311        _ => Val::Null,
312    }
313}
314
315pub fn is_empty(a: Val) -> Val {
316    match length(a) {
317        Val::Num(n) => Val::Bool(n == 0.0),
318        _ => Val::Null,
319    }
320}
321
322fn text_fn(a: Val, f: fn(&str) -> String) -> Val {
323    a.text().map(|s| Val::Text(f(s))).unwrap_or(Val::Null)
324}
325
326pub fn trim(a: Val) -> Val {
327    text_fn(a, |s| s.trim().to_string())
328}
329
330pub fn lower(a: Val) -> Val {
331    text_fn(a, str::to_lowercase)
332}
333
334pub fn upper(a: Val) -> Val {
335    text_fn(a, str::to_uppercase)
336}
337
338/// `a.contains(b)`: a text containing a text, or a list containing an item.
339pub fn contains(a: Val, b: Val) -> Val {
340    match (a, b) {
341        (Val::Text(s), Val::Text(t)) => Val::Bool(s.contains(t.as_str())),
342        (list @ Val::List(_), item) => in_list(item, list),
343        _ => Val::Null,
344    }
345}
346
347pub fn starts_with(a: Val, b: Val) -> Val {
348    match (a.text(), b.text()) {
349        (Some(s), Some(t)) => Val::Bool(s.starts_with(t)),
350        _ => Val::Null,
351    }
352}
353
354pub fn ends_with(a: Val, b: Val) -> Val {
355    match (a.text(), b.text()) {
356        (Some(s), Some(t)) => Val::Bool(s.ends_with(t)),
357        _ => Val::Null,
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use serde_json::json;
365
366    #[test]
367    fn a_rule_over_a_null_field_holds_as_a_check_constraint_does() {
368        let row = json!({ "name": null });
369        let rule = ge(length(field(&row, "name")), Val::Num(1.0));
370        assert_eq!(rule, Val::Null);
371        assert!(holds(&rule));
372        // A missing field reads the same as a null one.
373        assert!(holds(&ge(length(field(&json!({}), "name")), Val::Num(1.0))));
374    }
375
376    #[test]
377    fn a_null_test_is_never_null_itself() {
378        let row = json!({ "email": null });
379        assert_eq!(is_not_null(field(&row, "email")), Val::Bool(false));
380        assert!(!holds(&and(is_not_null(field(&row, "email")), matches(field(&row, "email"), "@"))));
381    }
382
383    #[test]
384    fn logic_is_three_valued() {
385        assert_eq!(and(Val::Bool(false), Val::Null), Val::Bool(false));
386        assert_eq!(and(Val::Bool(true), Val::Null), Val::Null);
387        assert_eq!(or(Val::Bool(true), Val::Null), Val::Bool(true));
388        assert_eq!(or(Val::Bool(false), Val::Null), Val::Null);
389        assert_eq!(not(Val::Null), Val::Null);
390        assert_eq!(cond(Val::Null, Val::Bool(true), Val::Bool(false)), Val::Bool(false));
391    }
392
393    #[test]
394    fn a_decimal_serialized_as_text_compares_as_a_number() {
395        let row = json!({ "risk_score": "0.75", "count": 3 });
396        assert_eq!(le(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(true));
397        assert_eq!(gt(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(false));
398        assert_eq!(ge(field(&row, "count"), Val::Num(1.0)), Val::Bool(true));
399    }
400
401    #[test]
402    fn in_follows_sql_in() {
403        let list = Val::List(vec![text("json"), text("csv")]);
404        assert_eq!(in_list(text("csv"), list.clone()), Val::Bool(true));
405        assert_eq!(in_list(text("pdf"), list.clone()), Val::Bool(false));
406        assert_eq!(in_list(Val::Null, list.clone()), Val::Null);
407        assert_eq!(not_in(text("pdf"), list), Val::Bool(true));
408    }
409
410    #[test]
411    fn text_helpers_count_characters_not_bytes() {
412        assert_eq!(length(text("héllo")), Val::Num(5.0));
413        assert_eq!(length(trim(text("  a "))), Val::Num(1.0));
414        assert_eq!(contains(text("a/../b"), text("..")), Val::Bool(true));
415        assert_eq!(matches(text("my-bucket_1"), "^[a-zA-Z0-9_-]+$"), Val::Bool(true));
416        assert_eq!(matches(text("no spaces"), "^[a-zA-Z0-9_-]+$"), Val::Bool(false));
417    }
418
419    #[test]
420    fn a_stored_timestamp_or_date_compares_with_the_time_of_the_write() {
421        let at = DateTime::parse_from_rfc3339("2026-10-11T10:00:00Z").unwrap().with_timezone(&Utc);
422        let row = json!({ "expires_at": "2026-10-12T00:00:00Z", "dob": "1990-05-01", "gone": "2026-01-01T00:00:00+07:00" });
423        assert_eq!(gt(field(&row, "expires_at"), super::at(at)), Val::Bool(true));
424        assert_eq!(lt(field(&row, "dob"), super::at(at)), Val::Bool(true));
425        assert_eq!(gt(field(&row, "gone"), super::at(at)), Val::Bool(false));
426        assert_eq!(gt(field(&row, "missing"), super::at(at)), Val::Null);
427    }
428
429    #[test]
430    fn the_writer_and_the_row_before_the_write_are_readable() {
431        assert_eq!(eq(actor(Some("u1")), text("u1")), Val::Bool(true));
432        assert_eq!(eq(actor(None), text("u1")), Val::Null, "no writer: the rule holds");
433        let (before, after) = (json!({ "progress": 40, "x": 1 }), json!({ "progress": 30, "x": 1 }));
434        assert_eq!(ge(field(&after, "progress"), field(&before, "progress")), Val::Bool(false));
435        assert!(changed(Some(&before), &after, &["progress"]));
436        assert!(!changed(Some(&before), &after, &["x"]));
437        assert!(changed(None, &after, &["x"]), "a create changes everything");
438    }
439
440    #[test]
441    fn division_by_zero_is_null_not_a_panic() {
442        assert_eq!(div(Val::Num(1.0), Val::Num(0.0)), Val::Null);
443        assert_eq!(rem(Val::Num(1.0), Val::Num(0.0)), Val::Null);
444    }
445}