Repository navigation
LowerBound(), UpperBound() and Clamp in a query read a date range another tool wrote with an exclusive infinite bound one day wider than memory loads it #489
Description
Package
CodoMetis.ValueRanges.EFCore.PostgreSQL (and the NodaTime satellite for LocalDateRange, by the same code)
Version
main at 46c31df (11.0.0-preview.1). 10.x shares the bound readers.
How does it fail?
Silently, and only for rows another tool wrote: a daterange with an exclusive infinite bound.
What you ran
Rows inserted by raw SQL, then read through EF Core:
INSERT INTO "Rows" ("Id", "Days") VALUES
(1, daterange('2026-01-01', 'infinity')), -- [2026-01-01,infinity)
(5, '(-infinity,2026-01-01)');context.Rows.Select(r => new { Upper = r.Days.UpperBound(), Lower = r.Days.LowerBound(), Hi = r.Days.Clamp(DateOnly.MaxValue), Lo = r.Days.Clamp(DateOnly.MinValue) })What you got
| Row | Loads as | Member | Memory | Database |
|---|---|---|---|---|
[2026-01-01,infinity) |
[2026-01-01,9999-12-30] |
UpperBound() |
9999-12-30 | 9999-12-31 |
Clamp(DateOnly.MaxValue) |
9999-12-30 | 9999-12-31 | ||
(-infinity,2026-01-01) |
[0001-01-02,2025-12-31] |
LowerBound() |
0001-01-02 | 0001-01-01 |
Clamp(DateOnly.MinValue) |
0001-01-02 | 0001-01-01 |
The plugin's own writes never produce these rows, and the inclusive infinity] and the absent bound agree. Measured on PostgreSQL 16 with Npgsql's infinity conversions on (the default).
UpperBound() reads upper(x) - 1 on a discrete type, and infinity - 1 is still infinity, which loads as DateOnly.MaxValue. LowerBound() reads lower(x), which is -infinity for (-infinity, which loads as DateOnly.MinValue. Clamp reads the same bounds (#486).
What you expected
Either the in-memory answer, or a sentence saying the database reads these rows differently. docs/efcore.md#rows-written-by-other-tools says such a range "loads one day short", and docs/architecture.md#domain-width-and-the-boundary says the server's range also holds the extreme day. Neither says that LowerBound(), UpperBound() and Clamp in a query read the server's range.
Environment
.NET 11 RC2 SDK, macOS (arm64), PostgreSQL 16.15 (postgres:16-alpine), EF Core 11 RC2 / Npgsql 11 RC2 (main). Found while measuring #486.
Options
- Read the bound as memory does. On a discrete date type, map an exclusive
infinityupper bound to 9999-12-30 and an exclusive-infinitylower bound to 0001-01-02, with aCASEonupper_inc/lower_incandisfinite.LowerBound(),UpperBound()andClampthen give memory's answer for these rows. That changes answers, so it ships as a minor on every supported line under### Changed answers. - Document it under Rows written by other tools, next to the
Countcaveat: in a query, the bound accessors andClampread the range as the database holds it.
To check
LocalDateRange, which loads the same rows one day short and goes through the sameUpperBoundFunction.- With
Npgsql.DisableDateTimeInfinityConversions(the InfinityDisabled project), such a row can't be loaded at all. Does the query still run?
Done when
- the option is decided here
- a live test inserts these rows by raw SQL and holds the decided answer for
LowerBound(),UpperBound()andClamponDateRangeandLocalDateRange
Measured and decided (2026-10-26)
Option 1, in PR #503 (main) and #502 (release/10.x, 10.4.0). On PostgreSQL 16.15 and 18.1:
- Wider than listed.
LowerBoundInclusive()readlower_inc, which isfalsefor(-infinity, where memory's closed form starts inclusive.RangeSet'sLowerBound(),UpperBound()andLowerBoundInclusive()go through the same helpers. All of them are fixed. - Not only other tools' rows. The server's algebra produces these forms:
(,) - (,-infinity]is(-infinity,), and(,) - [infinity,)is(,infinity). Sor.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true)).LowerBound()returned 0001-01-01. LocalDateRange: the upper side is fixed. On the lower side memory loads(-infinityas -9998-01-02, the day afterLocalDate.MinIsoValue, which PostgreSQL's dates can't hold. SoLowerBound()andClamp(MinIsoValue)there keepMinIsoValue, as documented under Rows written by other tools.YearMonthRangecan't load either form (it throws for a partial month), so there's nothing to agree with, and its SQL is unchanged.- With
Npgsql.DisableDateTimeInfinityConversionsset, no extreme is an infinity. The SQL is unchanged, the row fails to load, and a query that projects the bound throws the sameInvalidCastException.
Activity
CaffeinatedCoder commentedon Oct 26, 2026
Decided (2026-10-26): option 1, in 10.4.0. In a query,
LowerBound(),UpperBound()andClampread an exclusive-infinitylower bound and an exclusiveinfinityupper bound on a date range as the days memory loads, 0001-01-02 and 9999-12-30. It lands onmainfirst, under### Breakingin 11.0.0 (### Changed answersis for minors only), and #502 ports it torelease/10.xunder### Changed answersin 10.4.0.Why it has to be in 10.4.0: on
release/10.xat 61308c6,ClampFunctionreads the sameUpperBoundFunction(upper(x) - 1) andlower(x)as onmain. #494 madeClamponDateRangeandLocalDateRangerun as SQL in this release, and the 10.4.0 changelog says it "answers as memory does, at the extremes too", which these rows contradict.RangeSet'sLowerBound()andUpperBound()go through the same two helpers, so the fix covers them too.YearMonthRangeis added to the list to check.Merge pull request #503 from CaffeinatedCoder/fix/489-exclusive-infin…
Merge pull request #504 from CaffeinatedCoder/fix/502-exclusive-infin…