fix: bound Timestamp date comparisons to the whole UTC day - #670
Open
vishal-bala wants to merge 2 commits into
Open
fix: bound Timestamp date comparisons to the whole UTC day#670vishal-bala wants to merge 2 commits into
vishal-bala wants to merge 2 commits into
Conversation
vishal-bala
force-pushed
the
fix/timestamp-date-comparison-operators
branch
from
August 6, 2026 13:40
3c60c4f to
f07a440
Compare
vishal-bala
marked this pull request as ready for review
August 6, 2026 15:57
`Timestamp.__gt__`, `__lt__`, `__ge__` and `__le__` all called
`_convert_to_timestamp(other)` without `end_date=True`, so a bare `date`
always resolved to that day's 00:00:00. Two of the four need the end of
the day instead:
> date(2023,3,17) -> @x:[(1679011200.0 +inf] # excluded only 00:00:00
<= date(2023,3,17) -> @x:[-inf 1679011200.0] # included only 00:00:00
So `> date(d)` behaved like `>=` (matching nearly all of day d) and
`<= date(d)` behaved like `<` (matching almost none of it). `>=` and `<`
were correct only coincidentally, since both legitimately anchor to the
start of the day.
Pass `end_date=True` in `__gt__`/`__le__` so a bare date resolves to
23:59:59.999999 — the same bound `between()` already uses for its upper
end. This treats a bare date as the whole UTC calendar day, consistent
with `==`/`!=`.
Date-only ISO strings ("2023-03-17") needed one extra step: they reach
`_convert_to_timestamp` as strings and `fromisoformat` turns them into a
midnight *datetime*, which skips the `date` branch that reads `end_date`
altogether. The new `_as_date` helper coerces them to a `date` first, so
strings and `date` objects take the same path.
The datetime, full-ISO-string and Unix-timestamp paths are untouched.
Tests: `test_timestamp_operators` exercised these four operators with a
full `datetime` only, never a bare `date`, which is why this went
unnoticed. Added `test_timestamp_comparison_operators_with_date` (bounds
hard-coded against 2023-03-17Z so it cannot drift with the
implementation) and `test_timestamp_comparison_date_bounds_are_utc_days`,
which sweeps five dates across five zones — including the non-hour
offsets +05:45 and +12:45/+13:45 — using `calendar.timegm` as a
timezone-independent oracle. All six fail on current `main`.
Review follow-up. The previous commit called `_as_date` at the two
operator call sites, which left `between()` — the third caller passing
`end_date=True` — still short-circuiting a date-only string into a
midnight datetime:
<= "2023-03-19" -> [-inf 1679270399.999999] end of day
between("2023-03-17", "2023-03-19") -> [... 1679184000.0] MIDNIGHT
Before the previous commit these agreed (both wrong); afterwards they
disagreed, and `between()` also disagreed with itself depending on
whether you passed `date(2023, 3, 19)` or `"2023-03-19"`.
Move the coercion into `_convert_to_timestamp` so every `end_date=True`
caller gets it, and drop it from `__gt__`/`__le__`. Verified across 52
input/operator combinations: output is byte-identical to the previous
commit except the two `between()`-with-date-string cases, which now
correctly reach the end of the day.
Also normalize the error for a date-shaped non-date. `_is_date_only`
matches on the digit pattern alone, so "2023-02-30" reached `strptime`
and raised its raw message from `>`/`<=` while `<`/`>=` raised the
curated one. `_as_date` now falls through on ValueError and lets
`_convert_to_timestamp` reject it, so all four operators report
"String timestamp must be in ISO format: ..." alike.
Docs: add `Timestamp` to docs/api/filter.rst, which autoclassed every
sibling filter (Tag, Text, Num, Geo, GeoRadius) but not this one — so
none of these docstrings reached docs.redisvl.com. Verified with a
sphinx build: the class plus all six operators and `between()` now
render, and the private helpers stay out. Document `between()`'s
whole-day semantics and its previously undocumented `inclusive`
argument, and `_convert_to_timestamp`'s `end_date` argument.
Tests: add `test_timestamp_between_date_only_string_matches_date_object`
and `test_timestamp_date_shaped_but_invalid_string` (12 cases, four
operators x three malformed inputs). Also note in the TZ test why its
`timegm(...) + 0.999999` oracle is not float-exact for pre-epoch dates,
so adding 1969-12-31 to that list doesn't look like a real failure.
82 pass in test_filter.py, under both TZ unset and TZ=Asia/Kathmandu.
vishal-bala
force-pushed
the
fix/timestamp-date-comparison-operators
branch
from
August 7, 2026 07:26
f07a440 to
f7861b2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Timestamptreats a baredateas a whole calendar day in==/!=, but not in the four comparison operators.__gt__,__lt__,__ge__and__le__all call_convert_to_timestamp(other)withoutend_date=True, and for a baredatethat function combines withtime.min— the start of the day — regardless of which operator is asking. Two of the four need the end of the day instead:> date(2023,3,17)@x:[(1679011200.0 +inf]@x:[(1679097599.999999 +inf]< date(2023,3,17)@x:[-inf (1679011200.0]>= date(2023,3,17)@x:[1679011200.0 +inf]<= date(2023,3,17)@x:[-inf 1679011200.0]@x:[-inf 1679097599.999999]So
> date(d)behaved like>= date(d), matching nearly all of daydthat the caller asked to exclude, and<= date(d)behaved like< date(d), matching almost none of the day the caller asked to include. Both fail silently — a filter that returns the wrong rows, with no error or warning.>=and<were correct only coincidentally: both legitimately anchor to the start of the day, so the missing argument never mattered for them.Date-only ISO strings (
"2023-03-17") are affected identically, since_is_dateputs them on the same footing.The fix
_convert_to_timestamp(value, end_date=True)already produces the 23:59:59.999999 bound — it's whatbetween()uses for its upper end — so__gt__and__le__now pass it, reusing the existing mechanism rather than adding a second way to compute a day boundary. Resulting semantics for a bare dated:> d— after the end of UTC dayd; the day is excluded<= d— through the end of UTC dayd; the day is included>= d/< d— the start of UTC dayd, unchangedOne extra step was needed for date-only strings. They arrive as
str, wherefromisoformatturns"2023-03-17"into a midnight datetime — which then fails theisinstance(value, date) and not isinstance(value, datetime)check and skips the branch that readsend_datealtogether. Soend_date=Truealone would have fixeddateobjects and silently done nothing for strings. The new_as_datehelper coerces a date-only string to adate, and it runs inside_convert_to_timestamprather than at the call sites, which matters — see below.The
datetime, full-ISO-string and Unix-timestamp paths are untouched:end_dateis only consulted for bare dates, and ints/floats return early."2023-03-17T00:00:00"still resolves to a midnight instant, not a day range — a date-only string and a midnight datetime string remain distinct.between()is fixed tooAn earlier revision of this PR applied
_as_dateat the two operator call sites. That leftbetween()— the third caller passingend_date=True— still short-circuiting:Before this PR those agreed (all anchored to midnight — consistently wrong). Fixing only the operators would have made
between()disagree with<=on identical input, and disagree with itself depending on whether you passeddate(2023, 3, 19)or"2023-03-19". Doing the coercion once inside_convert_to_timestampfixes all three call sites and means futureend_date=Truecallers can't forget it.Verified across 52 input/operator combinations: identical output to the call-site version everywhere except the two
between()-with-date-string cases, which now correctly reach end-of-day.Consistent error for a date-shaped non-date
_is_date_onlymatches on the digit pattern (^\d{4}-\d{2}-\d{2}$) alone, so"2023-02-30"reachedstrptimeand raised its raw message from>/<=while</>=raised the curated one._as_datenow falls through onValueError, so all four operators reportString timestamp must be in ISO format: 2023-02-30alike. Still aValueErrorin every case, so no caller'sexceptclause changes.Docs
docs/api/filter.rstautoclassed every sibling filter —Tag,Text,Num,Geo,GeoRadius,FilterExpression— but notTimestamp, so none of these docstrings reached docs.redisvl.com. Added the missing block. Confirmed with a sphinx build that the class, all six operators andbetween()now render, and that the private helpers stay out.Also documented
between()'s whole-day semantics and its previously undocumentedinclusiveargument, and_convert_to_timestamp'send_dateargument.Tests
test_timestamp_operatorsexercised all four operators with a fulldatetimeonly, never a baredate— which is why this went unnoticed. Added:test_timestamp_comparison_operators_with_date— all four operators × {dateobject, date-only string}, with bounds hard-coded against2023-03-17T00:00:00Z/T23:59:59.999999Zso the test cannot drift along with the implementation.test_timestamp_comparison_date_bounds_are_utc_days— the same matrix across five dates (ordinary, DST spring-forward, DST fall-back, the Unix epoch, past the signed 32-bit rollover) and five zones, usingcalendar.timegmas a timezone-independent oracle sharing no code with the path under test. Zones include the non-hour offsetsAsia/Kathmandu(+05:45) andPacific/Chatham(+12:45/+13:45), which an hour-granularity mistake would slip past. CI sets noTZand GitHub runners default to UTC, so this is what would catch a regression to local-day bounds.test_timestamp_between_date_only_string_matches_date_object— pins thebetween()fix above.test_timestamp_date_shaped_but_invalid_string— 12 cases, four operators × three malformed-but-date-shaped inputs.The six original cases fail without the fix (verified by stashing the
filter.pychange).tests/unit/test_filter.py: 87 pass, under bothTZunset andTZ=Asia/Kathmandu. Full unit suite: 1011 pass (the 2 failures are missing optionalsentence-transformersin my venv, unrelated).make format,make check-typesandpre-commitclean.Release note
This is a user-visible behavior change. Of the four operators,
<=is the only one that now matches a larger row set (up to one extra calendar day);>matches a smaller one;<and>=are byte-identical.between()with a date-only string end bound also widens by up to a day. So a caller who used<= date(d)as a hard cutoff — a subscription end date, a retention or legal-hold boundary — will now see records from that final UTC day that were previously excluded. In every case the new result is what the documented semantics call for, but the widening is worth checking for at upgrade time.Code that wants the old start-of-day bound explicitly can pass a
datetime:Relationship to #666
#666 has now merged (
3707059) and this branch is rebased on top of it. It fixed a different defect in the same class: the==/!=date branches anchored to the host's local day rather than the UTC day. The two changes are independent — the comparison operators go through_convert_to_timestamp, which already anchored bare dates to UTC, so this branch needed no UTC fix and makes none.Together the two land the full contract. All six date forms now agree and are identical in every zone — verified under
UTC,Europe/BerlinandAsia/Kathmandu:< date(2023,3,17)@x:[-inf (1679011200.0]== date(2023,3,17)@x:[1679011200.0 1679097599.999999]> date(2023,3,17)@x:[(1679097599.999999 +inf]>= date(2023,3,17)@x:[1679011200.0 +inf]<= date(2023,3,17)@x:[-inf 1679097599.999999]between("2023-03-17", "2023-03-17")@x:[1679011200.0 1679097599.999999]<,==and>now tile the timeline exactly, with no gap and no overlap — which was not true of either PR alone. The rebase conflict was a single hunk (both PRs add imports totests/unit/test_filter.py); #666'stest_timestamp_date_bounds_are_utc_regardless_of_local_timezoneand this PR's tests coexist, 87 passing in that file.One consistency gap is deliberately left alone:
__eq__/__ne__still do their own inlinestrptimefor date-only strings rather than going through_as_date, so they raise the rawstrptimemessage for a date-shaped non-date like"2023-02-30"where the four comparison operators now raise the curatedString timestamp must be in ISO format. Routing those two through_as_datewould remove the duplication and unify the error, but it touches lines #666 just rewrote, so it belongs in a follow-up rather than here.