Skip to main content

nodedb_query/
json_ops.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared JSON value operations: comparison, coercion, truthiness.
4//!
5//! Used by both expression evaluation (computed projections) and scan filters
6//! (WHERE predicate evaluation). Pure functions on `serde_json::Value`.
7
8use std::cmp::Ordering;
9
10use crate::numeric_cmp::{Numeric, cmp_numeric, numeric_eq, parse_numeric_str};
11
12/// Coerce a JSON value to f64.
13///
14/// - Numbers: `as_f64()` directly
15/// - Strings: parse as f64 (`"5"` → `5.0`)
16/// - Booleans: `true` → `1.0`, `false` → `0.0` (when `coerce_bool` is true)
17/// - Other types: `None`
18pub fn json_to_f64(v: &serde_json::Value, coerce_bool: bool) -> Option<f64> {
19    match v {
20        serde_json::Value::Number(n) => n.as_f64(),
21        serde_json::Value::String(s) => s.parse::<f64>().ok(),
22        serde_json::Value::Bool(b) if coerce_bool => Some(if *b { 1.0 } else { 0.0 }),
23        _ => None,
24    }
25}
26
27/// A JSON number read without rounding an integer through `f64`.
28fn number_reading(n: &serde_json::Number) -> Option<Numeric> {
29    if let Some(i) = n.as_i64() {
30        return Some(Numeric::Int(i128::from(i)));
31    }
32    if let Some(u) = n.as_u64() {
33        return Some(Numeric::Int(i128::from(u)));
34    }
35    n.as_f64().map(Numeric::Float)
36}
37
38/// `v` read as a number: numbers, numeric strings, and bools (`true` = 1).
39/// Integer text stays exact, and fractional decimal text reads as an exact
40/// `Decimal`.
41fn numeric_reading(v: &serde_json::Value) -> Option<Numeric> {
42    match v {
43        serde_json::Value::Number(n) => number_reading(n),
44        serde_json::Value::Bool(b) => Some(Numeric::Int(i128::from(*b))),
45        serde_json::Value::String(s) => parse_numeric_str(s),
46        _ => None,
47    }
48}
49
50/// Exact order of two JSON numbers. `i64` and `u64` pairs compare exactly,
51/// and an integer against a float compares without rounding. `None` when a
52/// side has no numeric reading.
53pub fn compare_json_numbers(a: &serde_json::Number, b: &serde_json::Number) -> Option<Ordering> {
54    Some(cmp_numeric(number_reading(a)?, number_reading(b)?))
55}
56
57/// Numeric order of `a` and `b` when both have a numeric reading (number,
58/// numeric string, bool). `None` when a side is not numeric. The order is
59/// total: NaN sorts above every number and equals NaN.
60pub fn numeric_order(a: &serde_json::Value, b: &serde_json::Value) -> Option<Ordering> {
61    let (na, nb) = (numeric_reading(a)?, numeric_reading(b)?);
62    Some(cmp_numeric(na, nb))
63}
64
65/// Compare two JSON values with type coercion.
66///
67/// Numeric comparison first (with bool coercion, exact for integers and
68/// decimal text, NaN above every number), then string comparison of the
69/// display forms.
70pub fn compare_json(a: &serde_json::Value, b: &serde_json::Value) -> Ordering {
71    if let Some(order) = numeric_order(a, b) {
72        return order;
73    }
74    // Fallback: string comparison.
75    let sa = json_to_display_string(a);
76    let sb = json_to_display_string(b);
77    sa.cmp(&sb)
78}
79
80/// Compare two optional JSON values (for scan_filter compatibility).
81pub fn compare_json_optional(
82    a: Option<&serde_json::Value>,
83    b: Option<&serde_json::Value>,
84) -> Ordering {
85    match (a, b) {
86        (None, None) => Ordering::Equal,
87        (None, Some(_)) => Ordering::Less,
88        (Some(_), None) => Ordering::Greater,
89        (Some(a), Some(b)) => compare_json(a, b),
90    }
91}
92
93/// Check equality with type coercion.
94///
95/// Handles `"5" == 5` by reading both sides as numbers when each has a
96/// numeric reading. A pair with an integer or decimal side compares
97/// exactly. A float pair is equal within `f64::EPSILON`.
98pub fn coerced_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool {
99    if a == b {
100        return true;
101    }
102    match (numeric_reading(a), numeric_reading(b)) {
103        (Some(na), Some(nb)) => numeric_eq(na, nb),
104        _ => false,
105    }
106}
107
108/// Check if a JSON value is truthy (for boolean contexts).
109///
110/// - `true` → true, `false` → false
111/// - `null` → false
112/// - Numbers: non-zero → true
113/// - Strings: non-empty → true
114/// - Arrays/Objects: always true
115pub fn is_truthy(v: &serde_json::Value) -> bool {
116    match v {
117        serde_json::Value::Bool(b) => *b,
118        serde_json::Value::Null => false,
119        serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
120        serde_json::Value::String(s) => !s.is_empty(),
121        _ => true,
122    }
123}
124
125/// Convert a JSON value to a display string.
126///
127/// - Strings: returned as-is (no quotes)
128/// - Null: empty string
129/// - Numbers/Bools: `.to_string()`
130/// - Objects/Arrays: JSON serialization
131pub fn json_to_display_string(v: &serde_json::Value) -> String {
132    match v {
133        serde_json::Value::String(s) => s.clone(),
134        serde_json::Value::Null => String::new(),
135        serde_json::Value::Number(n) => n.to_string(),
136        serde_json::Value::Bool(b) => b.to_string(),
137        other => other.to_string(),
138    }
139}
140
141/// Convert a f64 to a JSON number, preferring integer representation.
142///
143/// Returns `Null` for NaN/Infinity.
144pub fn to_json_number(n: f64) -> serde_json::Value {
145    if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
146        serde_json::Value::Number(serde_json::Number::from(n as i64))
147    } else {
148        serde_json::Number::from_f64(n)
149            .map(serde_json::Value::Number)
150            .unwrap_or(serde_json::Value::Null)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn coerced_eq_mixed_types() {
161        assert!(coerced_eq(&json!(5), &json!("5")));
162        assert!(coerced_eq(&json!(3.15), &json!("3.15")));
163        assert!(!coerced_eq(&json!(5), &json!("6")));
164    }
165
166    #[test]
167    fn coerced_eq_bool_numeric() {
168        assert!(coerced_eq(&json!(true), &json!(1)));
169        assert!(coerced_eq(&json!(false), &json!(0)));
170        assert!(!coerced_eq(&json!(true), &json!(0)));
171    }
172
173    #[test]
174    fn compare_numeric_coercion() {
175        assert_eq!(
176            compare_json(&json!(5), &json!("4")),
177            std::cmp::Ordering::Greater
178        );
179        assert_eq!(
180            compare_json(&json!("10"), &json!(9)),
181            std::cmp::Ordering::Greater
182        );
183    }
184
185    /// `2^53 + 1` and `2^53` collapse to one `f64`. Integers compare exactly.
186    #[test]
187    fn integers_past_two_pow_53_compare_exactly() {
188        let above = json!(9_007_199_254_740_993_i64);
189        let at = json!(9_007_199_254_740_992_i64);
190        assert!(!coerced_eq(&above, &at));
191        assert_eq!(compare_json(&above, &at), Ordering::Greater);
192        assert_eq!(compare_json(&at, &above), Ordering::Less);
193        assert!(coerced_eq(&above, &json!(9_007_199_254_740_993_u64)));
194    }
195
196    #[test]
197    fn nanosecond_timestamps_one_tick_apart() {
198        let t0 = json!(1_700_000_000_000_000_001_i64);
199        let t1 = json!(1_700_000_000_000_000_002_i64);
200        assert!(!coerced_eq(&t0, &t1));
201        assert_eq!(compare_json(&t0, &t1), Ordering::Less);
202        assert_eq!(compare_json(&t1, &t0), Ordering::Greater);
203    }
204
205    #[test]
206    fn u64_against_i64_compares_exactly() {
207        let u_max = json!(u64::MAX);
208        let i_max = json!(i64::MAX);
209        assert!(!coerced_eq(&u_max, &i_max));
210        assert_eq!(compare_json(&u_max, &i_max), Ordering::Greater);
211        assert_eq!(compare_json(&i_max, &u_max), Ordering::Less);
212        assert_eq!(compare_json(&json!(u64::MAX - 1), &u_max), Ordering::Less);
213        assert_eq!(compare_json(&json!(i64::MIN), &u_max), Ordering::Less);
214        // `i64::MAX + 1` as `u64` against `i64::MAX`.
215        assert_eq!(
216            compare_json(&json!(9_223_372_036_854_775_808_u64), &i_max),
217            Ordering::Greater
218        );
219        assert_eq!(
220            compare_json_numbers(
221                &serde_json::Number::from(u64::MAX),
222                &serde_json::Number::from(i64::MAX)
223            ),
224            Some(Ordering::Greater)
225        );
226    }
227
228    #[test]
229    fn integer_against_float_compares_without_rounding() {
230        let above = json!(9_007_199_254_740_993_i64);
231        let float = json!(9_007_199_254_740_992.0_f64);
232        assert!(!coerced_eq(&above, &float));
233        assert_eq!(compare_json(&above, &float), Ordering::Greater);
234        assert_eq!(compare_json(&float, &above), Ordering::Less);
235        assert!(coerced_eq(&json!(9_007_199_254_740_992_i64), &float));
236        assert_eq!(compare_json(&json!(2), &json!(2.5)), Ordering::Less);
237        assert_eq!(compare_json(&json!(-2), &json!(-2.5)), Ordering::Greater);
238        assert!(coerced_eq(&json!(3), &json!(3.0)));
239        // `u64::MAX` rounds up to `2^64` as an `f64`, so it is below that float.
240        assert_eq!(
241            compare_json(&json!(u64::MAX), &json!(18_446_744_073_709_551_616.0_f64)),
242            Ordering::Less
243        );
244        assert!(!coerced_eq(
245            &json!(u64::MAX),
246            &json!(18_446_744_073_709_551_616.0_f64)
247        ));
248        assert_eq!(
249            compare_json(&json!(i64::MIN), &json!(-9_223_372_036_854_775_808.0_f64)),
250            Ordering::Equal
251        );
252    }
253
254    #[test]
255    fn integer_strings_compare_exactly_against_integers() {
256        let text = json!("9007199254740993");
257        assert!(!coerced_eq(&text, &json!(9_007_199_254_740_992_i64)));
258        assert!(coerced_eq(&text, &json!(9_007_199_254_740_993_i64)));
259        assert_eq!(
260            compare_json(&text, &json!(9_007_199_254_740_992_i64)),
261            Ordering::Greater
262        );
263        assert!(coerced_eq(&json!("18446744073709551615"), &json!(u64::MAX)));
264        assert!(coerced_eq(&json!("5.0"), &json!(5)));
265    }
266
267    #[test]
268    fn small_numbers_keep_their_order() {
269        assert_eq!(compare_json(&json!(1), &json!(2)), Ordering::Less);
270        assert_eq!(compare_json(&json!(1.5), &json!(1.25)), Ordering::Greater);
271        assert_eq!(compare_json(&json!(-3), &json!(-3)), Ordering::Equal);
272        assert_eq!(compare_json(&json!(true), &json!(0)), Ordering::Greater);
273        assert_eq!(compare_json(&json!("abc"), &json!("abd")), Ordering::Less);
274        // Float equality is exact, as PostgreSQL's float8 `=` is.
275        assert!(!coerced_eq(&json!(0.1 + 0.2), &json!(0.3)));
276        assert!(coerced_eq(&json!(0.5), &json!(0.5)));
277        assert!(!coerced_eq(&json!("abc"), &json!(1)));
278    }
279
280    /// NaN text sorts above every number and equals NaN, so a sort over it
281    /// is total.
282    #[test]
283    fn nan_text_sorts_above_every_number() {
284        assert_eq!(compare_json(&json!("NaN"), &json!(1)), Ordering::Greater);
285        assert_eq!(compare_json(&json!(1e308), &json!("NaN")), Ordering::Less);
286        assert_eq!(compare_json(&json!("NaN"), &json!("NaN")), Ordering::Equal);
287        assert_eq!(
288            compare_json(&json!("NaN"), &json!("inf")),
289            Ordering::Greater
290        );
291        assert!(coerced_eq(&json!("NaN"), &json!("nan")));
292        let mut values = [
293            json!("NaN"),
294            json!(2),
295            json!("-inf"),
296            json!("NaN"),
297            json!(1.5),
298        ];
299        values.sort_by(compare_json);
300        assert_eq!(values[0], json!("-inf"));
301        assert_eq!(values[1], json!(1.5));
302        assert_eq!(values[2], json!(2));
303    }
304
305    /// Two decimal strings one hundredth apart past `2^53` compare exactly.
306    #[test]
307    fn fractional_decimal_text_compares_exactly() {
308        let low = json!("12345678901234567.01");
309        let high = json!("12345678901234567.02");
310        assert_eq!(compare_json(&low, &high), Ordering::Less);
311        assert_eq!(compare_json(&high, &low), Ordering::Greater);
312        assert!(!coerced_eq(&low, &high));
313        assert_eq!(
314            compare_json(
315                &json!("9007199254740993.5"),
316                &json!(9_007_199_254_740_993_i64)
317            ),
318            Ordering::Greater
319        );
320        assert!(coerced_eq(&json!("2.50"), &json!("2.5")));
321    }
322
323    #[test]
324    fn truthiness() {
325        assert!(is_truthy(&json!(true)));
326        assert!(!is_truthy(&json!(false)));
327        assert!(!is_truthy(&json!(null)));
328        assert!(is_truthy(&json!(1)));
329        assert!(!is_truthy(&json!(0)));
330        assert!(is_truthy(&json!("hello")));
331        assert!(!is_truthy(&json!("")));
332    }
333
334    #[test]
335    fn to_json_number_nan() {
336        assert_eq!(to_json_number(f64::NAN), serde_json::Value::Null);
337    }
338
339    #[test]
340    fn to_json_number_integer() {
341        assert_eq!(to_json_number(42.0), json!(42));
342    }
343
344    #[test]
345    fn to_json_number_float() {
346        assert_eq!(to_json_number(3.15), json!(3.15));
347    }
348}