Skip to content

fix: read a YearMonthRange upper bound in a query as the first day of its month (#506) - #509

Merged
CaffeinatedCoder merged 5 commits into
mainfrom
fix/506-yearmonth-upper-bound
Oct 26, 2026
Merged

CaffeinatedCoder merged 5 commits into
mainfrom
fix/506-yearmonth-upper-bound

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

Closes #506

Problem

A YearMonthRange is stored as a month-aligned daterange, and a YearMonth you pass in is sent as the first day of its month. In a query, UpperBound() read upper(x) - 1, the last day of the last month. That loads as the right month, so projections agreed with memory and the tests that project the bound passed. In a filter it compared unequal to the month, and greater:

On [2026-01,2026-01] Memory Query
UpperBound() == 2026-01 true false
UpperBound() > 2026-01 false true
Clamp(2026-02) == 2026-01 true false

RangeSet<YearMonthRange, YearMonth>.UpperBound() and Clamp read the same helper. Clamp on YearMonthRange runs as SQL since #486 (10.4.0, 11.0.0-preview.1), and UpperBound() since the type's EF mapping.

Change

  • CoarseUpperBound (new, internal) and IRangeTypeDefinition.CoarseUpperBound. It holds the span of one element as an interval, and the element an inclusive infinity stands for. YearMonthRangeTypeDefinition sets ("1 month", 9999-12).
  • ValueRangesMethodCallTranslator.UpperBoundFunction: on such a type, CoarseUpper writes CASE WHEN NOT isfinite(upper(x)) THEN DATE '9999-12-01' ELSE CAST(upper(x) - INTERVAL '1 month' AS date) END. 9999-12 ends on LocalDate.MaxIsoValue, which Npgsql writes as infinity, and no interval steps back from that. With Npgsql.DisableDateTimeInfinityConversions the bound is a finite 10000-01-01, and the interval reaches 9999-12-01 on its own. Other types produce the same SQL as before.
  • Docs: Store a YearMonthRange (efcore.md) says how the bound compares, and the discrete-bounds note points there. architecture.md says how it works. Migration from v10.x gets a bullet and the section Check YearMonthRange upper bounds compared in queries. A ### Breaking line in 11.0.0-preview.1 of the root and EF Core NodaTime changelogs.
  • Oracles: claim E88. YearMonthBoundParityTests is registered as a differential oracle, with two seeds.

It changes answers, so it's labelled silent. On 10.x it ships in 10.4.0 under ### Changed answers (port issue to follow).

Tests

  • ClampDomainParityTests now compares Clamp(@p) with every point by ==, < and > in a filter, on all 13 range types. Before, it compared with the same point only, which never reaches the clamped bound. With the fix reverted, YearMonthRange has 420 disagreements, and the other twelve types agree.
  • YearMonthBoundParityTests (new, integration): LowerBound() and UpperBound() of the range and its RangeSet, over every shape between five months (9999-12 among them), compared with each month by ==, < and > in a filter against memory over the loaded rows.
  • YearMonthUpperBoundTests (infinity-disabled project): UpperBound() == month at 2026-01, 9999-11 and 9999-12, with the conversions off.
  • NodaQueryTranslationTests: the two asserts on the old upper(x) - 1 now assert the new SQL.
  • Proved by reverting:
    • Without the dispatch line, all three live tests fail.
    • oracles.cs seeds yearmonth-upper catches both seeds: the day step in both oracles, and the dropped infinity arm in YearMonthBoundParityTests. Clamp can't reach that arm, because no month lies above 9999-12.
  • Full suite green on PostgreSQL 16, and the three YearMonth classes on PostgreSQL 18.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 4 commits October 26, 2026 17:58
… its month (#506)

A YearMonthRange is a month-aligned daterange, and a YearMonth operand
binds as the first day of its month. UpperBound() read upper(x) - 1, the
last day of the last month: it loaded as that month, but in a filter it
compared unequal to it, and greater. UpperBound() == january and
Clamp(february) == january were false for [2026-01,2026-01], where memory
gives true; RangeSet.UpperBound() and Clamp read the same helper.

A definition can name a CoarseUpperBound: the span of one element as an
interval, and the element an inclusive infinity stands for. YearMonthRange
steps back a month, CAST(upper(x) - INTERVAL '1 month' AS date), and
answers 9999-12-01 when the bound is infinity, which no interval steps
back from (9999-12 ends on the date Npgsql writes as infinity). With the
infinity conversions disabled the bound is a finite 10000-01-01 and the
interval reaches 9999-12-01 itself.

ClampDomainParityTests compares Clamp(@p) with every point by ==, < and >
in a filter on all thirteen range types; YearMonthBoundParityTests does
the same for LowerBound() and UpperBound() of the range and its set. With
the dispatch removed both fail on YearMonthRange (420 Clamp
disagreements), and the other twelve types agree.

Found-By: RV
Defect-Class: SQL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… bound (#506)

Two seeds: the bound stepping back a day (both oracles catch it), and the
infinity arm dropped, which only the bound readers reach, since no month
lies above 9999-12 for Clamp to clamp to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry, with the migration and changelog (#506)

Store a YearMonthRange says the query reads the first day of the end
month and compares with a month in a filter as memory does, 9999-12
included; the discrete-bounds note points there; architecture.md says how
(CoarseUpperBound). The v10.x migration list, a section with the before
and after table, the Breaking lines of 11.0.0-preview.1 in the root and
EF Core NodaTime changelogs, and claim E88.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nfinity conversions off (#506)

9999-12 ends on 9999-12-31, which Npgsql then sends as a date, so the
server keeps [...,10000-01-01): a month back reaches 9999-12-01 without
the infinity arm. Fails with the step reverted to a day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit cc0d001 into main Oct 26, 2026
14 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/506-yearmonth-upper-bound branch October 26, 2026 17: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/medium Inconsistency or failure in a narrow scenario 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.

YearMonthRange Clamp and UpperBound() in a filter compare as the month's last day, so == against the month is false

1 participant