Skip to main content

nodedb_query/scan_filter/
op.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `FilterOp` enum and its string/serde conversions.
4//!
5//! `FilterOp` is an O(1)-dispatch discriminant used by the scan filter
6//! evaluator. On-wire it travels as a lowercase string tag so physical
7//! plans remain debuggable by hand. Decoding a tag is fallible: a name
8//! `FilterOp::parse` does not know is an error, never a default operator.
9
10/// An operator name no `FilterOp` variant carries.
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
12#[error("unknown filter operator `{op}`")]
13pub struct UnknownFilterOp {
14    /// The operator text as it arrived.
15    pub op: String,
16}
17
18impl From<UnknownFilterOp> for zerompk::Error {
19    fn from(e: UnknownFilterOp) -> Self {
20        zerompk::Error::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
21    }
22}
23
24/// Filter operator enum for O(1) dispatch instead of string comparison.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FilterOp {
27    Eq,
28    Ne,
29    Gt,
30    Gte,
31    Lt,
32    Lte,
33    Contains,
34    Like,
35    NotLike,
36    Ilike,
37    NotIlike,
38    In,
39    NotIn,
40    IsNull,
41    IsNotNull,
42    ArrayContains,
43    ArrayContainsAll,
44    ArrayOverlap,
45    /// Every row passes. Emitted by policy lowering for a predicate decided
46    /// true at plan time and by the planner for a filter it cannot lower.
47    MatchAll,
48    Exists,
49    NotExists,
50    Or,
51    /// Arbitrary expression predicate: the filter's `expr` field holds a
52    /// `nodedb_query::expr::SqlExpr`. The scan evaluator runs the expression
53    /// against the full row and treats truthy results as a match. Used when
54    /// the planner cannot reduce the WHERE clause to a simple `(field, op, value)`
55    /// — e.g. `LOWER(col) = 'x'`, `qty + 1 = 5`, `NOT (col = 'x')`.
56    Expr,
57    /// Column-vs-column comparison: `field` op `value` where `value` is a
58    /// `Value::String` containing the name of the other column. The comparison
59    /// reads both fields from the same document row.
60    GtColumn,
61    GteColumn,
62    LtColumn,
63    LteColumn,
64    EqColumn,
65    NeColumn,
66}
67
68impl FilterOp {
69    /// Parse a wire operator name. Every name `as_str` emits parses back to
70    /// the same variant. `ne`/`neq`, `gte`/`ge`, and `lte`/`le` are aliases.
71    pub fn parse(name: &str) -> Result<Self, UnknownFilterOp> {
72        Ok(match name {
73            "eq" => Self::Eq,
74            "ne" | "neq" => Self::Ne,
75            "gt" => Self::Gt,
76            "gte" | "ge" => Self::Gte,
77            "lt" => Self::Lt,
78            "lte" | "le" => Self::Lte,
79            "contains" => Self::Contains,
80            "like" => Self::Like,
81            "not_like" => Self::NotLike,
82            "ilike" => Self::Ilike,
83            "not_ilike" => Self::NotIlike,
84            "in" => Self::In,
85            "not_in" => Self::NotIn,
86            "is_null" => Self::IsNull,
87            "is_not_null" => Self::IsNotNull,
88            "array_contains" => Self::ArrayContains,
89            "array_contains_all" => Self::ArrayContainsAll,
90            "array_overlap" => Self::ArrayOverlap,
91            "match_all" => Self::MatchAll,
92            "exists" => Self::Exists,
93            "not_exists" => Self::NotExists,
94            "or" => Self::Or,
95            "expr" => Self::Expr,
96            "gt_col" => Self::GtColumn,
97            "gte_col" => Self::GteColumn,
98            "lt_col" => Self::LtColumn,
99            "lte_col" => Self::LteColumn,
100            "eq_col" => Self::EqColumn,
101            "ne_col" => Self::NeColumn,
102            other => {
103                return Err(UnknownFilterOp {
104                    op: other.to_string(),
105                });
106            }
107        })
108    }
109
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            Self::Eq => "eq",
113            Self::Ne => "ne",
114            Self::Gt => "gt",
115            Self::Gte => "gte",
116            Self::Lt => "lt",
117            Self::Lte => "lte",
118            Self::Contains => "contains",
119            Self::Like => "like",
120            Self::NotLike => "not_like",
121            Self::Ilike => "ilike",
122            Self::NotIlike => "not_ilike",
123            Self::In => "in",
124            Self::NotIn => "not_in",
125            Self::IsNull => "is_null",
126            Self::IsNotNull => "is_not_null",
127            Self::ArrayContains => "array_contains",
128            Self::ArrayContainsAll => "array_contains_all",
129            Self::ArrayOverlap => "array_overlap",
130            Self::MatchAll => "match_all",
131            Self::Exists => "exists",
132            Self::NotExists => "not_exists",
133            Self::Or => "or",
134            Self::Expr => "expr",
135            Self::GtColumn => "gt_col",
136            Self::GteColumn => "gte_col",
137            Self::LtColumn => "lt_col",
138            Self::LteColumn => "lte_col",
139            Self::EqColumn => "eq_col",
140            Self::NeColumn => "ne_col",
141        }
142    }
143}
144
145impl serde::Serialize for FilterOp {
146    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
147        serializer.serialize_str(self.as_str())
148    }
149}
150
151impl<'de> serde::Deserialize<'de> for FilterOp {
152    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153        let s = String::deserialize(deserializer)?;
154        FilterOp::parse(&s).map_err(serde::de::Error::custom)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    const ALL: [FilterOp; 29] = [
163        FilterOp::Eq,
164        FilterOp::Ne,
165        FilterOp::Gt,
166        FilterOp::Gte,
167        FilterOp::Lt,
168        FilterOp::Lte,
169        FilterOp::Contains,
170        FilterOp::Like,
171        FilterOp::NotLike,
172        FilterOp::Ilike,
173        FilterOp::NotIlike,
174        FilterOp::In,
175        FilterOp::NotIn,
176        FilterOp::IsNull,
177        FilterOp::IsNotNull,
178        FilterOp::ArrayContains,
179        FilterOp::ArrayContainsAll,
180        FilterOp::ArrayOverlap,
181        FilterOp::MatchAll,
182        FilterOp::Exists,
183        FilterOp::NotExists,
184        FilterOp::Or,
185        FilterOp::Expr,
186        FilterOp::GtColumn,
187        FilterOp::GteColumn,
188        FilterOp::LtColumn,
189        FilterOp::LteColumn,
190        FilterOp::EqColumn,
191        FilterOp::NeColumn,
192    ];
193
194    #[test]
195    fn every_name_round_trips() {
196        for op in ALL {
197            assert_eq!(FilterOp::parse(op.as_str()), Ok(op), "{op:?}");
198        }
199    }
200
201    #[test]
202    fn aliases_parse_to_their_canonical_variant() {
203        assert_eq!(FilterOp::parse("neq"), Ok(FilterOp::Ne));
204        assert_eq!(FilterOp::parse("ge"), Ok(FilterOp::Gte));
205        assert_eq!(FilterOp::parse("le"), Ok(FilterOp::Lte));
206    }
207
208    #[test]
209    fn unknown_name_is_an_error_naming_the_text() {
210        let err = FilterOp::parse("any_in").expect_err("unknown");
211        assert_eq!(err.op, "any_in");
212        assert_eq!(err.to_string(), "unknown filter operator `any_in`");
213        assert!(FilterOp::parse("").is_err());
214        assert!(FilterOp::parse("EQ").is_err());
215    }
216
217    #[test]
218    fn serde_rejects_an_unknown_name() {
219        let ok: FilterOp = serde_json::from_str("\"array_overlap\"").expect("known");
220        assert_eq!(ok, FilterOp::ArrayOverlap);
221        let err = serde_json::from_str::<FilterOp>("\"any_in\"").expect_err("unknown");
222        assert!(err.to_string().contains("any_in"), "{err}");
223    }
224}