1
+ import datetime
2
+ import re
1
3
from enum import Enum
2
4
from functools import wraps
3
5
from typing import Any , Callable , Dict , List , Optional , Set , Tuple , Union
8
10
# mypy: disable-error-code="override"
9
11
10
12
13
+ class Inclusive (str , Enum ):
14
+ """Enum for valid inclusive options"""
15
+
16
+ BOTH = "both"
17
+ """Inclusive of both sides of range (default)"""
18
+ NEITHER = "neither"
19
+ """Inclusive of neither side of range"""
20
+ LEFT = "left"
21
+ """Inclusive of only left"""
22
+ RIGHT = "right"
23
+ """Inclusive of only right"""
24
+
25
+
11
26
class FilterOperator (Enum ):
12
27
EQ = 1
13
28
NE = 2
@@ -19,6 +34,7 @@ class FilterOperator(Enum):
19
34
AND = 8
20
35
LIKE = 9
21
36
IN = 10
37
+ BETWEEN = 11
22
38
23
39
24
40
class FilterField :
@@ -267,6 +283,7 @@ class Num(FilterField):
267
283
FilterOperator .GT : ">" ,
268
284
FilterOperator .LE : "<=" ,
269
285
FilterOperator .GE : ">=" ,
286
+ FilterOperator .BETWEEN : "between" ,
270
287
}
271
288
OPERATOR_MAP : Dict [FilterOperator , str ] = {
272
289
FilterOperator .EQ : "@%s:[%s %s]" ,
@@ -275,8 +292,10 @@ class Num(FilterField):
275
292
FilterOperator .LT : "@%s:[-inf (%s]" ,
276
293
FilterOperator .GE : "@%s:[%s +inf]" ,
277
294
FilterOperator .LE : "@%s:[-inf %s]" ,
295
+ FilterOperator .BETWEEN : "@%s:[%s %s]" ,
278
296
}
279
- SUPPORTED_VAL_TYPES = (int , float , type (None ))
297
+
298
+ SUPPORTED_VAL_TYPES = (int , float , tuple , type (None ))
280
299
281
300
def __eq__ (self , other : int ) -> "FilterExpression" :
282
301
"""Create a Numeric equality filter expression.
@@ -373,10 +392,51 @@ def __le__(self, other: int) -> "FilterExpression":
373
392
self ._set_value (other , self .SUPPORTED_VAL_TYPES , FilterOperator .LE )
374
393
return FilterExpression (str (self ))
375
394
395
+ @staticmethod
396
+ def _validate_inclusive_string (inclusive : str ) -> Inclusive :
397
+ try :
398
+ return Inclusive (inclusive )
399
+ except :
400
+ raise ValueError (
401
+ f"Invalid inclusive value must be: { [i .value for i in Inclusive ]} "
402
+ )
403
+
404
+ def _format_inclusive_between (
405
+ self , inclusive : Inclusive , start : int , end : int
406
+ ) -> str :
407
+ if inclusive .value == Inclusive .BOTH .value :
408
+ return f"@{ self ._field } :[{ start } { end } ]"
409
+
410
+ if inclusive .value == Inclusive .NEITHER .value :
411
+ return f"@{ self ._field } :[({ start } ({ end } ]"
412
+
413
+ if inclusive .value == Inclusive .LEFT .value :
414
+ return f"@{ self ._field } :[{ start } ({ end } ]"
415
+
416
+ if inclusive .value == Inclusive .RIGHT .value :
417
+ return f"@{ self ._field } :[({ start } { end } ]"
418
+
419
+ raise ValueError (f"Inclusive value not found" )
420
+
421
+ def between (
422
+ self , start : int , end : int , inclusive : str = "both"
423
+ ) -> "FilterExpression" :
424
+ """Operator for searching values between two numeric values."""
425
+ inclusive = self ._validate_inclusive_string (inclusive )
426
+ expression = self ._format_inclusive_between (inclusive , start , end )
427
+
428
+ return FilterExpression (expression )
429
+
376
430
def __str__ (self ) -> str :
377
431
"""Return the Redis Query string for the Numeric filter"""
378
432
if self ._value is None :
379
433
return "*"
434
+ if self ._operator == FilterOperator .BETWEEN :
435
+ return self .OPERATOR_MAP [self ._operator ] % (
436
+ self ._field ,
437
+ self ._value [0 ],
438
+ self ._value [1 ],
439
+ )
380
440
if self ._operator == FilterOperator .EQ or self ._operator == FilterOperator .NE :
381
441
return self .OPERATOR_MAP [self ._operator ] % (
382
442
self ._field ,
@@ -562,3 +622,213 @@ def __str__(self) -> str:
562
622
if not self ._filter :
563
623
raise ValueError ("Improperly initialized FilterExpression" )
564
624
return self ._filter
625
+
626
+
627
+ class Timestamp (Num ):
628
+ """
629
+ A timestamp filter for querying date/time fields in Redis.
630
+
631
+ This filter can handle various date and time formats, including:
632
+ - datetime objects (with or without timezone)
633
+ - date objects
634
+ - ISO-8601 formatted strings
635
+ - Unix timestamps (as integers or floats)
636
+
637
+ All timestamps are converted to Unix timestamps in UTC for consistency.
638
+ """
639
+
640
+ SUPPORTED_TYPES = (
641
+ datetime .datetime ,
642
+ datetime .date ,
643
+ tuple , # Date range
644
+ str , # ISO format
645
+ int , # Unix timestamp
646
+ float , # Unix timestamp with fractional seconds
647
+ type (None ),
648
+ )
649
+
650
+ @staticmethod
651
+ def _is_date (value : Any ) -> bool :
652
+ """Check if the value is a date object. Either ISO string or datetime.date."""
653
+ return (
654
+ isinstance (value , datetime .date )
655
+ and not isinstance (value , datetime .datetime )
656
+ ) or (isinstance (value , str ) and Timestamp ._is_date_only (value ))
657
+
658
+ @staticmethod
659
+ def _is_date_only (iso_string : str ) -> bool :
660
+ """Check if an ISO formatted string only includes date information using regex."""
661
+ # Match YYYY-MM-DD format exactly
662
+ date_pattern = r"^\d{4}-\d{2}-\d{2}$"
663
+ return bool (re .match (date_pattern , iso_string ))
664
+
665
+ def _convert_to_timestamp (self , value , end_date = False ):
666
+ """
667
+ Convert various inputs to a Unix timestamp (seconds since epoch in UTC).
668
+
669
+ Args:
670
+ value: A datetime, date, string, int, or float
671
+
672
+ Returns:
673
+ float: Unix timestamp
674
+ """
675
+ if value is None :
676
+ return None
677
+
678
+ if isinstance (value , (int , float )):
679
+ # Already a Unix timestamp
680
+ return float (value )
681
+
682
+ if isinstance (value , str ):
683
+ # Parse ISO format
684
+ try :
685
+ value = datetime .datetime .fromisoformat (value )
686
+ except ValueError :
687
+ raise ValueError (f"String timestamp must be in ISO format: { value } " )
688
+
689
+ if isinstance (value , datetime .date ) and not isinstance (
690
+ value , datetime .datetime
691
+ ):
692
+ # Convert to max or min if for dates based on end or not
693
+ if end_date :
694
+ value = datetime .datetime .combine (value , datetime .time .max )
695
+ else :
696
+ value = datetime .datetime .combine (value , datetime .time .min )
697
+
698
+ # Ensure the datetime is timezone-aware (UTC)
699
+ if isinstance (value , datetime .datetime ):
700
+ if value .tzinfo is None :
701
+ value = value .replace (tzinfo = datetime .timezone .utc )
702
+ else :
703
+ value = value .astimezone (datetime .timezone .utc )
704
+
705
+ # Convert to Unix timestamp
706
+ return value .timestamp ()
707
+
708
+ raise TypeError (f"Unsupported type for timestamp conversion: { type (value )} " )
709
+
710
+ def __eq__ (self , other ) -> FilterExpression :
711
+ """
712
+ Filter for timestamps equal to the specified value.
713
+ For date objects (without time), this matches the entire day.
714
+
715
+ Args:
716
+ other: A datetime, date, ISO string, or Unix timestamp
717
+
718
+ Returns:
719
+ self: The filter object for method chaining
720
+ """
721
+ if self ._is_date (other ):
722
+ # For date objects, match the entire day
723
+ if isinstance (other , str ):
724
+ other = datetime .datetime .strptime (other , "%Y-%m-%d" ).date ()
725
+ start = datetime .datetime .combine (other , datetime .time .min ).astimezone (
726
+ datetime .timezone .utc
727
+ )
728
+ end = datetime .datetime .combine (other , datetime .time .max ).astimezone (
729
+ datetime .timezone .utc
730
+ )
731
+ return self .between (start , end )
732
+
733
+ timestamp = self ._convert_to_timestamp (other )
734
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .EQ )
735
+ return FilterExpression (str (self ))
736
+
737
+ def __ne__ (self , other ) -> FilterExpression :
738
+ """
739
+ Filter for timestamps not equal to the specified value.
740
+ For date objects (without time), this excludes the entire day.
741
+
742
+ Args:
743
+ other: A datetime, date, ISO string, or Unix timestamp
744
+
745
+ Returns:
746
+ self: The filter object for method chaining
747
+ """
748
+ if self ._is_date (other ):
749
+ # For date objects, exclude the entire day
750
+ if isinstance (other , str ):
751
+ other = datetime .datetime .strptime (other , "%Y-%m-%d" ).date ()
752
+ start = datetime .datetime .combine (other , datetime .time .min )
753
+ end = datetime .datetime .combine (other , datetime .time .max )
754
+ return self .between (start , end )
755
+
756
+ timestamp = self ._convert_to_timestamp (other )
757
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .NE )
758
+ return FilterExpression (str (self ))
759
+
760
+ def __gt__ (self , other ):
761
+ """
762
+ Filter for timestamps greater than the specified value.
763
+
764
+ Args:
765
+ other: A datetime, date, ISO string, or Unix timestamp
766
+
767
+ Returns:
768
+ self: The filter object for method chaining
769
+ """
770
+ timestamp = self ._convert_to_timestamp (other )
771
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .GT )
772
+ return FilterExpression (str (self ))
773
+
774
+ def __lt__ (self , other ):
775
+ """
776
+ Filter for timestamps less than the specified value.
777
+
778
+ Args:
779
+ other: A datetime, date, ISO string, or Unix timestamp
780
+
781
+ Returns:
782
+ self: The filter object for method chaining
783
+ """
784
+ timestamp = self ._convert_to_timestamp (other )
785
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .LT )
786
+ return FilterExpression (str (self ))
787
+
788
+ def __ge__ (self , other ):
789
+ """
790
+ Filter for timestamps greater than or equal to the specified value.
791
+
792
+ Args:
793
+ other: A datetime, date, ISO string, or Unix timestamp
794
+
795
+ Returns:
796
+ self: The filter object for method chaining
797
+ """
798
+ timestamp = self ._convert_to_timestamp (other )
799
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .GE )
800
+ return FilterExpression (str (self ))
801
+
802
+ def __le__ (self , other ):
803
+ """
804
+ Filter for timestamps less than or equal to the specified value.
805
+
806
+ Args:
807
+ other: A datetime, date, ISO string, or Unix timestamp
808
+
809
+ Returns:
810
+ self: The filter object for method chaining
811
+ """
812
+ timestamp = self ._convert_to_timestamp (other )
813
+ self ._set_value (timestamp , self .SUPPORTED_TYPES , FilterOperator .LE )
814
+ return FilterExpression (str (self ))
815
+
816
+ def between (self , start , end , inclusive : str = "both" ):
817
+ """
818
+ Filter for timestamps between start and end (inclusive).
819
+
820
+ Args:
821
+ start: A datetime, date, ISO string, or Unix timestamp
822
+ end: A datetime, date, ISO string, or Unix timestamp
823
+
824
+ Returns:
825
+ self: The filter object for method chaining
826
+ """
827
+ inclusive = self ._validate_inclusive_string (inclusive )
828
+
829
+ start_ts = self ._convert_to_timestamp (start )
830
+ end_ts = self ._convert_to_timestamp (end , end_date = True )
831
+
832
+ expression = self ._format_inclusive_between (inclusive , start_ts , end_ts )
833
+
834
+ return FilterExpression (expression )
0 commit comments