From 3f914e833c1a7b488c1e25e9aa69ba67073d73ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 24 Oct 2026 22:35:12 +0200 Subject: [PATCH 1/2] fix: a range factory over a NULL element is NULL in a query (#382) CreateFinite fell into its inverted-bounds guard and gave empty, and CreateUnboundedStart/End passed the NULL to PostgreSQL's range constructor, which reads a NULL bound as unbounded and gave (,). So Contains(v) was true for a row without the element: the optional side of an outer join, or a nullable column read with .Value. The factories are now CASE WHEN e IS NULL THEN NULL ELSE END, which EF Core folds away for required elements, non-null parameters and constants. Found-By: OT Defect-Class: SQL Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 5cf384545ac6bf5648cc6441c6c9ed6fe9b8ef55) --- CHANGELOG.md | 1 + docs/efcore.md | 4 +- docs/migration.md | 4 +- .../CHANGELOG.md | 1 + .../CHANGELOG.md | 1 + .../Query/ValueRangesMethodCallTranslator.cs | 48 +++++++++++++++---- .../NullElementTests.cs | 21 +++++++- .../NullOperandTranslationTests.cs | 20 ++++++++ test/oracles/claims.json | 8 ++-- 9 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9729ca7..63594a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/efcore.md b/docs/efcore.md index a0697107..40abb9d7 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -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 diff --git a/docs/migration.md b/docs/migration.md index 8cee0e3a..6701f65d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 8c8a72f6..19398b05 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -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 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index 73c2223d..95e0c8e9 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -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 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs index a3bf2905..40ab2d28 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs @@ -524,29 +524,57 @@ IReadOnlyList 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; } } + /// + /// A factory over a NULL element is NULL, as EF Core's own value-producing members are over NULL + /// (#382). Without this, CreateFinite fell into its inverted-bounds guard and gave + /// empty, and the unbounded factories gave (,), 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 .Value. 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. + /// + 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); + /// /// A range constructor call guarded against inverted bounds: CreateFinite returns /// the empty range in that case, while the bare PostgreSQL constructor raises an error. diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs index 36210e2a..973d05b8 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs @@ -7,12 +7,13 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; /// A NULL element against live PostgreSQL: a nullable column read with .Value, which is what the /// optional side of an outer join gives too. Row 1 has a Day inside every range, row 2 has none. /// In memory the call cannot be made (.Value throws), so the reference is rule (c), EF Core's own -/// convention: a predicate over NULL is false and its negation true (#350, #381). +/// convention: a predicate over NULL is false and its negation true (#350, #381), and a +/// value built from NULL is NULL (#382). /// /// /// On main the composition sweeps (OuterJoinProbeTests, NullableOperandPairProbeTests, /// QuantifierProbeTests) 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. /// [TestClass] public class NullElementTests @@ -100,4 +101,20 @@ 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); + } } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/NullOperandTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/NullOperandTranslationTests.cs index 625214b1..b507dad8 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/NullOperandTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/NullOperandTranslationTests.cs @@ -47,6 +47,9 @@ private static string Where(Func, IQueryable> query) return query(context.Rows).ToQueryString().Split("WHERE")[1].Trim(); } + /// with line breaks as one space, for a CASE the SQL spreads over lines. + private static string Flat(Func, IQueryable> query) => Where(query).ReplaceLineEndings(" "); + [TestMethod] public void ARequiredColumn_KeepsItsSql() { @@ -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)))); } + /// + /// A range factory over a NULL element is NULL (#382). CreateFinite gave empty through + /// its inverted-bounds guard, and the unbounded factories gave (,), because PostgreSQL's + /// constructor reads a NULL bound as unbounded, so Contains(v) was true for a row with + /// no element. Over required elements the test folds away and the SQL is what it was. + /// + [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()))); + } } diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 6f3cbcb7..25905153 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -112,8 +112,8 @@ { "id": "E04-factories-translate-guarded", "source": "docs/efcore.md#query-ranges-with-linq", - "quote": "**`CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd`** become range constructor calls inside a guard. As in memory, a start after the end gives you an empty range.", - "claim": "When a factory is called on column values inside a query, it translates to a CASE-guarded range constructor and the server-built range equals what the in-memory factory builds, including Empty for inverted bounds.", + "quote": "**`CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd`** become range constructor 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.", + "claim": "When a factory is called on column values inside a query, it translates to a CASE-guarded range constructor and the server-built range equals what the in-memory factory builds, including Empty for inverted bounds; over a NULL element (the optional side of an outer join, or a nullable column read with .Value) it is NULL, and the SQL over required elements carries no null test.", "oracle": "differential", "surfaces": [ "*Range.CreateFinite", @@ -124,7 +124,9 @@ "heldBy": [ "QueryTranslationTests", "MultirangeCoverageTests", - "NodaQueryTranslationTests" + "NodaQueryTranslationTests", + "NullOperandTranslationTests", + "NullElementTests" ] }, { From fa110cf35c065d3c46a145f656e8cef9ee56f41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 24 Oct 2026 22:49:29 +0200 Subject: [PATCH 2/2] test: hold the 10.x NULL-element fixes where a predicate consumes them (#387, #388) On main the composition sweeps hold #381 and #382 over every shape; this line has none of them. Adds the cases the release review named: a bool projection of RangeSet.Contains, and a range factory over a NULL element consumed by Contains in a projection, by Overlaps in a filter, its negation and a projection, and by All over a captured list. Each new assertion fails with the translator from before #382's fix (and the RangeSet projection with the one from before #381's). Co-Authored-By: Claude Opus 5.5 --- .../NullElementTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs index 973d05b8..1e6823d8 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NullElementTests.cs @@ -89,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] @@ -101,6 +102,7 @@ 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() { @@ -117,4 +119,20 @@ public async Task RangeFactories_OverANullElement_AreNull() 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.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)))))); + } }