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 never matches.
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}
28
29impl Filter {
30    pub fn eq(field: impl Into<String>, value: impl Into<Value>) -> Self {
31        Filter::Eq {
32            field: field.into(),
33            value: value.into(),
34        }
35    }
36
37    pub fn is_in(field: impl Into<String>, values: impl IntoIterator<Item = Value>) -> Self {
38        Filter::In {
39            field: field.into(),
40            values: values.into_iter().collect(),
41        }
42    }
43
44    /// Inclusive range. Either bound may be omitted.
45    pub fn between(field: impl Into<String>, gte: Option<f64>, lte: Option<f64>) -> Self {
46        Filter::Range {
47            field: field.into(),
48            gt: None,
49            gte,
50            lt: None,
51            lte,
52        }
53    }
54
55    pub fn matches(&self, metadata: Option<&Value>) -> bool {
56        match self {
57            Filter::And(filters) => filters.iter().all(|f| f.matches(metadata)),
58            Filter::Eq { field, value } => lookup(metadata, field).is_some_and(|v| same(v, value)),
59            Filter::In { field, values } => {
60                lookup(metadata, field).is_some_and(|v| values.iter().any(|x| same(v, x)))
61            }
62            Filter::Range {
63                field,
64                gt,
65                gte,
66                lt,
67                lte,
68            } => match lookup(metadata, field).and_then(Value::as_f64) {
69                Some(x) => {
70                    gt.is_none_or(|b| x > b)
71                        && gte.is_none_or(|b| x >= b)
72                        && lt.is_none_or(|b| x < b)
73                        && lte.is_none_or(|b| x <= b)
74                }
75                None => false,
76            },
77        }
78    }
79
80    /// Parses a MongoDB-style filter:
81    ///
82    /// ```text
83    /// {"lang": "en", "year": {"$gte": 2020, "$lt": 2025}, "tag": {"$in": ["a", "b"]}}
84    /// ```
85    ///
86    /// Top-level keys are combined with AND. Supported operators: `$eq`,
87    /// `$in`, `$gt`, `$gte`, `$lt`, `$lte`.
88    pub fn from_json(value: &Value) -> Result<Self> {
89        let object = value
90            .as_object()
91            .ok_or_else(|| Error::InvalidArgument("filter must be a JSON object".into()))?;
92        let mut parts = Vec::with_capacity(object.len());
93        for (field, condition) in object {
94            match condition {
95                Value::Object(ops) if !ops.is_empty() && ops.keys().all(|k| k.starts_with('$')) => {
96                    parts.push(parse_operators(field, ops)?);
97                }
98                other => parts.push(Filter::eq(field.clone(), other.clone())),
99            }
100        }
101        Ok(combine(parts))
102    }
103}
104
105fn parse_operators(field: &str, ops: &Map<String, Value>) -> Result<Filter> {
106    let mut parts = Vec::new();
107    let (mut gt, mut gte, mut lt, mut lte) = (None, None, None, None);
108    for (op, arg) in ops {
109        match op.as_str() {
110            "$eq" => parts.push(Filter::eq(field, arg.clone())),
111            "$in" => {
112                let values = arg.as_array().ok_or_else(|| {
113                    Error::InvalidArgument(format!("$in on '{field}' expects an array"))
114                })?;
115                parts.push(Filter::is_in(field, values.iter().cloned()));
116            }
117            "$gt" | "$gte" | "$lt" | "$lte" => {
118                let bound = arg.as_f64().ok_or_else(|| {
119                    Error::InvalidArgument(format!("{op} on '{field}' expects a number"))
120                })?;
121                match op.as_str() {
122                    "$gt" => gt = Some(bound),
123                    "$gte" => gte = Some(bound),
124                    "$lt" => lt = Some(bound),
125                    _ => lte = Some(bound),
126                }
127            }
128            other => {
129                return Err(Error::InvalidArgument(format!(
130                    "unsupported filter operator {other}"
131                )));
132            }
133        }
134    }
135    if gt.is_some() || gte.is_some() || lt.is_some() || lte.is_some() {
136        parts.push(Filter::Range {
137            field: field.to_owned(),
138            gt,
139            gte,
140            lt,
141            lte,
142        });
143    }
144    Ok(combine(parts))
145}
146
147fn combine(mut parts: Vec<Filter>) -> Filter {
148    if parts.len() == 1 {
149        parts.pop().unwrap()
150    } else {
151        Filter::And(parts)
152    }
153}
154
155fn lookup<'a>(metadata: Option<&'a Value>, path: &str) -> Option<&'a Value> {
156    path.split('.')
157        .try_fold(metadata?, |value, key| value.get(key))
158}
159
160/// JSON equality that treats `1` and `1.0` as equal.
161fn same(a: &Value, b: &Value) -> bool {
162    match (a, b) {
163        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
164        _ => a == b,
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use serde_json::json;
171
172    use super::*;
173
174    #[test]
175    fn parses_and_matches_mongo_style_filters() {
176        let filter = Filter::from_json(&json!({
177            "lang": "en",
178            "year": {"$gte": 2020, "$lt": 2025},
179            "source.kind": {"$in": ["docs", "blog"]}
180        }))
181        .unwrap();
182        let doc = json!({"lang": "en", "year": 2022, "source": {"kind": "docs"}});
183        assert!(filter.matches(Some(&doc)));
184        assert!(!filter.matches(Some(
185            &json!({"lang": "en", "year": 2025, "source": {"kind": "docs"}})
186        )));
187        assert!(!filter.matches(Some(
188            &json!({"lang": "de", "year": 2022, "source": {"kind": "docs"}})
189        )));
190        assert!(!filter.matches(None));
191    }
192
193    #[test]
194    fn numbers_compare_by_value() {
195        assert!(Filter::eq("n", json!(1)).matches(Some(&json!({"n": 1.0}))));
196    }
197
198    #[test]
199    fn empty_filter_matches_everything() {
200        let filter = Filter::from_json(&json!({})).unwrap();
201        assert!(filter.matches(None));
202    }
203
204    #[test]
205    fn rejects_unknown_operators() {
206        assert!(Filter::from_json(&json!({"a": {"$regex": "x"}})).is_err());
207        assert!(Filter::from_json(&json!([1, 2])).is_err());
208    }
209}