Skip to main content

nodedb_query/
value_ops.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Core value operations on `nodedb_types::Value`: comparison, coercion, truthiness.
4//!
5//! Equivalent to `json_ops.rs` but operates on the internal `Value` type directly,
6//! avoiding the `serde_json::Value` intermediate. Used by expression evaluation,
7//! computed projections, and aggregate expression paths.
8
9use std::cmp::Ordering;
10
11use nodedb_types::Value;
12
13use crate::numeric_cmp::{Numeric, cmp_numeric, decimal_reading, numeric_eq, parse_numeric_str};
14
15/// Coerce a Value to f64.
16///
17/// - Integer/Float: direct conversion
18/// - String: parse as f64
19/// - Bool: `true` → `1.0`, `false` → `0.0` (when `coerce_bool` is true)
20/// - Other types: `None`
21pub fn value_to_f64(v: &Value, coerce_bool: bool) -> Option<f64> {
22    match v {
23        Value::Integer(i) => Some(*i as f64),
24        Value::Float(f) => Some(*f),
25        Value::String(s) => s.parse::<f64>().ok(),
26        Value::Bool(b) if coerce_bool => Some(if *b { 1.0 } else { 0.0 }),
27        Value::Decimal(d) => {
28            use rust_decimal::prelude::ToPrimitive;
29            d.to_f64()
30        }
31        _ => None,
32    }
33}
34
35/// Whether either side is a typed instant, so the pair compares by epoch
36/// microseconds through `Value::partial_cmp_coerced` (an ISO string on the
37/// other side is parsed).
38pub fn involves_instant(a: &Value, b: &Value) -> bool {
39    a.as_instant().is_some() || b.as_instant().is_some()
40}
41
42/// `v` read as a number: integers, floats, decimals, numeric strings, and
43/// bools (`true` = 1). Integers, decimals, integer text and fractional
44/// decimal text stay exact.
45fn numeric_reading(v: &Value) -> Option<Numeric> {
46    match v {
47        Value::Integer(i) => Some(Numeric::Int(i128::from(*i))),
48        Value::Bool(b) => Some(Numeric::Int(i128::from(*b))),
49        Value::String(s) => parse_numeric_str(s),
50        Value::Float(f) => Some(Numeric::Float(*f)),
51        Value::Decimal(d) => Some(decimal_reading(d)),
52        _ => None,
53    }
54}
55
56/// Numeric order of `a` and `b` when both have a numeric reading (number,
57/// decimal, numeric string, bool). `None` when a side is not numeric. The
58/// order is total: NaN sorts above every number and equals NaN.
59pub fn numeric_order(a: &Value, b: &Value) -> Option<Ordering> {
60    let (na, nb) = (numeric_reading(a)?, numeric_reading(b)?);
61    Some(cmp_numeric(na, nb))
62}
63
64/// Compare two Values with type coercion, for a predicate.
65///
66/// A typed instant compares by epoch microseconds against another instant
67/// or an ISO-8601 string, and against anything else has no order: `None`,
68/// so `WHERE at >= 5` over an instant matches nothing. Otherwise numeric
69/// comparison first (with bool coercion, exact for integers and decimals,
70/// NaN above every number), then string comparison of the display forms.
71pub fn partial_compare_values(a: &Value, b: &Value) -> Option<Ordering> {
72    if involves_instant(a, b) {
73        return a.partial_cmp_coerced(b);
74    }
75    if let Some(order) = numeric_order(a, b) {
76        return Some(order);
77    }
78    let sa = value_to_display_string(a);
79    let sb = value_to_display_string(b);
80    Some(sa.cmp(&sb))
81}
82
83/// Compare two Values with type coercion, for sorting and extremum
84/// selection (ORDER BY, MIN / MAX, GREATEST / LEAST, window frames).
85///
86/// [`partial_compare_values`] with an unordered pair placed as `Equal`, so a
87/// stable sort keeps such rows in their input order. A predicate uses
88/// `partial_compare_values` instead.
89pub fn compare_values(a: &Value, b: &Value) -> Ordering {
90    partial_compare_values(a, b).unwrap_or(Ordering::Equal)
91}
92
93/// Order of two non-NULL values for an ORDER BY over output rows (window
94/// ORDER BY, post-aggregate ORDER BY). Text orders by its bytes, so `"10"`
95/// sorts before `"9"`. Booleans sort `false` first. Any other pair takes
96/// [`compare_values`]: numbers by their exact reading, NaN above every
97/// number. The caller places NULLs.
98pub fn compare_sort_values(a: &Value, b: &Value) -> Ordering {
99    match (a, b) {
100        (Value::String(x), Value::String(y)) => x.cmp(y),
101        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
102        _ => compare_values(a, b),
103    }
104}
105
106/// Whether two values are peers under an ORDER BY: both NULL, or both
107/// non-NULL and ordered `Equal` by [`compare_sort_values`]. NaN is a peer
108/// of NaN.
109pub fn sort_peers(a: &Value, b: &Value) -> bool {
110    match (a.is_null(), b.is_null()) {
111        (true, true) => true,
112        (false, false) => compare_sort_values(a, b) == Ordering::Equal,
113        _ => false,
114    }
115}
116
117/// Check equality with type coercion.
118///
119/// A typed instant equals another instant or an ISO-8601 string with the
120/// same epoch microseconds. Handles `"5" == 5` by reading both sides as
121/// numbers when each has a numeric reading. Every numeric pair compares
122/// exactly, and NaN equals NaN.
123pub fn coerced_eq(a: &Value, b: &Value) -> bool {
124    if a == b {
125        return true;
126    }
127    if involves_instant(a, b) {
128        return a.eq_coerced(b);
129    }
130    match (numeric_reading(a), numeric_reading(b)) {
131        (Some(na), Some(nb)) => numeric_eq(na, nb),
132        _ => false,
133    }
134}
135
136/// Check if a Value is truthy (for boolean contexts).
137///
138/// - `true` → true, `false` → false
139/// - `Null` → false
140/// - Numbers: non-zero → true
141/// - Strings: non-empty → true
142/// - Arrays/Objects: always true
143pub fn is_truthy(v: &Value) -> bool {
144    match v {
145        Value::Bool(b) => *b,
146        Value::Null => false,
147        Value::Integer(i) => *i != 0,
148        Value::Float(f) => *f != 0.0,
149        Value::String(s) => !s.is_empty(),
150        _ => true,
151    }
152}
153
154/// Convert a Value to a display string.
155///
156/// - Strings: returned as-is (no quotes)
157/// - Null: empty string
158/// - Numbers/Bools: `.to_string()`
159/// - Objects/Arrays: JSON serialization
160pub fn value_to_display_string(v: &Value) -> String {
161    match v {
162        Value::String(s) => s.clone(),
163        Value::Null => String::new(),
164        Value::Integer(i) => i.to_string(),
165        Value::Float(f) => f.to_string(),
166        Value::Bool(b) => b.to_string(),
167        Value::Uuid(s) | Value::Ulid(s) | Value::Regex(s) => s.clone(),
168        Value::DateTime(dt) | Value::NaiveDateTime(dt) => dt.to_iso8601(),
169        Value::Duration(d) => d.to_human(),
170        Value::Decimal(d) => d.to_string(),
171        other => {
172            // Fallback: convert to JSON string representation.
173            let json = serde_json::Value::from(other.clone());
174            json.to_string()
175        }
176    }
177}
178
179/// Convert an f64 to a Value, preferring integer representation.
180///
181/// Returns `Null` for NaN/Infinity.
182pub fn to_value_number(n: f64) -> Value {
183    if n.is_nan() || n.is_infinite() {
184        Value::Null
185    } else if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
186        Value::Integer(n as i64)
187    } else {
188        Value::Float(n)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn coerced_eq_mixed_types() {
198        assert!(coerced_eq(&Value::Integer(5), &Value::String("5".into())));
199        assert!(!coerced_eq(&Value::Integer(5), &Value::String("6".into())));
200    }
201
202    #[test]
203    fn coerced_eq_bool_numeric() {
204        assert!(coerced_eq(&Value::Bool(true), &Value::Integer(1)));
205        assert!(coerced_eq(&Value::Bool(false), &Value::Integer(0)));
206        assert!(!coerced_eq(&Value::Bool(true), &Value::Integer(0)));
207    }
208
209    #[test]
210    fn compare_numeric_coercion() {
211        assert_eq!(
212            compare_values(&Value::Integer(5), &Value::String("4".into())),
213            Ordering::Greater
214        );
215    }
216
217    /// `2^53 + 1` and `2^53` collapse to one `f64`. Integers compare exactly.
218    #[test]
219    fn integers_past_two_pow_53_compare_exactly() {
220        let above = Value::Integer(9_007_199_254_740_993);
221        let at = Value::Integer(9_007_199_254_740_992);
222        assert!(!coerced_eq(&above, &at));
223        assert_eq!(partial_compare_values(&above, &at), Some(Ordering::Greater));
224        assert_eq!(partial_compare_values(&at, &above), Some(Ordering::Less));
225        // Nanosecond timestamps one tick apart.
226        let t0 = Value::Integer(1_700_000_000_000_000_001);
227        let t1 = Value::Integer(1_700_000_000_000_000_002);
228        assert!(!coerced_eq(&t0, &t1));
229        assert_eq!(partial_compare_values(&t0, &t1), Some(Ordering::Less));
230        assert_eq!(
231            partial_compare_values(&Value::Integer(i64::MAX), &Value::Integer(i64::MAX - 1)),
232            Some(Ordering::Greater)
233        );
234    }
235
236    #[test]
237    fn integer_against_float_compares_without_rounding() {
238        let above = Value::Integer(9_007_199_254_740_993);
239        let float = Value::Float(9_007_199_254_740_992.0);
240        assert!(!coerced_eq(&above, &float));
241        assert_eq!(
242            partial_compare_values(&above, &float),
243            Some(Ordering::Greater)
244        );
245        assert_eq!(partial_compare_values(&float, &above), Some(Ordering::Less));
246        assert_eq!(
247            partial_compare_values(&Value::Integer(2), &Value::Float(2.5)),
248            Some(Ordering::Less)
249        );
250        assert_eq!(
251            partial_compare_values(&Value::Integer(-2), &Value::Float(-2.5)),
252            Some(Ordering::Greater)
253        );
254        assert!(coerced_eq(&Value::Integer(3), &Value::Float(3.0)));
255        // `i64::MAX` rounds up to `2^63` as an `f64`, so it is below that float.
256        const TWO_POW_63: f64 = 9_223_372_036_854_775_808.0;
257        assert_eq!(
258            partial_compare_values(&Value::Integer(i64::MAX), &Value::Float(TWO_POW_63)),
259            Some(Ordering::Less)
260        );
261        assert_eq!(
262            partial_compare_values(&Value::Integer(i64::MIN), &Value::Float(-TWO_POW_63)),
263            Some(Ordering::Equal)
264        );
265        assert_eq!(
266            partial_compare_values(&Value::Integer(i64::MIN), &Value::Float(f64::NEG_INFINITY)),
267            Some(Ordering::Greater)
268        );
269        assert_eq!(
270            partial_compare_values(&Value::Integer(0), &Value::Float(f64::NAN)),
271            Some(Ordering::Less)
272        );
273    }
274
275    /// NaN sorts above every number and equals NaN, so a sort is total.
276    #[test]
277    fn nan_sorts_above_every_number() {
278        let nan = Value::Float(f64::NAN);
279        assert_eq!(
280            compare_values(&nan, &Value::Float(f64::INFINITY)),
281            Ordering::Greater
282        );
283        assert_eq!(
284            compare_values(&Value::Integer(i64::MAX), &nan),
285            Ordering::Less
286        );
287        assert_eq!(
288            compare_values(&Value::Decimal(rust_decimal::Decimal::MAX), &nan),
289            Ordering::Less
290        );
291        assert_eq!(compare_values(&nan, &nan), Ordering::Equal);
292        assert!(coerced_eq(&nan, &Value::Float(f64::NAN)));
293        let mut values = [
294            nan.clone(),
295            Value::Integer(3),
296            Value::Float(f64::NEG_INFINITY),
297            nan.clone(),
298            Value::Float(0.5),
299        ];
300        values.sort_by(compare_values);
301        assert_eq!(values[0], Value::Float(f64::NEG_INFINITY));
302        assert_eq!(values[1], Value::Float(0.5));
303        assert_eq!(values[2], Value::Integer(3));
304        assert!(matches!(values[3], Value::Float(f) if f.is_nan()));
305        assert!(matches!(values[4], Value::Float(f) if f.is_nan()));
306    }
307
308    /// Fractional decimals and decimal text compare exactly, never through
309    /// `f64`.
310    #[test]
311    fn fractional_decimals_compare_exactly() {
312        use std::str::FromStr;
313        let low = rust_decimal::Decimal::from_str("12345678901234567.01").unwrap();
314        let high = rust_decimal::Decimal::from_str("12345678901234567.02").unwrap();
315        assert_eq!(
316            partial_compare_values(&Value::Decimal(low), &Value::Decimal(high)),
317            Some(Ordering::Less)
318        );
319        assert!(!coerced_eq(&Value::Decimal(low), &Value::Decimal(high)));
320        let low_text = Value::String("12345678901234567.01".into());
321        let high_text = Value::String("12345678901234567.02".into());
322        assert_eq!(compare_values(&high_text, &low_text), Ordering::Greater);
323        assert!(!coerced_eq(&low_text, &high_text));
324        assert!(coerced_eq(&low_text, &Value::Decimal(low)));
325        let half = rust_decimal::Decimal::from_str("9007199254740993.5").unwrap();
326        assert_eq!(
327            partial_compare_values(
328                &Value::Decimal(half),
329                &Value::Integer(9_007_199_254_740_993)
330            ),
331            Some(Ordering::Greater)
332        );
333        assert_eq!(
334            partial_compare_values(
335                &Value::Integer(9_007_199_254_740_994),
336                &Value::Decimal(half)
337            ),
338            Some(Ordering::Greater)
339        );
340    }
341
342    /// A `u64` above `i64::MAX` is an integral `Decimal`, which compares
343    /// exactly against integers instead of rounding through `f64`.
344    #[test]
345    fn integral_decimals_compare_exactly() {
346        let u_max = Value::from_u64(u64::MAX);
347        let u_max_less_one = Value::from_u64(u64::MAX - 1);
348        assert_eq!(
349            partial_compare_values(&u_max_less_one, &u_max),
350            Some(Ordering::Less)
351        );
352        assert!(!coerced_eq(&u_max_less_one, &u_max));
353        assert_eq!(
354            partial_compare_values(&u_max, &Value::Integer(i64::MAX)),
355            Some(Ordering::Greater)
356        );
357        let two_pow_63 = Value::from_u64(1 << 63);
358        assert_eq!(
359            partial_compare_values(&two_pow_63, &Value::Integer(i64::MAX)),
360            Some(Ordering::Greater)
361        );
362        assert_eq!(
363            partial_compare_values(
364                &Value::Decimal(rust_decimal::Decimal::new(15, 1)),
365                &Value::Integer(1)
366            ),
367            Some(Ordering::Greater)
368        );
369    }
370
371    #[test]
372    fn integer_strings_compare_exactly_against_integers() {
373        let text = Value::String("9007199254740993".into());
374        assert!(!coerced_eq(&text, &Value::Integer(9_007_199_254_740_992)));
375        assert!(coerced_eq(&text, &Value::Integer(9_007_199_254_740_993)));
376        assert!(coerced_eq(&Value::String("5.0".into()), &Value::Integer(5)));
377    }
378
379    #[test]
380    fn instants_compare_by_micros_against_instants_and_iso_strings() {
381        let earlier = Value::NaiveDateTime(nodedb_types::NdbDateTime::from_micros(
382            1_583_402_400_000_000,
383        ));
384        let later = Value::DateTime(nodedb_types::NdbDateTime::from_micros(
385            1_583_406_000_000_000,
386        ));
387        assert_eq!(compare_values(&earlier, &later), Ordering::Less);
388        assert_eq!(
389            compare_values(&later, &Value::String("2020-03-05 10:00:00".into())),
390            Ordering::Greater
391        );
392        assert!(coerced_eq(
393            &earlier,
394            &Value::String("2020-03-05T10:00:00Z".into())
395        ));
396        assert!(!coerced_eq(&earlier, &later));
397    }
398
399    /// An instant against a bare integer has no order on the predicate path
400    /// and sorts as equal on the extremum path.
401    #[test]
402    fn an_instant_against_an_integer_is_unordered_for_predicates() {
403        let at = Value::NaiveDateTime(nodedb_types::NdbDateTime::from_micros(
404            1_583_402_400_000_000,
405        ));
406        assert_eq!(partial_compare_values(&at, &Value::Integer(5)), None);
407        assert_eq!(partial_compare_values(&Value::Integer(5), &at), None);
408        assert_eq!(compare_values(&at, &Value::Integer(5)), Ordering::Equal);
409    }
410
411    #[test]
412    fn truthiness() {
413        assert!(is_truthy(&Value::Bool(true)));
414        assert!(!is_truthy(&Value::Bool(false)));
415        assert!(!is_truthy(&Value::Null));
416        assert!(is_truthy(&Value::Integer(1)));
417        assert!(!is_truthy(&Value::Integer(0)));
418        assert!(is_truthy(&Value::String("hello".into())));
419        assert!(!is_truthy(&Value::String(String::new())));
420    }
421
422    #[test]
423    fn to_value_number_nan() {
424        assert_eq!(to_value_number(f64::NAN), Value::Null);
425    }
426
427    #[test]
428    fn to_value_number_integer() {
429        assert_eq!(to_value_number(42.0), Value::Integer(42));
430    }
431
432    #[test]
433    fn to_value_number_float() {
434        assert_eq!(to_value_number(3.15), Value::Float(3.15));
435    }
436}