1use std::collections::HashMap;
26use std::sync::{Mutex, OnceLock};
27
28use chrono::{DateTime, NaiveDate, Utc};
29use serde_json::Value;
30
31#[derive(Debug, Clone, PartialEq)]
33pub enum Val {
34 Null,
35 Bool(bool),
36 Num(f64),
37 Text(String),
38 List(Vec<Val>),
39 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
91pub fn field(row: &Value, name: &str) -> Val {
93 row.get(name).map(Val::from_json).unwrap_or(Val::Null)
94}
95
96pub fn actor(actor: Option<&str>) -> Val {
98 actor.map(|a| Val::Text(a.to_string())).unwrap_or(Val::Null)
99}
100
101pub fn at(at: DateTime<Utc>) -> Val {
103 Val::Time(at)
104}
105
106pub 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 async fn has_role(actor: Option<&str>, role: &str) -> Val {
119 static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
120 let Some(actor) = actor else { return Val::Null };
121 let Some(roles) = crate::actor_roles::installed() else {
122 if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
123 tracing::warn!(
124 target: "backbone::write_guard",
125 "a role rule ran with no roles source installed (backbone_core::actor_roles::install); role rules check nothing"
126 );
127 }
128 return Val::Null;
129 };
130 roles.holds(actor, role).await.map(Val::Bool).unwrap_or(Val::Null)
131}
132
133pub async fn referencing(sql: &str, id: Val) -> Val {
136 let Val::Text(id) = id else { return Val::Null };
137 backbone_orm::company_scope::count_on_request_conn(sql, &[id.as_str()])
138 .await
139 .map(|n| Val::Num(n as f64))
140 .unwrap_or(Val::Null)
141}
142
143pub fn text(s: &str) -> Val {
144 Val::Text(s.to_string())
145}
146
147pub fn holds(v: &Val) -> bool {
149 !matches!(v, Val::Bool(false))
150}
151
152pub fn is_null(a: Val) -> Val {
155 Val::Bool(matches!(a, Val::Null))
156}
157
158pub fn is_not_null(a: Val) -> Val {
159 Val::Bool(!matches!(a, Val::Null))
160}
161
162pub fn and(a: Val, b: Val) -> Val {
165 match (a.truth(), b.truth()) {
166 (Some(false), _) | (_, Some(false)) => Val::Bool(false),
167 (Some(true), Some(true)) => Val::Bool(true),
168 _ => Val::Null,
169 }
170}
171
172pub fn or(a: Val, b: Val) -> Val {
173 match (a.truth(), b.truth()) {
174 (Some(true), _) | (_, Some(true)) => Val::Bool(true),
175 (Some(false), Some(false)) => Val::Bool(false),
176 _ => Val::Null,
177 }
178}
179
180pub fn not(a: Val) -> Val {
181 a.truth().map(|b| Val::Bool(!b)).unwrap_or(Val::Null)
182}
183
184pub fn cond(c: Val, t: Val, e: Val) -> Val {
186 if c.truth() == Some(true) {
187 t
188 } else {
189 e
190 }
191}
192
193fn order(a: &Val, b: &Val) -> Option<std::cmp::Ordering> {
196 match (a, b) {
197 (Val::Null, _) | (_, Val::Null) => None,
198 (Val::Text(x), Val::Text(y)) => Some(x.cmp(y)),
199 (Val::Bool(x), Val::Bool(y)) => Some(x.cmp(y)),
200 (Val::Time(_), _) | (_, Val::Time(_)) => Some(a.time()?.cmp(&b.time()?)),
201 _ => a.num()?.partial_cmp(&b.num()?),
202 }
203}
204
205fn compare(a: Val, b: Val, test: fn(std::cmp::Ordering) -> bool) -> Val {
206 order(&a, &b).map(|o| Val::Bool(test(o))).unwrap_or(Val::Null)
207}
208
209pub fn eq(a: Val, b: Val) -> Val {
210 compare(a, b, |o| o.is_eq())
211}
212
213pub fn ne(a: Val, b: Val) -> Val {
214 compare(a, b, |o| o.is_ne())
215}
216
217pub fn lt(a: Val, b: Val) -> Val {
218 compare(a, b, |o| o.is_lt())
219}
220
221pub fn le(a: Val, b: Val) -> Val {
222 compare(a, b, |o| o.is_le())
223}
224
225pub fn gt(a: Val, b: Val) -> Val {
226 compare(a, b, |o| o.is_gt())
227}
228
229pub fn ge(a: Val, b: Val) -> Val {
230 compare(a, b, |o| o.is_ge())
231}
232
233pub fn in_list(a: Val, list: Val) -> Val {
236 let Val::List(items) = list else { return Val::Null };
237 if matches!(a, Val::Null) {
238 return Val::Null;
239 }
240 let mut unknown = false;
241 for item in items {
242 match eq(a.clone(), item) {
243 Val::Bool(true) => return Val::Bool(true),
244 Val::Null => unknown = true,
245 _ => {}
246 }
247 }
248 if unknown {
249 Val::Null
250 } else {
251 Val::Bool(false)
252 }
253}
254
255pub fn not_in(a: Val, list: Val) -> Val {
256 not(in_list(a, list))
257}
258
259pub fn matches(a: Val, pattern: &str) -> Val {
262 static CACHE: OnceLock<Mutex<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
263 let Some(s) = a.text() else { return Val::Null };
264 let mut cache = CACHE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
265 let re = cache.entry(pattern.to_string()).or_insert_with(|| regex::Regex::new(pattern).ok());
266 match re {
267 Some(re) => Val::Bool(re.is_match(s)),
268 None => Val::Null,
269 }
270}
271
272fn arith(a: Val, b: Val, f: fn(f64, f64) -> Option<f64>) -> Val {
275 match (a.num(), b.num()) {
276 (Some(x), Some(y)) => f(x, y).map(Val::Num).unwrap_or(Val::Null),
277 _ => Val::Null,
278 }
279}
280
281pub fn add(a: Val, b: Val) -> Val {
282 arith(a, b, |x, y| Some(x + y))
283}
284
285pub fn sub(a: Val, b: Val) -> Val {
286 arith(a, b, |x, y| Some(x - y))
287}
288
289pub fn mul(a: Val, b: Val) -> Val {
290 arith(a, b, |x, y| Some(x * y))
291}
292
293pub fn div(a: Val, b: Val) -> Val {
295 arith(a, b, |x, y| (y != 0.0).then(|| x / y))
296}
297
298pub fn rem(a: Val, b: Val) -> Val {
299 arith(a, b, |x, y| (y != 0.0).then(|| x % y))
300}
301
302pub fn neg(a: Val) -> Val {
303 a.num().map(|n| Val::Num(-n)).unwrap_or(Val::Null)
304}
305
306fn num_fn(a: Val, f: fn(f64) -> f64) -> Val {
307 a.num().map(|n| Val::Num(f(n))).unwrap_or(Val::Null)
308}
309
310pub fn abs(a: Val) -> Val {
311 num_fn(a, f64::abs)
312}
313
314pub fn ceil(a: Val) -> Val {
315 num_fn(a, f64::ceil)
316}
317
318pub fn floor(a: Val) -> Val {
319 num_fn(a, f64::floor)
320}
321
322pub fn round(a: Val) -> Val {
323 num_fn(a, f64::round)
324}
325
326pub fn coalesce(values: Vec<Val>) -> Val {
328 values.into_iter().find(|v| !matches!(v, Val::Null)).unwrap_or(Val::Null)
329}
330
331pub fn length(a: Val) -> Val {
335 match a {
336 Val::Text(s) => Val::Num(s.chars().count() as f64),
337 Val::List(items) => Val::Num(items.len() as f64),
338 _ => Val::Null,
339 }
340}
341
342pub fn is_empty(a: Val) -> Val {
343 match length(a) {
344 Val::Num(n) => Val::Bool(n == 0.0),
345 _ => Val::Null,
346 }
347}
348
349fn text_fn(a: Val, f: fn(&str) -> String) -> Val {
350 a.text().map(|s| Val::Text(f(s))).unwrap_or(Val::Null)
351}
352
353pub fn trim(a: Val) -> Val {
354 text_fn(a, |s| s.trim().to_string())
355}
356
357pub fn lower(a: Val) -> Val {
358 text_fn(a, str::to_lowercase)
359}
360
361pub fn upper(a: Val) -> Val {
362 text_fn(a, str::to_uppercase)
363}
364
365pub fn contains(a: Val, b: Val) -> Val {
367 match (a, b) {
368 (Val::Text(s), Val::Text(t)) => Val::Bool(s.contains(t.as_str())),
369 (list @ Val::List(_), item) => in_list(item, list),
370 _ => Val::Null,
371 }
372}
373
374pub fn starts_with(a: Val, b: Val) -> Val {
375 match (a.text(), b.text()) {
376 (Some(s), Some(t)) => Val::Bool(s.starts_with(t)),
377 _ => Val::Null,
378 }
379}
380
381pub fn ends_with(a: Val, b: Val) -> Val {
382 match (a.text(), b.text()) {
383 (Some(s), Some(t)) => Val::Bool(s.ends_with(t)),
384 _ => Val::Null,
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use serde_json::json;
392
393 #[test]
394 fn a_rule_over_a_null_field_holds_as_a_check_constraint_does() {
395 let row = json!({ "name": null });
396 let rule = ge(length(field(&row, "name")), Val::Num(1.0));
397 assert_eq!(rule, Val::Null);
398 assert!(holds(&rule));
399 assert!(holds(&ge(length(field(&json!({}), "name")), Val::Num(1.0))));
401 }
402
403 #[test]
404 fn a_null_test_is_never_null_itself() {
405 let row = json!({ "email": null });
406 assert_eq!(is_not_null(field(&row, "email")), Val::Bool(false));
407 assert!(!holds(&and(is_not_null(field(&row, "email")), matches(field(&row, "email"), "@"))));
408 }
409
410 #[test]
411 fn logic_is_three_valued() {
412 assert_eq!(and(Val::Bool(false), Val::Null), Val::Bool(false));
413 assert_eq!(and(Val::Bool(true), Val::Null), Val::Null);
414 assert_eq!(or(Val::Bool(true), Val::Null), Val::Bool(true));
415 assert_eq!(or(Val::Bool(false), Val::Null), Val::Null);
416 assert_eq!(not(Val::Null), Val::Null);
417 assert_eq!(cond(Val::Null, Val::Bool(true), Val::Bool(false)), Val::Bool(false));
418 }
419
420 #[test]
421 fn a_decimal_serialized_as_text_compares_as_a_number() {
422 let row = json!({ "risk_score": "0.75", "count": 3 });
423 assert_eq!(le(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(true));
424 assert_eq!(gt(field(&row, "risk_score"), Val::Num(1.0)), Val::Bool(false));
425 assert_eq!(ge(field(&row, "count"), Val::Num(1.0)), Val::Bool(true));
426 }
427
428 #[test]
429 fn in_follows_sql_in() {
430 let list = Val::List(vec![text("json"), text("csv")]);
431 assert_eq!(in_list(text("csv"), list.clone()), Val::Bool(true));
432 assert_eq!(in_list(text("pdf"), list.clone()), Val::Bool(false));
433 assert_eq!(in_list(Val::Null, list.clone()), Val::Null);
434 assert_eq!(not_in(text("pdf"), list), Val::Bool(true));
435 }
436
437 #[test]
438 fn text_helpers_count_characters_not_bytes() {
439 assert_eq!(length(text("héllo")), Val::Num(5.0));
440 assert_eq!(length(trim(text(" a "))), Val::Num(1.0));
441 assert_eq!(contains(text("a/../b"), text("..")), Val::Bool(true));
442 assert_eq!(matches(text("my-bucket_1"), "^[a-zA-Z0-9_-]+$"), Val::Bool(true));
443 assert_eq!(matches(text("no spaces"), "^[a-zA-Z0-9_-]+$"), Val::Bool(false));
444 }
445
446 #[test]
447 fn a_stored_timestamp_or_date_compares_with_the_time_of_the_write() {
448 let at = DateTime::parse_from_rfc3339("2026-10-11T10:00:00Z").unwrap().with_timezone(&Utc);
449 let row = json!({ "expires_at": "2026-10-12T00:00:00Z", "dob": "1990-05-01", "gone": "2026-01-01T00:00:00+07:00" });
450 assert_eq!(gt(field(&row, "expires_at"), super::at(at)), Val::Bool(true));
451 assert_eq!(lt(field(&row, "dob"), super::at(at)), Val::Bool(true));
452 assert_eq!(gt(field(&row, "gone"), super::at(at)), Val::Bool(false));
453 assert_eq!(gt(field(&row, "missing"), super::at(at)), Val::Null);
454 }
455
456 #[test]
457 fn the_writer_and_the_row_before_the_write_are_readable() {
458 assert_eq!(eq(actor(Some("u1")), text("u1")), Val::Bool(true));
459 assert_eq!(eq(actor(None), text("u1")), Val::Null, "no writer: the rule holds");
460 let (before, after) = (json!({ "progress": 40, "x": 1 }), json!({ "progress": 30, "x": 1 }));
461 assert_eq!(ge(field(&after, "progress"), field(&before, "progress")), Val::Bool(false));
462 assert!(changed(Some(&before), &after, &["progress"]));
463 assert!(!changed(Some(&before), &after, &["x"]));
464 assert!(changed(None, &after, &["x"]), "a create changes everything");
465 }
466
467 #[tokio::test]
468 async fn a_role_rule_asks_the_installed_source_and_holds_without_one() {
469 struct Fixed;
470 #[async_trait::async_trait]
471 impl crate::actor_roles::ActorRoles for Fixed {
472 async fn holds(&self, actor: &str, role: &str) -> Option<bool> {
473 (actor == "u1").then(|| role.eq_ignore_ascii_case("ADMIN"))
474 }
475 }
476 assert_eq!(has_role(None, "admin").await, Val::Null, "no writer");
477 crate::actor_roles::install(std::sync::Arc::new(Fixed));
478 assert_eq!(has_role(Some("u1"), "admin").await, Val::Bool(true));
479 assert_eq!(has_role(Some("u1"), "auditor").await, Val::Bool(false));
480 assert_eq!(has_role(Some("u2"), "admin").await, Val::Null, "the source cannot tell");
481 assert_eq!(referencing("SELECT count(*) FROM t WHERE x = $1::uuid", text("id")).await, Val::Null);
483 assert_eq!(referencing("SELECT 1", Val::Null).await, Val::Null);
484 }
485
486 #[test]
487 fn division_by_zero_is_null_not_a_panic() {
488 assert_eq!(div(Val::Num(1.0), Val::Num(0.0)), Val::Null);
489 assert_eq!(rem(Val::Num(1.0), Val::Num(0.0)), Val::Null);
490 }
491}