1use std::{
24 fmt::{self, Display, Formatter},
25 ops::{BitAnd, BitOr, Not},
26};
27
28use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
29
30#[derive(Debug, Clone, Copy, Eq, PartialEq)]
32pub enum ComparisonOp {
33 GreaterThan,
35 GreaterThanOrEqual,
37 LessThan,
39 LessThanOrEqual,
41}
42
43#[derive(Debug, Clone, Copy, Eq, PartialEq)]
45pub enum BetweenInclusivity {
46 Both,
48 Neither,
50 Left,
52 Right,
54}
55
56#[derive(Debug, Clone)]
58pub enum FilterExpression {
59 MatchAll,
61 Raw(String),
63 Tag {
65 field: String,
67 values: Vec<String>,
69 },
70 TextExact {
72 field: String,
74 value: String,
76 },
77 TextLike {
79 field: String,
81 value: String,
83 },
84 NumericRange {
86 field: String,
88 min: String,
90 max: String,
92 },
93 GeoRadius {
95 field: String,
97 longitude: f64,
99 latitude: f64,
101 radius: f64,
103 unit: String,
105 },
106 TimestampRange {
108 field: String,
110 min: String,
112 max: String,
114 },
115 And(Box<FilterExpression>, Box<FilterExpression>),
117 Or(Box<FilterExpression>, Box<FilterExpression>),
119 Not(Box<FilterExpression>),
121 IsMissing {
123 field: String,
125 },
126}
127
128impl FilterExpression {
129 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 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#[derive(Debug, Clone)]
237pub struct Tag {
238 field: String,
239}
240
241impl Tag {
242 pub fn new(field: impl Into<String>) -> Self {
244 Self {
245 field: field.into(),
246 }
247 }
248
249 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 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 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 pub fn ne(self, value: impl Into<String>) -> FilterExpression {
298 !self.eq(value)
299 }
300
301 pub fn is_missing(self) -> FilterExpression {
303 FilterExpression::IsMissing { field: self.field }
304 }
305}
306
307#[derive(Debug, Clone)]
309pub struct Text {
310 field: String,
311}
312
313impl Text {
314 pub fn new(field: impl Into<String>) -> Self {
316 Self {
317 field: field.into(),
318 }
319 }
320
321 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 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 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 pub fn matches(self, value: impl Into<String>) -> FilterExpression {
359 self.like(value)
360 }
361
362 pub fn is_missing(self) -> FilterExpression {
364 FilterExpression::IsMissing { field: self.field }
365 }
366}
367
368#[derive(Debug, Clone)]
370pub struct Num {
371 field: String,
372}
373
374impl Num {
375 pub fn new(field: impl Into<String>) -> Self {
377 Self {
378 field: field.into(),
379 }
380 }
381
382 pub fn eq(self, value: f64) -> FilterExpression {
384 range_expr(self.field, format_number(value), format_number(value))
385 }
386
387 pub fn ne(self, value: f64) -> FilterExpression {
389 !self.eq(value)
390 }
391
392 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 pub fn gte(self, value: f64) -> FilterExpression {
403 range_expr(self.field, format_number(value), "+inf".to_owned())
404 }
405
406 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 pub fn lte(self, value: f64) -> FilterExpression {
417 range_expr(self.field, "-inf".to_owned(), format_number(value))
418 }
419
420 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 pub fn is_missing(self) -> FilterExpression {
439 FilterExpression::IsMissing { field: self.field }
440 }
441}
442
443#[derive(Debug, Clone)]
445pub struct Geo {
446 field: String,
447}
448
449impl Geo {
450 pub fn new(field: impl Into<String>) -> Self {
452 Self {
453 field: field.into(),
454 }
455 }
456
457 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 pub fn ne(self, radius: GeoRadius) -> FilterExpression {
470 !self.eq(radius)
471 }
472
473 pub fn within_radius(self, radius: GeoRadius) -> FilterExpression {
475 self.eq(radius)
476 }
477
478 pub fn is_missing(self) -> FilterExpression {
480 FilterExpression::IsMissing { field: self.field }
481 }
482}
483
484#[derive(Debug, Clone)]
486pub struct GeoRadius {
487 longitude: f64,
488 latitude: f64,
489 radius: f64,
490 unit: String,
491}
492
493impl GeoRadius {
494 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#[derive(Debug, Clone)]
507pub struct Timestamp {
508 field: String,
509}
510
511impl Timestamp {
512 pub fn new(field: impl Into<String>) -> Self {
514 Self {
515 field: field.into(),
516 }
517 }
518
519 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 pub fn ne<T>(self, value: T) -> FilterExpression
534 where
535 T: IntoTimestampRange,
536 {
537 !self.eq(value)
538 }
539
540 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 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 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 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 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 pub fn is_missing(self) -> FilterExpression {
621 FilterExpression::IsMissing { field: self.field }
622 }
623}
624
625pub trait IntoTimestampRange {
627 fn into_timestamp_range(self) -> (f64, f64);
629}
630
631pub trait IntoTimestampPoint {
633 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 #[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}