Skip to main content

oxigdal_shapefile/
filter.rs

1//! Attribute filtering for Shapefile features.
2//!
3//! This module provides:
4//! - [`FieldFilter`] – a structured filter that matches features whose named
5//!   attribute satisfies a comparison against a [`FilterValue`].
6//! - [`FieldFilterOp`] – the comparison operators supported.
7//! - [`FilterValue`] – the right-hand side of a comparison (String, Integer,
8//!   Float, or Bool).
9//!
10//! All types are cheaply cloneable and `Send + Sync`, so they can be used in
11//! parallel read scenarios.
12
13use crate::reader::ShapefileFeature;
14use oxigdal_core::vector::FieldValue;
15
16// ── Comparison operators ───────────────────────────────────────────────────────
17
18/// Operator for comparing a feature attribute to a [`FilterValue`].
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum FieldFilterOp {
21    /// Equal to.
22    Eq,
23    /// Not equal to.
24    Ne,
25    /// Greater than (numeric comparison).
26    Gt,
27    /// Less than (numeric comparison).
28    Lt,
29    /// Greater than or equal to (numeric comparison).
30    Gte,
31    /// Less than or equal to (numeric comparison).
32    Lte,
33    /// String contains the value as a substring (string types only).
34    Contains,
35    /// String starts with the value as a prefix (string types only).
36    StartsWith,
37}
38
39// ── Filter value ──────────────────────────────────────────────────────────────
40
41/// The right-hand side of an attribute filter comparison.
42#[derive(Debug, Clone, PartialEq)]
43pub enum FilterValue {
44    /// A UTF-8 string value.
45    String(String),
46    /// A 64-bit signed integer value.
47    Integer(i64),
48    /// A 64-bit floating-point value.
49    Float(f64),
50    /// A boolean value.
51    Bool(bool),
52}
53
54impl FilterValue {
55    /// Converts the filter value to `f64` for numeric comparisons.
56    ///
57    /// Returns `None` if the value is not numeric (i.e. String or Bool).
58    fn as_f64(&self) -> Option<f64> {
59        match self {
60            Self::Integer(i) => Some(*i as f64),
61            Self::Float(f) => Some(*f),
62            Self::String(_) | Self::Bool(_) => None,
63        }
64    }
65}
66
67// ── Field filter ──────────────────────────────────────────────────────────────
68
69/// Structured filter that tests a single named attribute of a
70/// [`ShapefileFeature`] against a [`FilterValue`] using a [`FieldFilterOp`].
71///
72/// # Example
73///
74/// ```rust,no_run
75/// use oxigdal_shapefile::filter::{FieldFilter, FieldFilterOp, FilterValue};
76///
77/// // Match features where "NAME" == "Paris"
78/// let filter = FieldFilter {
79///     field: "NAME".to_string(),
80///     op: FieldFilterOp::Eq,
81///     value: FilterValue::String("Paris".to_string()),
82/// };
83/// ```
84#[derive(Debug, Clone)]
85pub struct FieldFilter {
86    /// The attribute field name to test.
87    pub field: String,
88    /// The comparison operator.
89    pub op: FieldFilterOp,
90    /// The right-hand side value.
91    pub value: FilterValue,
92}
93
94impl FieldFilter {
95    /// Returns `true` if `feature` satisfies this filter.
96    ///
97    /// Rules:
98    /// - If the field is absent from the feature's attributes, returns `false`.
99    /// - `Eq`/`Ne`: value-level equality for all types.  Float equality is
100    ///   strict (`==` on `f64`); callers should avoid using `Eq`/`Ne` with
101    ///   floating-point values.
102    /// - `Gt`/`Lt`/`Gte`/`Lte`: numeric comparison; both the attribute and the
103    ///   filter value are cast to `f64`.  Non-numeric types on either side
104    ///   return `false`.
105    /// - `Contains`/`StartsWith`: substring / prefix match on string types
106    ///   only; any other type returns `false`.
107    pub fn matches(&self, feature: &ShapefileFeature) -> bool {
108        let Some(attr) = feature.attributes.get(&self.field) else {
109            return false;
110        };
111
112        match self.op {
113            FieldFilterOp::Eq => self.eq_match(attr),
114            FieldFilterOp::Ne => !self.eq_match(attr),
115            FieldFilterOp::Gt => self.numeric_cmp(attr).is_some_and(|o| o > 0),
116            FieldFilterOp::Lt => self.numeric_cmp(attr).is_some_and(|o| o < 0),
117            FieldFilterOp::Gte => self.numeric_cmp(attr).is_some_and(|o| o >= 0),
118            FieldFilterOp::Lte => self.numeric_cmp(attr).is_some_and(|o| o <= 0),
119            FieldFilterOp::Contains => self.string_contains(attr),
120            FieldFilterOp::StartsWith => self.string_starts_with(attr),
121        }
122    }
123
124    // ── Helpers ────────────────────────────────────────────────────────────────
125
126    /// Value-equality check between `attr` and `self.value`.
127    fn eq_match(&self, attr: &FieldValue) -> bool {
128        match (attr, &self.value) {
129            (FieldValue::String(a), FilterValue::String(v)) => a == v,
130            (FieldValue::Integer(a), FilterValue::Integer(v)) => a == v,
131            (FieldValue::Float(a), FilterValue::Float(v)) => a == v,
132            (FieldValue::Bool(a), FilterValue::Bool(v)) => a == v,
133            // Allow cross-type numeric comparisons (Integer attribute vs Float filter).
134            (FieldValue::Integer(a), FilterValue::Float(v)) => (*a as f64) == *v,
135            (FieldValue::Float(a), FilterValue::Integer(v)) => *a == (*v as f64),
136            _ => false,
137        }
138    }
139
140    /// Returns a numeric comparison result (`-1`, `0`, `1`) between the
141    /// attribute and the filter value, or `None` if either side is not numeric.
142    ///
143    /// Negative means `attr < value`, zero means equal, positive means `attr > value`.
144    fn numeric_cmp(&self, attr: &FieldValue) -> Option<i8> {
145        let lhs: f64 = match attr {
146            FieldValue::Integer(i) => *i as f64,
147            FieldValue::Float(f) => *f,
148            _ => return None,
149        };
150        let rhs = self.value.as_f64()?;
151        if lhs < rhs {
152            Some(-1)
153        } else if lhs > rhs {
154            Some(1)
155        } else {
156            Some(0)
157        }
158    }
159
160    /// Returns `true` if the string attribute contains the filter value as a
161    /// substring.  Non-string attribute types always return `false`.
162    fn string_contains(&self, attr: &FieldValue) -> bool {
163        let FilterValue::String(needle) = &self.value else {
164            return false;
165        };
166        match attr {
167            FieldValue::String(haystack) => haystack.contains(needle.as_str()),
168            _ => false,
169        }
170    }
171
172    /// Returns `true` if the string attribute starts with the filter value as a
173    /// prefix.  Non-string attribute types always return `false`.
174    fn string_starts_with(&self, attr: &FieldValue) -> bool {
175        let FilterValue::String(prefix) = &self.value else {
176            return false;
177        };
178        match attr {
179            FieldValue::String(haystack) => haystack.starts_with(prefix.as_str()),
180            _ => false,
181        }
182    }
183}
184
185// ── Unit tests ─────────────────────────────────────────────────────────────────
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::reader::ShapefileFeature;
191    use std::collections::HashMap;
192
193    fn make_feature(attrs: Vec<(&str, FieldValue)>) -> ShapefileFeature {
194        let mut attributes = HashMap::new();
195        for (k, v) in attrs {
196            attributes.insert(k.to_string(), v);
197        }
198        ShapefileFeature::new(1, None, attributes)
199    }
200
201    #[test]
202    fn test_string_eq() {
203        let filter = FieldFilter {
204            field: "NAME".to_string(),
205            op: FieldFilterOp::Eq,
206            value: FilterValue::String("Paris".to_string()),
207        };
208        let yes = make_feature(vec![("NAME", FieldValue::String("Paris".to_string()))]);
209        let no = make_feature(vec![("NAME", FieldValue::String("London".to_string()))]);
210        assert!(filter.matches(&yes));
211        assert!(!filter.matches(&no));
212    }
213
214    #[test]
215    fn test_integer_ne() {
216        let filter = FieldFilter {
217            field: "VAL".to_string(),
218            op: FieldFilterOp::Ne,
219            value: FilterValue::Integer(0),
220        };
221        let yes = make_feature(vec![("VAL", FieldValue::Integer(5))]);
222        let no = make_feature(vec![("VAL", FieldValue::Integer(0))]);
223        assert!(filter.matches(&yes));
224        assert!(!filter.matches(&no));
225    }
226
227    #[test]
228    fn test_float_gt() {
229        let filter = FieldFilter {
230            field: "SCORE".to_string(),
231            op: FieldFilterOp::Gt,
232            value: FilterValue::Float(5.0),
233        };
234        let yes = make_feature(vec![("SCORE", FieldValue::Float(6.0))]);
235        let no = make_feature(vec![("SCORE", FieldValue::Float(4.0))]);
236        let equal = make_feature(vec![("SCORE", FieldValue::Float(5.0))]);
237        assert!(filter.matches(&yes));
238        assert!(!filter.matches(&no));
239        assert!(!filter.matches(&equal));
240    }
241
242    #[test]
243    fn test_contains() {
244        let filter = FieldFilter {
245            field: "NAME".to_string(),
246            op: FieldFilterOp::Contains,
247            value: FilterValue::String("oint".to_string()),
248        };
249        let yes = make_feature(vec![("NAME", FieldValue::String("Point A".to_string()))]);
250        let no = make_feature(vec![("NAME", FieldValue::String("Region B".to_string()))]);
251        assert!(filter.matches(&yes));
252        assert!(!filter.matches(&no));
253    }
254
255    #[test]
256    fn test_missing_field_returns_false() {
257        let filter = FieldFilter {
258            field: "NONEXISTENT".to_string(),
259            op: FieldFilterOp::Eq,
260            value: FilterValue::String("x".to_string()),
261        };
262        let feature = make_feature(vec![("NAME", FieldValue::String("y".to_string()))]);
263        assert!(!filter.matches(&feature));
264    }
265}