Skip to main content

redis_vl/
filter.rs

1//! Filter expression DSL for Redis Search.
2//!
3//! Build composable filter expressions using the [`Tag`], [`Text`], [`Num`],
4//! [`Geo`], [`GeoRadius`], and [`Timestamp`] builders. Filters compile to
5//! Redis Search query syntax and can be attached to any query type.
6//!
7//! # Boolean composition
8//!
9//! Filters compose using Rust operators:
10//! - `&` – AND
11//! - `|` – OR
12//! - `!` – NOT
13//!
14//! # Example
15//!
16//! ```
17//! use redis_vl::filter::{Tag, Num};
18//!
19//! let filter = Tag::new("color").eq("red") & Num::new("price").lt(100.0);
20//! // Renders to: (@color:{red} @price:[-inf (100])
21//! ```
22
23use std::{
24    fmt::{self, Display, Formatter},
25    ops::{BitAnd, BitOr, Not},
26};
27
28use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
29
30/// Comparison operators used by numeric and timestamp predicates.
31#[derive(Debug, Clone, Copy, Eq, PartialEq)]
32pub enum ComparisonOp {
33    /// Strictly greater than.
34    GreaterThan,
35    /// Greater than or equal to.
36    GreaterThanOrEqual,
37    /// Strictly less than.
38    LessThan,
39    /// Less than or equal to.
40    LessThanOrEqual,
41}
42
43/// Inclusivity used by `between` predicates.
44#[derive(Debug, Clone, Copy, Eq, PartialEq)]
45pub enum BetweenInclusivity {
46    /// Include both endpoints.
47    Both,
48    /// Exclude both endpoints.
49    Neither,
50    /// Include only the left endpoint.
51    Left,
52    /// Include only the right endpoint.
53    Right,
54}
55
56/// Filter expression tree.
57#[derive(Debug, Clone)]
58pub enum FilterExpression {
59    /// Wildcard that matches everything.
60    MatchAll,
61    /// Raw Redis Search filter expression supplied verbatim.
62    Raw(String),
63    /// Tag equality or membership expression.
64    Tag {
65        /// Field name.
66        field: String,
67        /// Accepted values.
68        values: Vec<String>,
69    },
70    /// Text equality expression.
71    TextExact {
72        /// Field name.
73        field: String,
74        /// Search term.
75        value: String,
76    },
77    /// Text wildcard/pattern expression.
78    TextLike {
79        /// Field name.
80        field: String,
81        /// Search term.
82        value: String,
83    },
84    /// Numeric equality/range expression.
85    NumericRange {
86        /// Field name.
87        field: String,
88        /// Minimum bound.
89        min: String,
90        /// Maximum bound.
91        max: String,
92    },
93    /// Geo radius expression.
94    GeoRadius {
95        /// Field name.
96        field: String,
97        /// Longitude.
98        longitude: f64,
99        /// Latitude.
100        latitude: f64,
101        /// Radius.
102        radius: f64,
103        /// Unit.
104        unit: String,
105    },
106    /// Timestamp equality/range expression.
107    TimestampRange {
108        /// Field name.
109        field: String,
110        /// Minimum bound.
111        min: String,
112        /// Maximum bound.
113        max: String,
114    },
115    /// Logical AND.
116    And(Box<FilterExpression>, Box<FilterExpression>),
117    /// Logical OR.
118    Or(Box<FilterExpression>, Box<FilterExpression>),
119    /// Logical NOT.
120    Not(Box<FilterExpression>),
121    /// IsMissing predicate – matches documents where a field is absent.
122    IsMissing {
123        /// Field name.
124        field: String,
125    },
126}
127
128impl FilterExpression {
129    /// Creates a raw Redis Search filter expression.
130    pub fn raw(expression: impl Into<String>) -> Self {
131        let expression = expression.into();
132        if expression.trim().is_empty() || expression.trim() == "*" {
133            Self::MatchAll
134        } else {
135            Self::Raw(expression)
136        }
137    }
138
139    /// Serializes the filter into Redis Search query syntax.
140    pub fn to_redis_syntax(&self) -> String {
141        match self {
142            Self::MatchAll => "*".to_owned(),
143            Self::Raw(expression) => expression.clone(),
144            Self::Tag { field, values } => {
145                format!("@{}:{{{}}}", field, values.join("|"))
146            }
147            Self::TextExact { field, value } => format!("@{}:(\"{}\")", field, value),
148            Self::TextLike { field, value } => format!("@{}:({})", field, value),
149            Self::NumericRange { field, min, max } => format!("@{}:[{} {}]", field, min, max),
150            Self::GeoRadius {
151                field,
152                longitude,
153                latitude,
154                radius,
155                unit,
156            } => format!(
157                "@{}:[{} {} {} {}]",
158                field, longitude, latitude, radius, unit
159            ),
160            Self::TimestampRange { field, min, max } => format!("@{}:[{} {}]", field, min, max),
161            Self::And(left, right) => {
162                format!("({} {})", left.to_redis_syntax(), right.to_redis_syntax())
163            }
164            Self::Or(left, right) => {
165                format!("({} | {})", left.to_redis_syntax(), right.to_redis_syntax())
166            }
167            Self::Not(inner) => format!("(-{})", inner.to_redis_syntax()),
168            Self::IsMissing { field } => format!("ismissing(@{})", field),
169        }
170    }
171
172    fn is_match_all(&self) -> bool {
173        matches!(self, Self::MatchAll)
174    }
175}
176
177impl Display for FilterExpression {
178    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
179        f.write_str(&self.to_redis_syntax())
180    }
181}
182
183impl From<&str> for FilterExpression {
184    fn from(value: &str) -> Self {
185        Self::raw(value)
186    }
187}
188
189impl From<String> for FilterExpression {
190    fn from(value: String) -> Self {
191        Self::raw(value)
192    }
193}
194
195impl BitAnd for FilterExpression {
196    type Output = Self;
197
198    fn bitand(self, rhs: Self) -> Self::Output {
199        if self.is_match_all() {
200            rhs
201        } else if rhs.is_match_all() {
202            self
203        } else {
204            Self::And(Box::new(self), Box::new(rhs))
205        }
206    }
207}
208
209impl BitOr for FilterExpression {
210    type Output = Self;
211
212    fn bitor(self, rhs: Self) -> Self::Output {
213        if self.is_match_all() {
214            rhs
215        } else if rhs.is_match_all() {
216            self
217        } else {
218            Self::Or(Box::new(self), Box::new(rhs))
219        }
220    }
221}
222
223impl Not for FilterExpression {
224    type Output = Self;
225
226    fn not(self) -> Self::Output {
227        if self.is_match_all() {
228            Self::MatchAll
229        } else {
230            Self::Not(Box::new(self))
231        }
232    }
233}
234
235/// Builder for tag predicates.
236#[derive(Debug, Clone)]
237pub struct Tag {
238    field: String,
239}
240
241impl Tag {
242    /// Creates a tag predicate builder.
243    pub fn new(field: impl Into<String>) -> Self {
244        Self {
245            field: field.into(),
246        }
247    }
248
249    /// Matches a single tag value.
250    pub fn eq(self, value: impl Into<String>) -> FilterExpression {
251        let value = value.into();
252        if value.is_empty() {
253            return FilterExpression::MatchAll;
254        }
255        FilterExpression::Tag {
256            field: self.field,
257            values: vec![escape_tag_value(&value, false)],
258        }
259    }
260
261    /// Matches any of the provided tag values.
262    pub fn one_of<I, S>(self, values: I) -> FilterExpression
263    where
264        I: IntoIterator<Item = S>,
265        S: Into<String>,
266    {
267        let values = values
268            .into_iter()
269            .map(Into::into)
270            .filter(|value| !value.is_empty())
271            .map(|value| escape_tag_value(&value, false))
272            .collect::<Vec<_>>();
273
274        if values.is_empty() {
275            FilterExpression::MatchAll
276        } else {
277            FilterExpression::Tag {
278                field: self.field,
279                values,
280            }
281        }
282    }
283
284    /// Matches a wildcard tag expression without escaping `*`.
285    pub fn like(self, value: impl Into<String>) -> FilterExpression {
286        let value = value.into();
287        if value.is_empty() {
288            return FilterExpression::MatchAll;
289        }
290        FilterExpression::Tag {
291            field: self.field,
292            values: vec![escape_tag_value(&value, true)],
293        }
294    }
295
296    /// Negates a tag equality predicate.
297    pub fn ne(self, value: impl Into<String>) -> FilterExpression {
298        !self.eq(value)
299    }
300
301    /// Matches documents where this tag field is absent.
302    pub fn is_missing(self) -> FilterExpression {
303        FilterExpression::IsMissing { field: self.field }
304    }
305}
306
307/// Builder for text predicates.
308#[derive(Debug, Clone)]
309pub struct Text {
310    field: String,
311}
312
313impl Text {
314    /// Creates a text predicate builder.
315    pub fn new(field: impl Into<String>) -> Self {
316        Self {
317            field: field.into(),
318        }
319    }
320
321    /// Matches an exact text term.
322    pub fn eq(self, value: impl Into<String>) -> FilterExpression {
323        let value = value.into();
324        if value.is_empty() {
325            return FilterExpression::MatchAll;
326        }
327        FilterExpression::TextExact {
328            field: self.field,
329            value: escape_exact_text(&value),
330        }
331    }
332
333    /// Negates an exact text term.
334    pub fn ne(self, value: impl Into<String>) -> FilterExpression {
335        let value = value.into();
336        if value.is_empty() {
337            return FilterExpression::MatchAll;
338        }
339        FilterExpression::Not(Box::new(FilterExpression::TextLike {
340            field: self.field,
341            value: format!("\"{}\"", escape_exact_text(&value)),
342        }))
343    }
344
345    /// Matches a wildcard/pattern text term.
346    pub fn like(self, value: impl Into<String>) -> FilterExpression {
347        let value = value.into();
348        if value.is_empty() {
349            return FilterExpression::MatchAll;
350        }
351        FilterExpression::TextLike {
352            field: self.field,
353            value,
354        }
355    }
356
357    /// Alias for `like`.
358    pub fn matches(self, value: impl Into<String>) -> FilterExpression {
359        self.like(value)
360    }
361
362    /// Matches documents where this text field is absent.
363    pub fn is_missing(self) -> FilterExpression {
364        FilterExpression::IsMissing { field: self.field }
365    }
366}
367
368/// Builder for numeric predicates.
369#[derive(Debug, Clone)]
370pub struct Num {
371    field: String,
372}
373
374impl Num {
375    /// Creates a numeric predicate builder.
376    pub fn new(field: impl Into<String>) -> Self {
377        Self {
378            field: field.into(),
379        }
380    }
381
382    /// Matches values equal to the supplied value.
383    pub fn eq(self, value: f64) -> FilterExpression {
384        range_expr(self.field, format_number(value), format_number(value))
385    }
386
387    /// Negates values equal to the supplied value.
388    pub fn ne(self, value: f64) -> FilterExpression {
389        !self.eq(value)
390    }
391
392    /// Matches values greater than the supplied value.
393    pub fn gt(self, value: f64) -> FilterExpression {
394        range_expr(
395            self.field,
396            format!("({}", format_number(value)),
397            "+inf".to_owned(),
398        )
399    }
400
401    /// Matches values greater than or equal to the supplied value.
402    pub fn gte(self, value: f64) -> FilterExpression {
403        range_expr(self.field, format_number(value), "+inf".to_owned())
404    }
405
406    /// Matches values less than the supplied value.
407    pub fn lt(self, value: f64) -> FilterExpression {
408        range_expr(
409            self.field,
410            "-inf".to_owned(),
411            format!("({}", format_number(value)),
412        )
413    }
414
415    /// Matches values less than or equal to the supplied value.
416    pub fn lte(self, value: f64) -> FilterExpression {
417        range_expr(self.field, "-inf".to_owned(), format_number(value))
418    }
419
420    /// Matches values between the supplied bounds.
421    pub fn between(self, left: f64, right: f64, inclusive: BetweenInclusivity) -> FilterExpression {
422        let min = match inclusive {
423            BetweenInclusivity::Both | BetweenInclusivity::Left => format_number(left),
424            BetweenInclusivity::Neither | BetweenInclusivity::Right => {
425                format!("({}", format_number(left))
426            }
427        };
428        let max = match inclusive {
429            BetweenInclusivity::Both | BetweenInclusivity::Right => format_number(right),
430            BetweenInclusivity::Neither | BetweenInclusivity::Left => {
431                format!("({}", format_number(right))
432            }
433        };
434        range_expr(self.field, min, max)
435    }
436
437    /// Matches documents where this numeric field is absent.
438    pub fn is_missing(self) -> FilterExpression {
439        FilterExpression::IsMissing { field: self.field }
440    }
441}
442
443/// Builder for geo predicates.
444#[derive(Debug, Clone)]
445pub struct Geo {
446    field: String,
447}
448
449impl Geo {
450    /// Creates a geo predicate builder.
451    pub fn new(field: impl Into<String>) -> Self {
452        Self {
453            field: field.into(),
454        }
455    }
456
457    /// Matches points within the supplied radius.
458    pub fn eq(self, radius: GeoRadius) -> FilterExpression {
459        FilterExpression::GeoRadius {
460            field: self.field,
461            longitude: radius.longitude,
462            latitude: radius.latitude,
463            radius: radius.radius,
464            unit: radius.unit,
465        }
466    }
467
468    /// Negates the supplied radius.
469    pub fn ne(self, radius: GeoRadius) -> FilterExpression {
470        !self.eq(radius)
471    }
472
473    /// Alias for `eq`.
474    pub fn within_radius(self, radius: GeoRadius) -> FilterExpression {
475        self.eq(radius)
476    }
477
478    /// Matches documents where this geo field is absent.
479    pub fn is_missing(self) -> FilterExpression {
480        FilterExpression::IsMissing { field: self.field }
481    }
482}
483
484/// Geo radius selector.
485#[derive(Debug, Clone)]
486pub struct GeoRadius {
487    longitude: f64,
488    latitude: f64,
489    radius: f64,
490    unit: String,
491}
492
493impl GeoRadius {
494    /// Creates a new geo radius selector.
495    pub fn new(longitude: f64, latitude: f64, radius: f64, unit: impl Into<String>) -> Self {
496        Self {
497            longitude,
498            latitude,
499            radius,
500            unit: unit.into(),
501        }
502    }
503}
504
505/// Builder for timestamp predicates.
506#[derive(Debug, Clone)]
507pub struct Timestamp {
508    field: String,
509}
510
511impl Timestamp {
512    /// Creates a timestamp predicate builder.
513    pub fn new(field: impl Into<String>) -> Self {
514        Self {
515            field: field.into(),
516        }
517    }
518
519    /// Matches timestamps equal to the supplied value or range-like date.
520    pub fn eq<T>(self, value: T) -> FilterExpression
521    where
522        T: IntoTimestampRange,
523    {
524        let (min, max) = value.into_timestamp_range();
525        FilterExpression::TimestampRange {
526            field: self.field,
527            min: format_timestamp(min),
528            max: format_timestamp(max),
529        }
530    }
531
532    /// Negates an equality predicate.
533    pub fn ne<T>(self, value: T) -> FilterExpression
534    where
535        T: IntoTimestampRange,
536    {
537        !self.eq(value)
538    }
539
540    /// Matches timestamps before the supplied value.
541    pub fn before<T>(self, value: T) -> FilterExpression
542    where
543        T: IntoTimestampPoint,
544    {
545        let value = value.into_timestamp_point();
546        FilterExpression::TimestampRange {
547            field: self.field,
548            min: "-inf".to_owned(),
549            max: format!("({}", format_timestamp(value)),
550        }
551    }
552
553    /// Matches timestamps after the supplied value.
554    pub fn after<T>(self, value: T) -> FilterExpression
555    where
556        T: IntoTimestampPoint,
557    {
558        let value = value.into_timestamp_point();
559        FilterExpression::TimestampRange {
560            field: self.field,
561            min: format!("({}", format_timestamp(value)),
562            max: "+inf".to_owned(),
563        }
564    }
565
566    /// Matches timestamps on or after the supplied value.
567    pub fn gte<T>(self, value: T) -> FilterExpression
568    where
569        T: IntoTimestampPoint,
570    {
571        let value = value.into_timestamp_point();
572        FilterExpression::TimestampRange {
573            field: self.field,
574            min: format_timestamp(value),
575            max: "+inf".to_owned(),
576        }
577    }
578
579    /// Matches timestamps on or before the supplied value.
580    pub fn lte<T>(self, value: T) -> FilterExpression
581    where
582        T: IntoTimestampPoint,
583    {
584        let value = value.into_timestamp_point();
585        FilterExpression::TimestampRange {
586            field: self.field,
587            min: "-inf".to_owned(),
588            max: format_timestamp(value),
589        }
590    }
591
592    /// Matches timestamps between two values.
593    pub fn between<L, R>(self, left: L, right: R, inclusive: BetweenInclusivity) -> FilterExpression
594    where
595        L: IntoTimestampPoint,
596        R: IntoTimestampPoint,
597    {
598        let left = left.into_timestamp_point();
599        let right = right.into_timestamp_point();
600        let min = match inclusive {
601            BetweenInclusivity::Both | BetweenInclusivity::Left => format_timestamp(left),
602            BetweenInclusivity::Neither | BetweenInclusivity::Right => {
603                format!("({}", format_timestamp(left))
604            }
605        };
606        let max = match inclusive {
607            BetweenInclusivity::Both | BetweenInclusivity::Right => format_timestamp(right),
608            BetweenInclusivity::Neither | BetweenInclusivity::Left => {
609                format!("({}", format_timestamp(right))
610            }
611        };
612        FilterExpression::TimestampRange {
613            field: self.field,
614            min,
615            max,
616        }
617    }
618
619    /// Matches documents where this timestamp field is absent.
620    pub fn is_missing(self) -> FilterExpression {
621        FilterExpression::IsMissing { field: self.field }
622    }
623}
624
625/// Converts an input into a timestamp range.
626pub trait IntoTimestampRange {
627    /// Returns inclusive min/max timestamps.
628    fn into_timestamp_range(self) -> (f64, f64);
629}
630
631/// Converts an input into a single timestamp point.
632pub trait IntoTimestampPoint {
633    /// Returns a timestamp point in seconds since the epoch.
634    fn into_timestamp_point(self) -> f64;
635}
636
637impl IntoTimestampPoint for i64 {
638    fn into_timestamp_point(self) -> f64 {
639        self as f64
640    }
641}
642
643impl IntoTimestampPoint for f64 {
644    fn into_timestamp_point(self) -> f64 {
645        self
646    }
647}
648
649impl IntoTimestampPoint for DateTime<Utc> {
650    fn into_timestamp_point(self) -> f64 {
651        self.timestamp() as f64
652    }
653}
654
655impl IntoTimestampPoint for NaiveDateTime {
656    fn into_timestamp_point(self) -> f64 {
657        Utc.from_utc_datetime(&self).timestamp() as f64
658    }
659}
660
661impl IntoTimestampPoint for &str {
662    fn into_timestamp_point(self) -> f64 {
663        parse_timestamp_string(self).0
664    }
665}
666
667impl IntoTimestampRange for i64 {
668    fn into_timestamp_range(self) -> (f64, f64) {
669        let ts = self as f64;
670        (ts, ts)
671    }
672}
673
674impl IntoTimestampRange for f64 {
675    fn into_timestamp_range(self) -> (f64, f64) {
676        (self, self)
677    }
678}
679
680impl IntoTimestampRange for DateTime<Utc> {
681    fn into_timestamp_range(self) -> (f64, f64) {
682        let ts = self.timestamp() as f64;
683        (ts, ts)
684    }
685}
686
687impl IntoTimestampRange for NaiveDateTime {
688    fn into_timestamp_range(self) -> (f64, f64) {
689        let ts = Utc.from_utc_datetime(&self).timestamp() as f64;
690        (ts, ts)
691    }
692}
693
694impl IntoTimestampRange for NaiveDate {
695    fn into_timestamp_range(self) -> (f64, f64) {
696        let start = self
697            .and_hms_opt(0, 0, 0)
698            .expect("valid start of day")
699            .and_utc()
700            .timestamp() as f64;
701        let end = self
702            .and_hms_micro_opt(23, 59, 59, 999_999)
703            .expect("valid end of day")
704            .and_utc()
705            .timestamp() as f64
706            + 0.999_999;
707        (start, end)
708    }
709}
710
711impl IntoTimestampRange for &str {
712    fn into_timestamp_range(self) -> (f64, f64) {
713        parse_timestamp_string(self)
714    }
715}
716
717fn range_expr(field: String, min: String, max: String) -> FilterExpression {
718    FilterExpression::NumericRange { field, min, max }
719}
720
721fn format_number(value: f64) -> String {
722    if value.fract() == 0.0 {
723        format!("{value:.0}")
724    } else {
725        value.to_string()
726    }
727}
728
729fn format_timestamp(value: f64) -> String {
730    value.to_string()
731}
732
733fn escape_tag_value(value: &str, allow_wildcard: bool) -> String {
734    value
735        .chars()
736        .flat_map(|ch| {
737            let should_escape = matches!(ch, ' ' | '$' | ':' | '&' | '/' | '-' | '.')
738                || (ch == '*' && !allow_wildcard);
739            if should_escape {
740                ['\\', ch].into_iter().collect::<Vec<_>>()
741            } else {
742                vec![ch]
743            }
744        })
745        .collect()
746}
747
748fn escape_exact_text(value: &str) -> String {
749    value.replace('"', "\\\"")
750}
751
752fn parse_timestamp_string(value: &str) -> (f64, f64) {
753    if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
754        return date.into_timestamp_range();
755    }
756
757    let datetime = DateTime::parse_from_rfc3339(value)
758        .map(|value| value.with_timezone(&Utc))
759        .or_else(|_| {
760            NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S")
761                .map(|value| Utc.from_utc_datetime(&value))
762        })
763        .expect("valid ISO timestamp");
764    let ts = datetime.timestamp() as f64;
765    (ts, ts)
766}
767
768#[cfg(test)]
769mod tests {
770    use chrono::{NaiveDate, TimeZone, Utc};
771
772    use super::{BetweenInclusivity, FilterExpression, Geo, GeoRadius, Num, Tag, Text, Timestamp};
773
774    #[test]
775    fn composed_filter_should_render() {
776        let filter = Tag::new("user").eq("john")
777            & Num::new("price").gte(10.0)
778            & !Timestamp::new("ts").before(9);
779
780        assert!(filter.to_string().contains("@user:{john}"));
781    }
782
783    #[test]
784    fn geo_filter_should_render() {
785        let filter = Geo::new("location").within_radius(GeoRadius::new(1.0, 2.0, 10.0, "km"));
786
787        assert!(matches!(filter, FilterExpression::GeoRadius { .. }));
788        assert_eq!(filter.to_string(), "@location:[1 2 10 km]");
789    }
790
791    #[test]
792    fn tag_should_escape_specials_like_python_unit_test_filter() {
793        assert_eq!(
794            Tag::new("tag_field").eq("tag with space").to_string(),
795            "@tag_field:{tag\\ with\\ space}"
796        );
797        assert_eq!(
798            Tag::new("tag_field").eq("special$char").to_string(),
799            "@tag_field:{special\\$char}"
800        );
801        assert_eq!(
802            Tag::new("tag_field").like("tech*").to_string(),
803            "@tag_field:{tech*}"
804        );
805        assert_eq!(
806            Tag::new("tag_field").eq("tech*").to_string(),
807            "@tag_field:{tech\\*}"
808        );
809    }
810
811    #[test]
812    fn match_all_should_be_neutral_in_combinations_like_python_unit_test_filter() {
813        let all = FilterExpression::MatchAll;
814        let tag = Tag::new("tag_field").eq("tag");
815        assert_eq!((all.clone() & tag.clone()).to_string(), tag.to_string());
816        assert_eq!((all | tag.clone()).to_string(), tag.to_string());
817    }
818
819    #[test]
820    fn raw_filter_should_round_trip_like_python_manual_string_filters() {
821        assert_eq!(
822            FilterExpression::raw("@credit_score:{high}").to_string(),
823            "@credit_score:{high}"
824        );
825        assert_eq!(FilterExpression::raw("*").to_string(), "*");
826    }
827
828    #[test]
829    fn numeric_between_should_render_like_python_unit_test_filter() {
830        assert_eq!(
831            Num::new("numeric_field")
832                .between(2.0, 5.0, BetweenInclusivity::Right)
833                .to_string(),
834            "@numeric_field:[(2 5]"
835        );
836    }
837
838    #[test]
839    fn text_filters_should_render_like_python_unit_test_filter() {
840        assert_eq!(
841            Text::new("text_field").eq("text").to_string(),
842            "@text_field:(\"text\")"
843        );
844        assert_eq!(
845            Text::new("text_field").ne("text").to_string(),
846            "(-@text_field:(\"text\"))"
847        );
848        assert_eq!(
849            Text::new("text_field").like("tex*").to_string(),
850            "@text_field:(tex*)"
851        );
852    }
853
854    #[test]
855    fn timestamp_date_should_expand_to_day_like_python_unit_test_filter() {
856        let date = NaiveDate::from_ymd_opt(2023, 3, 17).expect("valid date");
857        let rendered = Timestamp::new("created_at").eq(date).to_string();
858        let start = date
859            .and_hms_opt(0, 0, 0)
860            .expect("start")
861            .and_utc()
862            .timestamp() as f64;
863        assert!(rendered.starts_with(&format!("@created_at:[{start} ")));
864    }
865
866    #[test]
867    fn timestamp_between_should_render_like_python_unit_test_filter() {
868        let start = Utc
869            .with_ymd_and_hms(2023, 3, 17, 14, 30, 0)
870            .single()
871            .expect("start");
872        let end = Utc
873            .with_ymd_and_hms(2023, 3, 22, 14, 30, 0)
874            .single()
875            .expect("end");
876        assert_eq!(
877            Timestamp::new("created_at")
878                .between(start, end, BetweenInclusivity::Left)
879                .to_string(),
880            format!(
881                "@created_at:[{} ({}]",
882                start.timestamp() as f64,
883                end.timestamp() as f64
884            )
885        );
886    }
887
888    // ── IsMissing parity tests (upstream: test_filter.py) ──
889
890    #[test]
891    fn tag_is_missing_like_python_test_filter() {
892        let expr = Tag::new("brand").is_missing();
893        assert_eq!(expr.to_redis_syntax(), "ismissing(@brand)");
894    }
895
896    #[test]
897    fn text_is_missing_like_python_test_filter() {
898        let expr = Text::new("description").is_missing();
899        assert_eq!(expr.to_redis_syntax(), "ismissing(@description)");
900    }
901
902    #[test]
903    fn num_is_missing_like_python_test_filter() {
904        let expr = Num::new("price").is_missing();
905        assert_eq!(expr.to_redis_syntax(), "ismissing(@price)");
906    }
907
908    #[test]
909    fn geo_is_missing_like_python_test_filter() {
910        let expr = Geo::new("location").is_missing();
911        assert_eq!(expr.to_redis_syntax(), "ismissing(@location)");
912    }
913
914    #[test]
915    fn timestamp_is_missing_like_python_test_filter() {
916        let expr = Timestamp::new("created_at").is_missing();
917        assert_eq!(expr.to_redis_syntax(), "ismissing(@created_at)");
918    }
919
920    #[test]
921    fn is_missing_combined_with_other_filters_like_python() {
922        let missing = Tag::new("brand").is_missing();
923        let price_filter = Num::new("price").gte(10.0);
924        let combined = missing & price_filter;
925        assert_eq!(
926            combined.to_redis_syntax(),
927            "(ismissing(@brand) @price:[10 +inf])"
928        );
929    }
930}