headless_lms_chatbot/
search_filter.rs

1//! A small DSL for building OData `$filter` expressions against Azure AI Search.
2//! See: <https://learn.microsoft.com/en-us/azure/search/search-query-odata-filter>
3//!
4//! This implementation is **fully compliant** with the OData specification for Azure AI Search.
5//! It uses a simplified approach to operator precedence by adding parentheses around logical
6//! operations for clarity. This ensures predictable behavior and generates valid OData that
7//! Azure AI Search accepts, even if it includes extra parentheses that aren't strictly necessary.
8//!
9//! # Operator Support
10//!
11//! This module implements several `std::ops` traits to provide ergonomic operator syntax:
12//!
13//! - `!` (Not): Logical negation - `!filter` is equivalent to `filter.not()`
14//! - `&` (BitAnd): Logical AND - `filter1 & filter2` is equivalent to `filter1.and(filter2)` but with explicit parentheses
15//! - `|` (BitOr): Logical OR - `filter1 | filter2` is equivalent to `filter1.or(filter2)` but with explicit parentheses
16//!
17//! **Note**: The operator implementations always add explicit parentheses to avoid any ambiguity
18//! about precedence, ensuring the generated OData expressions are unambiguous.
19//!
20//! **Operator Precedence**: Rust's standard operator precedence applies:
21//! - `!` (highest precedence)
22//! - `&`
23//! - `|` (lowest precedence)
24//!
25//! This means `a | b & c` is parsed as `a | (b & c)`, not `(a | b) & c`.
26//! Use explicit parentheses `(a | b) & c` if you need different grouping.
27//!
28//! # Examples
29//!
30//! ```rust
31//! use headless_lms_chatbot::search_filter::{SearchFilter, SearchFilterError};
32//!
33//! fn example() -> Result<(), SearchFilterError> {
34//!     // Simple comparison
35//!     let filter = SearchFilter::eq("Rating", 5);
36//!     assert_eq!(filter.to_odata()?, "Rating eq 5");
37//!
38//!     // Using operators for logical combinations (note the explicit parentheses)
39//!     let filter = SearchFilter::eq("Category", "Luxury")
40//!         | SearchFilter::eq("ParkingIncluded", true)
41//!         & SearchFilter::eq("Rating", 5);
42//!     assert_eq!(filter.to_odata()?, "(Category eq 'Luxury' or (ParkingIncluded eq true and Rating eq 5))");
43//!
44//!     // Using negation operator
45//!     let filter = !SearchFilter::eq("Deleted", true) & SearchFilter::eq("Status", "active");
46//!     assert_eq!(filter.to_odata()?, "((not (Deleted eq true)) and Status eq 'active')");
47//!
48//!     // Collection operations
49//!     let filter = SearchFilter::any_with_filter(
50//!         "Rooms",
51//!         SearchFilter::raw("room: room/BaseRate lt 200.0")
52//!     );
53//!     assert_eq!(filter.to_odata()?, "Rooms/any(room: room/BaseRate lt 200.0)");
54//!     Ok(())
55//! }
56//! # example().unwrap();
57//! ```
58
59use std::fmt;
60use std::ops;
61use uuid::Uuid;
62
63/// Error type for search filter operations.
64#[derive(Debug, Clone, PartialEq)]
65pub enum SearchFilterError {
66    /// List values can only be used with search.in operations, not as direct OData literals
67    ListNotSupportedInOData,
68}
69
70impl fmt::Display for SearchFilterError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            SearchFilterError::ListNotSupportedInOData => {
74                write!(
75                    f,
76                    "List values can only be used with search.in operations, not as direct OData literals"
77                )
78            }
79        }
80    }
81}
82
83impl std::error::Error for SearchFilterError {}
84
85/// A strongly-typed OData filter value.
86///
87/// This covers strings, numbers, booleans, and lists (for `search.in`).
88#[derive(Debug, Clone, PartialEq)]
89pub enum SearchFilterValue {
90    Str(String),
91    Bool(bool),
92    Int(i64),
93    Float(f64),
94    List(Vec<String>),
95    Null,
96}
97
98impl SearchFilterValue {
99    /// Create a null value
100    pub fn null() -> Self {
101        SearchFilterValue::Null
102    }
103
104    /// Serialize this value into an OData literal.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// # use headless_lms_chatbot::search_filter::{SearchFilterValue, SearchFilterError};
110    /// # fn example() -> Result<(), SearchFilterError> {
111    /// assert_eq!(SearchFilterValue::Str("abc".into()).to_odata()?, "'abc'");
112    /// assert_eq!(SearchFilterValue::Int(42).to_odata()?, "42");
113    /// assert_eq!(SearchFilterValue::Bool(false).to_odata()?, "false");
114    /// assert_eq!(SearchFilterValue::Null.to_odata()?, "null");
115    /// # Ok(())
116    /// # }
117    /// # example().unwrap();
118    /// ```
119    ///
120    /// # Errors
121    ///
122    /// Returns `SearchFilterError::ListNotSupportedInOData` if called on a `List` variant,
123    /// as lists can only be used with `search.in` operations.
124    pub fn to_odata(&self) -> Result<String, SearchFilterError> {
125        match self {
126            SearchFilterValue::Str(s) => Ok(format!("'{}'", s.replace('\'', "''"))),
127            SearchFilterValue::Bool(b) => Ok(b.to_string()),
128            SearchFilterValue::Int(i) => Ok(i.to_string()),
129            SearchFilterValue::Float(f) => Ok(f.to_string()),
130            SearchFilterValue::List(_) => Err(SearchFilterError::ListNotSupportedInOData),
131            SearchFilterValue::Null => Ok("null".to_string()),
132        }
133    }
134}
135
136impl From<Uuid> for SearchFilterValue {
137    fn from(id: Uuid) -> Self {
138        SearchFilterValue::Str(id.to_string())
139    }
140}
141
142impl From<String> for SearchFilterValue {
143    fn from(val: String) -> Self {
144        SearchFilterValue::Str(val)
145    }
146}
147
148impl From<&str> for SearchFilterValue {
149    fn from(val: &str) -> Self {
150        SearchFilterValue::Str(val.to_string())
151    }
152}
153
154impl From<bool> for SearchFilterValue {
155    fn from(val: bool) -> Self {
156        SearchFilterValue::Bool(val)
157    }
158}
159
160impl From<i64> for SearchFilterValue {
161    fn from(val: i64) -> Self {
162        SearchFilterValue::Int(val)
163    }
164}
165
166impl From<i32> for SearchFilterValue {
167    fn from(val: i32) -> Self {
168        SearchFilterValue::Int(val as i64)
169    }
170}
171
172impl From<f64> for SearchFilterValue {
173    fn from(val: f64) -> Self {
174        SearchFilterValue::Float(val)
175    }
176}
177
178impl From<f32> for SearchFilterValue {
179    fn from(val: f32) -> Self {
180        SearchFilterValue::Float(val as f64)
181    }
182}
183
184impl From<Vec<Uuid>> for SearchFilterValue {
185    fn from(ids: Vec<Uuid>) -> Self {
186        SearchFilterValue::List(ids.into_iter().map(|u| u.to_string()).collect())
187    }
188}
189
190/// A composable OData boolean expression.
191///
192/// Supports comparison operators, logical combinators, `search.in`, and raw sub-expressions.
193#[derive(Debug, Clone, PartialEq)]
194pub enum SearchFilter {
195    Eq(String, SearchFilterValue),
196    Ne(String, SearchFilterValue),
197    Gt(String, SearchFilterValue),
198    Lt(String, SearchFilterValue),
199    Ge(String, SearchFilterValue),
200    Le(String, SearchFilterValue),
201    In(String, Vec<String>),
202    /// `search.in(field, 'v1|v2|...', '|')` with custom delimiter
203    InWithDelimiter(String, Vec<String>, String),
204    And(Box<SearchFilter>, Box<SearchFilter>),
205    Or(Box<SearchFilter>, Box<SearchFilter>),
206    Not(Box<SearchFilter>),
207    /// Explicit parentheses to override precedence
208    Parentheses(Box<SearchFilter>),
209    Raw(String),
210    /// Boolean field expression (e.g., `IsEnabled`)
211    Field(String),
212    /// Collection any() operator (e.g., `Rooms/any()` or `Rooms/any(room: room/BaseRate lt 200)`)
213    Any(String, Option<Box<SearchFilter>>),
214    /// Collection all() operator (e.g., `Rooms/all(room: not room/SmokingAllowed)`)
215    All(String, Box<SearchFilter>),
216}
217
218impl SearchFilter {
219    /// `field eq value`
220    pub fn eq<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
221        SearchFilter::Eq(field.into(), value.into())
222    }
223
224    /// `field ne value`
225    pub fn ne<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
226        SearchFilter::Ne(field.into(), value.into())
227    }
228
229    /// `field gt value`
230    pub fn gt<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
231        SearchFilter::Gt(field.into(), value.into())
232    }
233
234    /// `field lt value`
235    pub fn lt<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
236        SearchFilter::Lt(field.into(), value.into())
237    }
238
239    /// `field ge value`
240    pub fn ge<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
241        SearchFilter::Ge(field.into(), value.into())
242    }
243
244    /// `field le value`
245    pub fn le<T: Into<SearchFilterValue>>(field: impl Into<String>, value: T) -> Self {
246        SearchFilter::Le(field.into(), value.into())
247    }
248
249    /// `search.in(field, 'v1,v2,...', ',')`
250    pub fn search_in(field: impl Into<String>, values: Vec<String>) -> Self {
251        SearchFilter::In(field.into(), values)
252    }
253
254    /// `search.in(field, 'v1|v2|...', '|')` with custom delimiter
255    pub fn search_in_with_delimiter(
256        field: impl Into<String>,
257        values: Vec<String>,
258        delimiter: &str,
259    ) -> Self {
260        SearchFilter::InWithDelimiter(field.into(), values, delimiter.to_string())
261    }
262
263    /// `expr1 and expr2`
264    pub fn and(self, other: SearchFilter) -> Self {
265        SearchFilter::And(Box::new(self), Box::new(other))
266    }
267
268    /// `expr1 or expr2`
269    pub fn or(self, other: SearchFilter) -> Self {
270        SearchFilter::Or(Box::new(self), Box::new(other))
271    }
272
273    /// `not (expr)`
274    #[allow(clippy::should_implement_trait)] // We do implement std::ops::Not, this method is for convenience
275    pub fn not(self) -> Self {
276        SearchFilter::Not(Box::new(self))
277    }
278
279    /// Wrap expression in explicit parentheses to override precedence
280    pub fn parentheses(self) -> Self {
281        SearchFilter::Parentheses(Box::new(self))
282    }
283
284    /// Insert an arbitrary OData sub-expression (e.g. `geo.distance(...)` or `search.ismatchscoring(...)`)
285    pub fn raw(expr: impl Into<String>) -> Self {
286        SearchFilter::Raw(expr.into())
287    }
288
289    /// Boolean field expression (e.g., `IsEnabled`)
290    pub fn field(field_name: impl Into<String>) -> Self {
291        SearchFilter::Field(field_name.into())
292    }
293
294    /// Collection any() operator without filter (e.g., `Rooms/any()`)
295    pub fn any(collection_path: impl Into<String>) -> Self {
296        SearchFilter::Any(collection_path.into(), None)
297    }
298
299    /// Collection any() operator with filter (e.g., `Rooms/any(room: room/BaseRate lt 200)`)
300    pub fn any_with_filter(collection_path: impl Into<String>, filter: SearchFilter) -> Self {
301        SearchFilter::Any(collection_path.into(), Some(Box::new(filter)))
302    }
303
304    /// Collection all() operator (e.g., `Rooms/all(room: not room/SmokingAllowed)`)
305    pub fn all(collection_path: impl Into<String>, filter: SearchFilter) -> Self {
306        SearchFilter::All(collection_path.into(), Box::new(filter))
307    }
308
309    /// Serialize this filter into a complete OData `$filter` string.
310    ///
311    /// # Errors
312    ///
313    /// Returns `SearchFilterError::ListNotSupportedInOData` if any filter value
314    /// contains a `List` variant that cannot be serialized to OData.
315    pub fn to_odata(&self) -> Result<String, SearchFilterError> {
316        self.to_odata_internal()
317    }
318
319    /// Helper function to format a filter operand, adding parentheses only when necessary.
320    /// Simple expressions (comparisons, raw, field, etc.) don't need parentheses,
321    /// but complex logical expressions do.
322    fn format_operand(filter: &SearchFilter) -> Result<String, SearchFilterError> {
323        match filter {
324            // Simple expressions that don't need parentheses
325            SearchFilter::Parentheses(_)
326            | SearchFilter::Raw(_)
327            | SearchFilter::Field(_)
328            | SearchFilter::Eq(_, _)
329            | SearchFilter::Ne(_, _)
330            | SearchFilter::Gt(_, _)
331            | SearchFilter::Lt(_, _)
332            | SearchFilter::Ge(_, _)
333            | SearchFilter::Le(_, _)
334            | SearchFilter::In(_, _)
335            | SearchFilter::InWithDelimiter(_, _, _)
336            | SearchFilter::Any(_, _)
337            | SearchFilter::All(_, _) => filter.to_odata_internal(),
338            // Complex expressions that need parentheses
339            _ => Ok(format!("({})", filter.to_odata_internal()?)),
340        }
341    }
342
343    fn to_odata_internal(&self) -> Result<String, SearchFilterError> {
344        match self {
345            // Comparison operators - no parentheses needed
346            SearchFilter::Eq(f, v) => Ok(format!("{} eq {}", f, v.to_odata()?)),
347            SearchFilter::Ne(f, v) => Ok(format!("{} ne {}", f, v.to_odata()?)),
348            SearchFilter::Gt(f, v) => Ok(format!("{} gt {}", f, v.to_odata()?)),
349            SearchFilter::Lt(f, v) => Ok(format!("{} lt {}", f, v.to_odata()?)),
350            SearchFilter::Ge(f, v) => Ok(format!("{} ge {}", f, v.to_odata()?)),
351            SearchFilter::Le(f, v) => Ok(format!("{} le {}", f, v.to_odata()?)),
352            SearchFilter::In(f, vs) => Ok(format!("search.in({}, '{}', ',')", f, vs.join(","))),
353            SearchFilter::InWithDelimiter(f, vs, delimiter) => Ok(format!(
354                "search.in({}, '{}', '{}')",
355                f,
356                vs.join(delimiter),
357                delimiter
358            )),
359
360            // Logical operators - always wrap operands in parentheses for clarity
361            SearchFilter::And(a, b) => {
362                let left = SearchFilter::format_operand(a.as_ref())?;
363                let right = SearchFilter::format_operand(b.as_ref())?;
364                Ok(format!("{} and {}", left, right))
365            }
366            SearchFilter::Or(a, b) => {
367                let left = SearchFilter::format_operand(a.as_ref())?;
368                let right = SearchFilter::format_operand(b.as_ref())?;
369                Ok(format!("{} or {}", left, right))
370            }
371
372            // Not always needs parentheses around its operand
373            SearchFilter::Not(i) => Ok(format!("not ({})", i.to_odata_internal()?)),
374
375            // Explicit parentheses
376            SearchFilter::Parentheses(f) => Ok(format!("({})", f.to_odata_internal()?)),
377
378            // High precedence expressions - no parentheses needed
379            SearchFilter::Raw(s) => Ok(s.clone()),
380            SearchFilter::Field(f) => Ok(f.clone()),
381            SearchFilter::Any(f, Some(filter)) => {
382                Ok(format!("{}/any({})", f, filter.to_odata_internal()?))
383            }
384            SearchFilter::Any(f, None) => Ok(format!("{}/any()", f)),
385            SearchFilter::All(f, filter) => {
386                Ok(format!("{}/all({})", f, filter.to_odata_internal()?))
387            }
388        }
389    }
390}
391
392impl fmt::Display for SearchFilter {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        match self.to_odata() {
395            Ok(odata) => write!(f, "{}", odata),
396            Err(err) => write!(f, "Error: {}", err),
397        }
398    }
399}
400
401impl ops::Not for SearchFilter {
402    type Output = Self;
403
404    fn not(self) -> Self::Output {
405        SearchFilter::Not(Box::new(self))
406    }
407}
408
409impl ops::BitAnd for SearchFilter {
410    type Output = Self;
411
412    fn bitand(self, rhs: Self) -> Self::Output {
413        SearchFilter::Parentheses(Box::new(SearchFilter::And(Box::new(self), Box::new(rhs))))
414    }
415}
416
417impl ops::BitOr for SearchFilter {
418    type Output = Self;
419
420    fn bitor(self, rhs: Self) -> Self::Output {
421        SearchFilter::Parentheses(Box::new(SearchFilter::Or(Box::new(self), Box::new(rhs))))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn value_to_odata_literals() -> Result<(), SearchFilterError> {
431        assert_eq!(
432            SearchFilterValue::Str("o'neil".into()).to_odata()?,
433            "'o''neil'"
434        );
435        assert_eq!(SearchFilterValue::Int(123).to_odata()?, "123");
436        assert_eq!(SearchFilterValue::Bool(true).to_odata()?, "true");
437        assert_eq!(SearchFilterValue::Float(3.5).to_odata()?, "3.5");
438        assert_eq!(SearchFilterValue::Null.to_odata()?, "null");
439        Ok(())
440    }
441
442    #[test]
443    fn list_value_error() {
444        let list_value = SearchFilterValue::List(vec!["a".to_string(), "b".to_string()]);
445        let result = list_value.to_odata();
446        assert!(result.is_err());
447        assert_eq!(
448            result.unwrap_err(),
449            SearchFilterError::ListNotSupportedInOData
450        );
451    }
452
453    #[test]
454    fn uuid_into_value() -> Result<(), SearchFilterError> {
455        let id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
456        assert_eq!(
457            SearchFilterValue::from(id).to_odata()?,
458            "'11111111-2222-3333-4444-555555555555'"
459        );
460        Ok(())
461    }
462
463    #[test]
464    fn simple_comparisons() {
465        let f = SearchFilter::eq("a", "x");
466        assert_eq!(f.to_string(), "a eq 'x'");
467        let f = SearchFilter::gt("n", 10);
468        assert_eq!(f.to_string(), "n gt 10");
469    }
470
471    #[test]
472    fn logical_combinations() -> Result<(), SearchFilterError> {
473        let a = SearchFilter::eq("x", 1);
474        let b = SearchFilter::ne("y", false);
475        let c = a.clone().and(b.clone());
476        assert_eq!(c.to_odata()?, "x eq 1 and y ne false");
477        let d = c.or(SearchFilter::raw(
478            "geo.distance(loc, geography'POINT(0 0)') le 5",
479        ));
480        assert_eq!(
481            d.to_string(),
482            "(x eq 1 and y ne false) or geo.distance(loc, geography'POINT(0 0)') le 5"
483        );
484        Ok(())
485    }
486
487    #[test]
488    fn search_in_multiple_values() -> Result<(), SearchFilterError> {
489        let vals = vec!["a".into(), "b".into(), "c".into()];
490        let f = SearchFilter::search_in("tags", vals);
491        assert_eq!(f.to_odata()?, "search.in(tags, 'a,b,c', ',')");
492        Ok(())
493    }
494
495    #[test]
496    fn not_operator() {
497        let f = SearchFilter::eq("status", "open").not();
498        assert_eq!(f.to_string(), "not (status eq 'open')");
499    }
500
501    #[test]
502    fn not_operator_trait() -> Result<(), SearchFilterError> {
503        // Test that both the method and the ! operator work
504        let filter = SearchFilter::eq("status", "open");
505        let method_result = filter.clone().not();
506        let operator_result = !filter;
507
508        assert_eq!(method_result.to_odata()?, "not (status eq 'open')");
509        assert_eq!(operator_result.to_odata()?, "not (status eq 'open')");
510        assert_eq!(method_result.to_odata()?, operator_result.to_odata()?);
511        Ok(())
512    }
513
514    #[test]
515    fn bitand_operator_trait() -> Result<(), SearchFilterError> {
516        // Test that both the method and the & operator work
517        let left = SearchFilter::eq("status", "active");
518        let right = SearchFilter::gt("rating", 4);
519
520        let method_result = left.clone().and(right.clone());
521        let operator_result = left & right;
522
523        assert_eq!(
524            method_result.to_odata()?,
525            "status eq 'active' and rating gt 4"
526        );
527        assert_eq!(
528            operator_result.to_odata()?,
529            "(status eq 'active' and rating gt 4)"
530        );
531        // Note: operator version has explicit parentheses for clarity
532        Ok(())
533    }
534
535    #[test]
536    fn bitor_operator_trait() -> Result<(), SearchFilterError> {
537        // Test that both the method and the | operator work
538        let left = SearchFilter::eq("category", "luxury");
539        let right = SearchFilter::eq("parking", true);
540
541        let method_result = left.clone().or(right.clone());
542        let operator_result = left | right;
543
544        assert_eq!(
545            method_result.to_odata()?,
546            "category eq 'luxury' or parking eq true"
547        );
548        assert_eq!(
549            operator_result.to_odata()?,
550            "(category eq 'luxury' or parking eq true)"
551        );
552        // Note: operator version has explicit parentheses for clarity
553        Ok(())
554    }
555
556    #[test]
557    fn combined_operators() -> Result<(), SearchFilterError> {
558        // Test combining multiple operators: !a & b | c
559        let a = SearchFilter::eq("deleted", true);
560        let b = SearchFilter::eq("status", "active");
561        let c = SearchFilter::eq("featured", true);
562
563        let result = !a & b | c;
564        assert_eq!(
565            result.to_odata()?,
566            "(((not (deleted eq true)) and status eq 'active') or featured eq true)"
567        );
568        Ok(())
569    }
570
571    #[test]
572    fn operator_precedence_demonstration() -> Result<(), SearchFilterError> {
573        // Demonstrate that Rust's operator precedence applies:
574        // & has higher precedence than |, so a | b & c is parsed as a | (b & c)
575        let a = SearchFilter::eq("a", 1);
576        let b = SearchFilter::eq("b", 2);
577        let c = SearchFilter::eq("c", 3);
578
579        let result = a | b & c;
580        assert_eq!(result.to_odata()?, "(a eq 1 or (b eq 2 and c eq 3))");
581
582        // To get (a | b) & c, you need explicit parentheses:
583        let a = SearchFilter::eq("a", 1);
584        let b = SearchFilter::eq("b", 2);
585        let c = SearchFilter::eq("c", 3);
586
587        let result = (a | b) & c;
588        assert_eq!(result.to_odata()?, "((a eq 1 or b eq 2) and c eq 3)");
589        Ok(())
590    }
591
592    #[test]
593    fn nested_mix() -> Result<(), SearchFilterError> {
594        let f = SearchFilter::eq("id", "abc")
595            .and(SearchFilter::search_in("cat", vec!["x".into(), "y".into()]))
596            .or(SearchFilter::raw("search.ismatchscoring('foo')"));
597        assert_eq!(
598            f.to_odata()?,
599            "(id eq 'abc' and search.in(cat, 'x,y', ',')) or search.ismatchscoring('foo')"
600        );
601        Ok(())
602    }
603
604    #[test]
605    fn null_comparisons() -> Result<(), SearchFilterError> {
606        let f = SearchFilter::eq("Description", SearchFilterValue::null());
607        assert_eq!(f.to_odata()?, "Description eq null");
608
609        let f = SearchFilter::ne("Title", SearchFilterValue::null());
610        assert_eq!(f.to_odata()?, "Title ne null");
611        Ok(())
612    }
613
614    #[test]
615    fn boolean_field_expressions() -> Result<(), SearchFilterError> {
616        let f = SearchFilter::field("IsEnabled");
617        assert_eq!(f.to_odata()?, "IsEnabled");
618
619        let f = SearchFilter::field("ParkingIncluded").and(SearchFilter::eq("Rating", 5));
620        assert_eq!(f.to_odata()?, "ParkingIncluded and Rating eq 5");
621        Ok(())
622    }
623
624    #[test]
625    fn collection_any_operator() -> Result<(), SearchFilterError> {
626        // Simple any() without filter
627        let f = SearchFilter::any("Rooms");
628        assert_eq!(f.to_odata()?, "Rooms/any()");
629
630        // any() with filter
631        let f = SearchFilter::any_with_filter(
632            "Rooms",
633            SearchFilter::raw("room: room/BaseRate lt 200.0"),
634        );
635        assert_eq!(f.to_odata()?, "Rooms/any(room: room/BaseRate lt 200.0)");
636
637        // Complex example from docs: Find all hotels with at least one room with base rate < $200 and rating >= 4
638        let f = SearchFilter::any_with_filter(
639            "Rooms",
640            SearchFilter::raw("room: room/BaseRate lt 200.0"),
641        )
642        .and(SearchFilter::ge("Rating", 4));
643        assert_eq!(
644            f.to_odata()?,
645            "Rooms/any(room: room/BaseRate lt 200.0) and Rating ge 4"
646        );
647        Ok(())
648    }
649
650    #[test]
651    fn collection_all_operator() -> Result<(), SearchFilterError> {
652        // all() with filter
653        let f = SearchFilter::all("Rooms", SearchFilter::raw("room: not room/SmokingAllowed"));
654        assert_eq!(f.to_odata()?, "Rooms/all(room: not room/SmokingAllowed)");
655
656        // Complex example from docs: Find hotels with parking and all rooms non-smoking
657        let f = SearchFilter::field("ParkingIncluded").and(SearchFilter::all(
658            "Rooms",
659            SearchFilter::raw("room: not room/SmokingAllowed"),
660        ));
661        assert_eq!(
662            f.to_odata()?,
663            "ParkingIncluded and Rooms/all(room: not room/SmokingAllowed)"
664        );
665        Ok(())
666    }
667
668    #[test]
669    fn operator_precedence_examples() -> Result<(), SearchFilterError> {
670        // Example from docs: Rating gt 0 and Rating lt 3 or Rating gt 7 and Rating lt 10
671        // Should be equivalent to: ((Rating gt 0) and (Rating lt 3)) or ((Rating gt 7) and (Rating lt 10))
672        let f = SearchFilter::gt("Rating", 0)
673            .and(SearchFilter::lt("Rating", 3))
674            .or(SearchFilter::gt("Rating", 7).and(SearchFilter::lt("Rating", 10)));
675        assert_eq!(
676            f.to_odata()?,
677            "(Rating gt 0 and Rating lt 3) or (Rating gt 7 and Rating lt 10)"
678        );
679        Ok(())
680    }
681
682    #[test]
683    fn not_operator_precedence() -> Result<(), SearchFilterError> {
684        // Example from docs: not (Rating gt 5) - parentheses are required
685        let f = SearchFilter::gt("Rating", 5).not();
686        assert_eq!(f.to_odata()?, "not (Rating gt 5)");
687
688        // Test with collection operator
689        let f = SearchFilter::any("Rooms").not();
690        assert_eq!(f.to_odata()?, "not (Rooms/any())");
691        Ok(())
692    }
693
694    #[test]
695    fn complex_real_world_examples() -> Result<(), SearchFilterError> {
696        // Example from docs: Find hotels that are Luxury or include parking and have rating of 5
697        // With our simplified approach, method chaining gives us the correct result
698        let f = SearchFilter::eq("Category", "Luxury")
699            .or(SearchFilter::eq("ParkingIncluded", true))
700            .and(SearchFilter::eq("Rating", 5));
701        assert_eq!(
702            f.to_odata()?,
703            "(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5"
704        );
705
706        // Example: Hotels other than "Sea View Motel" renovated since 2010
707        let f = SearchFilter::ne("HotelName", "Sea View Motel").and(SearchFilter::ge(
708            "LastRenovationDate",
709            "2010-01-01T00:00:00Z",
710        ));
711        assert_eq!(
712            f.to_odata()?,
713            "HotelName ne 'Sea View Motel' and LastRenovationDate ge '2010-01-01T00:00:00Z'"
714        );
715        Ok(())
716    }
717
718    #[test]
719    fn search_in_with_different_delimiters() -> Result<(), SearchFilterError> {
720        // Example from docs with comma delimiter
721        let f = SearchFilter::search_in(
722            "HotelName",
723            vec!["Sea View motel".into(), "Budget hotel".into()],
724        );
725        assert_eq!(
726            f.to_odata()?,
727            "search.in(HotelName, 'Sea View motel,Budget hotel', ',')"
728        );
729
730        // Test with pipe delimiter as shown in docs
731        let f = SearchFilter::search_in_with_delimiter(
732            "HotelName",
733            vec!["Sea View motel".into(), "Budget hotel".into()],
734            "|",
735        );
736        assert_eq!(
737            f.to_odata()?,
738            "search.in(HotelName, 'Sea View motel|Budget hotel', '|')"
739        );
740
741        // Test with spaces in values (should work with comma delimiter)
742        let vals = vec!["heated towel racks".into(), "hairdryer included".into()];
743        let f = SearchFilter::search_in("Tags", vals);
744        assert_eq!(
745            f.to_odata()?,
746            "search.in(Tags, 'heated towel racks,hairdryer included', ',')"
747        );
748        Ok(())
749    }
750
751    #[test]
752    fn documentation_examples_comprehensive() -> Result<(), SearchFilterError> {
753        // Example 1: Find all hotels with at least one room with a base rate less than $200 that are rated at or above 4
754        let f = SearchFilter::any_with_filter(
755            "Rooms",
756            SearchFilter::raw("room: room/BaseRate lt 200.0"),
757        )
758        .and(SearchFilter::ge("Rating", 4));
759        assert_eq!(
760            f.to_odata()?,
761            "Rooms/any(room: room/BaseRate lt 200.0) and Rating ge 4"
762        );
763
764        // Example 2: Find all hotels other than "Sea View Motel" that have been renovated since 2010
765        let f = SearchFilter::ne("HotelName", "Sea View Motel").and(SearchFilter::ge(
766            "LastRenovationDate",
767            "2010-01-01T00:00:00Z",
768        ));
769        assert_eq!(
770            f.to_odata()?,
771            "HotelName ne 'Sea View Motel' and LastRenovationDate ge '2010-01-01T00:00:00Z'"
772        );
773
774        // Example 3: Find all hotels that were renovated in 2010 or later with timezone
775        let f = SearchFilter::ge("LastRenovationDate", "2010-01-01T00:00:00-08:00");
776        assert_eq!(
777            f.to_odata()?,
778            "LastRenovationDate ge '2010-01-01T00:00:00-08:00'"
779        );
780
781        // Example 4: Find all hotels that have parking included and where all rooms are non-smoking
782        let f = SearchFilter::field("ParkingIncluded").and(SearchFilter::all(
783            "Rooms",
784            SearchFilter::raw("room: not room/SmokingAllowed"),
785        ));
786        assert_eq!(
787            f.to_odata()?,
788            "ParkingIncluded and Rooms/all(room: not room/SmokingAllowed)"
789        );
790
791        // Alternative form with explicit boolean comparison
792        let f = SearchFilter::eq("ParkingIncluded", true).and(SearchFilter::all(
793            "Rooms",
794            SearchFilter::raw("room: room/SmokingAllowed eq false"),
795        ));
796        assert_eq!(
797            f.to_odata()?,
798            "ParkingIncluded eq true and Rooms/all(room: room/SmokingAllowed eq false)"
799        );
800
801        // Example 5: Find all hotels that are Luxury or include parking and have a rating of 5
802        // From docs: $filter=(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5
803        // With our simplified approach, method chaining gives us the correct result
804        let f = SearchFilter::eq("Category", "Luxury")
805            .or(SearchFilter::eq("ParkingIncluded", true))
806            .and(SearchFilter::eq("Rating", 5));
807        assert_eq!(
808            f.to_odata()?,
809            "(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5"
810        );
811        Ok(())
812    }
813
814    #[test]
815    fn nested_collection_operations() -> Result<(), SearchFilterError> {
816        // Example: Find all hotels with the tag "wifi" in at least one room
817        let f = SearchFilter::any_with_filter(
818            "Rooms",
819            SearchFilter::raw("room: room/Tags/any(tag: tag eq 'wifi')"),
820        );
821        assert_eq!(
822            f.to_odata()?,
823            "Rooms/any(room: room/Tags/any(tag: tag eq 'wifi'))"
824        );
825
826        // Example: Find all hotels with any rooms
827        let f = SearchFilter::any("Rooms");
828        assert_eq!(f.to_odata()?, "Rooms/any()");
829
830        // Example: Find all hotels that don't have rooms
831        let f = SearchFilter::any("Rooms").not();
832        assert_eq!(f.to_odata()?, "not (Rooms/any())");
833
834        // Example: Find all hotels where all rooms have the tag 'wifi' or 'tub'
835        let f = SearchFilter::any_with_filter(
836            "Rooms",
837            SearchFilter::raw("room: room/Tags/any(tag: search.in(tag, 'wifi, tub'))"),
838        );
839        assert_eq!(
840            f.to_odata()?,
841            "Rooms/any(room: room/Tags/any(tag: search.in(tag, 'wifi, tub')))"
842        );
843        Ok(())
844    }
845
846    #[test]
847    fn geospatial_examples() -> Result<(), SearchFilterError> {
848        // Example: Find all hotels within 10 kilometers of a given reference point
849        let f = SearchFilter::raw(
850            "geo.distance(Location, geography'POINT(-122.131577 47.678581)') le 10",
851        );
852        assert_eq!(
853            f.to_odata()?,
854            "geo.distance(Location, geography'POINT(-122.131577 47.678581)') le 10"
855        );
856
857        // Example: Find all hotels within a given viewport described as a polygon
858        let f = SearchFilter::raw(
859            "geo.intersects(Location, geography'POLYGON((-122.031577 47.578581, -122.031577 47.678581, -122.131577 47.678581, -122.031577 47.578581))')",
860        );
861        assert_eq!(
862            f.to_odata()?,
863            "geo.intersects(Location, geography'POLYGON((-122.031577 47.578581, -122.031577 47.678581, -122.131577 47.678581, -122.031577 47.578581))')"
864        );
865        Ok(())
866    }
867
868    #[test]
869    fn full_text_search_examples() -> Result<(), SearchFilterError> {
870        // Example: Find documents with the word "waterfront"
871        let f = SearchFilter::raw("search.ismatchscoring('waterfront')");
872        assert_eq!(f.to_odata()?, "search.ismatchscoring('waterfront')");
873
874        // Example: Find documents with the word "hostel" and rating >= 4, or "motel" and rating = 5
875        let f = SearchFilter::raw("search.ismatchscoring('hostel')")
876            .and(SearchFilter::ge("rating", 4))
877            .or(SearchFilter::raw("search.ismatchscoring('motel')")
878                .and(SearchFilter::eq("rating", 5)));
879        assert_eq!(
880            f.to_odata()?,
881            "(search.ismatchscoring('hostel') and rating ge 4) or (search.ismatchscoring('motel') and rating eq 5)"
882        );
883
884        // Example: Find documents without the word "luxury"
885        let f = SearchFilter::raw("search.ismatch('luxury')").not();
886        assert_eq!(f.to_odata()?, "not (search.ismatch('luxury'))");
887
888        // Example: Find documents with phrase "ocean view" or rating = 5
889        let f =
890            SearchFilter::raw("search.ismatchscoring('\"ocean view\"', 'Description,HotelName')")
891                .or(SearchFilter::eq("Rating", 5));
892        assert_eq!(
893            f.to_odata()?,
894            "search.ismatchscoring('\"ocean view\"', 'Description,HotelName') or Rating eq 5"
895        );
896
897        // Example: Complex query with Lucene syntax
898        let f = SearchFilter::raw(
899            "search.ismatch('\"hotel airport\"~5', 'Description', 'full', 'any')",
900        )
901        .and(
902            SearchFilter::any_with_filter("Rooms", SearchFilter::raw("room: room/SmokingAllowed"))
903                .not(),
904        );
905        assert_eq!(
906            f.to_odata()?,
907            "search.ismatch('\"hotel airport\"~5', 'Description', 'full', 'any') and (not (Rooms/any(room: room/SmokingAllowed)))"
908        );
909
910        // Example: Prefix search
911        let f = SearchFilter::raw("search.ismatch('lux*', 'Description')");
912        assert_eq!(f.to_odata()?, "search.ismatch('lux*', 'Description')");
913        Ok(())
914    }
915
916    #[test]
917    fn operator_precedence_documentation_examples() -> Result<(), SearchFilterError> {
918        // The key example from docs: Rating gt 0 and Rating lt 3 or Rating gt 7 and Rating lt 10
919        // Should be equivalent to: ((Rating gt 0) and (Rating lt 3)) or ((Rating gt 7) and (Rating lt 10))
920        let f = SearchFilter::gt("Rating", 0)
921            .and(SearchFilter::lt("Rating", 3))
922            .or(SearchFilter::gt("Rating", 7).and(SearchFilter::lt("Rating", 10)));
923        assert_eq!(
924            f.to_odata()?,
925            "(Rating gt 0 and Rating lt 3) or (Rating gt 7 and Rating lt 10)"
926        );
927
928        // Test that 'and' has higher precedence than 'or'
929        let f = SearchFilter::eq("A", 1).or(SearchFilter::eq("B", 2).and(SearchFilter::eq("C", 3)));
930        assert_eq!(f.to_odata()?, "A eq 1 or (B eq 2 and C eq 3)");
931        Ok(())
932    }
933
934    #[test]
935    fn explicit_parentheses_for_precedence_override() -> Result<(), SearchFilterError> {
936        // Example from docs that requires explicit parentheses:
937        // $filter=(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5
938        let f = SearchFilter::eq("Category", "Luxury")
939            .or(SearchFilter::eq("ParkingIncluded", true))
940            .parentheses()
941            .and(SearchFilter::eq("Rating", 5));
942        assert_eq!(
943            f.to_odata()?,
944            "(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5"
945        );
946
947        // Without explicit parentheses, we get the same result due to method chaining
948        let f_no_parens = SearchFilter::eq("Category", "Luxury")
949            .or(SearchFilter::eq("ParkingIncluded", true))
950            .and(SearchFilter::eq("Rating", 5));
951        assert_eq!(
952            f_no_parens.to_odata()?,
953            "(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5"
954        );
955        Ok(())
956    }
957
958    #[test]
959    fn odata_specification_compliance() -> Result<(), SearchFilterError> {
960        // Test cases based on the official OData specification examples
961
962        // Example: "Find all hotels other than 'Sea View Motel' that have been renovated since 2010"
963        // Expected: $filter=HotelName ne 'Sea View Motel' and LastRenovationDate ge 2010-01-01T00:00:00Z
964        let filter = SearchFilter::ne("HotelName", "Sea View Motel").and(SearchFilter::ge(
965            "LastRenovationDate",
966            "2010-01-01T00:00:00Z",
967        ));
968        assert_eq!(
969            filter.to_odata()?,
970            "HotelName ne 'Sea View Motel' and LastRenovationDate ge '2010-01-01T00:00:00Z'"
971        );
972
973        // Example: "Find all hotels that are Luxury or include parking and have a rating of 5"
974        // Expected: $filter=(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5
975        // Our method chaining produces the same logical result with extra parentheses
976        let filter = SearchFilter::eq("Category", "Luxury")
977            .or(SearchFilter::eq("ParkingIncluded", true))
978            .and(SearchFilter::eq("Rating", 5));
979        assert_eq!(
980            filter.to_odata()?,
981            "(Category eq 'Luxury' or ParkingIncluded eq true) and Rating eq 5"
982        );
983
984        // Example: "Find all hotels that have parking included and where all rooms are non-smoking"
985        // Expected: $filter=ParkingIncluded and Rooms/all(room: not room/SmokingAllowed)
986        let filter = SearchFilter::field("ParkingIncluded").and(SearchFilter::all(
987            "Rooms",
988            SearchFilter::raw("room: not room/SmokingAllowed"),
989        ));
990        assert_eq!(
991            filter.to_odata()?,
992            "ParkingIncluded and Rooms/all(room: not room/SmokingAllowed)"
993        );
994
995        // Example: "Find all hotels that don't have rooms"
996        // Expected: $filter=not Rooms/any()
997        let filter = SearchFilter::any("Rooms").not();
998        assert_eq!(filter.to_odata()?, "not (Rooms/any())");
999
1000        // Example: "Find all hotels where 'Description' field is null"
1001        // Expected: $filter=Description eq null
1002        let filter = SearchFilter::eq("Description", SearchFilterValue::null());
1003        assert_eq!(filter.to_odata()?, "Description eq null");
1004
1005        // Example: search.in function
1006        // Expected: $filter=search.in(HotelName, 'Sea View motel,Budget hotel', ',')
1007        let filter = SearchFilter::search_in(
1008            "HotelName",
1009            vec!["Sea View motel".into(), "Budget hotel".into()],
1010        );
1011        assert_eq!(
1012            filter.to_odata()?,
1013            "search.in(HotelName, 'Sea View motel,Budget hotel', ',')"
1014        );
1015
1016        // Test operator precedence matches OData spec
1017        // OData: "Rating gt 0 and Rating lt 3 or Rating gt 7 and Rating lt 10"
1018        // Should be equivalent to: "((Rating gt 0) and (Rating lt 3)) or ((Rating gt 7) and (Rating lt 10))"
1019        let filter = SearchFilter::gt("Rating", 0)
1020            .and(SearchFilter::lt("Rating", 3))
1021            .or(SearchFilter::gt("Rating", 7).and(SearchFilter::lt("Rating", 10)));
1022        assert_eq!(
1023            filter.to_odata()?,
1024            "(Rating gt 0 and Rating lt 3) or (Rating gt 7 and Rating lt 10)"
1025        );
1026        Ok(())
1027    }
1028}