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`.
20
21use std::collections::HashMap;
22use std::sync::{Mutex, OnceLock};
23
24use serde_json::Value;
25
26/// One value inside a rule.
27#[derive(Debug, Clone, PartialEq)]
28pub enum Val {
29    Null,
30    Bool(bool),
31    Num(f64),
32    Text(String),
33    List(Vec<Val>),
34}
35
36impl Val {
37    fn from_json(v: &Value) -> Val {
38        match v {
39            Value::Null | Value::Object(_) => Val::Null,
40            Value::Bool(b) => Val::Bool(*b),
41            Value::Number(n) => n.as_f64().map(Val::Num).unwrap_or(Val::Null),
42            Value::String(s) => Val::Text(s.clone()),
43            Value::Array(items) => Val::List(items.iter().map(Val::from_json).collect()),
44        }
45    }
46
47    fn num(&self) -> Option<f64> {
48        match self {
49            Val::Num(n) => Some(*n),
50            Val::Text(s) => s.trim().parse().ok(),
51            _ => None,
52        }
53    }
54
55    fn text(&self) -> Option<&str> {
56        match self {
57            Val::Text(s) => Some(s),
58            _ => None,
59        }
60    }
61
62    fn truth(&self) -> Option<bool> {
63        match self {
64            Val::Bool(b) => Some(*b),
65            _ => None,
66        }
67    }
68}
69
70/// The row's field `name`; [`Val::Null`] when it is absent or null.
71pub fn field(row: &Value, name: &str) -> Val {
72    row.get(name).map(Val::from_json).unwrap_or(Val::Null)
73}
74
75pub fn text(s: &str) -> Val {
76    Val::Text(s.to_string())
77}
78
79/// Whether a rule's value lets the write through: everything but a definite `false`.
80pub fn holds(v: &Val) -> bool {
81    !matches!(v, Val::Bool(false))
82}
83
84// ── Null tests ───────────────────────────────────────────────────────────
85
86pub fn is_null(a: Val) -> Val {
87    Val::Bool(matches!(a, Val::Null))
88}
89
90pub fn is_not_null(a: Val) -> Val {
91    Val::Bool(!matches!(a, Val::Null))
92}
93
94// ── Logic ────────────────────────────────────────────────────────────────
95
96pub fn and(a: Val, b: Val) -> Val {
97    match (a.truth(), b.truth()) {
98        (Some(false), _) | (_, Some(false)) => Val::Bool(false),
99        (Some(true), Some(true)) => Val::Bool(true),
100        _ => Val::Null,
101    }
102}
103
104pub fn or(a: Val, b: Val) -> Val {
105    match (a.truth(), b.truth()) {
106        (Some(true), _) | (_, Some(true)) => Val::Bool(true),
107        (Some(false), Some(false)) => Val::Bool(false),
108        _ => Val::Null,
109    }
110}
111
112pub fn not(a: Val) -> Val {
113    a.truth().map(|b| Val::Bool(!b)).unwrap_or(Val::Null)
114}
115
116/// `c ? t : e`, as SQL's `CASE WHEN c THEN t ELSE e END`: a null condition takes `e`.
117pub fn cond(c: Val, t: Val, e: Val) -> Val {
118    if c.truth() == Some(true) {
119        t
120    } else {
121        e
122    }
123}
124
125// ── Comparison ───────────────────────────────────────────────────────────
126
127fn order(a: &Val, b: &Val) -> Option<std::cmp::Ordering> {
128    match (a, b) {
129        (Val::Null, _) | (_, Val::Null) => None,
130        (Val::Text(x), Val::Text(y)) => Some(x.cmp(y)),
131        (Val::Bool(x), Val::Bool(y)) => Some(x.cmp(y)),
132        _ => a.num()?.partial_cmp(&b.num()?),
133    }
134}
135
136fn compare(a: Val, b: Val, test: fn(std::cmp::Ordering) -> bool) -> Val {
137    order(&a, &b).map(|o| Val::Bool(test(o))).unwrap_or(Val::Null)
138}
139
140pub fn eq(a: Val, b: Val) -> Val {
141    compare(a, b, |o| o.is_eq())
142}
143
144pub fn ne(a: Val, b: Val) -> Val {
145    compare(a, b, |o| o.is_ne())
146}
147
148pub fn lt(a: Val, b: Val) -> Val {
149    compare(a, b, |o| o.is_lt())
150}
151
152pub fn le(a: Val, b: Val) -> Val {
153    compare(a, b, |o| o.is_le())
154}
155
156pub fn gt(a: Val, b: Val) -> Val {
157    compare(a, b, |o| o.is_gt())
158}
159
160pub fn ge(a: Val, b: Val) -> Val {
161    compare(a, b, |o| o.is_ge())
162}
163
164/// `a in [..]`, as SQL's `IN`: true on a match, null when `a` is null or no
165/// item matched but one was null, false otherwise.
166pub fn in_list(a: Val, list: Val) -> Val {
167    let Val::List(items) = list else { return Val::Null };
168    if matches!(a, Val::Null) {
169        return Val::Null;
170    }
171    let mut unknown = false;
172    for item in items {
173        match eq(a.clone(), item) {
174            Val::Bool(true) => return Val::Bool(true),
175            Val::Null => unknown = true,
176            _ => {}
177        }
178    }
179    if unknown {
180        Val::Null
181    } else {
182        Val::Bool(false)
183    }
184}
185
186pub fn not_in(a: Val, list: Val) -> Val {
187    not(in_list(a, list))
188}
189
190/// `a matches 'pattern'`: whether the text contains a match of the pattern.
191/// The generator only emits patterns that compile.
192pub fn matches(a: Val, pattern: &str) -> Val {
193    static CACHE: OnceLock<Mutex<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
194    let Some(s) = a.text() else { return Val::Null };
195    let mut cache = CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
196    let re = cache.entry(pattern.to_string()).or_insert_with(|| regex::Regex::new(pattern).ok());
197    match re {
198        Some(re) => Val::Bool(re.is_match(s)),
199        None => Val::Null,
200    }
201}
202
203// ── Arithmetic ───────────────────────────────────────────────────────────
204
205fn arith(a: Val, b: Val, f: fn(f64, f64) -> Option<f64>) -> Val {
206    match (a.num(), b.num()) {
207        (Some(x), Some(y)) => f(x, y).map(Val::Num).unwrap_or(Val::Null),
208        _ => Val::Null,
209    }
210}
211
212pub fn add(a: Val, b: Val) -> Val {
213    arith(a, b, |x, y| Some(x + y))
214}
215
216pub fn sub(a: Val, b: Val) -> Val {
217    arith(a, b, |x, y| Some(x - y))
218}
219
220pub fn mul(a: Val, b: Val) -> Val {
221    arith(a, b, |x, y| Some(x * y))
222}
223
224/// Division by zero is null rather than an error: a rule never panics a write.
225pub fn div(a: Val, b: Val) -> Val {
226    arith(a, b, |x, y| (y != 0.0).then(|| x / y))
227}
228
229pub fn rem(a: Val, b: Val) -> Val {
230    arith(a, b, |x, y| (y != 0.0).then(|| x % y))
231}
232
233pub fn neg(a: Val) -> Val {
234    a.num().map(|n| Val::Num(-n)).unwrap_or(Val::Null)
235}
236
237fn num_fn(a: Val, f: fn(f64) -> f64) -> Val {
238    a.num().map(|n| Val::Num(f(n))).unwrap_or(Val::Null)
239}
240
241pub fn abs(a: Val) -> Val {
242    num_fn(a, f64::abs)
243}
244
245pub fn ceil(a: Val) -> Val {
246    num_fn(a, f64::ceil)
247}
248
249pub fn floor(a: Val) -> Val {
250    num_fn(a, f64::floor)
251}
252
253pub fn round(a: Val) -> Val {
254    num_fn(a, f64::round)
255}
256
257/// The first value that is not null.
258pub fn coalesce(values: Vec<Val>) -> Val {
259    values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
260}
261
262// ── Text and lists ───────────────────────────────────────────────────────
263
264/// A text's length in characters (code points, as on every target), or a list's.
265pub fn length(a: Val) -> Val {
266    match a {
267        Val::Text(s) => Val::Num(s.chars().count() as f64),
268        Val::List(items) => Val::Num(items.len() as f64),
269        _ => Val::Null,
270    }
271}
272
273pub fn is_empty(a: Val) -> Val {
274    match length(a) {
275        Val::Num(n) => Val::Bool(n == 0.0),
276        _ => Val::Null,
277    }
278}
279
280fn text_fn(a: Val, f: fn(&str) -> String) -> Val {
281    a.text().map(|s| Val::Text(f(s))).unwrap_or(Val::Null)
282}
283
284pub fn trim(a: Val) -> Val {
285    text_fn(a, |s| s.trim().to_string())
286}
287
288pub fn lower(a: Val) -> Val {
289    text_fn(a, str::to_lowercase)
290}
291
292pub fn upper(a: Val) -> Val {
293    text_fn(a, str::to_uppercase)
294}
295
296/// `a.contains(b)`: a text containing a text, or a list containing an item.
297pub fn contains(a: Val, b: Val) -> Val {
298    match (a, b) {
299        (Val::Text(s), Val::Text(t)) => Val::Bool(s.contains(t.as_str())),
300        (list @ Val::List(_), item) => in_list(item, list),
301        _ => Val::Null,
302    }
303}
304
305pub fn starts_with(a: Val, b: Val) -> Val {
306    match (a.text(), b.text()) {
307        (Some(s), Some(t)) => Val::Bool(s.starts_with(t)),
308        _ => Val::Null,
309    }
310}
311
312pub fn ends_with(a: Val, b: Val) -> Val {
313    match (a.text(), b.text()) {
314        (Some(s), Some(t)) => Val::Bool(s.ends_with(t)),
315        _ => Val::Null,
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use serde_json::json;
323
324    #[test]
325    fn a_rule_over_a_null_field_holds_as_a_check_constraint_does() {
326        let row = json!({ "name": null });
327        let rule = ge(length(field(&row, "name")), Val::Num(1.0));
328        assert_eq!(rule, Val::Null);
329        assert!(holds(&rule));
330        // A missing field reads the same as a null one.
331        assert!(holds(&ge(length(field(&json!({}), "name")), Val::Num(1.0))));
332    }
333
334    #[test]
335    fn a_null_test_is_never_null_itself() {
336        let row = json!({ "email": null });
337        assert_eq!(is_not_null(field(&row, "email")), Val::Bool(false));
338        assert!(!holds(&and(is_not_null(field(&row, "email")), matches(field(&row, "email"), "@"))));
339    }
340
341    #[test]
342    fn logic_is_three_valued() {
343        assert_eq!(and(Val::Bool(false), Val::Null), Val::Bool(false));
344        assert_eq!(and(Val::Bool(true), Val::Null), Val::Null);
345        assert_eq!(or(Val::Bool(true), Val::Null), Val::Bool(true));
346        assert_eq!(or(Val::Bool(false), Val::Null), Val::Null);
347        assert_eq!(not(Val::Null), Val::Null);
348        assert_eq!(cond(Val::Null, Val::Bool(true), Val::Bool(false)), Val::Bool(false));
349    }
350
351    #[test]
352    fn a_decimal_serialized_as_text_compares_as_a_number() {
353        let row = json!({ "risk_score": "0.75", "count": 3 });
354        assert_eq!(le(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(true));
355        assert_eq!(gt(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(false));
356        assert_eq!(ge(field(&row, "count"), Val::Num(1.0)), Val::Bool(true));
357    }
358
359    #[test]
360    fn in_follows_sql_in() {
361        let list = Val::List(vec![text("json"), text("csv")]);
362        assert_eq!(in_list(text("csv"), list.clone()), Val::Bool(true));
363        assert_eq!(in_list(text("pdf"), list.clone()), Val::Bool(false));
364        assert_eq!(in_list(Val::Null, list.clone()), Val::Null);
365        assert_eq!(not_in(text("pdf"), list), Val::Bool(true));
366    }
367
368    #[test]
369    fn text_helpers_count_characters_not_bytes() {
370        assert_eq!(length(text("héllo")), Val::Num(5.0));
371        assert_eq!(length(trim(text("  a "))), Val::Num(1.0));
372        assert_eq!(contains(text("a/../b"), text("..")), Val::Bool(true));
373        assert_eq!(matches(text("my-bucket_1"), "^[a-zA-Z0-9_-]+$"), Val::Bool(true));
374        assert_eq!(matches(text("no spaces"), "^[a-zA-Z0-9_-]+$"), Val::Bool(false));
375    }
376
377    #[test]
378    fn division_by_zero_is_null_not_a_panic() {
379        assert_eq!(div(Val::Num(1.0), Val::Num(0.0)), Val::Null);
380        assert_eq!(rem(Val::Num(1.0), Val::Num(0.0)), Val::Null);
381    }
382}