Repository navigation
YearMonthRange Clamp and UpperBound() in a filter compare as the month's last day, so == against the month is false #506
Description
Found by a pre-release review of release/10.x @ a958bef (an outside review); the UpperBound() sibling was found by the sweep while verifying it. Reproduced against PostgreSQL 16 in a scratch test. Also on main @ 2949415.
Problem
A YearMonthRange is stored as a month-aligned daterange, and a YearMonth value binds as the first day of its month. In SQL, UpperBoundFunction reads the upper bound as upper(x) - 1, which is the last day of the last month (src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs:1055). ClampFunction (#486/#494) builds on it (ValueRangesMethodCallTranslator.cs:1133).
Loading converts both dates to their month, so a projection agrees with memory. That is all the existing tests check (TimeAndYearMonthIntegrationTests.YearMonthRange_ServerBoundAccessors_AgreeWithInMemory).
A comparison in a filter does not agree: 2026-01-31 is not = 2026-01-01. The remark at ValueRangesMethodCallTranslator.cs:1126 says the stepped-back bound "compares as memory compares months". That holds for < against a later month and fails for =, <>, > and <= against the same month.
Scenario
period = [2026-01, 2026-01]. In memory, period.Clamp(2026-02) == 2026-01 and period.UpperBound() == 2026-01 are both true.
WHERE r."Id" = 990021 AND CASE WHEN isempty(r."BillingPeriod") THEN NULL
ELSE LEAST(GREATEST(@february, lower(r."BillingPeriod")), upper(r."BillingPeriod") - 1) END = @january
-- no rows
WHERE r."Id" = 990031 AND upper(r."BillingPeriod") - 1 = @january
-- no rowsUpperBound() > january is true on the server and false in memory (traced). The same UpperBoundFunction serves RangeSet<YearMonthRange, YearMonth>.UpperBound() (ValueRangesMethodCallTranslator.cs:511; traced, not run).
Clamp on YearMonthRange is new in 10.4.0. The UpperBound() half is older.
Change
On a type whose elements are coarser than their store type, read the upper bound as the first day of its month, e.g. date_trunc('month', upper(x) - 1)::date, or upper(x) - interval '1 month' for a finite bound. That makes UpperBound() and Clamp compare as memory does. Keep the extremes and #489's exclusive infinity in the measurement. A definition hook (as ExclusiveInfinity is) keeps this out of the other date types.
Test
YearMonthRange and its range set in ClampDomainParityTests and the bound-reader parity tests, compared in a filter (==, >, <= against the same month), not only projected. Revert to prove each fails, and add a seed.
Release
silent: ### Changed answers in 10.4.0, main first, then a port.
Activity
CaffeinatedCoder commentedon Oct 26, 2026
Fix on
main: #509 (addsCoarseUpperBound,YearMonthBoundParityTests, claim E88, two seeds). Port torelease/10.x: #511 → PR #515, under### Changed answersin 10.4.0.Merge pull request #509 from CaffeinatedCoder/fix/506-yearmonth-upper…
Merge pull request #515 from CaffeinatedCoder/fix/511-yearmonth-upper…