Skip to content

fix: read an exclusive infinite date bound as memory loads it (#489) - #503

Merged
CaffeinatedCoder merged 3 commits into
mainfrom
fix/489-exclusive-infinite-date-bounds
Oct 26, 2026
Merged

CaffeinatedCoder merged 3 commits into
mainfrom
fix/489-exclusive-infinite-date-bounds

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

Closes #489

Problem

PostgreSQL canonicalizes a finite discrete bound but leaves an infinite one as it is, so a daterange can hold [2026-01-01,infinity) or (-infinity,2026-01-01). Another tool can write these forms, and the server's own algebra produces them: (,) - (,-infinity] is (-infinity,), and (,) - [infinity,) is (,infinity) (measured on PostgreSQL 16.15 and 18.1). Memory loads them one day inside the extreme, as [2026-01-01,9999-12-30] and [0001-01-02,2025-12-31]. In a query:

  • UpperBound() read upper(x) - 1, which is still infinity, so it returned 9999-12-31 where memory gives 9999-12-30. Clamp(DateOnly.MaxValue) did the same.
  • LowerBound() read lower(x), which is -infinity, so it returned 0001-01-01 where memory gives 0001-01-02. Clamp(DateOnly.MinValue) did the same.
  • LowerBoundInclusive() read lower_inc, which is false for (-infinity, where memory's closed form starts inclusive. The issue didn't list this one; the sweep found it.
  • RangeSet's bound readers use the same helpers, so they had the same faults.

ClampFunction on release/10.x reads the same helpers, and 10.4.0's changelog says Clamp "answers as memory does, at the extremes too", so #502 ports this into 10.4.0.

Change

  • ExclusiveInfinity (new, internal) and IRangeTypeDefinition.ExclusiveInfinity. A discrete definition names the extremes Npgsql writes as infinities, and the bounds memory loads for an exclusive one. RangeTypeDefinition takes it only while infinityText maps the extremes, so it is null with Npgsql.DisableDateTimeInfinityConversions set.
  • Registrations. DateRange gets both sides, (MinValue, 0001-01-02, MaxValue, 9999-12-30). LocalDateRange gets the upper side only, because memory starts (-infinity on -9998-01-02, which is below PostgreSQL's dates.
  • ValueRangesMethodCallTranslator: the new LowerBoundFunction and the changed UpperBoundFunction wrap the bound in CASE WHEN bound = <extreme> AND NOT <inclusive>(x) THEN <inside> ELSE … END (InsideExclusiveInfinity). LowerBound(), UpperBound(), Clamp and the RangeSet readers all use these two. LowerInclusiveFunction mirrors UpperInclusiveFunction on discrete types: NOT lower_inf(x) AND NOT isempty(x). Types with no ExclusiveInfinity (integers, YearMonthRange, continuous types) produce the same SQL as before, apart from LowerBoundInclusive() on a discrete type.
  • Docs: Rows written by other tools (efcore.md) covers the bound readers, the LocalDateRange lower side and the disabled-conversions mode. The discrete-bounds note now names LowerBoundInclusive(), and architecture.md's domain-boundary section explains the CASE. Migration from v10.x gets a bullet and the section Check date bounds at an exclusive infinity in queries. Each of the three changelogs (root, EF Core, EF Core NodaTime) gets a line under ### Breaking in 11.0.0-preview.1.
  • Oracles: new claim E87. E06 now quotes the reworded sentence. ExclusiveInfinityBoundParityTests is registered as a differential oracle with two seeds, and there's a clone waiver for the new definition argument. Two seeds whose context lines moved are regenerated.

It changes answers, so it's labelled silent. On 10.x it ships in the 10.4.0 minor under ### Changed answers (#502).

Tests

  • ExclusiveInfinityBoundParityTests (new, integration). It writes each of the eight infinite forms by raw SQL and loads the row. Each reader then runs as a predicate, reader(r) == memory's answer, so it can't fall back to the client. Readers covered:
    • DateRange: all seven, i.e. the four bound accessors and Clamp at the minimum, the maximum and a mid value.
    • LocalDateRange: the same seven, with the documented lower-side exception.
    • Both multiranges: the four bound accessors.
    • The server's Except at the extremes.
    • Plus a test that YearMonthRange can't load these forms.
  • InfinityConversionsDisabledTests: with the switch set, the SQL stays as it was, and a projected bound throws exactly as loading the row does.
  • Translation tests: the new CASE for DateRange and LocalDateRange (upper side only); no CASE for Int32Range or YearMonthRange; LowerBoundInclusive() on a discrete type; plus updates to the tests that asserted the old SQL.
  • Proved by reverting the translator change: all five parity tests fail, each failure naming the form, the reader, and the query's answer next to memory's. dotnet run tools/oracles.cs seeds catches both new seeds (5 of 7 and 2 of 7 tests fail) and both regenerated ones.
  • Full suite green: unit, conventions, ASP.NET Core and the EF no-database projects, and both integration projects on PostgreSQL 16 and 18.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 3 commits October 26, 2026 15:59
PostgreSQL canonicalizes a finite discrete bound but leaves an infinite one
as it is, so [2026-01-01,infinity) and (-infinity,2026-01-01) exist: another
tool writes them, and the server's algebra computes them ((,) - (,-infinity]
is (-infinity,), (,) - [infinity,) is (,infinity)). Memory loads them one day
inside, [2026-01-01,9999-12-30] and [0001-01-02,2025-12-31]. In a query,
upper(x) - 1 is still infinity and lower(x) is -infinity, so UpperBound(),
LowerBound() and Clamp at the extremes answered 9999-12-31 and 0001-01-01,
and LowerBoundInclusive() read lower_inc, which is false for (-infinity,
where memory's closed form starts inclusive. RangeSet's bound readers go
through the same helpers.

A definition now names the extremes Npgsql writes as infinities and the
bound memory loads for an exclusive one (ExclusiveInfinity), and the bound
readers test for the exclusive sentinel in a CASE. LowerBoundInclusive()
on a discrete type mirrors UpperBoundInclusive(): a finite lower bound
exists and the range isn't empty. On LocalDateRange only the upper side
applies: memory starts (-infinity on -9998-01-02, below PostgreSQL's dates.
With Npgsql.DisableDateTimeInfinityConversions set no extreme is an
infinity, the SQL stays as it was, and such a row fails to read in a query
as it fails to load. YearMonthRange can't load these rows (partial months).

ExclusiveInfinityBoundParityTests writes every infinite form by raw SQL and
runs each reader as a predicate against memory's answer for the loaded row,
on DateRange, LocalDateRange and both multiranges, plus the server-computed
Except; with the translator change reverted all five parity tests fail for
these rows. Measured on PostgreSQL 16.15 and 18.1.

Found-By: CA
Defect-Class: SQL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or seeds (#489)

ExclusiveInfinityBoundParityTests is registered as a differential oracle with
two seeds: the CASE answering the extreme instead of the day inside it (5 of
7 tests fail), and LowerBoundInclusive() reading lower_inc again (2 of 7).
clamp-reads-the-stored-exclusive-upper-bound and range-isfinite-drops-isempty
had context lines the fix moved; regenerated, both still caught
(ClampParityTests, ClampDomainParityTests; ExecutedQueryTests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he migration and changelog (#489)

Rows written by other tools gains the bound readers and the two exceptions
(LocalDateRange's lower side, the infinity conversions disabled); the
discrete-bounds note names LowerBoundInclusive(); the domain-boundary
section says how the readers do it. The v10.x migration list and a section
for it, the Breaking lines of 11.0.0-preview.1 in the root and both EF
changelogs, claim E87 (and E06 following its reworded sentence), and the
clone waiver for the definitions' new argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder CaffeinatedCoder added this to the 10.4.0 milestone Oct 26, 2026
@CaffeinatedCoder CaffeinatedCoder added bug Something isn't working silent Answers wrongly without throwing — fixes go in the next major, never deferred area/ranges Range unions, variants and the shape engines area/range-set RangeSet and multirange algebra package/efcore Affects CodoMetis.ValueRanges.EFCore.PostgreSQL package/efcore-nodatime Affects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime severity/low Cosmetic, message or documentation-level difference labels Oct 26, 2026
@CaffeinatedCoder
CaffeinatedCoder merged commit 2949415 into main Oct 26, 2026
14 of 16 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/489-exclusive-infinite-date-bounds branch October 26, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/range-set RangeSet and multirange algebra area/ranges Range unions, variants and the shape engines bug Something isn't working package/efcore Affects CodoMetis.ValueRanges.EFCore.PostgreSQL package/efcore-nodatime Affects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime severity/low Cosmetic, message or documentation-level difference silent Answers wrongly without throwing — fixes go in the next major, never deferred

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant