From 1157c08941ed77839ad7925b464d3a56bee77594 Mon Sep 17 00:00:00 2001 From: SaiEaranti Date: Sat, 25 Jul 2026 19:12:14 -0500 Subject: [PATCH] fix: negate the day range in Timestamp != date filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timestamp.__ne__'s date branch called self.between(start, end) — the same positive range construction as __eq__ — so `Timestamp("f") != date(...)` matched the entire day its docstring says it excludes, silently returning exactly the rows the caller asked to filter out. The non-date path already negates via FilterOperator.NE, as do the NE forms of Tag, Text, Num and Geo. Compute the day bounds exactly as __eq__ does (including the astimezone conversion the date branch of __ne__ was also missing) and emit them through the existing NE operator template, making `!= date` the exact string negation of `== date` on any machine. Date-only ISO strings take the same path. --- redisvl/query/filter.py | 17 +++++++++++++---- tests/unit/test_filter.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/redisvl/query/filter.py b/redisvl/query/filter.py index 04d3e77d..e92e2e8b 100644 --- a/redisvl/query/filter.py +++ b/redisvl/query/filter.py @@ -803,13 +803,22 @@ def __ne__( self: The filter object for method chaining """ if self._is_date(other): - # For date objects, exclude the entire day + # For date objects, exclude the entire day by negating exactly the + # range that __eq__ matches. if isinstance(other, str): other = datetime.datetime.strptime(other, "%Y-%m-%d").date() assert isinstance(other, datetime.date) # validate for mypy - start = datetime.datetime.combine(other, datetime.time.min) - end = datetime.datetime.combine(other, datetime.time.max) - return self.between(start, end) + start = datetime.datetime.combine(other, datetime.time.min).astimezone( + datetime.timezone.utc + ) + end = datetime.datetime.combine(other, datetime.time.max).astimezone( + datetime.timezone.utc + ) + start_ts = self._convert_to_timestamp(start) + end_ts = self._convert_to_timestamp(end, end_date=True) + return FilterExpression( + self.OPERATOR_MAP[FilterOperator.NE] % (self._field, start_ts, end_ts) + ) timestamp = self._convert_to_timestamp(other) self._set_value(timestamp, self.SUPPORTED_TYPES, FilterOperator.NE) diff --git a/tests/unit/test_filter.py b/tests/unit/test_filter.py index fa66a0f1..a7be2651 100644 --- a/tests/unit/test_filter.py +++ b/tests/unit/test_filter.py @@ -412,6 +412,26 @@ def test_timestamp_date(): assert str(ts) == f"@created_at:[{expected_ts_start} {expected_ts_end}]" +def test_timestamp_not_equal_date(): + """Test Timestamp != with date objects (should exclude the full day).""" + d = date(2023, 3, 17) + ts = Timestamp("created_at") != d + + expected_ts_start = ( + datetime.combine(d, time.min).astimezone(timezone.utc).timestamp() + ) + expected_ts_end = datetime.combine(d, time.max).astimezone(timezone.utc).timestamp() + + assert str(ts) == f"(-@created_at:[{expected_ts_start} {expected_ts_end}])" + + # != must be the exact negation of == for the same date + eq = Timestamp("created_at") == d + assert str(ts) == f"(-{eq!s})" + + # A date-only ISO string takes the same path as a date object + assert str(Timestamp("created_at") != "2023-03-17") == str(ts) + + def test_timestamp_iso_string(): """Test Timestamp filter with ISO format strings.""" # Date-only ISO string