Skip to main content

toolkit_odata/
filter.rs

1use std::fmt;
2
3use thiserror::Error;
4
5use crate::ast as odata_ast;
6
7pub use crate::ast::Value as ODataValue;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum FieldKind {
11    String,
12    I64,
13    F64,
14    Bool,
15    Uuid,
16    DateTimeUtc,
17    Date,
18    Time,
19    Decimal,
20}
21
22impl fmt::Display for FieldKind {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            FieldKind::String => write!(f, "String"),
26            FieldKind::I64 => write!(f, "I64"),
27            FieldKind::F64 => write!(f, "F64"),
28            FieldKind::Bool => write!(f, "Bool"),
29            FieldKind::Uuid => write!(f, "Uuid"),
30            FieldKind::DateTimeUtc => write!(f, "DateTimeUtc"),
31            FieldKind::Date => write!(f, "Date"),
32            FieldKind::Time => write!(f, "Time"),
33            FieldKind::Decimal => write!(f, "Decimal"),
34        }
35    }
36}
37
38pub trait FilterField: Copy + Eq + std::hash::Hash + fmt::Debug + 'static {
39    const FIELDS: &'static [Self];
40
41    fn name(&self) -> &'static str;
42
43    fn kind(&self) -> FieldKind;
44
45    fn from_name(name: &str) -> Option<Self> {
46        // Try exact match first (handles both simple names and slash-delimited property paths
47        // like "hierarchy/depth" if the enum defines them).
48        let exact = Self::FIELDS
49            .iter()
50            .copied()
51            .find(|f| f.name().eq_ignore_ascii_case(name));
52        if exact.is_some() {
53            return exact;
54        }
55        // Fallback: resolve by the last segment of a property path (e.g. "depth" from
56        // "hierarchy/depth") so field enums that only define simple names still work.
57        //
58        // Note: if multiple fields share the same terminal segment this returns the
59        // first match. Callers that define ambiguous field names should override
60        // `from_name` with explicit slash-delimited entries.
61        if let Some(last) = name.rsplit('/').next()
62            && last != name
63        {
64            let mut iter = Self::FIELDS
65                .iter()
66                .copied()
67                .filter(|f| f.name().eq_ignore_ascii_case(last));
68            if let Some(first) = iter.next() {
69                // Ambiguous: more than one field shares the same terminal segment.
70                // Return None so the caller reports UnknownField instead of silently
71                // picking the wrong field.
72                if iter.next().is_some() {
73                    return None;
74                }
75                return Some(first);
76            }
77        }
78        None
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum FilterOp {
84    Eq,
85    Ne,
86    Gt,
87    Ge,
88    Lt,
89    Le,
90    In,
91    Contains,
92    StartsWith,
93    EndsWith,
94    And,
95    Or,
96}
97
98impl FieldKind {
99    /// Whether a field of this kind accepts `op`.
100    ///
101    /// This is the table `OperationBuilder::with_odata_filter` publishes as
102    /// each endpoint's `x-odata-filter.allowedFields`, so what a caller reads
103    /// in the contract is what the parser enforces: ordering operators are
104    /// meaningless on a `Bool` or a `Uuid`, and the string functions only
105    /// apply to `String`.
106    #[must_use]
107    pub const fn allows(self, op: FilterOp) -> bool {
108        match self {
109            Self::String => matches!(
110                op,
111                FilterOp::Eq
112                    | FilterOp::Ne
113                    | FilterOp::In
114                    | FilterOp::Contains
115                    | FilterOp::StartsWith
116                    | FilterOp::EndsWith
117            ),
118            Self::Uuid => matches!(op, FilterOp::Eq | FilterOp::Ne | FilterOp::In),
119            Self::Bool => matches!(op, FilterOp::Eq | FilterOp::Ne),
120            Self::I64 | Self::F64 | Self::Decimal | Self::DateTimeUtc | Self::Date | Self::Time => {
121                matches!(
122                    op,
123                    FilterOp::Eq
124                        | FilterOp::Ne
125                        | FilterOp::Gt
126                        | FilterOp::Ge
127                        | FilterOp::Lt
128                        | FilterOp::Le
129                        | FilterOp::In
130                )
131            }
132        }
133    }
134}
135
136impl fmt::Display for FilterOp {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            FilterOp::Eq => write!(f, "eq"),
140            FilterOp::Ne => write!(f, "ne"),
141            FilterOp::Gt => write!(f, "gt"),
142            FilterOp::Ge => write!(f, "ge"),
143            FilterOp::Lt => write!(f, "lt"),
144            FilterOp::Le => write!(f, "le"),
145            FilterOp::In => write!(f, "in"),
146            FilterOp::Contains => write!(f, "contains"),
147            FilterOp::StartsWith => write!(f, "startswith"),
148            FilterOp::EndsWith => write!(f, "endswith"),
149            FilterOp::And => write!(f, "and"),
150            FilterOp::Or => write!(f, "or"),
151        }
152    }
153}
154
155#[derive(Debug, Clone)]
156pub enum FilterNode<F: FilterField> {
157    Binary {
158        field: F,
159        op: FilterOp,
160        value: ODataValue,
161    },
162    InList {
163        field: F,
164        values: Vec<ODataValue>,
165    },
166    Composite {
167        op: FilterOp,
168        children: Vec<FilterNode<F>>,
169    },
170    Not(Box<FilterNode<F>>),
171}
172
173impl<F: FilterField> FilterNode<F> {
174    pub fn binary(field: F, op: FilterOp, value: ODataValue) -> Self {
175        FilterNode::Binary { field, op, value }
176    }
177
178    #[must_use]
179    pub fn and(children: Vec<FilterNode<F>>) -> Self {
180        FilterNode::Composite {
181            op: FilterOp::And,
182            children,
183        }
184    }
185
186    #[must_use]
187    pub fn or(children: Vec<FilterNode<F>>) -> Self {
188        FilterNode::Composite {
189            op: FilterOp::Or,
190            children,
191        }
192    }
193
194    #[allow(clippy::should_implement_trait)]
195    pub fn not(inner: FilterNode<F>) -> Self {
196        FilterNode::Not(Box::new(inner))
197    }
198}
199
200#[derive(Debug, Error, Clone)]
201pub enum FilterError {
202    #[error("Unknown field: {0}")]
203    UnknownField(String),
204
205    #[error("Type mismatch for field {field}: expected {expected}, got {got}")]
206    TypeMismatch {
207        field: String,
208        expected: FieldKind,
209        got: String,
210    },
211
212    #[error("Unsupported operation: {0}")]
213    UnsupportedOperation(String),
214
215    #[error("Invalid filter expression: {0}")]
216    InvalidExpression(String),
217
218    #[error("Field-to-field comparisons are not supported")]
219    FieldToFieldComparison,
220
221    #[error("Bare identifier in filter: {0}")]
222    BareIdentifier(String),
223
224    #[error("Bare literal in filter")]
225    BareLiteral,
226}
227
228pub type FilterResult<T> = Result<T, FilterError>;
229
230/// Parse an `OData` filter string into a typed `FilterNode`.
231///
232/// # Errors
233///
234/// Returns `FilterError::InvalidExpression` if parsing fails
235/// or the expression cannot be converted into a typed filter node.
236pub fn parse_odata_filter<F: FilterField>(raw: &str) -> FilterResult<FilterNode<F>> {
237    use crate::odata_filters::parse_str;
238
239    let ast = parse_str(raw).map_err(|e| FilterError::InvalidExpression(format!("{e:?}")))?;
240    let ast: odata_ast::Expr = ast.into();
241    convert_expr_to_filter_node::<F>(&ast)
242}
243
244/// Convert a parsed `OData` AST expression into a typed `FilterNode`.
245///
246/// # Errors
247///
248/// Returns `FilterError` if the expression is invalid, references unknown fields, uses unsupported
249/// operations, or contains type mismatches.
250pub fn convert_expr_to_filter_node<F: FilterField>(
251    expr: &odata_ast::Expr,
252) -> FilterResult<FilterNode<F>> {
253    use odata_ast::Expr as E;
254
255    match expr {
256        E::And(left, right) => {
257            let left_node = convert_expr_to_filter_node::<F>(left)?;
258            let right_node = convert_expr_to_filter_node::<F>(right)?;
259            Ok(FilterNode::and(vec![left_node, right_node]))
260        }
261        E::Or(left, right) => {
262            let left_node = convert_expr_to_filter_node::<F>(left)?;
263            let right_node = convert_expr_to_filter_node::<F>(right)?;
264            Ok(FilterNode::or(vec![left_node, right_node]))
265        }
266        E::Not(inner) => {
267            let inner_node = convert_expr_to_filter_node::<F>(inner)?;
268            Ok(FilterNode::not(inner_node))
269        }
270
271        E::Compare(left, op, right) => {
272            let (field_name, value) = match (&**left, &**right) {
273                (E::Identifier(name), E::Value(val)) => (name.as_str(), val.clone()),
274                (E::Identifier(_), E::Identifier(_)) => {
275                    return Err(FilterError::FieldToFieldComparison);
276                }
277                _ => {
278                    return Err(FilterError::InvalidExpression(
279                        "Comparison must be between field and value".to_owned(),
280                    ));
281                }
282            };
283
284            let field = F::from_name(field_name)
285                .ok_or_else(|| FilterError::UnknownField(field_name.to_owned()))?;
286
287            validate_value_type(field, &value)?;
288
289            let filter_op = match op {
290                odata_ast::CompareOperator::Eq => FilterOp::Eq,
291                odata_ast::CompareOperator::Ne => FilterOp::Ne,
292                odata_ast::CompareOperator::Gt => FilterOp::Gt,
293                odata_ast::CompareOperator::Ge => FilterOp::Ge,
294                odata_ast::CompareOperator::Lt => FilterOp::Lt,
295                odata_ast::CompareOperator::Le => FilterOp::Le,
296            };
297            reject_unsupported_op(field, field_name, filter_op)?;
298
299            Ok(FilterNode::binary(field, filter_op, value))
300        }
301
302        E::Function(func_name, args) => {
303            let name_lower = func_name.to_ascii_lowercase();
304            match (name_lower.as_str(), args.as_slice()) {
305                (
306                    "contains",
307                    [
308                        E::Identifier(field_name),
309                        E::Value(odata_ast::Value::String(s)),
310                    ],
311                ) => {
312                    let field = F::from_name(field_name)
313                        .ok_or_else(|| FilterError::UnknownField(field_name.clone()))?;
314
315                    if field.kind() != FieldKind::String {
316                        return Err(FilterError::TypeMismatch {
317                            field: field_name.clone(),
318                            expected: FieldKind::String,
319                            got: "non-string".to_owned(),
320                        });
321                    }
322
323                    Ok(FilterNode::binary(
324                        field,
325                        FilterOp::Contains,
326                        odata_ast::Value::String(s.clone()),
327                    ))
328                }
329                (
330                    "startswith",
331                    [
332                        E::Identifier(field_name),
333                        E::Value(odata_ast::Value::String(s)),
334                    ],
335                ) => {
336                    let field = F::from_name(field_name)
337                        .ok_or_else(|| FilterError::UnknownField(field_name.clone()))?;
338
339                    if field.kind() != FieldKind::String {
340                        return Err(FilterError::TypeMismatch {
341                            field: field_name.clone(),
342                            expected: FieldKind::String,
343                            got: "non-string".to_owned(),
344                        });
345                    }
346
347                    Ok(FilterNode::binary(
348                        field,
349                        FilterOp::StartsWith,
350                        odata_ast::Value::String(s.clone()),
351                    ))
352                }
353                (
354                    "endswith",
355                    [
356                        E::Identifier(field_name),
357                        E::Value(odata_ast::Value::String(s)),
358                    ],
359                ) => {
360                    let field = F::from_name(field_name)
361                        .ok_or_else(|| FilterError::UnknownField(field_name.clone()))?;
362
363                    if field.kind() != FieldKind::String {
364                        return Err(FilterError::TypeMismatch {
365                            field: field_name.clone(),
366                            expected: FieldKind::String,
367                            got: "non-string".to_owned(),
368                        });
369                    }
370
371                    Ok(FilterNode::binary(
372                        field,
373                        FilterOp::EndsWith,
374                        odata_ast::Value::String(s.clone()),
375                    ))
376                }
377                _ => Err(FilterError::UnsupportedOperation(format!(
378                    "Function '{func_name}'"
379                ))),
380            }
381        }
382
383        E::In(left, list) => {
384            let field_name = match &**left {
385                E::Identifier(name) => name.as_str(),
386                _ => {
387                    return Err(FilterError::InvalidExpression(
388                        "IN operator requires a field identifier on the left side".to_owned(),
389                    ));
390                }
391            };
392
393            let field = F::from_name(field_name)
394                .ok_or_else(|| FilterError::UnknownField(field_name.to_owned()))?;
395
396            let mut values = Vec::with_capacity(list.len());
397            for item in list {
398                match item {
399                    E::Value(val) => {
400                        validate_value_type(field, val)?;
401                        values.push(val.clone());
402                    }
403                    _ => {
404                        return Err(FilterError::InvalidExpression(
405                            "IN operator values must be literals".to_owned(),
406                        ));
407                    }
408                }
409            }
410
411            if values.is_empty() {
412                return Err(FilterError::InvalidExpression(
413                    "IN operator requires at least one value".to_owned(),
414                ));
415            }
416
417            reject_unsupported_op(field, field_name, FilterOp::In)?;
418
419            Ok(FilterNode::InList { field, values })
420        }
421
422        E::Identifier(name) => Err(FilterError::BareIdentifier(name.clone())),
423        E::Value(_) => Err(FilterError::BareLiteral),
424    }
425}
426
427/// Refuse an operator the field's kind does not accept, so the parser holds
428/// to the same table the contract publishes.
429fn reject_unsupported_op<F: FilterField>(field: F, name: &str, op: FilterOp) -> FilterResult<()> {
430    if field.kind().allows(op) {
431        Ok(())
432    } else {
433        Err(FilterError::UnsupportedOperation(format!(
434            "`{op}` on field `{name}` of type {}",
435            field.kind()
436        )))
437    }
438}
439
440fn validate_value_type<F: FilterField>(field: F, value: &odata_ast::Value) -> FilterResult<()> {
441    use odata_ast::Value as V;
442
443    let kind = field.kind();
444    let matches = matches!(
445        (kind, value),
446        (FieldKind::String, V::String(_))
447            | (
448                FieldKind::I64 | FieldKind::F64 | FieldKind::Decimal,
449                V::Number(_)
450            )
451            | (FieldKind::Bool, V::Bool(_))
452            | (FieldKind::Uuid, V::Uuid(_))
453            | (FieldKind::DateTimeUtc, V::DateTime(_))
454            | (FieldKind::Date, V::Date(_))
455            | (FieldKind::Time, V::Time(_))
456    );
457
458    if matches {
459        Ok(())
460    } else {
461        Err(FilterError::TypeMismatch {
462            field: field.name().to_owned(),
463            expected: kind,
464            got: value.to_string(),
465        })
466    }
467}