From 530e98f21aaddfcaa5c6b12553562eb4cbe60cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 25 Oct 2026 20:07:16 +0100 Subject: [PATCH] docs: say exactly where the calendar factories' limits come from (#450) - PostgreSQL's first date is 4714-11-24 BC, so the month and the year that hold it have no start, with Npgsql's infinity conversions on or off. That, not -infinity, is why the LocalDateRange and YearMonthRange forms stay in memory. Five places. - The first period starts at -infinity and the last ends at infinity, and both are the days themselves with Npgsql.DisableDateTimeInfinityConversions. E76 follows. - An out-of-range month, quarter or year 0 raises make_date's own error, which shows the date it was asked for (Quarter(2026, 5) reports 2026-13-01); the year and week guards name the argument. Measured on PostgreSQL 16.15. Co-Authored-By: Claude Opus 5.5 --- docs/efcore.md | 13 ++++++++----- .../README.md | 2 +- .../NodaCalendarFactoryClientSideTests.cs | 6 +++--- test/oracles/claims.json | 2 +- test/oracles/translation-support.json | 8 ++++---- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/efcore.md b/docs/efcore.md index 592beb0..65b5709 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -919,7 +919,7 @@ translation, and in the final projection EF runs them in memory instead: | `Values()` and `Values(step)` on a discrete range, `Split` on any range | Not translated. Each returns a sequence, which in SQL would be `generate_series` wrapped in `ARRAY(SELECT …)`. A long range produces one element per step. | | `ToRangeSet()` / `ToInt32Set()` and the other bridge conversions | Not translated. An array becomes a multirange through `range_agg` over one-element ranges, which needs care at the element type's extremes: `[2147483647,2147483647]` can't be built as an `int4range`. A multirange becomes an array only by enumerating it, as `Values` does. | | `Shift`, `Expand` on any range | Not translated. Rebuilding the range from `lower(x) + d` differs from memory unless each case is handled: the empty range becomes `(,)`, `time` wraps at midnight where `TimeRange` throws, and a bound moved past the last date .NET holds throws in memory where PostgreSQL returns a later date. A bound at `infinity` stays there in both. | -| `LocalDateRange.Month`, `Quarter`, `Year` and `IsoWeek`, and `YearMonthRange.Quarter` and `Year`, with an argument read from a column | Not translated. A `LocalDate` column holding `-infinity` stands for -9998-01-01, and neither its month nor any year before 4713 BC has a PostgreSQL date, so the database would fail where memory returns a range. Built from values your code passes in, the range is computed in memory and sent as one constant, so comparing a column against a month runs as SQL. On `DateRange` these run as SQL. | +| `LocalDateRange.Month`, `Quarter`, `Year` and `IsoWeek`, and `YearMonthRange.Quarter` and `Year`, with an argument read from a column | Not translated. PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start, and the database would fail where memory returns a range. That holds with Npgsql's infinity conversions on or off. Built from values your code passes in, the range is computed in memory and sent as one constant, so comparing a column against a month runs as SQL. On `DateRange` these run as SQL. | | `Clamp(value)` on the other range types | Not translated. The same `LEAST(GREATEST(…))` form would apply, but it has only been checked against memory on `Int32Range`, `Int64Range` and `DecimalRange`, where it runs as SQL. | | `Intersect`, `Except` on value sets | Not translated. Filtering the left array with `unnest … WITH ORDINALITY` keeps its order, but the result is canonical only when the stored arrays are, as with `Union`. | | `Add` on value sets | Inserting in canonical order means sorting in SQL, where `text` follows the database collation rather than ordinal order. | @@ -940,12 +940,15 @@ Good to know: passes, even an exclusive one. The empty range gives `null`, and so does a `null` range or value. - **`DateRange.Month`, `Quarter`, `Year` and `IsoWeek` over a column build the same range in the database as in memory.** `DateRange.Month(b.Day)` gives the month that holds each row's day, so - you can filter, join and sort by it. The first and last periods end at `-infinity` and `infinity`, - as Npgsql writes `DateOnly.MinValue` and `MaxValue`, so they still equal a stored range. + you can filter, join and sort by it. The first period starts at `-infinity` and the last ends at + `infinity`, as Npgsql writes `DateOnly.MinValue` and `MaxValue` (the days themselves with + `Npgsql.DisableDateTimeInfinityConversions`), so they still equal a stored range. - **An argument out of range fails in the database too.** A month outside 1 to 12, a year outside 1 to 9999, a week its week-year doesn't have, and the ISO week that runs past 9999-12-31 throw - `ArgumentOutOfRangeException` in memory. In a query, PostgreSQL raises an error that names the - argument (`PostgresException`). + `ArgumentOutOfRangeException` in memory. In a query, PostgreSQL raises an error + (`PostgresException`). For a week, or a year other than 0, the message names the argument. For a + month, a quarter or the year 0, it shows the date PostgreSQL was asked to build: `Quarter(2026, 5)` + reports `2026-13-01`. - **An in-memory operation inside a `Where` fails loudly.** EF throws rather than silently fetching the whole table, and that is the intended behaviour. - **In the final `Select` it runs in memory.** EF Core evaluates the outermost projection on the diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md index 0f110d3..d9b6241 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md @@ -177,7 +177,7 @@ table. In the final `Select`, EF Core computes them in memory after it loads the | `Length` on the range types other than `Int32Range` and `Int64Range` | not translated: a temporal range open to `infinity` fails in PostgreSQL | | `Values()` and `Values(step)` on a discrete range, `Split` on any range | not translated: each would be `generate_series` collected into an array | | `Shift`, `Expand` on any range | not translated: rebuilding the range from `lower(x) + d` answers differently for the empty range, `time` past midnight and a bound moved past the last date .NET holds | -| `LocalDateRange.Month`, `Quarter`, `Year` and `IsoWeek`, and `YearMonthRange.Quarter` and `Year`, with an argument read from a column | not translated: a `LocalDate` at `-infinity` and the years before 4713 BC have no PostgreSQL date. Built from values your code passes in, the range is sent as one constant, so comparing a column against a month runs as SQL. On `DateRange` they run as SQL | +| `LocalDateRange.Month`, `Quarter`, `Year` and `IsoWeek`, and `YearMonthRange.Quarter` and `Year`, with an argument read from a column | not translated: PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start, with Npgsql's infinity conversions on or off. Built from values your code passes in, the range is sent as one constant, so comparing a column against a month runs as SQL. On `DateRange` they run as SQL | | `ToRangeSet()` / `ToInt32Set()` and the other bridge conversions | not translated: an array becomes a multirange by aggregating one-element ranges, and the reverse is enumeration | | `Clamp(value)` on the range types other than `Int32Range`, `Int64Range` and `DecimalRange` | not translated: the same `LEAST`/`GREATEST` form has only been checked against memory on those three | | the value set indexer, `set[0]` | a row another tool wrote may not be canonical, arrays start at 1, and an index past the end is `NULL` where memory throws | diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaCalendarFactoryClientSideTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaCalendarFactoryClientSideTests.cs index 7c63abd..f2fa6d5 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaCalendarFactoryClientSideTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaCalendarFactoryClientSideTests.cs @@ -7,9 +7,9 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests; /// /// The calendar factories of and over a column stay in -/// memory, where DateRange's run as SQL (#191): a column holding -infinity stands for -/// , -9998-01-01, and neither its month nor any year before 4713 BC has a -/// PostgreSQL date, so the server would raise where memory answers. Inside a Where they fail +/// memory, where DateRange's run as SQL (#191): PostgreSQL has no dates before 4714-11-24 BC, so the +/// month and the year that hold its first day have no start, and the server would raise where memory answers. +/// That holds with Npgsql's infinity conversions on or off (#450). Inside a Where they fail /// translation; built from values the query does not read, they are one constant and run as SQL. /// [TestClass] diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 9c12361..50c0b5d 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1321,7 +1321,7 @@ { "id": "E76-calendar-factories-over-a-column-run-as-sql", "source": "docs/efcore.md#what-runs-where", - "quote": "**`DateRange.Month`, `Quarter`, `Year` and `IsoWeek` over a column build the same range in the database as in memory.** `DateRange.Month(b.Day)` gives the month that holds each row's day, so you can filter, join and sort by it. The first and last periods end at `-infinity` and `infinity`, as Npgsql writes `DateOnly.MinValue` and `MaxValue`, so they still equal a stored range.", + "quote": "**`DateRange.Month`, `Quarter`, `Year` and `IsoWeek` over a column build the same range in the database as in memory.** `DateRange.Month(b.Day)` gives the month that holds each row's day, so you can filter, join and sort by it. The first period starts at `-infinity` and the last ends at `infinity`, as Npgsql writes `DateOnly.MinValue` and `MaxValue` (the days themselves with `Npgsql.DisableDateTimeInfinityConversions`), so they still equal a stored range.", "claim": "DateRange.Month, Quarter, Year and IsoWeek over a date column or integer columns run as SQL (daterange(first, last, '[]') with memory's first and last periods as constants, -infinity/infinity under Npgsql's conversions and the days themselves without them) and match memory over every period boundary, the ISO weeks of the neighbouring year and the extremes, NULL arguments (rule (c)) and a missing outer-join child, in a filter, its negation, a null test, equality with a stored range, a compound predicate, an ordering and a final projection; an argument memory rejects raises a PostgreSQL error, never a rolled-over week.", "oracle": "differential", "surfaces": [ diff --git a/test/oracles/translation-support.json b/test/oracles/translation-support.json index 958f047..8b968e2 100644 --- a/test/oracles/translation-support.json +++ b/test/oracles/translation-support.json @@ -72,7 +72,7 @@ { "members": "*Range.Month(*", "support": "client", - "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange a LocalDate at -infinity (-9998-01-01) and the years before 4713 BC have no PostgreSQL date, so the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", + "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start (with Npgsql's infinity conversions on or off), and the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", "except": { "DateRange": "server" } @@ -80,7 +80,7 @@ { "members": "*Range.Quarter(*", "support": "client", - "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange a LocalDate at -infinity (-9998-01-01) and the years before 4713 BC have no PostgreSQL date, so the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", + "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start (with Npgsql's infinity conversions on or off), and the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", "except": { "DateRange": "server" } @@ -88,7 +88,7 @@ { "members": "*Range.Year(*", "support": "client", - "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange a LocalDate at -infinity (-9998-01-01) and the years before 4713 BC have no PostgreSQL date, so the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", + "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start (with Npgsql's infinity conversions on or off), and the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", "except": { "DateRange": "server" } @@ -96,7 +96,7 @@ { "members": "*Range.IsoWeek(*", "support": "client", - "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange a LocalDate at -infinity (-9998-01-01) and the years before 4713 BC have no PostgreSQL date, so the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", + "reason": "docs/efcore.md#what-runs-where: with an argument read from a column, daterange(first, last, '[]') on DateRange, with memory's first and last periods as constants and an error where memory throws; on LocalDateRange and YearMonthRange PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start (with Npgsql's infinity conversions on or off), and the database would fail where memory returns a range. Over values the code passes in, the range is computed in memory and sent as a constant", "except": { "DateRange": "server" }