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//! - Two texts that both read as decimals compare as numbers, exactly: a
22//!   decimal column serializes as a string, so `amount <= budget_cap` reads two
23//!   texts, and as text `"50" <= "100"` is false. A decimal here is written as a
24//!   decimal serializes (an optional `-`, digits without a leading zero, an
25//!   optional fraction), so a code such as `"007"` still compares as text.
26//!
27//! A rule may also read the row before the write (`$old.x`, through [`field`]
28//! on that row), the writer ([`actor`]) and the time of the write ([`at`]).
29
30use std::collections::HashMap;
31use std::sync::{Mutex, OnceLock};
32
33use chrono::{DateTime, NaiveDate, Utc};
34use serde_json::Value;
35
36/// One value inside a rule.
37#[derive(Debug, Clone, PartialEq)]
38pub enum Val {
39    Null,
40    Bool(bool),
41    Num(f64),
42    Text(String),
43    List(Vec<Val>),
44    /// A point in time: the write's `now()`, or a stored timestamp or date read as one.
45    Time(DateTime<Utc>),
46}
47
48impl Val {
49    fn from_json(v: &Value) -> Val {
50        match v {
51            Value::Null | Value::Object(_) => Val::Null,
52            Value::Bool(b) => Val::Bool(*b),
53            Value::Number(n) => n.as_f64().map(Val::Num).unwrap_or(Val::Null),
54            Value::String(s) => Val::Text(s.clone()),
55            Value::Array(items) => Val::List(items.iter().map(Val::from_json).collect()),
56        }
57    }
58
59    fn num(&self) -> Option<f64> {
60        match self {
61            Val::Num(n) => Some(*n),
62            Val::Text(s) => s.trim().parse().ok(),
63            _ => None,
64        }
65    }
66
67    fn time(&self) -> Option<DateTime<Utc>> {
68        match self {
69            Val::Time(t) => Some(*t),
70            Val::Text(s) => DateTime::parse_from_rfc3339(s)
71                .map(|t| t.with_timezone(&Utc))
72                .ok()
73                .or_else(|| {
74                    let d = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
75                    Some(d.and_hms_opt(0, 0, 0)?.and_utc())
76                }),
77            _ => None,
78        }
79    }
80
81    fn text(&self) -> Option<&str> {
82        match self {
83            Val::Text(s) => Some(s),
84            _ => None,
85        }
86    }
87
88    fn truth(&self) -> Option<bool> {
89        match self {
90            Val::Bool(b) => Some(*b),
91            _ => None,
92        }
93    }
94}
95
96/// The row's field `name`; [`Val::Null`] when it is absent or null.
97pub fn field(row: &Value, name: &str) -> Val {
98    row.get(name).map(Val::from_json).unwrap_or(Val::Null)
99}
100
101/// The writer, as `$actor.id` reads it; null for a job, a seed or an unscoped caller.
102pub fn actor(actor: Option<&str>) -> Val {
103    actor.map(|a| Val::Text(a.to_string())).unwrap_or(Val::Null)
104}
105
106/// The time of the write, as `now()` reads it.
107pub fn at(at: DateTime<Utc>) -> Val {
108    Val::Time(at)
109}
110
111/// Whether any of `fields` differs between the row before and the row after a write. A rule
112/// about the time of the write checks an update only when it changes what the rule reads, so a
113/// later edit of a row whose deadline has passed is not a break.
114pub fn changed(before: Option<&Value>, after: &Value, fields: &[&str]) -> bool {
115    match before {
116        None => true,
117        Some(b) => fields.iter().any(|f| b.get(*f) != after.get(*f)),
118    }
119}
120
121/// `$actor.has_role(role)`: null without a writer, without an installed roles source
122/// ([`crate::actor_roles`]), or when the source cannot tell; the rule then holds.
123pub async fn has_role(actor: Option<&str>, role: &str) -> Val {
124    static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
125    let Some(actor) = actor else { return Val::Null };
126    let Some(roles) = crate::actor_roles::installed() else {
127        if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
128            tracing::warn!(
129                target: "backbone::write_guard",
130                "a role rule ran with no roles source installed (backbone_core::actor_roles::install); role rules check nothing"
131            );
132        }
133        return Val::Null;
134    };
135    roles.holds(actor, role).await.map(Val::Bool).unwrap_or(Val::Null)
136}
137
138/// How many rows still reference the row being deleted: `sql` counts them with the row's id as
139/// `$1`, run on the request's own connection. Null outside a request or without an id.
140pub async fn referencing(sql: &str, id: Val) -> Val {
141    let Val::Text(id) = id else { return Val::Null };
142    backbone_orm::company_scope::count_on_request_conn(sql, &[id.as_str()])
143        .await
144        .map(|n| Val::Num(n as f64))
145        .unwrap_or(Val::Null)
146}
147
148pub fn text(s: &str) -> Val {
149    Val::Text(s.to_string())
150}
151
152/// A rule value as JSON, for a hook that builds a payload or sets a field from an expression: a
153/// whole number is a JSON integer (so it reads into an integer column), a time its RFC 3339 text.
154pub fn to_json(v: &Val) -> Value {
155    match v {
156        Val::Null => Value::Null,
157        Val::Bool(b) => Value::Bool(*b),
158        Val::Num(n) if n.fract() == 0.0 && n.abs() < 9.0e15 => Value::from(*n as i64),
159        Val::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
160        Val::Text(s) => Value::String(s.clone()),
161        Val::List(items) => Value::Array(items.iter().map(to_json).collect()),
162        Val::Time(t) => Value::String(t.to_rfc3339()),
163    }
164}
165
166/// A rule value as text, for interpolating into a message: null is empty, a list its items joined
167/// with a comma, a whole number without a fraction.
168pub fn to_text(v: &Val) -> String {
169    match v {
170        Val::Null => String::new(),
171        Val::Bool(b) => b.to_string(),
172        Val::Num(n) if n.fract() == 0.0 && n.abs() < 9.0e15 => (*n as i64).to_string(),
173        Val::Num(n) => n.to_string(),
174        Val::Text(s) => s.clone(),
175        Val::List(items) => items.iter().map(to_text).collect::<Vec<_>>().join(", "),
176        Val::Time(t) => t.to_rfc3339(),
177    }
178}
179
180/// Whether a rule's value lets the write through: everything but a definite `false`.
181pub fn holds(v: &Val) -> bool {
182    !matches!(v, Val::Bool(false))
183}
184
185// ── Null tests ───────────────────────────────────────────────────────────
186
187pub fn is_null(a: Val) -> Val {
188    Val::Bool(matches!(a, Val::Null))
189}
190
191pub fn is_not_null(a: Val) -> Val {
192    Val::Bool(!matches!(a, Val::Null))
193}
194
195// ── Logic ────────────────────────────────────────────────────────────────
196
197pub fn and(a: Val, b: Val) -> Val {
198    match (a.truth(), b.truth()) {
199        (Some(false), _) | (_, Some(false)) => Val::Bool(false),
200        (Some(true), Some(true)) => Val::Bool(true),
201        _ => Val::Null,
202    }
203}
204
205pub fn or(a: Val, b: Val) -> Val {
206    match (a.truth(), b.truth()) {
207        (Some(true), _) | (_, Some(true)) => Val::Bool(true),
208        (Some(false), Some(false)) => Val::Bool(false),
209        _ => Val::Null,
210    }
211}
212
213pub fn not(a: Val) -> Val {
214    a.truth().map(|b| Val::Bool(!b)).unwrap_or(Val::Null)
215}
216
217/// `c ? t : e`, as SQL's `CASE WHEN c THEN t ELSE e END`: a null condition takes `e`.
218pub fn cond(c: Val, t: Val, e: Val) -> Val {
219    if c.truth() == Some(true) {
220        t
221    } else {
222        e
223    }
224}
225
226// ── Comparison ───────────────────────────────────────────────────────────
227
228fn order(a: &Val, b: &Val) -> Option<std::cmp::Ordering> {
229    match (a, b) {
230        (Val::Null, _) | (_, Val::Null) => None,
231        (Val::Text(x), Val::Text(y)) => match (decimal(x), decimal(y)) {
232            (Some(x), Some(y)) => Some(x.cmp(&y)),
233            _ => Some(x.cmp(y)),
234        },
235        (Val::Bool(x), Val::Bool(y)) => Some(x.cmp(y)),
236        (Val::Time(_), _) | (_, Val::Time(_)) => Some(a.time()?.cmp(&b.time()?)),
237        _ => a.num()?.partial_cmp(&b.num()?),
238    }
239}
240
241/// `s` as an exact decimal, when it is written the way a decimal serializes: an optional
242/// `-`, an integer part with no leading zero (or `0` alone), and an optional fraction. Text
243/// that only looks numeric — `"007"`, `"+1"`, `"1e3"`, `" 1"` — stays text.
244fn decimal(s: &str) -> Option<rust_decimal::Decimal> {
245    let digits = s.strip_prefix('-').unwrap_or(s);
246    let (int, frac) = match digits.split_once('.') {
247        Some((i, f)) => (i, Some(f)),
248        None => (digits, None),
249    };
250    let int_ok = !int.is_empty()
251        && int.bytes().all(|b| b.is_ascii_digit())
252        && (int == "0" || !int.starts_with('0'));
253    let frac_ok = frac.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()));
254    if !(int_ok && frac_ok) {
255        return None;
256    }
257    s.parse().ok()
258}
259
260fn compare(a: Val, b: Val, test: fn(std::cmp::Ordering) -> bool) -> Val {
261    order(&a, &b).map(|o| Val::Bool(test(o))).unwrap_or(Val::Null)
262}
263
264pub fn eq(a: Val, b: Val) -> Val {
265    compare(a, b, |o| o.is_eq())
266}
267
268pub fn ne(a: Val, b: Val) -> Val {
269    compare(a, b, |o| o.is_ne())
270}
271
272pub fn lt(a: Val, b: Val) -> Val {
273    compare(a, b, |o| o.is_lt())
274}
275
276pub fn le(a: Val, b: Val) -> Val {
277    compare(a, b, |o| o.is_le())
278}
279
280pub fn gt(a: Val, b: Val) -> Val {
281    compare(a, b, |o| o.is_gt())
282}
283
284pub fn ge(a: Val, b: Val) -> Val {
285    compare(a, b, |o| o.is_ge())
286}
287
288/// `a in [..]`, as SQL's `IN`: true on a match, null when `a` is null or no
289/// item matched but one was null, false otherwise.
290pub fn in_list(a: Val, list: Val) -> Val {
291    let Val::List(items) = list else { return Val::Null };
292    if matches!(a, Val::Null) {
293        return Val::Null;
294    }
295    let mut unknown = false;
296    for item in items {
297        match eq(a.clone(), item) {
298            Val::Bool(true) => return Val::Bool(true),
299            Val::Null => unknown = true,
300            _ => {}
301        }
302    }
303    if unknown {
304        Val::Null
305    } else {
306        Val::Bool(false)
307    }
308}
309
310pub fn not_in(a: Val, list: Val) -> Val {
311    not(in_list(a, list))
312}
313
314/// `a matches 'pattern'`: whether the text contains a match of the pattern.
315/// The generator only emits patterns that compile.
316pub fn matches(a: Val, pattern: &str) -> Val {
317    static CACHE: OnceLock<Mutex<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
318    let Some(s) = a.text() else { return Val::Null };
319    let mut cache = CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
320    let re = cache.entry(pattern.to_string()).or_insert_with(|| regex::Regex::new(pattern).ok());
321    match re {
322        Some(re) => Val::Bool(re.is_match(s)),
323        None => Val::Null,
324    }
325}
326
327// ── Arithmetic ───────────────────────────────────────────────────────────
328
329fn arith(a: Val, b: Val, f: fn(f64, f64) -> Option<f64>) -> Val {
330    match (a.num(), b.num()) {
331        (Some(x), Some(y)) => f(x, y).map(Val::Num).unwrap_or(Val::Null),
332        _ => Val::Null,
333    }
334}
335
336pub fn add(a: Val, b: Val) -> Val {
337    arith(a, b, |x, y| Some(x + y))
338}
339
340pub fn sub(a: Val, b: Val) -> Val {
341    arith(a, b, |x, y| Some(x - y))
342}
343
344pub fn mul(a: Val, b: Val) -> Val {
345    arith(a, b, |x, y| Some(x * y))
346}
347
348/// Division by zero is null rather than an error: a rule never panics a write.
349pub fn div(a: Val, b: Val) -> Val {
350    arith(a, b, |x, y| (y != 0.0).then(|| x / y))
351}
352
353pub fn rem(a: Val, b: Val) -> Val {
354    arith(a, b, |x, y| (y != 0.0).then(|| x % y))
355}
356
357pub fn neg(a: Val) -> Val {
358    a.num().map(|n| Val::Num(-n)).unwrap_or(Val::Null)
359}
360
361fn num_fn(a: Val, f: fn(f64) -> f64) -> Val {
362    a.num().map(|n| Val::Num(f(n))).unwrap_or(Val::Null)
363}
364
365pub fn abs(a: Val) -> Val {
366    num_fn(a, f64::abs)
367}
368
369pub fn ceil(a: Val) -> Val {
370    num_fn(a, f64::ceil)
371}
372
373pub fn floor(a: Val) -> Val {
374    num_fn(a, f64::floor)
375}
376
377pub fn round(a: Val) -> Val {
378    num_fn(a, f64::round)
379}
380
381/// The first value that is not null.
382pub fn coalesce(values: Vec<Val>) -> Val {
383    values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
384}
385
386// ── Text and lists ───────────────────────────────────────────────────────
387
388/// A text's length in characters (code points, as on every target), or a list's.
389pub fn length(a: Val) -> Val {
390    match a {
391        Val::Text(s) => Val::Num(s.chars().count() as f64),
392        Val::List(items) => Val::Num(items.len() as f64),
393        _ => Val::Null,
394    }
395}
396
397pub fn is_empty(a: Val) -> Val {
398    match length(a) {
399        Val::Num(n) => Val::Bool(n == 0.0),
400        _ => Val::Null,
401    }
402}
403
404fn text_fn(a: Val, f: fn(&str) -> String) -> Val {
405    a.text().map(|s| Val::Text(f(s))).unwrap_or(Val::Null)
406}
407
408pub fn trim(a: Val) -> Val {
409    text_fn(a, |s| s.trim().to_string())
410}
411
412pub fn lower(a: Val) -> Val {
413    text_fn(a, str::to_lowercase)
414}
415
416pub fn upper(a: Val) -> Val {
417    text_fn(a, str::to_uppercase)
418}
419
420/// `a.contains(b)`: a text containing a text, or a list containing an item.
421pub fn contains(a: Val, b: Val) -> Val {
422    match (a, b) {
423        (Val::Text(s), Val::Text(t)) => Val::Bool(s.contains(t.as_str())),
424        (list @ Val::List(_), item) => in_list(item, list),
425        _ => Val::Null,
426    }
427}
428
429pub fn starts_with(a: Val, b: Val) -> Val {
430    match (a.text(), b.text()) {
431        (Some(s), Some(t)) => Val::Bool(s.starts_with(t)),
432        _ => Val::Null,
433    }
434}
435
436pub fn ends_with(a: Val, b: Val) -> Val {
437    match (a.text(), b.text()) {
438        (Some(s), Some(t)) => Val::Bool(s.ends_with(t)),
439        _ => Val::Null,
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use serde_json::json;
447
448    #[test]
449    fn two_decimal_columns_compare_as_numbers_not_as_text() {
450        // rust_decimal serializes as a JSON string, so a rule over two decimals reads two texts.
451        let row = json!({ "amount": "50", "budget_cap": "100" });
452        assert_eq!(le(field(&row, "amount"), field(&row, "budget_cap")), Val::Bool(true));
453        assert_eq!(gt(text("50"), text("100")), Val::Bool(false));
454        assert_eq!(lt(text("9.5"), text("10")), Val::Bool(true));
455        assert_eq!(lt(text("-20"), text("-3")), Val::Bool(true));
456        assert_eq!(gt(text("-0.5"), text("-1")), Val::Bool(true));
457        // Scale does not change the value.
458        assert_eq!(eq(text("1.10"), text("1.1")), Val::Bool(true));
459        assert_eq!(ge(text("100.00"), text("100")), Val::Bool(true));
460    }
461
462    #[test]
463    fn a_value_reads_as_json_and_as_text() {
464        let at = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z").unwrap().with_timezone(&Utc);
465        assert_eq!(to_json(&Val::Num(3.0)), json!(3), "a whole number is an integer, so it reads into an int column");
466        assert_eq!(to_json(&Val::Num(2.5)), json!(2.5));
467        assert_eq!(to_json(&Val::Null), Value::Null);
468        assert_eq!(to_json(&Val::List(vec![text("a"), Val::Bool(true)])), json!(["a", true]));
469        assert_eq!(to_json(&Val::Time(at)), json!("2026-01-02T03:04:05+00:00"));
470        assert_eq!(to_text(&Val::Num(3.0)), "3");
471        assert_eq!(to_text(&Val::Null), "");
472        assert_eq!(to_text(&Val::List(vec![text("a"), Val::Num(2.0)])), "a, 2");
473        assert_eq!(to_text(&text("hi")), "hi");
474    }
475
476    #[test]
477    fn text_that_only_looks_numeric_still_compares_as_text() {
478        assert_eq!(eq(text("007"), text("7")), Val::Bool(false));
479        assert_eq!(lt(text("abc"), text("abd")), Val::Bool(true));
480        assert_eq!(lt(text("10"), text("9a")), Val::Bool(true), "one side is not a decimal");
481        assert_eq!(eq(text("1e3"), text("1000")), Val::Bool(false));
482    }
483
484    #[test]
485    fn a_rule_over_a_null_field_holds_as_a_check_constraint_does() {
486        let row = json!({ "name": null });
487        let rule = ge(length(field(&row, "name")), Val::Num(1.0));
488        assert_eq!(rule, Val::Null);
489        assert!(holds(&rule));
490        // A missing field reads the same as a null one.
491        assert!(holds(&ge(length(field(&json!({}), "name")), Val::Num(1.0))));
492    }
493
494    #[test]
495    fn a_null_test_is_never_null_itself() {
496        let row = json!({ "email": null });
497        assert_eq!(is_not_null(field(&row, "email")), Val::Bool(false));
498        assert!(!holds(&and(is_not_null(field(&row, "email")), matches(field(&row, "email"), "@"))));
499    }
500
501    #[test]
502    fn logic_is_three_valued() {
503        assert_eq!(and(Val::Bool(false), Val::Null), Val::Bool(false));
504        assert_eq!(and(Val::Bool(true), Val::Null), Val::Null);
505        assert_eq!(or(Val::Bool(true), Val::Null), Val::Bool(true));
506        assert_eq!(or(Val::Bool(false), Val::Null), Val::Null);
507        assert_eq!(not(Val::Null), Val::Null);
508        assert_eq!(cond(Val::Null, Val::Bool(true), Val::Bool(false)), Val::Bool(false));
509    }
510
511    #[test]
512    fn a_decimal_serialized_as_text_compares_as_a_number() {
513        let row = json!({ "risk_score": "0.75", "count": 3 });
514        assert_eq!(le(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(true));
515        assert_eq!(gt(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(false));
516        assert_eq!(ge(field(&row, "count"), Val::Num(1.0)), Val::Bool(true));
517    }
518
519    #[test]
520    fn in_follows_sql_in() {
521        let list = Val::List(vec![text("json"), text("csv")]);
522        assert_eq!(in_list(text("csv"), list.clone()), Val::Bool(true));
523        assert_eq!(in_list(text("pdf"), list.clone()), Val::Bool(false));
524        assert_eq!(in_list(Val::Null, list.clone()), Val::Null);
525        assert_eq!(not_in(text("pdf"), list), Val::Bool(true));
526    }
527
528    #[test]
529    fn text_helpers_count_characters_not_bytes() {
530        assert_eq!(length(text("héllo")), Val::Num(5.0));
531        assert_eq!(length(trim(text("  a "))), Val::Num(1.0));
532        assert_eq!(contains(text("a/../b"), text("..")), Val::Bool(true));
533        assert_eq!(matches(text("my-bucket_1"), "^[a-zA-Z0-9_-]+$"), Val::Bool(true));
534        assert_eq!(matches(text("no spaces"), "^[a-zA-Z0-9_-]+$"), Val::Bool(false));
535    }
536
537    #[test]
538    fn a_stored_timestamp_or_date_compares_with_the_time_of_the_write() {
539        let at = DateTime::parse_from_rfc3339("2026-10-11T10:00:00Z").unwrap().with_timezone(&Utc);
540        let row = json!({ "expires_at": "2026-10-12T00:00:00Z", "dob": "1990-05-01", "gone": "2026-01-01T00:00:00+07:00" });
541        assert_eq!(gt(field(&row, "expires_at"), super::at(at)), Val::Bool(true));
542        assert_eq!(lt(field(&row, "dob"), super::at(at)), Val::Bool(true));
543        assert_eq!(gt(field(&row, "gone"), super::at(at)), Val::Bool(false));
544        assert_eq!(gt(field(&row, "missing"), super::at(at)), Val::Null);
545    }
546
547    #[test]
548    fn the_writer_and_the_row_before_the_write_are_readable() {
549        assert_eq!(eq(actor(Some("u1")), text("u1")), Val::Bool(true));
550        assert_eq!(eq(actor(None), text("u1")), Val::Null, "no writer: the rule holds");
551        let (before, after) = (json!({ "progress": 40, "x": 1 }), json!({ "progress": 30, "x": 1 }));
552        assert_eq!(ge(field(&after, "progress"), field(&before, "progress")), Val::Bool(false));
553        assert!(changed(Some(&before), &after, &["progress"]));
554        assert!(!changed(Some(&before), &after, &["x"]));
555        assert!(changed(None, &after, &["x"]), "a create changes everything");
556    }
557
558    #[tokio::test]
559    async fn a_role_rule_asks_the_installed_source_and_holds_without_one() {
560        struct Fixed;
561        #[async_trait::async_trait]
562        impl crate::actor_roles::ActorRoles for Fixed {
563            async fn holds(&self, actor: &str, role: &str) -> Option<bool> {
564                (actor == "u1").then(|| role.eq_ignore_ascii_case("ADMIN"))
565            }
566        }
567        assert_eq!(has_role(None, "admin").await, Val::Null, "no writer");
568        crate::actor_roles::install(std::sync::Arc::new(Fixed));
569        assert_eq!(has_role(Some("u1"), "admin").await, Val::Bool(true));
570        assert_eq!(has_role(Some("u1"), "auditor").await, Val::Bool(false));
571        assert_eq!(has_role(Some("u2"), "admin").await, Val::Null, "the source cannot tell");
572        // Outside a request there is no connection to count on: the delete guard holds.
573        assert_eq!(referencing("SELECT count(*) FROM t WHERE x = $1::uuid", text("id")).await, Val::Null);
574        assert_eq!(referencing("SELECT 1", Val::Null).await, Val::Null);
575    }
576
577    #[test]
578    fn division_by_zero_is_null_not_a_panic() {
579        assert_eq!(div(Val::Num(1.0), Val::Num(0.0)), Val::Null);
580        assert_eq!(rem(Val::Num(1.0), Val::Num(0.0)), Val::Null);
581    }
582}