1use std::collections::HashMap;
31use std::sync::{Mutex, OnceLock};
32
33use chrono::{DateTime, NaiveDate, Utc};
34use serde_json::Value;
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum Val {
39 Null,
40 Bool(bool),
41 Num(f64),
42 Text(String),
43 List(Vec<Val>),
44 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
96pub fn field(row: &Value, name: &str) -> Val {
98 row.get(name).map(Val::from_json).unwrap_or(Val::Null)
99}
100
101pub fn actor(actor: Option<&str>) -> Val {
103 actor.map(|a| Val::Text(a.to_string())).unwrap_or(Val::Null)
104}
105
106pub fn at(at: DateTime<Utc>) -> Val {
108 Val::Time(at)
109}
110
111pub 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
121pub 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
138pub 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
152pub 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
166pub 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
180pub fn holds(v: &Val) -> bool {
182 !matches!(v, Val::Bool(false))
183}
184
185pub 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
195pub 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
217pub fn cond(c: Val, t: Val, e: Val) -> Val {
219 if c.truth() == Some(true) {
220 t
221 } else {
222 e
223 }
224}
225
226fn 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
241fn 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
288pub 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
314pub 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
327fn 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
348pub 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
381pub fn coalesce(values: Vec<Val>) -> Val {
383 values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
384}
385
386pub 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
420pub 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 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 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 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 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}