Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11. Not
- EF Core: a value set `Union` with a NULL set, or `Remove` with a NULL set or element, is NULL in a query, as `Where(… == null)` already said. A `Select` loaded the other set instead, for a nullable column, a missing outer-join child or a `null` parameter ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-value-set-union-and-remove-over-null-in-queries)).
- EF Core: any `Compare(x, y)` over ranges or sets, `RangeLowerBoundComparer.Compare` and every other `IComparer` included, runs in memory only in a query, as the method called. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([#464](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/464), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)).
- EF Core: a `StringSet` or `StringSet<TElement>` property with a collation that may not compare bytes throws when the model is built, and so does `EF.Functions.Collate` over one in a query. Under a case-insensitive collation, `Contains("a")` matched `{A}` and `Remove("a")` deleted `A`, unlike in memory ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-string-set-columns-with-a-collation)).
- EF Core: on a `DateRange`, a `LocalDateRange` and their multiranges, `LowerBound()`, `UpperBound()`, `LowerBoundInclusive()` and `Clamp` read an exclusive `infinity` or `-infinity` in a query as the row loads. On `[2026-01-01,infinity)`, `UpperBound()` returned 9999-12-31 where memory gives 9999-12-30 ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-date-bounds-at-an-exclusive-infinity-in-queries)).

### Added

Expand Down
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ was subtracted. Ranges another tool wrote this way load one day short
([Rows written by other tools](efcore.md#rows-written-by-other-tools)).
`DomainBoundaryDivergenceTests` asserts that the column and the text agree for every form.

A query's bound readers read the same model (#489). `upper(x) - 1` leaves `infinity` as it is and
`lower(x)` returns `-infinity`, so a definition with `ExclusiveInfinity` names its extremes and the
bounds memory loads for an exclusive one, and `LowerBound()`, `UpperBound()` and `Clamp` test for the
exclusive sentinel in a `CASE`. `LowerBoundInclusive()` on a discrete type is `NOT lower_inf(x) AND
NOT isempty(x)`, as the upper side is, because `lower_inc` is false for an exclusive `-infinity`.
`LocalDateRange` has the upper side only: memory starts `(-infinity` on -9998-01-02, below
PostgreSQL's dates. With `Npgsql.DisableDateTimeInfinityConversions` set no extreme is an infinity,
and the SQL is the plain one. `ExclusiveInfinityBoundParityTests` asserts every reader over every form.

**Calendars.** NodaTime equality includes the calendar system, and of its nineteen calendars only ISO
and Gregorian can represent `9999-12-31` at all. Gregorian shares ISO's arithmetic but is a distinct
`CalendarSystem` instance, so a Gregorian-spelled domain maximum compares unequal to
Expand Down
22 changes: 19 additions & 3 deletions docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,13 @@ Good to know:
([Sort ranges](ranges.md#sort-ranges)).
- **Discrete upper bounds are adjusted for you.** For `int4range`, `int8range` and `daterange`,
PostgreSQL normalizes to the half-open form `[lower, upper)`. This library normalizes to the
closed form `[lower, upper]`. So `UpperBound()` becomes `upper(x) - 1`, and
`UpperBoundInclusive()` becomes `NOT upper_inf(x) AND NOT isempty(x)`. The database always
closed form `[lower, upper]`. So `UpperBound()` becomes `upper(x) - 1`.
`UpperBoundInclusive()` becomes `NOT upper_inf(x) AND NOT isempty(x)`, and
`LowerBoundInclusive()` becomes `NOT lower_inf(x) AND NOT isempty(x)`. The database always
returns the same results as memory
([verified against live PostgreSQL](../README.md#verified-against-postgresql)).
([verified against live PostgreSQL](../README.md#verified-against-postgresql)). On a date range,
an exclusive `infinity` or `-infinity` is read one day inside, as the row loads
([Rows written by other tools](#rows-written-by-other-tools)).
- **Aggregates over zero rows differ.** In SQL the aggregates return `NULL` for zero input rows,
which is standard PostgreSQL aggregate behaviour. The in-memory `RangeAgg()` returns the empty set.
`RangeIntersectAgg()` returns `null` in both.
Expand Down Expand Up @@ -717,6 +720,19 @@ example, `daterange('2026-01-01', 'infinity')` loads as `[2026-01-01,9999-12-30]
`infinity` and `-infinity`, so the range can hold the extreme only when it includes the infinity.
The literal, JSON and a JSON column read the same text the same way.

In a query, the bound methods read those rows as they load. On a `DateRange` or a `RangeSet` of
them, `LowerBound()` gives 0001-01-02 and `UpperBound()` gives 9999-12-30 in the database as in
memory. `LowerBoundInclusive()` gives `true`, and `Clamp` clamps to those days. The same goes for a
bound the database computes: `(,)` minus a range that ends at `DateOnly.MinValue` starts at an
exclusive `-infinity` in the database, and on 0001-01-02 in memory.

- **A `LocalDateRange` that starts at an exclusive `-infinity` keeps the extreme in a query.** It
loads as starting on -9998-01-02, the day after `LocalDate.MinIsoValue`, and PostgreSQL has no
such date. So in a query `LowerBound()` and `Clamp(LocalDate.MinIsoValue)` give
`LocalDate.MinIsoValue`. Its upper side reads 9999-12-30, as a `DateRange` does.
- **With `Npgsql.DisableDateTimeInfinityConversions` set, such a row doesn't load.** Reading it
throws (`InvalidCastException`), and a query that returns its infinite bound throws the same way.

`Shift` and `Expand` keep a bound that stands for an infinity where it is, so a row read from
`tsrange('2026-01-01', 'infinity')`, moved and saved, still ends at `infinity`
([Move or widen a range](ranges.md#move-or-widen-a-range)). A `daterange` with an exclusive
Expand Down
39 changes: 36 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -831,10 +831,13 @@ where, so check these:
- **EF Core: a string set column with a collation that may not compare bytes is rejected.** Building
the model throws. Under a case-insensitive collation, `Contains("a")` matched `{A}`, and `Remove("a")`
in `ExecuteUpdate` deleted `A` ([details](#check-string-set-columns-with-a-collation)).
- **EF Core: a date bound at an exclusive infinity reads in a query as the row loads.** On
`[2026-01-01,infinity)`, `UpperBound()` returned 9999-12-31 in a query and 9999-12-30 in memory
([details](#check-date-bounds-at-an-exclusive-infinity-in-queries)).

The first four also shipped in 10.2.0, the next two in 10.3.0, and the last in 10.4.0. If you upgrade
from 10.2.0 or later, you've already met the first four, from 10.3.0 or later the first six, and from
10.4.0 or later all seven.
The first four also shipped in 10.2.0, the next two in 10.3.0, and the last two in 10.4.0. If you
upgrade from 10.2.0 or later, you've already met the first four, from 10.3.0 or later the first six,
and from 10.4.0 or later all eight.

### Check bound comparisons with `null` in queries

Expand Down Expand Up @@ -1020,6 +1023,36 @@ compare bytes are allowed: `"C"`, `"POSIX"`, `ucs_basic`, `pg_c_utf8` and `defau
answers as memory does, but the model can't tell it from a nondeterministic one by its name.
- **A collation given to the column outside your model isn't seen.** Keep it deterministic.

### Check date bounds at an exclusive infinity in queries

Check EF Core queries that call `LowerBound()`, `UpperBound()`, `LowerBoundInclusive()` or `Clamp` on
a `DateRange`, a `LocalDateRange` or a `RangeSet` of them. Rows that another tool wrote are the ones
to look at, and so are ranges your query computes at the date extremes.

PostgreSQL keeps an infinite bound as written, so `[2026-01-01,infinity)` and `(-infinity,2026-01-01)`
exist. Memory loads them one day inside the extreme, as `[2026-01-01,9999-12-30]` and
`[0001-01-02,2025-12-31]`. Before 10.4.0 a query read the bound the database holds instead:

| Query | Memory | Before 10.4.0 |
|---|---|---|
| `Select(r => r.Period.UpperBound())` on `[2026-01-01,infinity)` | 9999-12-30 | 9999-12-31 |
| `Select(r => r.Period.Clamp(DateOnly.MaxValue))` on `[2026-01-01,infinity)` | 9999-12-30 | 9999-12-31 |
| `Select(r => r.Period.LowerBound())` on `(-infinity,2026-01-01)` | 0001-01-02 | 0001-01-01 |
| `Select(r => r.Period.Clamp(DateOnly.MinValue))` on `(-infinity,2026-01-01)` | 0001-01-02 | 0001-01-01 |
| `Where(r => r.Period.LowerBoundInclusive())` on `(-infinity,2026-01-01)` | matches | no match |

Now the query returns what memory returns. The EF Core package never writes these forms, so the rows
it stored give the same answers as before. A range your query computes can hold one:
`r.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true))` on `(,)` is
`(-infinity,)` in the database, and its `LowerBound()` was 0001-01-01.

- **On a `LocalDateRange`, only the upper side changes.** Memory starts `(-infinity` on -9998-01-02,
and PostgreSQL has no such date. So in a query `LowerBound()` still gives `LocalDate.MinIsoValue`.
- **With `Npgsql.DisableDateTimeInfinityConversions` set, nothing changes.** Such a row doesn't load,
and a query that returns its infinite bound throws as it did.
- **A `YearMonthRange` can't hold these forms.** Loading one throws, as before
([Rows written by other tools](efcore.md#rows-written-by-other-tools)).

### Move `RangeLowerBoundComparer` out of queries

Check EF Core queries that call `Compare` on `RangeLowerBoundComparer`, on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11, EF
- EF Core: a range, multirange or value set predicate over a NULL column is `false`, so its negation keeps the row, as EF Core does for `string` and `string[]`. `!b.Period.Overlaps(p)` dropped the NULL rows, and a projected predicate threw ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)).
- EF Core: `Contains(T)` on a range or multirange is `false` in a query when the element is NULL: a column of a missing child in an outer join, or a nullable column read with `.Value`. Its negation dropped the row, and a projection threw ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)).
- EF Core: `CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd` over a NULL element are NULL in a query, as EF Core's own `AddDays` is. They built `empty` or `(,)`, so `Contains(v)` selected a row without the element ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)).
- EF Core: on a `LocalDateRange` and its multirange, `UpperBound()` and `Clamp` read an exclusive `infinity` as 9999-12-30 in a query, as the row loads, where they returned 9999-12-31, and `LowerBoundInclusive()` is `true` at an exclusive `-infinity`. `LowerBound()` there keeps `LocalDate.MinIsoValue`, because memory's -9998-01-02 isn't a PostgreSQL date ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-date-bounds-at-an-exclusive-infinity-in-queries)).

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ public static class NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions
// resolution — nanoseconds truncated to microseconds and the extremes written as
// infinity, exactly as Npgsql sends them, so a JSON document and a SQL literal carry
// the value a parameter would.
// A bound reader answers an exclusive infinite bound as memory loads it (#489), on the upper side only:
// memory starts (-infinity on -9998-01-02, the day after LocalDate.MinIsoValue, which is below
// PostgreSQL's 4714 BC, so the lower side keeps the extreme.
new RangeTypeDefinition<LocalDateRange, LocalDate>(
"daterange", "datemultirange", "date",
normalizeValue: static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso),
infinityText: NodaStoreTemporal.InfinityText),
infinityText: NodaStoreTemporal.InfinityText,
exclusiveInfinity: (LocalDate.MinIsoValue, null, LocalDate.MaxIsoValue, new LocalDate(9999, 12, 30))),
new RangeTypeDefinition<LocalDateTimeRange, LocalDateTime>(
"tsrange", "tsmultirange", "timestamp without time zone",
normalizeValue: static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso),
Expand Down
1 change: 1 addition & 0 deletions src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11, EF
- EF Core: a value set `Union` with a NULL set, or `Remove` with a NULL set or element, is NULL in a query, as `Where(… == null)` already said. A `Select` loaded the other set instead, for a nullable column, a missing outer-join child or a `null` parameter ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-value-set-union-and-remove-over-null-in-queries)).
- EF Core: any `Compare(x, y)` over ranges or sets, `RangeLowerBoundComparer.Compare` and every other `IComparer` included, runs in memory only in a query, as the method called. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([#464](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/464), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)).
- EF Core: a `StringSet` or `StringSet<TElement>` property with a collation that may not compare bytes throws when the model is built, and so does `EF.Functions.Collate` over one in a query. Under a case-insensitive collation, `Contains("a")` matched `{A}` and `Remove("a")` deleted `A`, unlike in memory ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-string-set-columns-with-a-collation)).
- EF Core: on a `DateRange` and its multirange, `LowerBound()`, `UpperBound()`, `LowerBoundInclusive()` and `Clamp` read an exclusive `infinity` or `-infinity` one day inside in a query, as the row loads. On `[2026-01-01,infinity)`, which another tool or the database's own `Except` writes, `UpperBound()` returned 9999-12-31 where memory gives 9999-12-30, and on `(-infinity,2026-01-01)` `LowerBound()` returned 0001-01-01 and `LowerBoundInclusive()` `false` ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-date-bounds-at-an-exclusive-infinity-in-queries)).

### Added

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal;

/// <summary>
/// The extremes of a discrete type that Npgsql writes as <c>-infinity</c> / <c>infinity</c>, and the bound memory
/// loads for an exclusive bound at each: one step inside, as the closed form puts it (#489).
/// </summary>
/// <remarks>
/// PostgreSQL canonicalizes a finite discrete bound to <c>[x</c> and <c>y)</c> but leaves an infinite one as it is,
/// so <c>[2026-01-01,infinity)</c> and <c>(-infinity,2026-01-01)</c> exist, from another tool or from the server's own
/// algebra (<c>(,) - (,-infinity]</c> is <c>(-infinity,)</c>). Memory loads them as <c>[2026-01-01,9999-12-30]</c> and
/// <c>[0001-01-02,2025-12-31]</c> (<c>docs/architecture.md#domain-width-and-the-boundary</c>), while
/// <c>upper(x) - 1</c> is still <c>infinity</c> and <c>lower(x)</c> is <c>-infinity</c>. The bound readers test for
/// the sentinel and answer <see cref="Lower"/> or <see cref="Upper"/> there. A bound PostgreSQL's <c>date</c> can't
/// hold is <see langword="null"/>, and the reader keeps the extreme on that side.
/// </remarks>
/// <param name="Min">The value Npgsql writes as <c>-infinity</c>.</param>
/// <param name="Lower">What memory loads for an exclusive <c>-infinity</c> lower bound, or <see langword="null"/>.</param>
/// <param name="Max">The value Npgsql writes as <c>infinity</c>.</param>
/// <param name="Upper">What memory loads for an exclusive <c>infinity</c> upper bound, or <see langword="null"/>.</param>
internal sealed record ExclusiveInfinity(object Min, object? Lower, object Max, object? Upper);
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ internal interface IRangeTypeDefinition
/// </summary>
bool IsDiscrete { get; }

/// <summary>
/// For a discrete type whose extremes Npgsql writes as <c>-infinity</c> / <c>infinity</c>, those extremes and
/// the bounds memory loads for an exclusive bound at each, which the bound readers answer there. <see langword="null"/>
/// for every other type, and with <c>Npgsql.DisableDateTimeInfinityConversions</c> set, where no extreme is an infinity.
/// </summary>
ExclusiveInfinity? ExclusiveInfinity => null;

/// <summary>
/// The type mapping for elements of the range, or <see langword="null"/> to resolve it
/// from the type mapping source via <see cref="ElementClrType"/>/<see cref="ElementStoreType"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,27 @@ internal sealed class RangeTypeDefinition<TRange, T> : IRangeTypeDefinition
/// <param name="infinityText">
/// <c>-infinity</c> / <c>infinity</c> for a bound Npgsql maps to it, otherwise <see langword="null"/>.
/// </param>
/// <param name="exclusiveInfinity">
/// For a discrete type, the extremes <paramref name="infinityText"/> writes as infinities and the bound memory
/// loads for an exclusive bound at each (<see cref="IRangeTypeDefinition.ExclusiveInfinity"/>). It applies only while
/// <paramref name="infinityText"/> maps them, so not with <c>Npgsql.DisableDateTimeInfinityConversions</c> set.
/// </param>
internal RangeTypeDefinition(
string rangeStoreType,
string multirangeStoreType,
string elementStoreType,
Func<T, T>? normalizeValue = null,
Func<T, T>? storeValue = null,
Func<T, string?>? infinityText = null)
string rangeStoreType,
string multirangeStoreType,
string elementStoreType,
Func<T, T>? normalizeValue = null,
Func<T, T>? storeValue = null,
Func<T, string?>? infinityText = null,
(T Min, T? Lower, T Max, T? Upper)? exclusiveInfinity = null)
{
RangeStoreType = rangeStoreType;
MultirangeStoreType = multirangeStoreType;
ElementStoreType = elementStoreType;

if (exclusiveInfinity is { } bounds && infinityText?.Invoke(bounds.Min) is not null && infinityText(bounds.Max) is not null)
ExclusiveInfinity = new ExclusiveInfinity(bounds.Min, bounds.Lower, bounds.Max, bounds.Upper);

var toStore = (normalizeValue, storeValue) switch
{
(null, null) => null,
Expand Down Expand Up @@ -79,6 +88,8 @@ internal RangeTypeDefinition(
// A domain with a successor function is discrete; continuous types return null.
public bool IsDiscrete { get; } = TRange.NextValueAfter(default) is not null;

public ExclusiveInfinity? ExclusiveInfinity { get; }

public RelationalTypeMapping RangeTypeMapping { get; }

public RelationalTypeMapping RangeSetTypeMapping { get; }
Expand Down
Loading
Loading