Skip to main content

uqa_core/
predicate.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Filter predicates evaluated against [`Value`] field contents.
8//!
9//! `Predicate` is an enum (rather than a trait) so callers can pattern
10//! match on the special `IsNull` / `IsNotNull` cases that need to see the
11//! `None` field value, while everything else short-circuits on missing
12//! fields. `Like` / `ILike` regex variants land alongside the SQL
13//! compiler.
14
15use std::collections::BTreeSet;
16
17use crate::types::Value;
18
19#[derive(Debug, Clone, PartialEq)]
20pub enum Predicate {
21    Equals(Value),
22    NotEquals(Value),
23    GreaterThan(Value),
24    GreaterThanOrEqual(Value),
25    LessThan(Value),
26    LessThanOrEqual(Value),
27    InSet(BTreeSet<Value>),
28    Between { low: Value, high: Value },
29    IsNull,
30    IsNotNull,
31}
32
33impl Predicate {
34    /// Returns `true` if the predicate must inspect `None` field values
35    /// (the `IsNull` / `IsNotNull` case). Filter operators short-circuit
36    /// missing fields for every other variant.
37    pub fn is_null_aware(&self) -> bool {
38        matches!(self, Predicate::IsNull | Predicate::IsNotNull)
39    }
40
41    /// Evaluate against an optional field value. The two null-aware
42    /// variants treat both an absent field (`None`) and an explicit
43    /// `Value::Null` as null, and test a row value field by field as
44    /// [`sql_null_test`] does; the rest reject a null value.
45    pub fn evaluate(&self, value: Option<&Value>) -> bool {
46        let is_null = matches!(value, None | Some(Value::Null));
47        match self {
48            Predicate::IsNull => sql_null_test(value, false),
49            Predicate::IsNotNull => sql_null_test(value, true),
50            Predicate::Equals(target) => !is_null && value.is_some_and(|v| values_equal(v, target)),
51            Predicate::NotEquals(target) => {
52                !is_null && value.is_some_and(|v| !values_equal(v, target))
53            }
54            Predicate::GreaterThan(target) => {
55                !is_null && value.is_some_and(|v| compare_values(v, target).is_gt())
56            }
57            Predicate::GreaterThanOrEqual(target) => {
58                !is_null && value.is_some_and(|v| compare_values(v, target).is_ge())
59            }
60            Predicate::LessThan(target) => {
61                !is_null && value.is_some_and(|v| compare_values(v, target).is_lt())
62            }
63            Predicate::LessThanOrEqual(target) => {
64                !is_null && value.is_some_and(|v| compare_values(v, target).is_le())
65            }
66            Predicate::InSet(values) => {
67                !is_null
68                    && value.is_some_and(|v| values.iter().any(|target| values_equal(v, target)))
69            }
70            Predicate::Between { low, high } => {
71                !is_null
72                    && value.is_some_and(|v| {
73                        compare_values(v, low).is_ge() && compare_values(v, high).is_le()
74                    })
75            }
76        }
77    }
78}
79
80/// SQL `IS NULL` (or `IS NOT NULL` when `negated`), as `PostgreSQL`'s `NullTest` evaluates it: a row value is null when every field is null and not null when no field is, so a row with both kinds of fields is neither; any other value is null only when it is the null value. An absent field is null.
81#[must_use]
82pub fn sql_null_test(value: Option<&Value>, negated: bool) -> bool {
83    let fields: Box<dyn Iterator<Item = &Value>> = match value {
84        None | Some(Value::Null) => return !negated,
85        Some(Value::Row(values)) => Box::new(values.iter()),
86        Some(Value::Record(fields)) => Box::new(fields.iter().map(|(_, value)| value)),
87        Some(_) => return negated,
88    };
89    let mut fields = fields;
90    if negated {
91        fields.all(|field| !matches!(field, Value::Null))
92    } else {
93        fields.all(|field| matches!(field, Value::Null))
94    }
95}
96
97fn values_equal(a: &Value, b: &Value) -> bool {
98    match (a, b) {
99        (Value::Temporal(x), Value::Temporal(y)) => x == y,
100        (Value::Temporal(x), Value::Str(y)) | (Value::Str(y), Value::Temporal(x)) => {
101            x.parse_same_kind(y).is_some_and(|parsed| parsed == *x)
102        }
103        (Value::FixedChar(x) | Value::Str(x), Value::FixedChar(y))
104        | (Value::FixedChar(x), Value::Str(y)) => {
105            x.trim_end_matches(' ') == y.trim_end_matches(' ')
106        }
107        _ => a == b,
108    }
109}
110
111fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
112    match (a, b) {
113        (Value::Temporal(x), Value::Temporal(y)) => x.cmp(y),
114        (Value::Temporal(x), Value::Str(y)) => x
115            .parse_same_kind(y)
116            .map_or_else(|| a.cmp(b), |parsed| x.cmp(&parsed)),
117        (Value::Str(x), Value::Temporal(y)) => y
118            .parse_same_kind(x)
119            .map_or_else(|| a.cmp(b), |parsed| parsed.cmp(y)),
120        (Value::FixedChar(x) | Value::Str(x), Value::FixedChar(y))
121        | (Value::FixedChar(x), Value::Str(y)) => {
122            x.trim_end_matches(' ').cmp(y.trim_end_matches(' '))
123        }
124        _ => a.cmp(b),
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn iv(n: i64) -> Value {
133        Value::Int(n)
134    }
135
136    #[test]
137    fn equals_matches_exact() {
138        let p = Predicate::Equals(iv(42));
139        assert!(p.evaluate(Some(&iv(42))));
140        assert!(!p.evaluate(Some(&iv(43))));
141        assert!(!p.evaluate(None));
142    }
143
144    #[test]
145    fn comparators_respect_ordering() {
146        let p = Predicate::GreaterThan(iv(10));
147        assert!(p.evaluate(Some(&iv(11))));
148        assert!(!p.evaluate(Some(&iv(10))));
149        assert!(!p.evaluate(Some(&iv(9))));
150    }
151
152    #[test]
153    fn between_inclusive_bounds() {
154        let p = Predicate::Between {
155            low: iv(1),
156            high: iv(3),
157        };
158        assert!(p.evaluate(Some(&iv(1))));
159        assert!(p.evaluate(Some(&iv(3))));
160        assert!(!p.evaluate(Some(&iv(0))));
161        assert!(!p.evaluate(Some(&iv(4))));
162    }
163
164    #[test]
165    fn in_set_membership() {
166        let s: BTreeSet<Value> = [iv(1), iv(2), iv(5)].into_iter().collect();
167        let p = Predicate::InSet(s);
168        assert!(p.evaluate(Some(&iv(2))));
169        assert!(!p.evaluate(Some(&iv(3))));
170    }
171
172    #[test]
173    fn null_aware_predicates_see_none() {
174        assert!(Predicate::IsNull.evaluate(None));
175        assert!(!Predicate::IsNull.evaluate(Some(&iv(0))));
176        assert!(!Predicate::IsNotNull.evaluate(None));
177        assert!(Predicate::IsNotNull.evaluate(Some(&iv(0))));
178    }
179
180    #[test]
181    fn fixed_character_predicates_ignore_blank_padding() {
182        let fixed = Value::FixedChar("x   ".into());
183        assert!(Predicate::Equals(Value::Str("x".into())).evaluate(Some(&fixed)));
184        assert!(Predicate::Equals(Value::Str("x  ".into())).evaluate(Some(&fixed)));
185        assert!(Predicate::LessThan(Value::Str("y".into())).evaluate(Some(&fixed)));
186    }
187}
188
189#[cfg(test)]
190mod null_test_tests {
191    use super::{sql_null_test, Predicate};
192    use crate::types::Value;
193
194    #[test]
195    fn row_values_are_null_only_when_every_field_is() {
196        let all_null = Value::Row(vec![Value::Null, Value::Null].into());
197        let mixed = Value::Record(vec![("x".into(), Value::Int(1)), ("y".into(), Value::Null)]);
198        let none_null = Value::Row(vec![Value::Int(1)].into());
199        let empty = Value::Row(Vec::new().into());
200        for (value, is_null, is_not_null) in [
201            (Some(&all_null), true, false),
202            (Some(&mixed), false, false),
203            (Some(&none_null), false, true),
204            (Some(&empty), true, true),
205            (Some(&Value::Null), true, false),
206            (None, true, false),
207            (Some(&Value::Int(0)), false, true),
208        ] {
209            assert_eq!(sql_null_test(value, false), is_null, "{value:?}");
210            assert_eq!(sql_null_test(value, true), is_not_null, "{value:?}");
211            assert_eq!(Predicate::IsNull.evaluate(value), is_null);
212            assert_eq!(Predicate::IsNotNull.evaluate(value), is_not_null);
213        }
214    }
215}