Skip to main content

rushdb_types/
value.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as Json;
3use std::cmp::Ordering;
4
5use crate::model::LogicalType;
6
7/// A single canonical scalar value.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum Scalar {
10    /// Boolean value.
11    Boolean(bool),
12    /// Signed 64-bit integer.
13    Int64(i64),
14    /// IEEE-754 double-precision float.
15    Float64(f64),
16    /// UTF-8 string.
17    Utf8(String),
18    /// UTC epoch milliseconds.
19    DateTime(i64),
20}
21
22/// Compare canonical scalars without widening integers through an imprecise
23/// floating-point representation. Integer/float equality is accepted only
24/// when the float is finite, integral, in range, and round-trips exactly.
25#[must_use]
26#[allow(
27    clippy::cast_possible_truncation,
28    clippy::cast_precision_loss,
29    clippy::float_cmp,
30    reason = "exact round-trip checks are the definition of canonical integer/float equality"
31)]
32pub fn scalar_equal(left: &Scalar, right: &Scalar) -> bool {
33    match (left, right) {
34        (Scalar::Boolean(a), Scalar::Boolean(b)) => a == b,
35        (Scalar::Utf8(a), Scalar::Utf8(b)) => a == b,
36        (Scalar::DateTime(a), Scalar::DateTime(b)) | (Scalar::Int64(a), Scalar::Int64(b)) => a == b,
37        (Scalar::Float64(a), Scalar::Float64(b)) => a.is_finite() && b.is_finite() && a == b,
38        (Scalar::Int64(integer), Scalar::Float64(float))
39        | (Scalar::Float64(float), Scalar::Int64(integer)) => {
40            if !float.is_finite() || float.fract() != 0.0 {
41                return false;
42            }
43            // Rust saturates float-to-integer casts at the integer bounds.
44            // Reject values outside the signed domain before casting so
45            // `2^63` cannot alias `i64::MAX` (and likewise at the negative
46            // boundary).
47            if *float >= 9_223_372_036_854_775_808.0 || *float < -9_223_372_036_854_775_808.0 {
48                return false;
49            }
50            let converted = *float as i64;
51            converted == *integer && (converted as f64) == *float
52        }
53        _ => false,
54    }
55}
56
57/// Compare canonical scalars without converting large integers to `f64`.
58/// Mixed integer/float comparisons preserve the exact ordering represented by
59/// the IEEE-754 value; non-numeric or mismatched domains return `None`.
60#[must_use]
61pub fn scalar_cmp(left: &Scalar, right: &Scalar) -> Option<Ordering> {
62    match (left, right) {
63        (Scalar::Boolean(a), Scalar::Boolean(b)) => Some(a.cmp(b)),
64        (Scalar::Utf8(a), Scalar::Utf8(b)) => Some(a.cmp(b)),
65        (Scalar::DateTime(a), Scalar::DateTime(b)) | (Scalar::Int64(a), Scalar::Int64(b)) => {
66            Some(a.cmp(b))
67        }
68        (Scalar::Float64(a), Scalar::Float64(b)) => a.partial_cmp(b),
69        (Scalar::Int64(integer), Scalar::Float64(float)) => cmp_int_float(*integer, *float),
70        (Scalar::Float64(float), Scalar::Int64(integer)) => {
71            cmp_int_float(*integer, *float).map(Ordering::reverse)
72        }
73        _ => None,
74    }
75}
76
77/// Compare JSON scalar values using the canonical integer/float ordering.
78/// Non-scalar values and mismatched domains return `None`.
79#[must_use]
80pub fn json_scalar_cmp(left: &Json, right: &Json) -> Option<Ordering> {
81    let (_, left) = infer_scalar(left)?;
82    let (_, right) = infer_scalar(right)?;
83    scalar_cmp(&left, &right)
84}
85
86#[allow(
87    clippy::cast_possible_truncation,
88    clippy::cast_precision_loss,
89    reason = "bounds and exact integer-part comparison are intentional"
90)]
91fn cmp_int_float(integer: i64, float: f64) -> Option<Ordering> {
92    if !float.is_finite() {
93        return float.partial_cmp(&0.0).map(Ordering::reverse);
94    }
95    let truncated = float.trunc();
96    // Use the exact signed-domain boundary rather than `i64::MAX as f64`,
97    // which rounds up to 2^63 and would make the saturating cast below alias
98    // the largest signed integer.
99    if truncated >= 9_223_372_036_854_775_808.0 {
100        return Some(Ordering::Less);
101    }
102    if truncated < -9_223_372_036_854_775_808.0 {
103        return Some(Ordering::Greater);
104    }
105    let integer_part = truncated as i64;
106    match integer.cmp(&integer_part) {
107        Ordering::Equal if float.fract() > 0.0 => Some(Ordering::Less),
108        Ordering::Equal if float.fract() < 0.0 => Some(Ordering::Greater),
109        ordering => Some(ordering),
110    }
111}
112
113/// Normalize a property name for lookup: ASCII-uppercase folds to lowercase,
114/// other alphanumerics are kept, and every other character becomes `_`.
115///
116/// # Examples
117///
118/// ```
119/// use rushdb_types::value::normalize_name;
120///
121/// assert_eq!(normalize_name("FirstName"), "firstname");
122/// assert_eq!(normalize_name("first-name"), "first_name");
123/// ```
124#[must_use]
125pub fn normalize_name(name: &str) -> String {
126    let mut out = String::with_capacity(name.len());
127    for c in name.chars() {
128        if c.is_ascii_uppercase() {
129            out.push(c.to_ascii_lowercase());
130        } else if c.is_alphanumeric() {
131            out.push(c);
132        } else {
133            out.push('_');
134        }
135    }
136    out
137}
138
139/// The record or relationship id in a reconstructed entity and in language
140/// results (SQL, Cypher, GQL, Gremlin, Bolt).
141pub const ID_FIELD: &str = "__id";
142/// The record label in a reconstructed record.
143pub const LABEL_FIELD: &str = "__label";
144/// The relationship type in a reconstructed relationship.
145pub const TYPE_FIELD: &str = "__type";
146/// The source record id in a reconstructed relationship.
147pub const SOURCE_FIELD: &str = "__source_id";
148/// The target record id in a reconstructed relationship.
149pub const TARGET_FIELD: &str = "__target_id";
150
151/// Whether a normalized property name is reserved for the engine: every name
152/// starting with `__` ([`ID_FIELD`], [`LABEL_FIELD`], `__proptypes`,
153/// `__score`, …). No record or relationship stores a property under such a
154/// name, so system fields never collide with user properties; `_id` is an
155/// ordinary property.
156///
157/// # Examples
158///
159/// ```
160/// use rushdb_types::value::{is_reserved_name, normalize_name};
161///
162/// assert!(is_reserved_name("__id"));
163/// assert!(is_reserved_name(&normalize_name("__Score")));
164/// assert!(!is_reserved_name("_id"));
165/// ```
166#[must_use]
167pub fn is_reserved_name(normalized: &str) -> bool {
168    normalized.starts_with("__")
169}
170
171/// Convert a canonical scalar to its JSON representation.
172#[must_use]
173pub fn scalar_to_json(s: &Scalar) -> Json {
174    match s {
175        Scalar::Boolean(b) => Json::Bool(*b),
176        Scalar::Int64(i) => Json::Number((*i).into()),
177        Scalar::Float64(f) => serde_json::Number::from_f64(*f).map_or(Json::Null, Json::Number),
178        Scalar::Utf8(st) => Json::String(st.clone()),
179        Scalar::DateTime(ms) => Json::Number((*ms).into()),
180    }
181}
182
183/// Infer a logical type and scalar from a JSON value.
184///
185/// Returns `None` for JSON nulls, arrays and objects, which have no scalar
186/// interpretation.
187#[must_use]
188pub fn infer_scalar(v: &Json) -> Option<(LogicalType, Scalar)> {
189    match v {
190        Json::Bool(b) => Some((LogicalType::Boolean, Scalar::Boolean(*b))),
191        Json::Number(n) => {
192            if let Some(i) = n.as_i64() {
193                Some((LogicalType::Int64, Scalar::Int64(i)))
194            } else if n.as_u64().is_some() {
195                // The engine's canonical integer domain is signed i64. Do
196                // not silently widen an out-of-domain JSON integer through
197                // f64: values above 2^63-1 would lose identity and could
198                // match the wrong index entry.
199                None
200            } else {
201                n.as_f64()
202                    .map(|f| (LogicalType::Float64, Scalar::Float64(f)))
203            }
204        }
205        Json::String(s) => Some((LogicalType::Utf8, Scalar::Utf8(s.clone()))),
206        _ => None,
207    }
208}
209
210/// Name of the JSON kind of a value (`"null"`, `"boolean"`, ...).
211#[must_use]
212pub fn json_type_name(v: &Json) -> &'static str {
213    match v {
214        Json::Null => "null",
215        Json::Bool(_) => "boolean",
216        Json::Number(_) => "number",
217        Json::String(_) => "string",
218        Json::Array(_) => "array",
219        Json::Object(_) => "object",
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn scalar_ordering_preserves_integer_precision() {
229        assert_eq!(
230            scalar_cmp(
231                &Scalar::Int64(9_007_199_254_740_993),
232                &Scalar::Float64(9_007_199_254_740_992.0)
233            ),
234            Some(Ordering::Greater)
235        );
236        assert_eq!(
237            scalar_cmp(&Scalar::Int64(2), &Scalar::Float64(2.5)),
238            Some(Ordering::Less)
239        );
240    }
241
242    #[test]
243    fn out_of_domain_unsigned_json_integer_is_not_widened() {
244        let value = serde_json::json!(u64::MAX);
245        assert!(infer_scalar(&value).is_none());
246    }
247
248    #[test]
249    fn json_scalar_ordering_preserves_integer_precision() {
250        let left = Json::Number(9_007_199_254_740_993i64.into());
251        let right = Json::Number(9_007_199_254_740_992i64.into());
252        assert_eq!(json_scalar_cmp(&left, &right), Some(Ordering::Greater));
253        assert_eq!(json_scalar_cmp(&Json::String("a".into()), &right), None);
254    }
255
256    #[test]
257    fn scalar_equality_preserves_integer_precision() {
258        assert!(scalar_equal(&Scalar::Int64(1), &Scalar::Float64(1.0)));
259        assert!(!scalar_equal(
260            &Scalar::Int64(9_007_199_254_740_993),
261            &Scalar::Float64(9_007_199_254_740_992.0)
262        ));
263        assert!(!scalar_equal(
264            &Scalar::Float64(f64::NAN),
265            &Scalar::Float64(f64::NAN)
266        ));
267        assert!(!scalar_equal(&Scalar::Utf8("1".into()), &Scalar::Int64(1)));
268        assert!(!scalar_equal(
269            &Scalar::Int64(i64::MAX),
270            &Scalar::Float64(9_223_372_036_854_775_808.0)
271        ));
272    }
273
274    #[test]
275    fn scalar_ordering_rejects_saturating_integer_boundary_aliases() {
276        assert_eq!(
277            scalar_cmp(
278                &Scalar::Int64(i64::MAX),
279                &Scalar::Float64(9_223_372_036_854_775_808.0)
280            ),
281            Some(Ordering::Less)
282        );
283        assert_eq!(
284            scalar_cmp(
285                &Scalar::Int64(i64::MIN),
286                &Scalar::Float64(-9_223_372_036_854_775_808.0)
287            ),
288            Some(Ordering::Equal)
289        );
290    }
291}