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 holds(v: &Val) -> bool {
154 !matches!(v, Val::Bool(false))
155}
156
157pub fn is_null(a: Val) -> Val {
160 Val::Bool(matches!(a, Val::Null))
161}
162
163pub fn is_not_null(a: Val) -> Val {
164 Val::Bool(!matches!(a, Val::Null))
165}
166
167pub fn and(a: Val, b: Val) -> Val {
170 match (a.truth(), b.truth()) {
171 (Some(false), _) | (_, Some(false)) => Val::Bool(false),
172 (Some(true), Some(true)) => Val::Bool(true),
173 _ => Val::Null,
174 }
175}
176
177pub fn or(a: Val, b: Val) -> Val {
178 match (a.truth(), b.truth()) {
179 (Some(true), _) | (_, Some(true)) => Val::Bool(true),
180 (Some(false), Some(false)) => Val::Bool(false),
181 _ => Val::Null,
182 }
183}
184
185pub fn not(a: Val) -> Val {
186 a.truth().map(|b| Val::Bool(!b)).unwrap_or(Val::Null)
187}
188
189pub fn cond(c: Val, t: Val, e: Val) -> Val {
191 if c.truth() == Some(true) {
192 t
193 } else {
194 e
195 }
196}
197
198fn order(a: &Val, b: &Val) -> Option<std::cmp::Ordering> {
201 match (a, b) {
202 (Val::Null, _) | (_, Val::Null) => None,
203 (Val::Text(x), Val::Text(y)) => match (decimal(x), decimal(y)) {
204 (Some(x), Some(y)) => Some(x.cmp(&y)),
205 _ => Some(x.cmp(y)),
206 },
207 (Val::Bool(x), Val::Bool(y)) => Some(x.cmp(y)),
208 (Val::Time(_), _) | (_, Val::Time(_)) => Some(a.time()?.cmp(&b.time()?)),
209 _ => a.num()?.partial_cmp(&b.num()?),
210 }
211}
212
213fn decimal(s: &str) -> Option<rust_decimal::Decimal> {
217 let digits = s.strip_prefix('-').unwrap_or(s);
218 let (int, frac) = match digits.split_once('.') {
219 Some((i, f)) => (i, Some(f)),
220 None => (digits, None),
221 };
222 let int_ok = !int.is_empty()
223 && int.bytes().all(|b| b.is_ascii_digit())
224 && (int == "0" || !int.starts_with('0'));
225 let frac_ok = frac.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()));
226 if !(int_ok && frac_ok) {
227 return None;
228 }
229 s.parse().ok()
230}
231
232fn compare(a: Val, b: Val, test: fn(std::cmp::Ordering) -> bool) -> Val {
233 order(&a, &b).map(|o| Val::Bool(test(o))).unwrap_or(Val::Null)
234}
235
236pub fn eq(a: Val, b: Val) -> Val {
237 compare(a, b, |o| o.is_eq())
238}
239
240pub fn ne(a: Val, b: Val) -> Val {
241 compare(a, b, |o| o.is_ne())
242}
243
244pub fn lt(a: Val, b: Val) -> Val {
245 compare(a, b, |o| o.is_lt())
246}
247
248pub fn le(a: Val, b: Val) -> Val {
249 compare(a, b, |o| o.is_le())
250}
251
252pub fn gt(a: Val, b: Val) -> Val {
253 compare(a, b, |o| o.is_gt())
254}
255
256pub fn ge(a: Val, b: Val) -> Val {
257 compare(a, b, |o| o.is_ge())
258}
259
260pub fn in_list(a: Val, list: Val) -> Val {
263 let Val::List(items) = list else { return Val::Null };
264 if matches!(a, Val::Null) {
265 return Val::Null;
266 }
267 let mut unknown = false;
268 for item in items {
269 match eq(a.clone(), item) {
270 Val::Bool(true) => return Val::Bool(true),
271 Val::Null => unknown = true,
272 _ => {}
273 }
274 }
275 if unknown {
276 Val::Null
277 } else {
278 Val::Bool(false)
279 }
280}
281
282pub fn not_in(a: Val, list: Val) -> Val {
283 not(in_list(a, list))
284}
285
286pub fn matches(a: Val, pattern: &str) -> Val {
289 static CACHE: OnceLock<Mutex<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
290 let Some(s) = a.text() else { return Val::Null };
291 let mut cache = CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
292 let re = cache.entry(pattern.to_string()).or_insert_with(|| regex::Regex::new(pattern).ok());
293 match re {
294 Some(re) => Val::Bool(re.is_match(s)),
295 None => Val::Null,
296 }
297}
298
299fn arith(a: Val, b: Val, f: fn(f64, f64) -> Option<f64>) -> Val {
302 match (a.num(), b.num()) {
303 (Some(x), Some(y)) => f(x, y).map(Val::Num).unwrap_or(Val::Null),
304 _ => Val::Null,
305 }
306}
307
308pub fn add(a: Val, b: Val) -> Val {
309 arith(a, b, |x, y| Some(x + y))
310}
311
312pub fn sub(a: Val, b: Val) -> Val {
313 arith(a, b, |x, y| Some(x - y))
314}
315
316pub fn mul(a: Val, b: Val) -> Val {
317 arith(a, b, |x, y| Some(x * y))
318}
319
320pub fn div(a: Val, b: Val) -> Val {
322 arith(a, b, |x, y| (y != 0.0).then(|| x / y))
323}
324
325pub fn rem(a: Val, b: Val) -> Val {
326 arith(a, b, |x, y| (y != 0.0).then(|| x % y))
327}
328
329pub fn neg(a: Val) -> Val {
330 a.num().map(|n| Val::Num(-n)).unwrap_or(Val::Null)
331}
332
333fn num_fn(a: Val, f: fn(f64) -> f64) -> Val {
334 a.num().map(|n| Val::Num(f(n))).unwrap_or(Val::Null)
335}
336
337pub fn abs(a: Val) -> Val {
338 num_fn(a, f64::abs)
339}
340
341pub fn ceil(a: Val) -> Val {
342 num_fn(a, f64::ceil)
343}
344
345pub fn floor(a: Val) -> Val {
346 num_fn(a, f64::floor)
347}
348
349pub fn round(a: Val) -> Val {
350 num_fn(a, f64::round)
351}
352
353pub fn coalesce(values: Vec<Val>) -> Val {
355 values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
356}
357
358pub fn length(a: Val) -> Val {
362 match a {
363 Val::Text(s) => Val::Num(s.chars().count() as f64),
364 Val::List(items) => Val::Num(items.len() as f64),
365 _ => Val::Null,
366 }
367}
368
369pub fn is_empty(a: Val) -> Val {
370 match length(a) {
371 Val::Num(n) => Val::Bool(n == 0.0),
372 _ => Val::Null,
373 }
374}
375
376fn text_fn(a: Val, f: fn(&str) -> String) -> Val {
377 a.text().map(|s| Val::Text(f(s))).unwrap_or(Val::Null)
378}
379
380pub fn trim(a: Val) -> Val {
381 text_fn(a, |s| s.trim().to_string())
382}
383
384pub fn lower(a: Val) -> Val {
385 text_fn(a, str::to_lowercase)
386}
387
388pub fn upper(a: Val) -> Val {
389 text_fn(a, str::to_uppercase)
390}
391
392pub fn contains(a: Val, b: Val) -> Val {
394 match (a, b) {
395 (Val::Text(s), Val::Text(t)) => Val::Bool(s.contains(t.as_str())),
396 (list @ Val::List(_), item) => in_list(item, list),
397 _ => Val::Null,
398 }
399}
400
401pub fn starts_with(a: Val, b: Val) -> Val {
402 match (a.text(), b.text()) {
403 (Some(s), Some(t)) => Val::Bool(s.starts_with(t)),
404 _ => Val::Null,
405 }
406}
407
408pub fn ends_with(a: Val, b: Val) -> Val {
409 match (a.text(), b.text()) {
410 (Some(s), Some(t)) => Val::Bool(s.ends_with(t)),
411 _ => Val::Null,
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use serde_json::json;
419
420 #[test]
421 fn two_decimal_columns_compare_as_numbers_not_as_text() {
422 let row = json!({ "amount": "50", "budget_cap": "100" });
424 assert_eq!(le(field(&row, "amount"), field(&row, "budget_cap")), Val::Bool(true));
425 assert_eq!(gt(text("50"), text("100")), Val::Bool(false));
426 assert_eq!(lt(text("9.5"), text("10")), Val::Bool(true));
427 assert_eq!(lt(text("-20"), text("-3")), Val::Bool(true));
428 assert_eq!(gt(text("-0.5"), text("-1")), Val::Bool(true));
429 assert_eq!(eq(text("1.10"), text("1.1")), Val::Bool(true));
431 assert_eq!(ge(text("100.00"), text("100")), Val::Bool(true));
432 }
433
434 #[test]
435 fn text_that_only_looks_numeric_still_compares_as_text() {
436 assert_eq!(eq(text("007"), text("7")), Val::Bool(false));
437 assert_eq!(lt(text("abc"), text("abd")), Val::Bool(true));
438 assert_eq!(lt(text("10"), text("9a")), Val::Bool(true), "one side is not a decimal");
439 assert_eq!(eq(text("1e3"), text("1000")), Val::Bool(false));
440 }
441
442 #[test]
443 fn a_rule_over_a_null_field_holds_as_a_check_constraint_does() {
444 let row = json!({ "name": null });
445 let rule = ge(length(field(&row, "name")), Val::Num(1.0));
446 assert_eq!(rule, Val::Null);
447 assert!(holds(&rule));
448 assert!(holds(&ge(length(field(&json!({}), "name")), Val::Num(1.0))));
450 }
451
452 #[test]
453 fn a_null_test_is_never_null_itself() {
454 let row = json!({ "email": null });
455 assert_eq!(is_not_null(field(&row, "email")), Val::Bool(false));
456 assert!(!holds(&and(is_not_null(field(&row, "email")), matches(field(&row, "email"), "@"))));
457 }
458
459 #[test]
460 fn logic_is_three_valued() {
461 assert_eq!(and(Val::Bool(false), Val::Null), Val::Bool(false));
462 assert_eq!(and(Val::Bool(true), Val::Null), Val::Null);
463 assert_eq!(or(Val::Bool(true), Val::Null), Val::Bool(true));
464 assert_eq!(or(Val::Bool(false), Val::Null), Val::Null);
465 assert_eq!(not(Val::Null), Val::Null);
466 assert_eq!(cond(Val::Null, Val::Bool(true), Val::Bool(false)), Val::Bool(false));
467 }
468
469 #[test]
470 fn a_decimal_serialized_as_text_compares_as_a_number() {
471 let row = json!({ "risk_score": "0.75", "count": 3 });
472 assert_eq!(le(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(true));
473 assert_eq!(gt(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(false));
474 assert_eq!(ge(field(&row, "count"), Val::Num(1.0)), Val::Bool(true));
475 }
476
477 #[test]
478 fn in_follows_sql_in() {
479 let list = Val::List(vec![text("json"), text("csv")]);
480 assert_eq!(in_list(text("csv"), list.clone()), Val::Bool(true));
481 assert_eq!(in_list(text("pdf"), list.clone()), Val::Bool(false));
482 assert_eq!(in_list(Val::Null, list.clone()), Val::Null);
483 assert_eq!(not_in(text("pdf"), list), Val::Bool(true));
484 }
485
486 #[test]
487 fn text_helpers_count_characters_not_bytes() {
488 assert_eq!(length(text("héllo")), Val::Num(5.0));
489 assert_eq!(length(trim(text(" a "))), Val::Num(1.0));
490 assert_eq!(contains(text("a/../b"), text("..")), Val::Bool(true));
491 assert_eq!(matches(text("my-bucket_1"), "^[a-zA-Z0-9_-]+$"), Val::Bool(true));
492 assert_eq!(matches(text("no spaces"), "^[a-zA-Z0-9_-]+$"), Val::Bool(false));
493 }
494
495 #[test]
496 fn a_stored_timestamp_or_date_compares_with_the_time_of_the_write() {
497 let at = DateTime::parse_from_rfc3339("2026-10-11T10:00:00Z").unwrap().with_timezone(&Utc);
498 let row = json!({ "expires_at": "2026-10-12T00:00:00Z", "dob": "1990-05-01", "gone": "2026-01-01T00:00:00+07:00" });
499 assert_eq!(gt(field(&row, "expires_at"), super::at(at)), Val::Bool(true));
500 assert_eq!(lt(field(&row, "dob"), super::at(at)), Val::Bool(true));
501 assert_eq!(gt(field(&row, "gone"), super::at(at)), Val::Bool(false));
502 assert_eq!(gt(field(&row, "missing"), super::at(at)), Val::Null);
503 }
504
505 #[test]
506 fn the_writer_and_the_row_before_the_write_are_readable() {
507 assert_eq!(eq(actor(Some("u1")), text("u1")), Val::Bool(true));
508 assert_eq!(eq(actor(None), text("u1")), Val::Null, "no writer: the rule holds");
509 let (before, after) = (json!({ "progress": 40, "x": 1 }), json!({ "progress": 30, "x": 1 }));
510 assert_eq!(ge(field(&after, "progress"), field(&before, "progress")), Val::Bool(false));
511 assert!(changed(Some(&before), &after, &["progress"]));
512 assert!(!changed(Some(&before), &after, &["x"]));
513 assert!(changed(None, &after, &["x"]), "a create changes everything");
514 }
515
516 #[tokio::test]
517 async fn a_role_rule_asks_the_installed_source_and_holds_without_one() {
518 struct Fixed;
519 #[async_trait::async_trait]
520 impl crate::actor_roles::ActorRoles for Fixed {
521 async fn holds(&self, actor: &str, role: &str) -> Option<bool> {
522 (actor == "u1").then(|| role.eq_ignore_ascii_case("ADMIN"))
523 }
524 }
525 assert_eq!(has_role(None, "admin").await, Val::Null, "no writer");
526 crate::actor_roles::install(std::sync::Arc::new(Fixed));
527 assert_eq!(has_role(Some("u1"), "admin").await, Val::Bool(true));
528 assert_eq!(has_role(Some("u1"), "auditor").await, Val::Bool(false));
529 assert_eq!(has_role(Some("u2"), "admin").await, Val::Null, "the source cannot tell");
530 assert_eq!(referencing("SELECT count(*) FROM t WHERE x = $1::uuid", text("id")).await, Val::Null);
532 assert_eq!(referencing("SELECT 1", Val::Null).await, Val::Null);
533 }
534
535 #[test]
536 fn division_by_zero_is_null_not_a_panic() {
537 assert_eq!(div(Val::Num(1.0), Val::Num(0.0)), Val::Null);
538 assert_eq!(rem(Val::Num(1.0), Val::Num(0.0)), Val::Null);
539 }
540}