uni_common/core/check_constraint.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Evaluation of `CHECK` constraint expressions.
5//!
6//! This is the single evaluator for both write paths. It previously existed as
7//! two token-for-token copies — one in `uni-bulk`'s `BulkWriter`, one in
8//! `uni-store`'s `Writer` — which had drifted in four places, two of them
9//! affecting accept/reject decisions:
10//!
11//! 1. **Numeric equality.** The bulk copy routed `=` / `!=` operands through
12//! `compare_values` when both sides are numeric, because [`Value`]'s
13//! `PartialEq` is type-strict and has no Int/Float arm. The writer copy used
14//! bare `==`. So `CHECK (score = 5)` against a stored `Float(5.0)` *passed*
15//! through the bulk loader and *failed* through `tx.execute`.
16//! 2. **Target-literal fallback.** The writer copy carried an extra
17//! `Number(...)` unwrap for internal-format wrappers. That arm was **dead**:
18//! `val_str` comes from `trim_end_matches(')')`, which strips every trailing
19//! paren, so its `ends_with(')')` guard could never hold. It is dropped here
20//! rather than resurrected — making it reachable would add a capability
21//! neither path has today, which a deduplication commit has no business
22//! doing. Established by testing, not by reading.
23//! 3. / 4. The writer copy warned on an unparseable expression and on an
24//! unknown operator; the bulk copy was silent.
25//!
26//! The divergence was a fix applied to one copy and never propagated — the bulk
27//! behaviour is pinned by a landed regression test, and the transactional path
28//! was simply never updated. This module takes the union of what actually ran:
29//! bulk's numeric coercion plus the writer's warnings.
30//!
31//! # Shape constraints
32//!
33//! [`evaluate`] is a free, **synchronous** function returning
34//! `anyhow::Result<bool>`, and must stay that way. One of its three call sites
35//! is a match *guard* (`if !evaluate(expr, props)? =>`): guards cannot `.await`
36//! and cannot take `&mut self`, and the `?` there propagates out of the
37//! enclosing function, so an `Err` aborts the write rather than reporting a
38//! constraint violation.
39//!
40//! # Scope
41//!
42//! The grammar handled is `prop op value` — three whitespace-separated tokens,
43//! with optional surrounding parentheses and an optional `variable.` prefix.
44//! Anything more complex is *allowed* with a warning rather than rejected, so
45//! that an expression this evaluator cannot parse never silently blocks a
46//! legitimate write. A missing property also passes; absence is `NOT NULL`'s
47//! concern, not `CHECK`'s.
48
49use std::cmp::Ordering;
50
51use anyhow::{Result, anyhow};
52
53use crate::{Properties, Value};
54
55/// Evaluate a `CHECK` constraint expression against a property bag.
56///
57/// Returns `Ok(true)` when the constraint holds, is inapplicable (missing
58/// property), or is outside the supported grammar.
59///
60/// # Errors
61///
62/// Returns an error only when the two operands cannot be ordered — e.g.
63/// `CHECK (name > 5)` against a string. Callers treat that as a failed write,
64/// not as a constraint violation.
65pub fn evaluate(expression: &str, properties: &Properties) -> Result<bool> {
66 let parts: Vec<&str> = expression.split_whitespace().collect();
67 if parts.len() != 3 {
68 tracing::warn!(
69 "Complex CHECK constraint expression '{}' not fully supported yet; allowing write.",
70 expression
71 );
72 return Ok(true);
73 }
74
75 let prop_part = parts[0].trim_start_matches('(');
76 // Handle "variable.property" — take the part after the dot.
77 let prop_name = match prop_part.find('.') {
78 Some(idx) => &prop_part[idx + 1..],
79 None => prop_part,
80 };
81
82 let op = parts[1];
83 let val_str = parts[2].trim_end_matches(')');
84
85 let prop_val = match properties.get(prop_name) {
86 Some(v) => v,
87 // A missing property passes; that is `NOT NULL`'s job.
88 None => return Ok(true),
89 };
90
91 let target_val = parse_target(val_str);
92
93 match op {
94 // Route numeric equality through `compare_values` so Int/Float coerce,
95 // matching the ordering operators below. `Value`'s `PartialEq` is
96 // type-strict and has no Int/Float arm, so `Float(5.0) == Int(5)` would
97 // otherwise be false. Non-numeric operands keep strict structural
98 // equality.
99 "=" | "==" => Ok(if prop_val.is_number() && target_val.is_number() {
100 compare_values(prop_val, &target_val)?.is_eq()
101 } else {
102 prop_val == &target_val
103 }),
104 "!=" | "<>" => Ok(if prop_val.is_number() && target_val.is_number() {
105 !compare_values(prop_val, &target_val)?.is_eq()
106 } else {
107 prop_val != &target_val
108 }),
109 ">" => Ok(compare_values(prop_val, &target_val)?.is_gt()),
110 "<" => Ok(compare_values(prop_val, &target_val)?.is_lt()),
111 ">=" => Ok(compare_values(prop_val, &target_val)?.is_ge()),
112 "<=" => Ok(compare_values(prop_val, &target_val)?.is_le()),
113 _ => {
114 tracing::warn!("Unsupported operator '{}' in CHECK constraint", op);
115 Ok(true)
116 }
117 }
118}
119
120/// Parse the right-hand token into a [`Value`].
121///
122/// Note the caller has already applied `trim_end_matches(')')`, so any wrapper
123/// syntax of the form `Name(...)` arrives with its closing paren gone. See the
124/// module docs on the dropped `Number(...)` arm.
125fn parse_target(val_str: &str) -> Value {
126 if (val_str.starts_with('\'') && val_str.ends_with('\''))
127 || (val_str.starts_with('"') && val_str.ends_with('"'))
128 {
129 return Value::String(val_str[1..val_str.len() - 1].to_string());
130 }
131 if let Ok(n) = val_str.parse::<i64>() {
132 return Value::Int(n);
133 }
134 if let Ok(n) = val_str.parse::<f64>() {
135 return Value::Float(n);
136 }
137 if let Ok(b) = val_str.parse::<bool>() {
138 return Value::Bool(b);
139 }
140 Value::String(val_str.to_string())
141}
142
143/// Compare two values for ordering.
144///
145/// Incomparable floats (NaN) compare as [`Ordering::Equal`], matching the
146/// branch-based implementations this replaces.
147///
148/// # Errors
149///
150/// Returns an error when the two values are not of comparable kinds.
151fn compare_values(a: &Value, b: &Value) -> Result<Ordering> {
152 match (a, b) {
153 (Value::Int(n1), Value::Int(n2)) => Ok(n1.cmp(n2)),
154 (Value::Float(f1), Value::Float(f2)) => Ok(f1.partial_cmp(f2).unwrap_or(Ordering::Equal)),
155 // Exact i64-vs-f64 order (no lossy `as f64` cast above 2^53); preserve
156 // the NaN-as-Equal behaviour for the degenerate case.
157 (Value::Int(n), Value::Float(f)) => Ok(if f.is_nan() {
158 Ordering::Equal
159 } else {
160 crate::cmp_i64_f64(*n, *f)
161 }),
162 (Value::Float(f), Value::Int(n)) => Ok(if f.is_nan() {
163 Ordering::Equal
164 } else {
165 crate::cmp_i64_f64(*n, *f).reverse()
166 }),
167 (Value::String(s1), Value::String(s2)) => Ok(s1.cmp(s2)),
168 _ => Err(anyhow!(
169 "Cannot compare incompatible types: {:?} vs {:?}",
170 a,
171 b
172 )),
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn props(pairs: &[(&str, Value)]) -> Properties {
181 pairs
182 .iter()
183 .map(|(k, v)| ((*k).to_string(), v.clone()))
184 .collect()
185 }
186
187 /// The divergence that mattered: a float-valued property against an
188 /// integer literal. The bulk path coerced, the transactional path did not.
189 #[test]
190 fn numeric_equality_coerces_across_int_and_float() {
191 let p = props(&[("score", Value::Float(5.0))]);
192 assert!(evaluate("(n.score = 5)", &p).unwrap());
193 assert!(!evaluate("(n.score != 5)", &p).unwrap());
194
195 let p = props(&[("score", Value::Int(5))]);
196 assert!(evaluate("(n.score = 5.0)", &p).unwrap());
197 }
198
199 /// Non-numeric operands keep strict structural equality.
200 #[test]
201 fn non_numeric_equality_stays_strict() {
202 let p = props(&[("name", Value::String("a".into()))]);
203 assert!(evaluate("(n.name = 'a')", &p).unwrap());
204 assert!(!evaluate("(n.name = 'b')", &p).unwrap());
205 }
206
207 /// Exactness above 2^53, where a lossy `as f64` cast would compare equal.
208 #[test]
209 fn large_integers_compare_exactly() {
210 let p = props(&[("v", Value::Int(9_007_199_254_740_993))]);
211 assert!(evaluate("(n.v > 9007199254740992.0)", &p).unwrap());
212 }
213
214 /// The writer copy's `Number(...)` arm was unreachable — `val_str` has had
215 /// every trailing paren stripped before it is inspected — so such a target
216 /// degrades to a string and an ordering comparison against it errors. This
217 /// pins that pre-existing behaviour rather than the dead branch's intent.
218 #[test]
219 fn number_wrapper_target_is_not_special_cased() {
220 let p = props(&[("v", Value::Int(7))]);
221 assert!(evaluate("(n.v < Number(8.5))", &p).is_err());
222 }
223
224 #[test]
225 fn ordering_operators() {
226 let p = props(&[("v", Value::Int(5))]);
227 assert!(evaluate("(n.v > 4)", &p).unwrap());
228 assert!(evaluate("(n.v >= 5)", &p).unwrap());
229 assert!(evaluate("(n.v < 6)", &p).unwrap());
230 assert!(evaluate("(n.v <= 5)", &p).unwrap());
231 assert!(!evaluate("(n.v > 5)", &p).unwrap());
232 }
233
234 /// Unsupported shapes allow the write rather than blocking it.
235 #[test]
236 fn unsupported_shapes_allow_the_write() {
237 let p = props(&[("v", Value::Int(5))]);
238 // Missing property.
239 assert!(evaluate("(n.other = 1)", &p).unwrap());
240 // Not three tokens.
241 assert!(evaluate("(n.v > 1 AND n.v < 9)", &p).unwrap());
242 // Unknown operator.
243 assert!(evaluate("(n.v ~~ 1)", &p).unwrap());
244 }
245
246 /// An un-orderable pair is an error, not a violation — the caller aborts
247 /// the write rather than reporting a failed constraint.
248 #[test]
249 fn incomparable_operands_error() {
250 let p = props(&[("name", Value::String("a".into()))]);
251 assert!(evaluate("(n.name > 5)", &p).is_err());
252 }
253
254 /// A bare property name, with no `variable.` prefix.
255 #[test]
256 fn bare_property_name_is_accepted() {
257 let p = props(&[("v", Value::Int(5))]);
258 assert!(evaluate("v = 5", &p).unwrap());
259 }
260}