Skip to content

Translate the calendar factories when they read a column #191

Closed

Description

@CaffeinatedCoder

#48 (PR #190) adds DateRange.Month, Quarter, Year and IsoWeek, and the same on LocalDateRange (Quarter and Year on YearMonthRange). Called with values the query does not read, EF evaluates them in memory and inlines one daterange constant, so b.Period.Overlaps(DateRange.Month(2026, 10)) runs as SQL. Called with a column, such as DateRange.Month(b.Day), they fail translation in a Where and run in memory in a Select. docs/efcore.md#what-runs-where lists them as "not translated yet".

Proposed translation (each a [) constructor, so no canonicalization is needed):

  • Month(date) → daterange(date_trunc('month', d)::date, (date_trunc('month', d) + interval '1 month')::date, '[)')
  • Quarter(date), Year(date) → the same with 'quarter' and 'year'
  • IsoWeek(date) → date_trunc('week', d), which is ISO (Monday) in PostgreSQL, plus 7 days
  • Month(y, m), Quarter(y, q), Year(y) → make_date(y, m, 1) and interval arithmetic
  • IsoWeek(weekYear, week) → to_date(weekYear || '-' || week, 'IYYY-IW'), which needs checking against ISOWeek for week 53 of a 52-week year, since the in-memory factory throws there

What to measure first: argument validation. In memory, month 13 or week 53 of a 52-week year throws ArgumentOutOfRangeException. make_date(2026, 13, 1) raises in PostgreSQL, but to_date may roll over, which would be a silent difference. The infinity date extremes (date_trunc('month', 'infinity')) also need checking.

Done when: the column forms run as SQL, a live test compares them with the in-memory answer for every factory over a span of dates, and the row leaves "runs in memory only".

Found while implementing #48, 2026-10-18.

Scope, after the measurements (2026-10-25). The DateRange forms run as SQL. The LocalDateRange and YearMonthRange forms stay in memory: a LocalDate column at -infinity stands for -9998-01-01, and neither its month nor any year before 4713 BC has a PostgreSQL date, so the server would fail where memory returns a range. Per #408, a cell that can't match keeps the member in memory for that type. The proposal changed in three places (measurements): no to_date, a guard on the year, and [] with memory's first and last periods as constants instead of [). main: PR #447. release/10.x: #448, once #447 merges.

Activity

  1. added
    enhancementNew feature or request
    area/rangesRange unions, variants and the shape engines
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
    on Oct 18, 2026
  2. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Plan (2026-10-18): not part of the 10.0.0 epic (#58). The column forms are additive (a translation failure becomes a working query), so this ships as a 10.x minor, 10.1.0 at the earliest, after the measurements the issue names (to_date rollover on week 53, date_trunc on the date extremes). 10.0.0 tags with the "runs in memory only" row in docs/efcore.md#what-runs-where.

  3. added this to the 10.3.0 milestone on Oct 25, 2026
  4. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Scheduled for 10.3.0 (#408), third in priority after #405 (Length) and #406 (Clamp). The measurements this issue names come first: to_date rollover on week 53 of a 52-week year, and date_trunc on the infinity extremes. Then the parity list on #408. If it isn't proven in time, it moves to the next minor rather than holding the release. main first, then the cherry-pick to release/10.x.

  5. added a commit that references this issue on Oct 25, 2026
  6. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Measurements, 2026-10-25, on PostgreSQL 16.15 and 18.1 (postgres:16-alpine, postgres:18-alpine). Both versions answer identically on every row.

    Expression Result Memory Verdict
    to_date('2025-53', 'IYYY-IW') (2025 has 52 ISO weeks) 2025-12-29, the Monday of 2026-W01 IsoWeek(2025, 53) throws silent rollover
    to_date('2026-54', 'IYYY-IW') 2027-01-04 throws silent rollover
    to_date('2026-0', 'IYYY-IW') 2026-01-01, not even a Monday throws silent
    to_date('2026-53', 'IYYY-IW') (2026 has 53) 2026-12-28 [2026-12-28,2027-01-03] agrees
    make_date(2026, 13, 1), make_date(2026, 0, 1), make_date(0, 1, 1) ERROR: date field value out of range (22008) throws both loud
    make_date(10000, 1, 1), make_date(-1, 1, 1) 10000-01-01, 0001-01-01 BC DateRange throws outside 1–9999 silent without a guard
    date_trunc('month' | 'quarter' | 'year' | 'week', 'infinity'::date) infinity (and -infinity for -infinity) Month(DateOnly.MaxValue) is [9999-12-01,9999-12-31] —
    daterange(date_trunc('month', 'infinity'), … + interval '1 month', '[)') empty (both infinities) as above silent: the proposed [) form fails at both extremes
    pg_typeof(date_trunc('month', current_date)) timestamp with time zone — the date argument resolves to the timestamptz overload, which depends on the session TimeZone
    daterange('9999-12-01', 'infinity', '[]'), daterange('-infinity', '0001-01-31', '[]') [9999-12-01,infinity], [-infinity,0001-02-01) what Npgsql binds for [9999-12-01,9999-12-31] and [0001-01-01,0001-01-31] agrees
    CASE WHEN 2026 BETWEEN 1 AND 9999 THEN 2026 ELSE CAST('…' || 10000 AS integer) END 2026; the planner drops the ELSE once a WHEN folds to true — a guard can raise only on the row that needs it
    date_part('week', make_date(y, 12, 28)) 53 for 2026, 52 for 2025, 1 and 9999 ISOWeek.GetWeeksInYear agrees

    What this changes in the proposal

    • IsoWeek(weekYear, week) does not use to_date. The Monday is computed from January 4 (make_date(y, 1, 4) less its isodow less one, plus 7 * (week - 1)), and a week outside 1 … date_part('week', make_date(y, 12, 28)) raises.
    • The integer forms guard the year. make_date raises for a bad month and for year 0, but accepts 10000 and negative years. A year outside the type's domain raises through a cast of a message to integer, so a bad argument fails in the database as it does in memory, with PostgreSQL's error (22P02) in place of ArgumentOutOfRangeException.
    • The constructor is [] over the first and last day, not [). A column holding infinity or -infinity (what Npgsql writes for DateOnly.MaxValue and MinValue) is first read as that extreme, as the column loads. A result whose first day is the domain's first, or last day the domain's last, is built with -infinity or infinity in that place, which is the value Npgsql binds for memory's answer. PostgreSQL canonicalizes the finite ] to ). With Npgsql.DisableDateTimeInfinityConversions set, neither mapping applies, as neither does in Npgsql.
    • date_trunc gets an explicit timestamp (date_trunc('month', d::timestamp)::date), so the session time zone plays no part.
    • IsoWeek past the last week raises. The week holding 9999-12-27 to 9999-12-31 ends after DateOnly.MaxValue, where memory throws; the SQL raises there too.

    Next: the translation on main, then a live parity test over every factory, a span of dates and the extremes, against memory.

  7. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    PR #447 on main. The DateRange forms run as SQL. The NodaTime forms stay in memory (scope note above).

    What the translation does, measured against memory on PostgreSQL 16 and 18:

    • Date form: daterange(first, last, '[]'). Inside the domain it is date_trunc(unit, d::timestamp) plus '<n> <unit> -1 day'. The first and last periods are constants from memory's own factory: [-infinity,0001-01-31] and [9999-12-01,infinity] with Npgsql's infinity conversions on, and the days themselves with them off. A column holding an infinity falls into those arms.
    • Integer forms: make_date, with a guard for a year outside 1 to 9999. IsoWeek(y, w) counts from January 4 and checks w against date_part('week', make_date(y, 12, 28)).
    • Every argument memory rejects raises. That covers week 53 of a 52-week year, week 0, the week of 9999-12-27 and of MaxValue, month 13, years 0, -1 and 10000, and quarters 0 and 5. The error is a cast of a readable message, for example invalid input syntax for type integer: "DateRange.Month: the year 10000 is outside 1 to 9999".
    • One more finding: PostgreSQL folds a constant cast while planning, even inside a CASE arm no row reaches. So each message holds an argument, or the raise fires for every query.

    Proof: CalendarFactoryParityTests covers #408's parity list. It is registered as an oracle, its seed is caught, and it fails with the translation removed. Full integration runs pass on PostgreSQL 16 and 18.

  8. added a commit that references this issue on Oct 25, 2026
  9. added a commit that references this issue on Oct 25, 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/rangesRange unions, variants and the shape enginesenhancementNew feature or requestpackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLpackage/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions