Skip to main content

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}