diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fb8635..d4ce51b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/docs/architecture.md b/docs/architecture.md index 7921826b..a43574c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/efcore.md b/docs/efcore.md index b73a6b83..d297aa63 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -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. @@ -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 diff --git a/docs/migration.md b/docs/migration.md index 93cfb070..973dbba9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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 @@ -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 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 1a55224f..76bab848 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -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 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs index d1dd7b30..0d95d03f 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs @@ -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( "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( "tsrange", "tsmultirange", "timestamp without time zone", normalizeValue: static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso), diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index 7ee83fbd..5ecba419 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -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` 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 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/ExclusiveInfinity.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/ExclusiveInfinity.cs new file mode 100644 index 00000000..48590971 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/ExclusiveInfinity.cs @@ -0,0 +1,20 @@ +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; + +/// +/// The extremes of a discrete type that Npgsql writes as -infinity / infinity, and the bound memory +/// loads for an exclusive bound at each: one step inside, as the closed form puts it (#489). +/// +/// +/// PostgreSQL canonicalizes a finite discrete bound to [x and y) but leaves an infinite one as it is, +/// so [2026-01-01,infinity) and (-infinity,2026-01-01) exist, from another tool or from the server's own +/// algebra ((,) - (,-infinity] is (-infinity,)). Memory loads them as [2026-01-01,9999-12-30] and +/// [0001-01-02,2025-12-31] (docs/architecture.md#domain-width-and-the-boundary), while +/// upper(x) - 1 is still infinity and lower(x) is -infinity. The bound readers test for +/// the sentinel and answer or there. A bound PostgreSQL's date can't +/// hold is , and the reader keeps the extreme on that side. +/// +/// The value Npgsql writes as -infinity. +/// What memory loads for an exclusive -infinity lower bound, or . +/// The value Npgsql writes as infinity. +/// What memory loads for an exclusive infinity upper bound, or . +internal sealed record ExclusiveInfinity(object Min, object? Lower, object Max, object? Upper); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs index ce5d48fd..0daf2a7d 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs @@ -46,6 +46,13 @@ internal interface IRangeTypeDefinition /// bool IsDiscrete { get; } + /// + /// For a discrete type whose extremes Npgsql writes as -infinity / infinity, those extremes and + /// the bounds memory loads for an exclusive bound at each, which the bound readers answer there. + /// for every other type, and with Npgsql.DisableDateTimeInfinityConversions set, where no extreme is an infinity. + /// + ExclusiveInfinity? ExclusiveInfinity => null; + /// /// The type mapping for elements of the range, or to resolve it /// from the type mapping source via /. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs index a33b2ff5..a8924f6e 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs @@ -31,18 +31,27 @@ internal sealed class RangeTypeDefinition : IRangeTypeDefinition /// /// -infinity / infinity for a bound Npgsql maps to it, otherwise . /// + /// + /// For a discrete type, the extremes writes as infinities and the bound memory + /// loads for an exclusive bound at each (). It applies only while + /// maps them, so not with Npgsql.DisableDateTimeInfinityConversions set. + /// internal RangeTypeDefinition( - string rangeStoreType, - string multirangeStoreType, - string elementStoreType, - Func? normalizeValue = null, - Func? storeValue = null, - Func? infinityText = null) + string rangeStoreType, + string multirangeStoreType, + string elementStoreType, + Func? normalizeValue = null, + Func? storeValue = null, + Func? 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, @@ -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; } diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs index e787ef8a..0d79fad9 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs @@ -51,8 +51,10 @@ FrozenSet AggregateDeclari // The temporal definitions end in StoreTemporal: bounds truncated to the store's // microsecond 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, one day inside (#489). new RangeTypeDefinition( - "daterange", "datemultirange", "date", infinityText: StoreTemporal.InfinityText), + "daterange", "datemultirange", "date", infinityText: StoreTemporal.InfinityText, + exclusiveInfinity: (DateOnly.MinValue, new DateOnly(1, 1, 2), DateOnly.MaxValue, new DateOnly(9999, 12, 30))), // PostgreSQL `timestamp` has no time zone: Npgsql rejects UTC-kinded values, so // bounds are written as wall-clock time with DateTimeKind.Unspecified. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs index ccf27476..ad8096ae 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs @@ -326,13 +326,13 @@ SqlExpression Range(int index) sqlExpressionFactory.Not(BoolFunction("isempty", Range(0))))); if (generic == LowerBoundMethod) - return BoundFunction("lower", Range(0), definition); + return LowerBoundFunction(Range(0), definition); if (generic == UpperBoundMethod) return UpperBoundFunction(Range(0), definition); if (generic == LowerBoundInclusiveMethod) - return BoolFunction("lower_inc", Range(0)); + return LowerInclusiveFunction(Range(0), definition); if (generic == UpperBoundInclusiveMethod) return UpperInclusiveFunction(Range(0), definition); @@ -505,13 +505,13 @@ SqlExpression Operand(int index) return SetBinary(PgExpressionType.RangeExcept, AsSet(0), Set(instance)); case nameof(RangeSet<,>.LowerBound) when instance is not null && arguments.Count == 0: - return BoundFunction("lower", Set(instance), definition); + return LowerBoundFunction(Set(instance), definition); case nameof(RangeSet<,>.UpperBound) when instance is not null && arguments.Count == 0: return UpperBoundFunction(Set(instance), definition); case nameof(RangeSet<,>.LowerBoundInclusive) when instance is not null && arguments.Count == 0: - return BoolFunction("lower_inc", Set(instance)); + return LowerInclusiveFunction(Set(instance), definition); case nameof(RangeSet<,>.UpperBoundInclusive) when instance is not null && arguments.Count == 0: return UpperInclusiveFunction(Set(instance), definition); @@ -1031,12 +1031,26 @@ private SqlExpression BoundFunction(string name, SqlExpression argument, IRangeT typeof(Nullable<>).MakeGenericType(definition.ElementClrType), ElementMapping(definition)); + /// + /// lower(), with an exclusive -infinity read as memory loads it on a type that has an + /// (). + /// + private SqlExpression LowerBoundFunction(SqlExpression argument, IRangeTypeDefinition definition) + { + var lower = BoundFunction("lower", argument, definition); + return definition.ExclusiveInfinity is { Lower: { } inside } bounds + ? InsideExclusiveInfinity(lower, lower, "lower_inc", argument, bounds.Min, inside, definition) + : lower; + } + /// /// upper() with discrete-canonicalization compensation. PostgreSQL stores /// discrete ranges half-open ([lower, upper)) while the model is closed /// ([lower, upper]), so the server's exclusive upper is one step past the /// model's inclusive upper: upper(x) - 1 restores agreement. NULL propagates - /// through the subtraction for empty or unbounded operands. + /// through the subtraction for empty or unbounded operands. An exclusive infinity, + /// which - 1 leaves as it is, is read as memory loads it on a type that has an + /// (). /// private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefinition definition) { @@ -1045,8 +1059,47 @@ private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefin // The step constant must keep its own integer mapping: `date - 1` is date arithmetic // in PostgreSQL, and applying the element mapping to the 1 would render it as a date. - var one = sqlExpressionFactory.Constant(1, typeMappingSource.FindMapping(typeof(int))); - return sqlExpressionFactory.Subtract(upper, one, ElementMapping(definition)); + var one = sqlExpressionFactory.Constant(1, typeMappingSource.FindMapping(typeof(int))); + var closed = sqlExpressionFactory.Subtract(upper, one, ElementMapping(definition)); + return definition.ExclusiveInfinity is { Upper: { } inside } bounds + ? InsideExclusiveInfinity(upper, closed, "upper_inc", argument, bounds.Max, inside, definition) + : closed; + } + + /// + /// CASE WHEN bound = extreme AND NOT inclusive(x) THEN inside ELSE otherwise END: a bound at the + /// infinity an extreme is written as, if it is exclusive, read as the value memory loads for it (#489). + /// + /// + /// PostgreSQL leaves an infinite discrete bound uncanonicalized, so [2026-01-01,infinity) and + /// (-infinity,2026-01-01) exist: another tool writes them, and the server's algebra computes them, + /// as (,) - (,-infinity] is (-infinity,) and (,) - [infinity,) is (,infinity). + /// Memory loads them one day inside, [2026-01-01,9999-12-30] and [0001-01-02,2025-12-31], but + /// upper(x) - 1 is still infinity and lower(x) is -infinity, which read as the + /// extremes. An inclusive infinity] or [-infinity, a finite bound, and the NULL of an empty or + /// unbounded side take the ELSE unchanged. Measured on PostgreSQL 16 and 18. + /// + private SqlExpression InsideExclusiveInfinity( + SqlExpression bound, + SqlExpression otherwise, + string inclusive, + SqlExpression argument, + object extreme, + object inside, + IRangeTypeDefinition definition) + { + var type = typeof(Nullable<>).MakeGenericType(definition.ElementClrType); + var mapping = ElementMapping(definition); + + return sqlExpressionFactory.Case( + [ + new CaseWhenClause( + sqlExpressionFactory.AndAlso( + sqlExpressionFactory.Equal(bound, sqlExpressionFactory.Constant(extreme, type, mapping)), + sqlExpressionFactory.Not(BoolFunction(inclusive, argument))), + sqlExpressionFactory.Constant(inside, type, mapping)) + ], + otherwise); } /// @@ -1070,7 +1123,9 @@ private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefin /// with the extremes as bounds and values (ClampDomainParityTests). A temporal extreme is an /// infinity on the server, which memory reads back as the extreme: infinity - 1 is still /// infinity, and a range ending at the last date is stored infinity], inclusive, so that is the - /// date memory clamps to. A YearMonthRange steps back one day from its + /// date memory clamps to. An exclusive infinity, which only another tool or the server's algebra writes, is + /// clamped to the day inside it that memory loads, as the two bound readers read it (#489). A + /// YearMonthRange steps back one day from its /// first-of-month upper bound to the last day of its last month, which reads as that month; its value is /// the first day of its month, so it compares as memory compares months. /// @@ -1097,7 +1152,7 @@ SqlExpression Skipping(string name, SqlExpression left, SqlExpression right) BoolFunction("isempty", range)), sqlExpressionFactory.Constant(null, type, mapping)) ], - Skipping("LEAST", Skipping("GREATEST", value, BoundFunction("lower", range, definition)), UpperBoundFunction(range, definition))); + Skipping("LEAST", Skipping("GREATEST", value, LowerBoundFunction(range, definition)), UpperBoundFunction(range, definition))); } /// @@ -1123,6 +1178,19 @@ private static bool CarriesTimeZone(string storeType) || storeType.StartsWith("timestamptz", StringComparison.OrdinalIgnoreCase) || storeType.StartsWith("timetz", StringComparison.OrdinalIgnoreCase); + /// + /// The lower_inc() equivalent, as is upper_inc()'s. On discrete + /// types the model's lower bound is inclusive exactly when a finite one exists, which lower_inc says too, + /// except for an exclusive -infinity: canonicalization leaves it exclusive, and memory loads it as the + /// inclusive day after the extreme (#489). + /// + private SqlExpression LowerInclusiveFunction(SqlExpression argument, IRangeTypeDefinition definition) + => definition.IsDiscrete + ? sqlExpressionFactory.AndAlso( + sqlExpressionFactory.Not(BoolFunction("lower_inf", argument)), + sqlExpressionFactory.Not(BoolFunction("isempty", argument))) + : BoolFunction("lower_inc", argument); + /// /// The upper_inc() equivalent. On discrete types the model's canonical form is /// closed, so the upper bound is inclusive exactly when a finite upper bound exists — diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs index 42af58c1..39b67aec 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs @@ -309,6 +309,36 @@ public async Task AtTheDateExtremes_AdjacencyAgrees_BecauseTheExtremesStayDates( Assert.IsTrue(reader.GetBoolean(1), "Npgsql sends 9999-12-31 as a date, so the server finds 9999-12-30 next to it"); } + /// + /// With the switch set no extreme is an infinity, so a date range another tool wrote open to an exclusive + /// infinity) can't be loaded. The bound readers then keep PostgreSQL's reading, upper(x) - 1, + /// rather than the 9999-12-30 they read where the extremes are infinities (#489): projecting the bound fails + /// as loading the row does, instead of answering for a row memory has no value for. + /// + [TestMethod] + public async Task AnExclusiveInfinity_AnotherToolWrote_FailsToReadInAQuery_AsTheRowFailsToLoad() + { + ContainerLifecycle.RequireDatabase(); + + await using (var write = new InfinityDisabledDbContext()) + { + write.Rows.Add(new ExtremesRow { Id = 41 }); + await write.SaveChangesAsync(); +#pragma warning disable EF1002 + await write.Database.ExecuteSqlRawAsync("UPDATE \"Rows\" SET \"Native_Day\" = daterange('2026-01-01', 'infinity') WHERE \"Id\" = 41"); +#pragma warning restore EF1002 + } + + await using var read = new InfinityDisabledDbContext(); + var upper = read.Rows.Where(r => r.Id == 41).Select(r => r.Native.Day.UpperBound()); + Assert.AreEqual("SELECT upper(r.\"Native_Day\") - 1\nFROM \"Rows\" AS r\nWHERE r.\"Id\" = 41", upper.ToQueryString()); + + var load = await Assert.ThrowsExactlyAsync(() => read.Rows.AsNoTracking().SingleAsync(r => r.Id == 41)); + StringAssert.Contains(load.Message, "Cannot read infinity value"); + var query = await Assert.ThrowsExactlyAsync(() => upper.SingleAsync()); + StringAssert.Contains(query.Message, "Cannot read infinity value"); + } + private static async Task Insert(int id) { await using (var write = new InfinityDisabledDbContext()) diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExclusiveInfinityBoundParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExclusiveInfinityBoundParityTests.cs new file mode 100644 index 00000000..b8ef4580 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExclusiveInfinityBoundParityTests.cs @@ -0,0 +1,202 @@ +using System.Linq.Expressions; +using CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.Composition; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// A date range with an exclusive infinite bound, [x,infinity) or (-infinity,x), is read in a query as +/// memory reads the loaded row (#489): LowerBound(), UpperBound(), both inclusiveness accessors and +/// Clamp at the extremes, on , and their multiranges. +/// +/// +/// +/// The plugin never writes such a bound, but another tool can, and the server's own algebra computes one: +/// (,) - (,-infinity] is (-infinity,). Memory loads it one day inside the extreme +/// (docs/architecture.md#domain-width-and-the-boundary). Before #489 a query read upper(x) - 1, which is +/// still infinity, and lower(x), which is -infinity: 9999-12-31 where memory answered 9999-12-30, +/// and 0001-01-01 where memory answered 0001-01-02. lower_inc was false where memory's closed form +/// starts inclusive. +/// +/// +/// Each reader runs as a predicate, reader(r) == memory, which can't fall back to the client as a final +/// projection could. Its projection is shown when the predicate misses. +/// +/// +[TestClass] +public sealed class ExclusiveInfinityBoundParityTests +{ + /// + /// The infinite forms a daterange holds. The first six have an exclusive infinity; the last two, and the + /// unbounded sides, agreed before #489 and must still. + /// + private static readonly string[] Forms = + [ + "daterange('2026-01-01', 'infinity')", + "daterange('-infinity', '2026-01-01', '()')", + "daterange('-infinity', 'infinity', '()')", + "daterange('-infinity', 'infinity')", + "daterange(NULL, 'infinity')", + "daterange('-infinity', NULL, '()')", + "daterange('2026-01-01', 'infinity', '[]')", + "daterange('-infinity', '2026-01-01', '[]')" + ]; + + /// The day after , which memory starts (-infinity on. + private static readonly LocalDate AfterMinIso = LocalDate.MinIsoValue.PlusDays(1); + + [TestMethod] + public Task DateRange_ReadsAnExclusiveInfinityAsMemoryLoadsIt() => Run(4900, "Period", form => form, + [ + ("LowerBound()", (Expression>)(r => r.Period.LowerBound())), + ("UpperBound()", (Expression>)(r => r.Period.UpperBound())), + ("LowerBoundInclusive()", (Expression>)(r => r.Period.LowerBoundInclusive())), + ("UpperBoundInclusive()", (Expression>)(r => r.Period.UpperBoundInclusive())), + ("Clamp(MinValue)", (Expression>)(r => r.Period.Clamp(DateOnly.MinValue))), + ("Clamp(MaxValue)", (Expression>)(r => r.Period.Clamp(DateOnly.MaxValue))), + ("Clamp(2026-01-01)", (Expression>)(r => r.Period.Clamp(new DateOnly(2026, 1, 1)))) + ]); + + [TestMethod] + public Task DateRangeSet_ReadsAnExclusiveInfinityAsMemoryLoadsIt() => Run(4910, "BlockedDays", form => $"datemultirange({form})", + [ + ("LowerBound()", (Expression>)(r => r.BlockedDays.LowerBound())), + ("UpperBound()", (Expression>)(r => r.BlockedDays.UpperBound())), + ("LowerBoundInclusive()", (Expression>)(r => r.BlockedDays.LowerBoundInclusive())), + ("UpperBoundInclusive()", (Expression>)(r => r.BlockedDays.UpperBoundInclusive())) + ]); + + /// + /// The upper side as 's. On the lower side memory starts (-infinity on -9998-01-02, + /// which PostgreSQL's dates can't hold, so there a query keeps , as documented + /// in docs/efcore.md#rows-written-by-other-tools. + /// + [TestMethod] + public Task LocalDateRange_ReadsAnExclusiveInfinityAsMemoryLoadsIt_ExceptBelowPostgresDates() => Run(4920, "NodaPeriod", form => form, + [ + ("LowerBound()", (Expression>)(r => r.NodaPeriod.LowerBound())), + ("UpperBound()", (Expression>)(r => r.NodaPeriod.UpperBound())), + ("LowerBoundInclusive()", (Expression>)(r => r.NodaPeriod.LowerBoundInclusive())), + ("UpperBoundInclusive()", (Expression>)(r => r.NodaPeriod.UpperBoundInclusive())), + ("Clamp(MinIsoValue)", (Expression>)(r => r.NodaPeriod.Clamp(LocalDate.MinIsoValue))), + ("Clamp(MaxIsoValue)", (Expression>)(r => r.NodaPeriod.Clamp(LocalDate.MaxIsoValue))), + ("Clamp(2026-01-01)", (Expression>)(r => r.NodaPeriod.Clamp(new LocalDate(2026, 1, 1)))) + ], KeepsTheIsoMinimum); + + [TestMethod] + public Task LocalDateRangeSet_ReadsAnExclusiveInfinityAsMemoryLoadsIt_ExceptBelowPostgresDates() => Run(4930, "NodaBlockedDays", form => $"datemultirange({form})", + [ + ("LowerBound()", (Expression>)(r => r.NodaBlockedDays.LowerBound())), + ("UpperBound()", (Expression>)(r => r.NodaBlockedDays.UpperBound())), + ("LowerBoundInclusive()", (Expression>)(r => r.NodaBlockedDays.LowerBoundInclusive())), + ("UpperBoundInclusive()", (Expression>)(r => r.NodaBlockedDays.UpperBoundInclusive())) + ], KeepsTheIsoMinimum); + + /// + /// No other tool needed: subtracting a range bounded at an extreme leaves the server an exclusive infinity, where + /// memory's result stops one day inside. (,) - (,-infinity] is (-infinity,), and + /// (,) - [infinity,) is (,infinity). + /// + [TestMethod] + public Task DateRange_AnExclusiveInfinityTheServerComputes_IsReadAsMemoryComputesIt() => Run(4940, "Period", form => form, + [ + ("Except((,MinValue]).LowerBound()", + (Expression>)(r => r.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true)).LowerBound())), + ("Except((,MinValue]).LowerBoundInclusive()", + (Expression>)(r => r.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true)).LowerBoundInclusive())), + ("Except([MaxValue,)).UpperBound()", + (Expression>)(r => r.Period.Except(DateRange.CreateUnboundedEnd(DateOnly.MaxValue, true)).UpperBound())), + ("Except([MaxValue,)).UpperBoundInclusive()", + (Expression>)(r => r.Period.Except(DateRange.CreateUnboundedEnd(DateOnly.MaxValue, true)).UpperBoundInclusive())) + ], forms: ["'(,)'::daterange"]); + + /// + /// A month range has no day inside an exclusive infinity to answer: memory refuses the row, which covers a partial + /// month, so keeps lower(x) and upper(x) - 1. + /// + [TestMethod] + [DataRow(4950, "daterange('2026-01-01', 'infinity')")] + [DataRow(4951, "daterange('-infinity', '2026-01-01', '()')")] + public async Task YearMonthRange_AnExclusiveInfinity_DoesNotLoad(int id, string form) + { + ContainerLifecycle.RequireDatabase(); + await Seed(id, "BillingPeriod", form); + + await using var context = new IntegrationDbContext(); + var refused = await Assert.ThrowsExactlyAsync(() => context.Reservations.AsNoTracking().SingleAsync(r => r.Id == id)); + StringAssert.Contains(refused.Message, "covers a partial month"); + } + + private static object? KeepsTheIsoMinimum(object? memory) => memory is LocalDate day && day == AfterMinIso ? LocalDate.MinIsoValue : memory; + + /// + /// Writes each form into of a row of its own, loads the row, and runs every reader on + /// the server as reader(r) == answer, where the answer is memory's over the loaded row, or what + /// makes of it. + /// + private static async Task Run( + int firstId, + string column, + Func wrap, + IReadOnlyList<(string Name, LambdaExpression Reader)> readers, + Func? expected = null, + IReadOnlyList? forms = null) + { + ContainerLifecycle.RequireDatabase(); + + var failures = new List(); + var compared = 0; + foreach (var (form, index) in (forms ?? Forms).Select((form, index) => (form, index))) + { + var id = firstId + index; + await Seed(id, column, wrap(form)); + + await using var context = new IntegrationDbContext(); + var loaded = await context.Reservations.AsNoTracking().SingleAsync(r => r.Id == id); + + foreach (var (name, reader) in readers) + { + var memory = reader.Compile().DynamicInvoke(loaded); + var answer = expected is null ? memory : expected(memory); + var row = Expression.Parameter(typeof(Reservation), "r"); + var body = new Rebind(reader.Parameters[0], row).Visit(reader.Body); + + var matches = Expression.Lambda>(Expression.Equal(body, Expression.Constant(answer, reader.ReturnType)), row); + var hits = await context.Reservations.Where(r => r.Id == id).Where(matches).CountAsync(); + compared++; + if (hits == 1) continue; + + var projected = await context.Reservations.Where(r => r.Id == id) + .Select(Expression.Lambda>(Expression.Convert(body, typeof(object)), row)) + .SingleAsync(); + failures.Add($"{form}, loaded as {CompositionRunner.Show(ColumnValue(loaded, column))}: {name} is " + + $"{CompositionRunner.Show(projected)} in the query, {CompositionRunner.Show(answer)} expected " + + $"(memory: {CompositionRunner.Show(memory)})"); + } + } + + Assert.AreEqual((forms ?? Forms).Count * readers.Count, compared); + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// A row of its own, whose another tool set to . + private static async Task Seed(int id, string column, string value) + { + await using var seed = new IntegrationDbContext(); + seed.Reservations.Add(new Reservation { Id = id }); + await seed.SaveChangesAsync(); + + // The column and the value are this test's own constants, not input. +#pragma warning disable EF1002 + await seed.Database.ExecuteSqlRawAsync($"UPDATE \"Reservations\" SET \"{column}\" = {value} WHERE \"Id\" = {id}"); +#pragma warning restore EF1002 + } + + private static object? ColumnValue(Reservation row, string column) => typeof(Reservation).GetProperty(column)!.GetValue(row); + + private sealed class Rebind(ParameterExpression from, Expression to) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : node; + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs index cade758e..77f47c3c 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs @@ -112,6 +112,34 @@ public void UpperBound_Discrete_CompensatesCanonicalForm() StringAssert.Contains(sql, "upper(r.\"Period\") - 1"); } + /// + /// An exclusive infinity upper bound is read as 9999-12-30, as memory loads it (#489). The lower side keeps + /// lower(x): memory starts (-infinity on -9998-01-02, the day after , + /// which PostgreSQL's dates can't hold. + /// + [TestMethod] + public void LocalDateRangeBounds_ReadAnExclusiveInfinityAsMemoryLoadsIt_OnTheUpperSide() + { + var sql = Sql(db => db.Reservations.Select(r => new { Lower = r.Period.LowerBound(), Upper = r.Period.UpperBound() })); + + StringAssert.Contains(sql, + """ + SELECT lower(r."Period") AS "Lower", CASE + WHEN upper(r."Period") = DATE 'infinity' AND NOT (upper_inc(r."Period")) THEN DATE '9999-12-30' + ELSE upper(r."Period") - 1 + END AS "Upper" + """); + } + + /// A month-aligned range can't load an exclusive infinity, so it has no day inside one to read. + [TestMethod] + public void YearMonthRangeBounds_HaveNoExclusiveInfinityToRead() + { + var sql = Sql(db => db.Reservations.Select(r => new { Lower = r.BillingPeriod.LowerBound(), Upper = r.BillingPeriod.UpperBound() })); + + StringAssert.Contains(sql, "SELECT lower(r.\"BillingPeriod\") AS \"Lower\", upper(r.\"BillingPeriod\") - 1 AS \"Upper\""); + } + [TestMethod] public void UpperBound_Continuous_IsBareUpper() { diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClampTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClampTranslationTests.cs index 4652e496..892ed7ba 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClampTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClampTranslationTests.cs @@ -59,6 +59,10 @@ ELSE LEAST(GREATEST(b."Amount", lower(b."Price")), upper(b."Price")) """)); } + /// + /// The bounds are the ones LowerBound() and UpperBound() read, so an exclusive infinity is clamped + /// to the day inside it that memory loads (#489). + /// [TestMethod] public void DateRangeClamp_OfAColumn_StepsBackADayFromTheExclusiveUpperBound() { @@ -70,7 +74,13 @@ public void DateRangeClamp_OfAColumn_StepsBackADayFromTheExclusiveUpperBound() """ SELECT CASE WHEN isempty(b."Period") THEN NULL - ELSE LEAST(GREATEST(b."Day", lower(b."Period")), upper(b."Period") - 1) + ELSE LEAST(GREATEST(b."Day", CASE + WHEN lower(b."Period") = DATE '-infinity' AND NOT (lower_inc(b."Period")) THEN DATE '0001-01-02' + ELSE lower(b."Period") + END), CASE + WHEN upper(b."Period") = DATE 'infinity' AND NOT (upper_inc(b."Period")) THEN DATE '9999-12-30' + ELSE upper(b."Period") - 1 + END) END """)); } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs index 13231407..7ff8f8ff 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/QueryTranslationTests.cs @@ -258,8 +258,40 @@ public void LowerBound_TranslatesToLower() [TestMethod] public void LowerBound_InOrderBy() { - var sql = Sql(db => db.Bookings.OrderBy(b => b.Period.LowerBound())); - StringAssert.Contains(sql, "ORDER BY lower(b.\"Period\")"); + var sql = Sql(db => db.Bookings.OrderBy(b => b.Seats.LowerBound())); + StringAssert.Contains(sql, "ORDER BY lower(b.\"Seats\")"); + } + + /// + /// A date range's exclusive infinite bound is read one day inside, as memory loads it (#489): + /// [x,infinity) as [x,9999-12-30] and (-infinity,x) as starting on 0001-01-02. PostgreSQL's + /// upper(x) - 1 is still infinity, and lower(x) is -infinity. + /// ExclusiveInfinityBoundParityTests (integration) is the authority. + /// + [TestMethod] + public void DateRangeBounds_ReadAnExclusiveInfinityOneDayInside() + { + var sql = Sql(db => db.Bookings.Select(b => new { Lower = b.Period.LowerBound(), Upper = b.Period.UpperBound() })); + + StringAssert.Contains(sql, + """ + SELECT CASE + WHEN lower(b."Period") = DATE '-infinity' AND NOT (lower_inc(b."Period")) THEN DATE '0001-01-02' + ELSE lower(b."Period") + END AS "Lower", CASE + WHEN upper(b."Period") = DATE 'infinity' AND NOT (upper_inc(b."Period")) THEN DATE '9999-12-30' + ELSE upper(b."Period") - 1 + END AS "Upper" + """); + } + + /// Only a type whose extremes Npgsql writes as infinities reads its bounds through a CASE. + [TestMethod] + public void IntegerBounds_HaveNoInfinityToReadInside() + { + var sql = Sql(db => db.Bookings.Select(b => new { Lower = b.Seats.LowerBound(), Upper = b.Seats.UpperBound() })); + + StringAssert.Contains(sql, "SELECT lower(b.\"Seats\") AS \"Lower\", upper(b.\"Seats\") - 1 AS \"Upper\""); } /// @@ -272,8 +304,8 @@ public void LowerBound_InOrderBy() public void BoundComparedWithNull_OnARequiredColumn_TestsTheBound() { var sql = Sql(db => db.Bookings.Where(b => b.Period.LowerBound() == null || b.BlockedDays.UpperBound() == null)); - StringAssert.Contains(sql, "lower(b.\"Period\") IS NULL"); - StringAssert.Contains(sql, "upper(b.\"BlockedDays\") IS NULL"); + StringAssert.Contains(sql, "ELSE lower(b.\"Period\")\nEND IS NULL"); + StringAssert.Contains(sql, "ELSE upper(b.\"BlockedDays\") - 1\nEND IS NULL"); Assert.IsFalse(sql.Contains("FALSE"), sql); } @@ -281,7 +313,8 @@ public void BoundComparedWithNull_OnARequiredColumn_TestsTheBound() public void BoundCoalesced_OnARequiredColumn_KeepsTheCoalesce() { var sql = Sql(db => db.Bookings.Where(b => (b.Period.UpperBound() ?? DateOnly.MaxValue) >= Day)); - StringAssert.Contains(sql, "COALESCE(upper(b.\"Period\") - 1, "); + StringAssert.Contains(sql, "ELSE upper(b.\"Period\") - 1\nEND, @MaxValue)"); + StringAssert.Contains(sql, "WHERE COALESCE(CASE"); } [TestMethod] @@ -301,13 +334,25 @@ public void UpperBound_Continuous_TranslatesToPlainUpper() } [TestMethod] - public void BoundInclusive_TranslatesToLowerIncUpperInc() + public void BoundInclusive_Continuous_TranslatesToLowerIncUpperInc() { - var sql = Sql(db => db.Bookings.Where(b => b.Period.LowerBoundInclusive() && b.Price.UpperBoundInclusive())); - StringAssert.Contains(sql, "lower_inc(b.\"Period\")"); + var sql = Sql(db => db.Bookings.Where(b => b.Price.LowerBoundInclusive() && b.Price.UpperBoundInclusive())); + StringAssert.Contains(sql, "lower_inc(b.\"Price\")"); StringAssert.Contains(sql, "upper_inc(b.\"Price\")"); } + /// + /// On the closed discrete model, the lower bound is inclusive iff it exists, as the upper is. PostgreSQL's + /// lower_inc says the same except for an exclusive -infinity, which canonicalization leaves + /// exclusive and memory loads as the inclusive 0001-01-02 (#489). + /// + [TestMethod] + public void LowerBoundInclusive_Discrete_ExpandsToBoundednessCheck() + { + var sql = Sql(db => db.Bookings.Where(b => b.Period.LowerBoundInclusive())); + StringAssert.EndsWith(sql, "WHERE NOT (lower_inf(b.\"Period\")) AND NOT (isempty(b.\"Period\"))"); + } + [TestMethod] public void UpperBoundInclusive_Discrete_ExpandsToBoundednessCheck() { @@ -366,9 +411,9 @@ public void RangeSet_BoundAccessors_TranslateToMultirangeFunctions() .OrderBy(b => b.BlockedDays.LowerBound()) .Select(b => (object?)b.BlockedDays.UpperBound())); - StringAssert.Contains(sql, "lower_inc(b.\"BlockedDays\")"); - StringAssert.Contains(sql, "ORDER BY lower(b.\"BlockedDays\")"); - StringAssert.Contains(sql, "upper(b.\"BlockedDays\")"); + StringAssert.Contains(sql, "WHERE NOT (lower_inf(b.\"BlockedDays\")) AND NOT (isempty(b.\"BlockedDays\"))"); + StringAssert.Contains(sql, "ORDER BY CASE\n WHEN lower(b.\"BlockedDays\") = DATE '-infinity' AND NOT (lower_inc(b.\"BlockedDays\")) THEN DATE '0001-01-02'"); + StringAssert.Contains(sql, "WHEN upper(b.\"BlockedDays\") = DATE 'infinity' AND NOT (upper_inc(b.\"BlockedDays\")) THEN DATE '9999-12-30'"); } // ------------------------------------------------------------------------- diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 251395e2..105527c5 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -154,8 +154,8 @@ { "id": "E06-discrete-upper-compensation", "source": "docs/efcore.md#read-bounds-merge-ranges-and-aggregate", - "quote": "So `UpperBound()` becomes `upper(x) - 1`, and `UpperBoundInclusive()` becomes `NOT upper_inf(x) AND NOT isempty(x)`. The database always returns the same results as memory ([verified against live PostgreSQL](../README.md#verified-against-postgresql)).", - "claim": "For a discrete range column (int4range, int8range, daterange and the NodaTime date forms), UpperBound() and UpperBoundInclusive() projected on the server equal the in-memory closed-canonical answers.", + "quote": "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)).", + "claim": "For a discrete range column (int4range, int8range, daterange and the NodaTime date forms), UpperBound(), UpperBoundInclusive() and LowerBoundInclusive() projected on the server equal the in-memory closed-canonical answers.", "oracle": "differential", "surfaces": [ "RangeExtensions.UpperBound", @@ -1504,6 +1504,27 @@ "StringSetCollationIntegrationTests" ] }, + { + "id": "E87-exclusive-infinity-bounds-read-as-the-row-loads", + "source": "docs/efcore.md#rows-written-by-other-tools", + "quote": "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.", + "claim": "Over a daterange another tool wrote with an exclusive infinity ([x,infinity), (-infinity,x), and the unbounded and doubly infinite forms), and over the server's Except at the extremes, LowerBound(), UpperBound(), LowerBoundInclusive(), UpperBoundInclusive() and Clamp(MinValue/MaxValue/a mid value) in a predicate equal memory's answer for the loaded row on DateRange and RangeSet, and on LocalDateRange and its RangeSet except LowerBound() and Clamp(MinIsoValue) at an exclusive -infinity, which keep LocalDate.MinIsoValue; with the infinity conversions disabled such a row fails to read in a query as it fails to load.", + "oracle": "differential", + "surfaces": [ + "RangeExtensions.LowerBound", + "RangeExtensions.UpperBound", + "RangeExtensions.LowerBoundInclusive", + "RangeExtensions.Clamp", + "RangeSet.LowerBound", + "RangeSet.UpperBound", + "RangeSet.LowerBoundInclusive" + ], + "heldBy": [ + "ExclusiveInfinityBoundParityTests", + "InfinityConversionsDisabledTests", + "QueryTranslationTests" + ] + }, { "id": "J01-tostring-is-postgres-literal", "source": "docs/serialization.md#print-a-range-as-a-literal", diff --git a/test/oracles/clone-waivers.json b/test/oracles/clone-waivers.json index 9e7f80fc..6f95a63c 100644 --- a/test/oracles/clone-waivers.json +++ b/test/oracles/clone-waivers.json @@ -1231,6 +1231,13 @@ "kind": "extra", "reason": "the ranges with a time part truncate to the store's microsecond as Npgsql does (StoreTemporal / NodaStoreTemporal); a date, a number and a year-month are stored as they are" }, + { + "family": "efcore range definitions", + "unit": "DateRange + LocalDateRange", + "member": "exclusiveInfinity", + "kind": "extra", + "reason": "only a discrete type whose extremes Npgsql writes as -infinity / infinity has an exclusive infinity to read one step inside (#489); a time part makes the type continuous, a number has no infinity, and YearMonthRange can't load the form. LocalDateRange's lower side is null because memory's -9998-01-02 is below PostgreSQL's dates" + }, { "family": "efcore set definitions", "unit": "DateTimeOffsetSet + DateTimeSet + Float32Set + Float64Set + LocalDateSet + LocalDateTimeSet", diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index 8402e51d..f3f39ea2 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -490,5 +490,14 @@ "needs": [ "docker" ] + }, + { + "class": "ExclusiveInfinityBoundParityTests", + "project": "CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests", + "kind": "differential", + "against": "memory over the loaded row, for LowerBound(), UpperBound(), both inclusiveness accessors and Clamp at the extremes on DateRange, LocalDateRange and their multiranges, over every infinite form a daterange holds, written by raw SQL, and over the server's Except at the extremes, each run as a predicate", + "needs": [ + "docker" + ] } ] diff --git a/test/oracles/seeds/bound-reader-reads-an-exclusive-infinity-as-the-extreme.patch b/test/oracles/seeds/bound-reader-reads-an-exclusive-infinity-as-the-extreme.patch new file mode 100644 index 00000000..d73cc2f0 --- /dev/null +++ b/test/oracles/seeds/bound-reader-reads-an-exclusive-infinity-as-the-extreme.patch @@ -0,0 +1,18 @@ +Seed: LowerBound(), UpperBound() and Clamp read an exclusive infinite date bound as the extreme PostgreSQL holds, one day outside the bound memory loads +Oracles: ExclusiveInfinityBoundParityTests +Fails: UpperBound() of [2026-01-01,infinity): server 9999-12-31, memory 9999-12-30; LowerBound() of (-infinity,2026-01-01): server 0001-01-01, memory 0001-01-02 +History: #489, read upper(x) - 1 and lower(x) as they are until 11.0.0 and 10.4.0 + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +index ad8096a..4e52643 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +@@ -1097,7 +1097,7 @@ internal sealed class ValueRangesMethodCallTranslator( + sqlExpressionFactory.AndAlso( + sqlExpressionFactory.Equal(bound, sqlExpressionFactory.Constant(extreme, type, mapping)), + sqlExpressionFactory.Not(BoolFunction(inclusive, argument))), +- sqlExpressionFactory.Constant(inside, type, mapping)) ++ sqlExpressionFactory.Constant(extreme, type, mapping)) + ], + otherwise); + } diff --git a/test/oracles/seeds/clamp-reads-the-stored-exclusive-upper-bound.patch b/test/oracles/seeds/clamp-reads-the-stored-exclusive-upper-bound.patch index 8928b0d3..13d4aa0f 100644 --- a/test/oracles/seeds/clamp-reads-the-stored-exclusive-upper-bound.patch +++ b/test/oracles/seeds/clamp-reads-the-stored-exclusive-upper-bound.patch @@ -3,15 +3,15 @@ Oracles: ClampParityTests, ClampDomainParityTests Fails: r.Seats.Clamp(r.Value) and r.Tickets.Clamp(r.Big) on the rows whose value is above the range: server End + 1, rule (c) End diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs -index 4054aff..6df3389 100644 +index ad8096a..3b4e432 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs -@@ -820,7 +820,7 @@ internal sealed class ValueRangesMethodCallTranslator( +@@ -1152,7 +1152,7 @@ internal sealed class ValueRangesMethodCallTranslator( BoolFunction("isempty", range)), sqlExpressionFactory.Constant(null, type, mapping)) ], -- Skipping("LEAST", Skipping("GREATEST", value, BoundFunction("lower", range, definition)), UpperBoundFunction(range, definition))); -+ Skipping("LEAST", Skipping("GREATEST", value, BoundFunction("lower", range, definition)), BoundFunction("upper", range, definition))); +- Skipping("LEAST", Skipping("GREATEST", value, LowerBoundFunction(range, definition)), UpperBoundFunction(range, definition))); ++ Skipping("LEAST", Skipping("GREATEST", value, LowerBoundFunction(range, definition)), BoundFunction("upper", range, definition))); } /// diff --git a/test/oracles/seeds/lower-bound-inclusive-reads-lower-inc.patch b/test/oracles/seeds/lower-bound-inclusive-reads-lower-inc.patch new file mode 100644 index 00000000..eec2231f --- /dev/null +++ b/test/oracles/seeds/lower-bound-inclusive-reads-lower-inc.patch @@ -0,0 +1,18 @@ +Seed: LowerBoundInclusive() on a discrete range reads lower_inc, which is false for an exclusive -infinity that memory loads as an inclusive bound +Oracles: ExclusiveInfinityBoundParityTests +Fails: LowerBoundInclusive() of (-infinity,2026-01-01) on DateRange and LocalDateRange: server false, memory true +History: #489 + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +index ad8096a..6956f94 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +@@ -332,7 +332,7 @@ internal sealed class ValueRangesMethodCallTranslator( + return UpperBoundFunction(Range(0), definition); + + if (generic == LowerBoundInclusiveMethod) +- return LowerInclusiveFunction(Range(0), definition); ++ return BoolFunction("lower_inc", Range(0)); + + if (generic == UpperBoundInclusiveMethod) + return UpperInclusiveFunction(Range(0), definition); diff --git a/test/oracles/seeds/range-isfinite-drops-isempty.patch b/test/oracles/seeds/range-isfinite-drops-isempty.patch index e61789a8..eddf7fbb 100644 --- a/test/oracles/seeds/range-isfinite-drops-isempty.patch +++ b/test/oracles/seeds/range-isfinite-drops-isempty.patch @@ -4,10 +4,10 @@ Fails: RangeShapePredicates_MatchInMemory — IsFinite on the empty row disagree History: the second state predicate that was only ever checked as SQL fragments (#118) diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs -index d7e38da..b829fbb 100644 +index ad8096a..79ff104 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs -@@ -181,9 +181,7 @@ internal sealed class ValueRangesMethodCallTranslator( +@@ -321,9 +321,7 @@ internal sealed class ValueRangesMethodCallTranslator( if (generic == IsFiniteMethod) return sqlExpressionFactory.AndAlso( sqlExpressionFactory.Not(BoolFunction("lower_inf", Range(0))), @@ -17,4 +17,4 @@ index d7e38da..b829fbb 100644 + sqlExpressionFactory.Not(BoolFunction("upper_inf", Range(0)))); if (generic == LowerBoundMethod) - return BoundFunction("lower", Range(0), definition); + return LowerBoundFunction(Range(0), definition);