Skip to content

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

Closed

Description

@CaffeinatedCoder

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

  1. Read the bound as memory does. On a discrete date type, map an exclusive infinity upper bound to 9999-12-30 and an exclusive -infinity lower bound to 0001-01-02, with a CASE on upper_inc/lower_inc and isfinite. LowerBound(), UpperBound() and Clamp then give memory's answer for these rows. That changes answers, so it ships as a minor on every supported line under ### Changed answers.
  2. Document it under Rows written by other tools, next to the Count caveat: in a query, the bound accessors and Clamp read the range as the database holds it.

To check

  • LocalDateRange, which loads the same rows one day short and goes through the same UpperBoundFunction.
  • 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() and Clamp on DateRange and LocalDateRange

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() read lower_inc, which is false for (-infinity, where memory's closed form starts inclusive. RangeSet's LowerBound(), UpperBound() and LowerBoundInclusive() 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). So r.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true)).LowerBound() returned 0001-01-01.
  • LocalDateRange: the upper side is fixed. On the lower side memory loads (-infinity as -9998-01-02, the day after LocalDate.MinIsoValue, which PostgreSQL's dates can't hold. So LowerBound() and Clamp(MinIsoValue) there keep MinIsoValue, as documented under Rows written by other tools.
  • YearMonthRange can't load either form (it throws for a partial month), so there's nothing to agree with, and its SQL is unchanged.
  • With Npgsql.DisableDateTimeInfinityConversions set, no extreme is an infinity. The SQL is unchanged, the row fails to load, and a query that projects the bound throws the same InvalidCastException.

Activity

  1. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    area/rangesRange unions, variants and the shape engines
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
    severity/lowCosmetic, message or documentation-level difference
    on Oct 26, 2026
  2. added this to the 10.4.0 milestone on Oct 26, 2026
  3. CaffeinatedCoder commented on Oct 26, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Decided (2026-10-26): option 1, in 10.4.0. In a query, LowerBound(), UpperBound() and Clamp read an exclusive -infinity lower bound and an exclusive infinity upper bound on a date range as the days memory loads, 0001-01-02 and 9999-12-30. It lands on main first, under ### Breaking in 11.0.0 (### Changed answers is for minors only), and #502 ports it to release/10.x under ### Changed answers in 10.4.0.

    Why it has to be in 10.4.0: on release/10.x at 61308c6, ClampFunction reads the same UpperBoundFunction (upper(x) - 1) and lower(x) as on main. #494 made Clamp on DateRange and LocalDateRange run 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's LowerBound() and UpperBound() go through the same two helpers, so the fix covers them too. YearMonthRange is added to the list to check.

  4. added a commit that references this issue on Oct 26, 2026
  5. added a commit that references this issue on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/range-setRangeSet and multirange algebraarea/rangesRange unions, variants and the shape enginesbugSomething isn't workingpackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLpackage/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTimeseverity/lowCosmetic, message or documentation-level differencesilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions