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 @@ -23,6 +23,7 @@ Some calls answer differently: [Changed answers in 10.2.0](https://github.com/Ca
- 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)).
- 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 ([#357](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/357), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#387](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/387), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#388](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/388), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

### Fixed

Expand Down
4 changes: 3 additions & 1 deletion docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ Each method becomes one PostgreSQL operator or function call:
matches their `RangeSet` return type. A union of two ranges that don't touch is a real
two-element multirange, never an error.
- **`CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd`** become range constructor
calls inside a guard. As in memory, a start after the end gives you an empty range.
calls inside a guard. As in memory, a start after the end gives you an empty range. Over a NULL
element, such as a column of a missing child in an outer join, the range is NULL, as EF Core's own
`AddDays` is.

Since v4 the eight predicates above (`Contains` through `IsAdjacentTo`) also work on a `RangeSet`,
with either a range or a multirange on the other side. Since 10.0.0 they also work on a range with a
Expand Down
4 changes: 3 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -903,10 +903,12 @@ whether the predicate was negated or not, and a projected predicate threw when i
| `Where(b => !b.Tags!.Contains("x"))` on a nullable value set | the NULL rows are missing | the NULL rows are included |
| `Where(b => !b.Period.Contains(b.Visit!.On))`, for a booking without a visit | the row is missing | the row is included |
| `Select(b => b.Period.Contains(b.Day!.Value))` on a nullable `DateOnly? Day` | throws (`InvalidOperationException`) at a NULL `Day` | `false` for it |
| `Where(b => DateRange.CreateUnboundedEnd(b.Visit!.From).Contains(v))`, for a booking without a visit | the row is included: the range was `(,)` | the row is missing: the range is NULL |

The same goes for the element `Contains(T)` looks for, when it is NULL: a required column of a
missing child in an outer join, or a nullable column read with `.Value`. The SQL of a required
column doesn't change. A nullable one gets `b."Period" IS NULL OR NOT (…)` under a negation.
column doesn't change. A nullable one gets `b."Period" IS NULL OR NOT (…)` under a negation. A
range factory over a NULL element is NULL, where it built `empty` (`CreateFinite`) or `(,)`.

- **Search for negated predicates over a nullable column.** A `!` that silenced the nullable warning,
as in `!b.Period!.Overlaps(p)`, often marks the place.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ covers all four packages, which share one version number and release together.
- 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)).
- 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 ([#357](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/357), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#387](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/387), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#388](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/388), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

## [10.1.0] — 2026-10-19

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 @@ -10,6 +10,7 @@ covers all four packages, which share one version number and release together.
- 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)).
- 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 ([#357](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/357), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#387](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/387), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).
- 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 ([#388](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/388), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1020)).

### Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -524,29 +524,57 @@ IReadOnlyList<SqlExpression> arguments
case "CreateFinite" when arguments.Count == 4
&& arguments[2] is SqlConstantExpression { Value: bool startInclusive }
&& arguments[3] is SqlConstantExpression { Value: bool endInclusive }:
return GuardedFiniteRangeConstructor(
{
var lower = ApplyElementMapping(arguments[0], definition);
var upper = ApplyElementMapping(arguments[1], definition);
return NullOverNullElement(
definition,
ApplyElementMapping(arguments[0], definition),
ApplyElementMapping(arguments[1], definition),
$"{(startInclusive ? '[' : '(')}{(endInclusive ? ']' : ')')}");
GuardedFiniteRangeConstructor(definition, lower, upper, $"{(startInclusive ? '[' : '(')}{(endInclusive ? ']' : ')')}"),
lower, upper);
}

case "CreateUnboundedStart" when arguments.Count == 2
&& arguments[1] is SqlConstantExpression { Value: bool endInclusive }:
return RangeConstructor(
definition, NullConstant(definition.ElementClrType), arguments[0],
$"({(endInclusive ? ']' : ')')}");
{
var upper = ApplyElementMapping(arguments[0], definition);
return NullOverNullElement(
definition,
RangeConstructor(definition, NullConstant(definition.ElementClrType), upper, $"({(endInclusive ? ']' : ')')}"),
upper);
}

case "CreateUnboundedEnd" when arguments.Count == 2
&& arguments[1] is SqlConstantExpression { Value: bool startInclusive }:
return RangeConstructor(
definition, arguments[0], NullConstant(definition.ElementClrType),
$"{(startInclusive ? '[' : '(')})");
{
var lower = ApplyElementMapping(arguments[0], definition);
return NullOverNullElement(
definition,
RangeConstructor(definition, lower, NullConstant(definition.ElementClrType), $"{(startInclusive ? '[' : '(')})"),
lower);
}

default:
return null;
}
}

/// <summary>
/// A factory over a NULL element is NULL, as EF Core's own value-producing members are over NULL
/// (#382). Without this, <c>CreateFinite</c> fell into its inverted-bounds guard and gave
/// <c>empty</c>, and the unbounded factories gave <c>(,)</c>, since PostgreSQL's range constructor
/// reads a NULL bound as unbounded. An element is NULL on the optional side of an outer join or in
/// a nullable column read with <c>.Value</c>. EF Core's nullability processing folds the test away
/// for a required column, a non-null parameter and a constant, so their SQL is unchanged.
/// </summary>
private SqlExpression NullOverNullElement(IRangeTypeDefinition definition, SqlExpression range, params SqlExpression[] elements)
=> sqlExpressionFactory.Case(
[
new CaseWhenClause(
elements.Select(element => sqlExpressionFactory.IsNull(element)).Aggregate(sqlExpressionFactory.OrElse),
sqlExpressionFactory.Constant(null, definition.RangeClrType, definition.RangeTypeMapping))
],
range);

/// <summary>
/// A range constructor call guarded against inverted bounds: <c>CreateFinite</c> returns
/// the empty range in that case, while the bare PostgreSQL constructor raises an error.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests;
/// A NULL element against live PostgreSQL: a nullable column read with <c>.Value</c>, which is what the
/// optional side of an outer join gives too. Row 1 has a <c>Day</c> inside every range, row 2 has none.
/// In memory the call cannot be made (<c>.Value</c> throws), so the reference is rule (c), EF Core's own
/// convention: a predicate over NULL is <c>false</c> and its negation <c>true</c> (#350, #381).
/// convention: a predicate over NULL is <c>false</c> and its negation <c>true</c> (#350, #381), and a
/// value built from NULL is NULL (#382).
/// </summary>
/// <remarks>
/// On <c>main</c> the composition sweeps (<c>OuterJoinProbeTests</c>, <c>NullableOperandPairProbeTests</c>,
/// <c>QuantifierProbeTests</c>) hold this over every shape; this line has none of them, so the ports
/// (#387) hold the cases the issues name here.
/// (#387, #388) hold the cases the issues name here.
/// </remarks>
[TestClass]
public class NullElementTests
Expand Down Expand Up @@ -88,6 +89,7 @@ public async Task Contains_OverANullElement_ProjectsFalse()

// It threw InvalidOperationException reading the NULL into bool.
CollectionAssert.AreEqual(new[] { true, false }, await db.Rows.OrderBy(r => r.Id).Select(r => r.Period.Contains(r.Day!.Value)).ToListAsync());
CollectionAssert.AreEqual(new[] { true, false }, await db.Rows.OrderBy(r => r.Id).Select(r => r.Blocks.Contains(r.Day!.Value)).ToListAsync());
}

[TestMethod]
Expand All @@ -100,4 +102,37 @@ public async Task All_OverACapturedList_DropsTheNullElement()
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => windows.All(x => x.Contains(r.Day!.Value)))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !windows.All(x => x.Contains(r.Day!.Value)))));
}

[TestMethod]
public async Task RangeFactories_OverANullElement_AreNull()
{
await using var db = await Seeded();
var v = March(9);

// The unbounded factories gave (,) and CreateFinite gave empty, so these selected row 2.
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => DateRange.CreateUnboundedEnd(r.Day!.Value, true).Contains(v))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !DateRange.CreateUnboundedEnd(r.Day!.Value, true).Contains(v))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => DateRange.CreateUnboundedStart(r.Day!.Value, true) == null)));
// Row 1's bounds are inverted, so it is empty, as in memory; row 2's range is NULL.
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => DateRange.CreateFinite(r.Day!.Value, r.Start, true, true).IsEmpty())));

var built = await db.Rows.OrderBy(r => r.Id).Select(r => DateRange.CreateUnboundedStart(r.Day!.Value, true)).ToListAsync();
CollectionAssert.AreEqual(new DateRange?[] { DateRange.CreateUnboundedStart(March(7), true), null }, built);
}

[TestMethod]
public async Task RangeFactories_OverANullElement_AreNull_WhereAPredicateConsumesThem()
{
await using var db = await Seeded();
var v = March(9);
var windows = new List<DateRange> { DateRange.CreateFinite(March(3), March(8), true, true) };

// Built from a NULL element, (,) contained and overlapped everything, so each of these answered true for row 2.
CollectionAssert.AreEqual(new[] { true, false }, await db.Rows.OrderBy(r => r.Id).Select(r => DateRange.CreateUnboundedEnd(r.Day!.Value, true).Contains(v)).ToListAsync());
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => r.Period.Overlaps(DateRange.CreateUnboundedEnd(r.Day!.Value, true)))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !r.Period.Overlaps(DateRange.CreateUnboundedEnd(r.Day!.Value, true)))));
CollectionAssert.AreEqual(new[] { true, false }, await db.Rows.OrderBy(r => r.Id).Select(r => r.Period.Overlaps(DateRange.CreateUnboundedEnd(r.Day!.Value, true))).ToListAsync());
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => windows.All(x => x.Overlaps(DateRange.CreateUnboundedEnd(r.Day!.Value, true))))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !windows.All(x => x.Overlaps(DateRange.CreateUnboundedEnd(r.Day!.Value, true))))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ private static string Where(Func<IQueryable<Row>, IQueryable<Row>> query)
return query(context.Rows).ToQueryString().Split("WHERE")[1].Trim();
}

/// <summary><see cref="Where"/> with line breaks as one space, for a <c>CASE</c> the SQL spreads over lines.</summary>
private static string Flat(Func<IQueryable<Row>, IQueryable<Row>> query) => Where(query).ReplaceLineEndings(" ");

[TestMethod]
public void ARequiredColumn_KeepsItsSql()
{
Expand Down Expand Up @@ -104,4 +107,21 @@ public void ANullableElement_IsCheckedForNull()
// A required element keeps its SQL.
Assert.AreEqual("NOT (r.\"Period\" @> r.\"Start\")", Where(rows => rows.Where(r => !r.Period.Contains(r.Start))));
}
/// <summary>
/// A range factory over a NULL element is NULL (#382). <c>CreateFinite</c> gave <c>empty</c> through
/// its inverted-bounds guard, and the unbounded factories gave <c>(,)</c>, because PostgreSQL's
/// constructor reads a NULL bound as unbounded, so <c>Contains(v)</c> was <c>true</c> for a row with
/// no element. Over required elements the test folds away and the SQL is what it was.
/// </summary>
[TestMethod]
public void ARangeFactoryOverANullableElement_IsNull()
{
Assert.AreEqual("CASE WHEN r.\"Day\" IS NULL THEN NULL ELSE daterange(r.\"Day\", NULL, '[)') END IS NOT NULL AND CASE WHEN r.\"Day\" IS NULL THEN NULL ELSE daterange(r.\"Day\", NULL, '[)') END @> r.\"Start\"", Flat(rows => rows.Where(r => DateRange.CreateUnboundedEnd(r.Day!.Value, true).Contains(r.Start))));
Assert.AreEqual("CASE WHEN r.\"Day\" IS NULL THEN NULL ELSE daterange(NULL, r.\"Day\", '(]') END IS NULL", Flat(rows => rows.Where(r => DateRange.CreateUnboundedStart(r.Day!.Value, true) == null)));
Assert.AreEqual("CASE WHEN r.\"Day\" IS NULL THEN NULL WHEN r.\"Start\" <= r.\"Day\" THEN daterange(r.\"Start\", r.\"Day\", '[]') ELSE 'empty'::daterange END IS NOT NULL AND isempty(CASE WHEN r.\"Day\" IS NULL THEN NULL WHEN r.\"Start\" <= r.\"Day\" THEN daterange(r.\"Start\", r.\"Day\", '[]') ELSE 'empty'::daterange END)", Flat(rows => rows.Where(r => DateRange.CreateFinite(r.Start, r.Day!.Value, true, true).IsEmpty())));

// Required elements keep their SQL.
Assert.AreEqual("daterange(r.\"Start\", NULL, '[)') IS NOT NULL AND daterange(r.\"Start\", NULL, '[)') && r.\"Period\"", Flat(rows => rows.Where(r => DateRange.CreateUnboundedEnd(r.Start, true).Overlaps(r.Period))));
Assert.AreEqual("CASE WHEN r.\"Start\" <= r.\"Start\" THEN daterange(r.\"Start\", r.\"Start\", '[]') ELSE 'empty'::daterange END IS NOT NULL AND isempty(CASE WHEN r.\"Start\" <= r.\"Start\" THEN daterange(r.\"Start\", r.\"Start\", '[]') ELSE 'empty'::daterange END)", Flat(rows => rows.Where(r => DateRange.CreateFinite(r.Start, r.Start, true, true).IsEmpty())));
}
}
Loading
Loading