Skip to main content

grafeo_core/execution/operators/
value_utils.rs

1//! Shared value comparison and conversion utilities.
2//!
3//! Used by pull-based and push-based aggregate, filter, and sort operators
4//! to avoid duplicating comparison logic across six different files.
5
6use std::cmp::Ordering;
7
8use grafeo_common::types::Value;
9
10use super::sort::NullOrder;
11
12/// Converts a value to `f64` for numeric aggregations.
13///
14/// Supports RDF values stored as strings by attempting numeric parsing.
15pub fn value_to_f64(value: &Value) -> Option<f64> {
16    match value {
17        Value::Int64(i) => Some(*i as f64),
18        Value::Float64(f) => Some(*f),
19        // RDF stores numeric literals as strings - try to parse them
20        Value::String(s) => s.parse::<f64>().ok(),
21        _ => None,
22    }
23}
24
25/// Compares two values with partial ordering (returns `None` for incomparable types).
26///
27/// Handles cross-type comparisons between Int64/Float64/String, including
28/// RDF numeric strings that need parsing before comparison.
29pub fn compare_values(a: &Value, b: &Value) -> Option<Ordering> {
30    match (a, b) {
31        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
32        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b),
33        (Value::String(a), Value::String(b)) => {
34            // Try numeric comparison first if both look like numbers
35            if let (Ok(a_num), Ok(b_num)) = (a.parse::<f64>(), b.parse::<f64>()) {
36                a_num.partial_cmp(&b_num)
37            } else {
38                Some(a.cmp(b))
39            }
40        }
41        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
42        (Value::Int64(a), Value::Float64(b)) => (*a as f64).partial_cmp(b),
43        (Value::Float64(a), Value::Int64(b)) => a.partial_cmp(&(*b as f64)),
44        // String-to-numeric comparisons for RDF
45        (Value::String(s), Value::Int64(i)) => s.parse::<f64>().ok()?.partial_cmp(&(*i as f64)),
46        (Value::String(s), Value::Float64(f)) => s.parse::<f64>().ok()?.partial_cmp(f),
47        (Value::Int64(i), Value::String(s)) => (*i as f64).partial_cmp(&s.parse::<f64>().ok()?),
48        (Value::Float64(f), Value::String(s)) => f.partial_cmp(&s.parse::<f64>().ok()?),
49        (Value::Timestamp(a), Value::Timestamp(b)) => Some(a.cmp(b)),
50        (Value::Date(a), Value::Date(b)) => Some(a.cmp(b)),
51        (Value::Time(a), Value::Time(b)) => Some(a.cmp(b)),
52        _ => None,
53    }
54}
55
56/// Compares two values with total ordering (returns `Equal` for incomparable types).
57///
58/// Used by sort operators where a total order is required.
59pub fn compare_values_total(a: &Value, b: &Value) -> Ordering {
60    match (a, b) {
61        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
62        (Value::Int64(a), Value::Int64(b)) => a.cmp(b),
63        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
64        (Value::String(a), Value::String(b)) => a.cmp(b),
65        (Value::Int64(a), Value::Float64(b)) => {
66            (*a as f64).partial_cmp(b).unwrap_or(Ordering::Equal)
67        }
68        (Value::Float64(a), Value::Int64(b)) => {
69            a.partial_cmp(&(*b as f64)).unwrap_or(Ordering::Equal)
70        }
71        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
72        (Value::Date(a), Value::Date(b)) => a.cmp(b),
73        (Value::Time(a), Value::Time(b)) => a.cmp(b),
74        _ => Ordering::Equal,
75    }
76}
77
78/// Compares two optional values with null handling.
79///
80/// Used by sort and top-K operators to handle the `NULLS FIRST` / `NULLS LAST`
81/// directive uniformly. Both `None` and `Some(Value::Null)` are treated as null.
82pub fn compare_values_with_nulls(
83    a: &Option<Value>,
84    b: &Option<Value>,
85    null_order: NullOrder,
86) -> Ordering {
87    match (a, b) {
88        (None, None) | (Some(Value::Null), Some(Value::Null)) => Ordering::Equal,
89        (None, _) | (Some(Value::Null), _) => match null_order {
90            NullOrder::NullsFirst => Ordering::Less,
91            NullOrder::NullsLast => Ordering::Greater,
92        },
93        (_, None) | (_, Some(Value::Null)) => match null_order {
94            NullOrder::NullsFirst => Ordering::Greater,
95            NullOrder::NullsLast => Ordering::Less,
96        },
97        (Some(a), Some(b)) => compare_values_total(a, b),
98    }
99}
100
101/// Returns `true` if `new` is less than `current` (for MIN aggregation).
102///
103/// Returns `true` when `current` is `None` (first value always wins).
104pub fn is_less_than(current: &Option<Value>, new: &Value) -> bool {
105    match current {
106        None => true,
107        Some(curr) => compare_values(new, curr) == Some(Ordering::Less),
108    }
109}
110
111/// Returns `true` if `new` is greater than `current` (for MAX aggregation).
112///
113/// Returns `true` when `current` is `None` (first value always wins).
114pub fn is_greater_than(current: &Option<Value>, new: &Value) -> bool {
115    match current {
116        None => true,
117        Some(curr) => compare_values(new, curr) == Some(Ordering::Greater),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn value_to_f64_int() {
127        assert_eq!(value_to_f64(&Value::Int64(42)), Some(42.0));
128    }
129
130    #[test]
131    fn value_to_f64_float() {
132        assert_eq!(value_to_f64(&Value::Float64(2.72)), Some(2.72));
133    }
134
135    #[test]
136    fn value_to_f64_numeric_string() {
137        assert_eq!(value_to_f64(&Value::String("2.5".into())), Some(2.5));
138    }
139
140    #[test]
141    fn value_to_f64_non_numeric_string() {
142        assert_eq!(value_to_f64(&Value::String("abc".into())), None);
143    }
144
145    #[test]
146    fn value_to_f64_null() {
147        assert_eq!(value_to_f64(&Value::Null), None);
148    }
149
150    #[test]
151    fn compare_same_type_int() {
152        assert_eq!(
153            compare_values(&Value::Int64(1), &Value::Int64(2)),
154            Some(Ordering::Less)
155        );
156    }
157
158    #[test]
159    fn compare_cross_type_int_float() {
160        assert_eq!(
161            compare_values(&Value::Int64(2), &Value::Float64(2.0)),
162            Some(Ordering::Equal)
163        );
164    }
165
166    #[test]
167    fn compare_rdf_numeric_strings() {
168        assert_eq!(
169            compare_values(&Value::String("10".into()), &Value::String("9".into())),
170            Some(Ordering::Greater)
171        );
172    }
173
174    #[test]
175    fn compare_incomparable() {
176        assert_eq!(compare_values(&Value::Bool(true), &Value::Int64(1)), None);
177    }
178
179    #[test]
180    fn total_ordering_incomparable_returns_equal() {
181        assert_eq!(
182            compare_values_total(&Value::Bool(true), &Value::Int64(1)),
183            Ordering::Equal
184        );
185    }
186
187    #[test]
188    fn is_less_than_none_always_true() {
189        assert!(is_less_than(&None, &Value::Int64(5)));
190    }
191
192    #[test]
193    fn is_less_than_smaller() {
194        assert!(is_less_than(&Some(Value::Int64(10)), &Value::Int64(5)));
195    }
196
197    #[test]
198    fn is_less_than_larger() {
199        assert!(!is_less_than(&Some(Value::Int64(3)), &Value::Int64(5)));
200    }
201
202    #[test]
203    fn is_greater_than_none_always_true() {
204        assert!(is_greater_than(&None, &Value::Int64(5)));
205    }
206
207    #[test]
208    fn is_greater_than_larger() {
209        assert!(is_greater_than(&Some(Value::Int64(3)), &Value::Int64(5)));
210    }
211
212    #[test]
213    fn is_greater_than_smaller() {
214        assert!(!is_greater_than(&Some(Value::Int64(10)), &Value::Int64(5)));
215    }
216}