Skip to main content

recern_vector/
filter.rs

1use serde_json::{Map, Value};
2
3use crate::error::{Error, Result};
4
5/// Metadata predicate applied during search.
6///
7/// Fields are addressed by dotted paths (`"source.lang"`). A record without
8/// the field does not match positive predicates; NOT negates that result.
9#[derive(Clone, Debug, PartialEq)]
10pub enum Filter {
11    Eq {
12        field: String,
13        value: Value,
14    },
15    In {
16        field: String,
17        values: Vec<Value>,
18    },
19    Range {
20        field: String,
21        gt: Option<f64>,
22        gte: Option<f64>,
23        lt: Option<f64>,
24        lte: Option<f64>,
25    },
26    And(Vec<Filter>),
27    Or(Vec<Filter>),
28    Not(Box<Filter>),
29}
30
31impl Filter {
32    pub fn eq(field: impl Into<String>, value: impl Into<Value>) -> Self {
33        Filter::Eq {
34            field: field.into(),
35            value: value.into(),
36        }
37    }
38
39    pub fn is_in(field: impl Into<String>, values: impl IntoIterator<Item = Value>) -> Self {
40        Filter::In {
41            field: field.into(),
42            values: values.into_iter().collect(),
43        }
44    }
45
46    /// Inclusive range. Either bound may be omitted.
47    pub fn between(field: impl Into<String>, gte: Option<f64>, lte: Option<f64>) -> Self {
48        Filter::Range {
49            field: field.into(),
50            gt: None,
51            gte,
52            lt: None,
53            lte,
54        }
55    }
56
57    pub fn matches(&self, metadata: Option<&Value>) -> bool {
58        match self {
59            Filter::Or(filters) => filters.iter().any(|f| f.matches(metadata)),
60            Filter::Not(filter) => !filter.matches(metadata),
61            Filter::And(filters) => filters.iter().all(|f| f.matches(metadata)),
62            Filter::Eq { field, value } => lookup(metadata, field).is_some_and(|v| same(v, value)),
63            Filter::In { field, values } => {
64                lookup(metadata, field).is_some_and(|v| values.iter().any(|x| same(v, x)))
65            }
66            Filter::Range {
67                field,
68                gt,
69                gte,
70                lt,
71                lte,
72            } => match lookup(metadata, field).and_then(Value::as_f64) {
73                Some(x) => {
74                    gt.is_none_or(|b| x > b)
75                        && gte.is_none_or(|b| x >= b)
76                        && lt.is_none_or(|b| x < b)
77                        && lte.is_none_or(|b| x <= b)
78                }
79                None => false,
80            },
81        }
82    }
83
84    /// Parses a MongoDB-style filter:
85    ///
86    /// ```text
87    /// {"lang": "en", "year": {"$gte": 2020, "$lt": 2025}, "tag": {"$in": ["a", "b"]}}
88    /// ```
89    ///
90    /// Top-level keys are combined with AND. Supported operators: `$eq`,
91    /// `$in`, `$gt`, `$gte`, `$lt`, `$lte`, and logical `$and`, `$or`, `$not`.
92    pub fn from_json(value: &Value) -> Result<Self> {
93        Self::parse(value, 0)
94    }
95
96    fn parse(value: &Value, depth: usize) -> Result<Self> {
97        if depth > 64 {
98            return Err(Error::InvalidArgument("filter nesting exceeds 64".into()));
99        }
100        let object = value
101            .as_object()
102            .ok_or_else(|| Error::InvalidArgument("filter must be a JSON object".into()))?;
103        let mut parts = Vec::with_capacity(object.len());
104        for (field, condition) in object {
105            if field.starts_with('$') {
106                parts.push(match field.as_str() {
107                    "$and" | "$or" => {
108                        let items =
109                            condition
110                                .as_array()
111                                .filter(|a| !a.is_empty())
112                                .ok_or_else(|| {
113                                    Error::InvalidArgument(format!(
114                                        "{field} expects a nonempty array"
115                                    ))
116                                })?;
117                        let filters = items
118                            .iter()
119                            .map(|v| Self::parse(v, depth + 1))
120                            .collect::<Result<Vec<_>>>()?;
121                        if field == "$and" {
122                            Self::And(filters)
123                        } else {
124                            Self::Or(filters)
125                        }
126                    }
127                    "$not" => Self::Not(Box::new(Self::parse(condition, depth + 1)?)),
128                    _ => {
129                        return Err(Error::InvalidArgument(format!(
130                            "unsupported filter operator {field}"
131                        )));
132                    }
133                });
134                continue;
135            }
136            match condition {
137                Value::Object(ops) if !ops.is_empty() && ops.keys().any(|k| k.starts_with('$')) => {
138                    parts.push(parse_operators(field, ops)?);
139                }
140                other => parts.push(Filter::eq(field.clone(), other.clone())),
141            }
142        }
143        Ok(combine(parts))
144    }
145}
146
147fn parse_operators(field: &str, ops: &Map<String, Value>) -> Result<Filter> {
148    let mut parts = Vec::new();
149    let (mut gt, mut gte, mut lt, mut lte) = (None, None, None, None);
150    for (op, arg) in ops {
151        match op.as_str() {
152            "$eq" => parts.push(Filter::eq(field, arg.clone())),
153            "$in" => {
154                let values = arg.as_array().ok_or_else(|| {
155                    Error::InvalidArgument(format!("$in on '{field}' expects an array"))
156                })?;
157                parts.push(Filter::is_in(field, values.iter().cloned()));
158            }
159            "$gt" | "$gte" | "$lt" | "$lte" => {
160                let bound = arg.as_f64().ok_or_else(|| {
161                    Error::InvalidArgument(format!("{op} on '{field}' expects a number"))
162                })?;
163                match op.as_str() {
164                    "$gt" => gt = Some(bound),
165                    "$gte" => gte = Some(bound),
166                    "$lt" => lt = Some(bound),
167                    _ => lte = Some(bound),
168                }
169            }
170            other => {
171                return Err(Error::InvalidArgument(format!(
172                    "unsupported filter operator {other}"
173                )));
174            }
175        }
176    }
177    if gt.is_some() || gte.is_some() || lt.is_some() || lte.is_some() {
178        parts.push(Filter::Range {
179            field: field.to_owned(),
180            gt,
181            gte,
182            lt,
183            lte,
184        });
185    }
186    Ok(combine(parts))
187}
188
189fn combine(mut parts: Vec<Filter>) -> Filter {
190    if parts.len() == 1 {
191        parts.pop().unwrap()
192    } else {
193        Filter::And(parts)
194    }
195}
196
197fn lookup<'a>(metadata: Option<&'a Value>, path: &str) -> Option<&'a Value> {
198    path.split('.')
199        .try_fold(metadata?, |value, key| value.get(key))
200}
201
202/// JSON equality that treats `1` and `1.0` as equal.
203fn same(a: &Value, b: &Value) -> bool {
204    match (a, b) {
205        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
206        _ => a == b,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use serde_json::json;
213
214    use super::*;
215
216    #[test]
217    fn parses_and_matches_mongo_style_filters() {
218        let filter = Filter::from_json(&json!({
219            "lang": "en",
220            "year": {"$gte": 2020, "$lt": 2025},
221            "source.kind": {"$in": ["docs", "blog"]}
222        }))
223        .unwrap();
224        let doc = json!({"lang": "en", "year": 2022, "source": {"kind": "docs"}});
225        assert!(filter.matches(Some(&doc)));
226        assert!(!filter.matches(Some(
227            &json!({"lang": "en", "year": 2025, "source": {"kind": "docs"}})
228        )));
229        assert!(!filter.matches(Some(
230            &json!({"lang": "de", "year": 2022, "source": {"kind": "docs"}})
231        )));
232        assert!(!filter.matches(None));
233    }
234
235    #[test]
236    fn numbers_compare_by_value() {
237        assert!(Filter::eq("n", json!(1)).matches(Some(&json!({"n": 1.0}))));
238    }
239
240    #[test]
241    fn empty_filter_matches_everything() {
242        let filter = Filter::from_json(&json!({})).unwrap();
243        assert!(filter.matches(None));
244    }
245
246    #[test]
247    fn rejects_unknown_operators() {
248        assert!(Filter::from_json(&json!({"a": {"$regex": "x"}})).is_err());
249        assert!(Filter::from_json(&json!([1, 2])).is_err());
250    }
251}