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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ has the rules.

## [10.2.0]

Some calls answer differently: [Changed answers in 10.2.0](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020).

### Changed answers

- EF Core: `LowerBound()` and `UpperBound()` in a query are `NULL` for an empty range or set and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

## [10.1.0] — 2026-10-19

One call answers differently, on the .NET 11 runtime only: [Changed answers in 10.1.0](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1010).
Expand Down
4 changes: 3 additions & 1 deletion docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ Good to know:
have a gap. PostgreSQL normalizes multiranges the same way this library does, so the equality is
exact ([verified against live PostgreSQL](../README.md#verified-against-postgresql)).
- **`LowerBound()` and `UpperBound()` return `T?`.** PostgreSQL's `lower` and `upper` return `NULL`
for an unbounded or empty range, and the in-memory methods match that.
for an unbounded or empty range, and the in-memory methods match that. So `== null`, `HasValue`
and `??` on a bound select the same rows in a query as in memory, on a range or a `RangeSet`
column.
- **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
Expand Down
39 changes: 39 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,42 @@ runtimes. Look at the text instead:
- **Say "the end of the day" another way.** Use an unbounded end, `[22:00,)`, or an inclusive
`23:59:59.9999999` bound
([TimeRange and the custom timerange type](efcore.md#timerange-and-the-custom-timerange-type)).

## Changed answers in 10.2.0

Some calls return a different value. Your code stays the same, so the compiler can't show you where.
Each section below says how to find the calls whose answer changed.

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

Check EF Core queries that call `LowerBound()` or `UpperBound()` on a range or `RangeSet` column and
then test the result for `null`. That covers `== null`, `!= null`, `HasValue` and `??` with a
fallback, in a filter or a projection. It also covers a filter on a bound you projected first, which
is what a data grid sends for a "blank" filter.

PostgreSQL's `lower` and `upper` return `NULL` for an empty range and for an unbounded side. The
in-memory methods return `null` for the same values. Before 10.2.0, the EF Core package declared
that a bound is `NULL` only when the column is, and EF Core simplified the query on that basis. On a
required column:

| Query | Before 10.2.0 | Now |
|---|---|---|
| `Where(r => r.Period.LowerBound() == null)` | no rows | the empty rows and the rows with no lower bound |
| `Where(r => r.Period.UpperBound() != null)`, or `HasValue` | every row | the rows with an upper bound |
| `Where(r => (r.Period.UpperBound() ?? DateOnly.MaxValue) >= day)` | the empty rows and the rows with no upper bound are missing | those rows match through the fallback |
| `Select(r => r.Period.UpperBound() ?? DateOnly.MaxValue)` | throws (`InvalidOperationException`) at an empty row or a row with no upper bound | returns the fallback for it |
| `Select(r => new { To = r.Period.UpperBound() }).Where(x => x.To == null)` | no rows | the empty rows and the rows with no upper bound |

On a nullable column, `LowerBound() == null` ran as the column's own `IS NULL`. It found the `NULL`
rows and missed the empty and unbounded ones.

This holds for every range type and for `RangeSet`: the .NET and NodaTime ranges, `TimeRange`, the
float ranges and `YearMonthRange`. Code that runs in memory is unchanged.

- **Search your queries for `LowerBound()` and `UpperBound()`.** Each one followed by `== null`,
`!= null`, `HasValue` or `??` now returns the rows the same code returns in memory.
- **Keep or drop the workarounds you wrote for it.** A query that adds `IsEmpty()`,
`IsUnboundedStart()` or `IsUnboundedEnd()` beside the `null` test returns the same rows as before,
so the extra test is now optional.
- **Check reports built on the old rows.** On a required column, a count of rows with no end date
was 0, and a filter on a date with a `??` fallback left out every open-ended row.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ covers all four packages, which share one version number and release together.

## [10.2.0]

No change in this package.
### Changed answers

- EF Core: `LowerBound()` and `UpperBound()` of a NodaTime range or multirange column, `YearMonthRange` included, are `NULL` in a query for an empty value and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

## [10.1.0] — 2026-10-19

Expand Down
4 changes: 3 additions & 1 deletion src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ covers all four packages, which share one version number and release together.

## [10.2.0]

No change in this package.
### Changed answers

- EF Core: `LowerBound()` and `UpperBound()` in a query are `NULL` for an empty range or set and on an unbounded side, as in memory. So `== null` selects those rows, where it selected none on a required column, and `?? fallback` returns the fallback, where the row was missing ([#354](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/354), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

## [10.1.0] — 2026-10-19

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,14 +634,17 @@ private SqlExpression BoolFunction(string name, SqlExpression argument)
/// <summary>
/// A bound-extraction function (<c>lower</c>/<c>upper</c>) on a range or multirange.
/// PostgreSQL returns NULL for an empty or correspondingly unbounded operand, hence the
/// nullable CLR type.
/// nullable CLR type. That NULL comes from a non-null argument, so the argument must not be
/// declared as propagating nullability: EF Core would then take the bound of a required column
/// as never NULL, fold <c>IS NULL</c> to <c>FALSE</c> and drop the <c>COALESCE</c> of a
/// <c>??</c>, and test a nullable column's bound with the column's own <c>IS NULL</c>.
/// </summary>
private SqlExpression BoundFunction(string name, SqlExpression argument, IRangeTypeDefinition definition)
=> sqlExpressionFactory.Function(
name,
[argument],
nullable: true,
argumentsPropagateNullability: [true],
argumentsPropagateNullability: [false],
typeof(Nullable<>).MakeGenericType(definition.ElementClrType),
ElementMapping(definition));

Expand Down
Loading
Loading