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 @@ -22,6 +22,7 @@ Some calls answer differently: [Changed answers in 10.2.0](https://github.com/Ca
- Ranges: a predicate called on a `null` range, or given a `null` range or set, returns `false`, as a query does over a NULL column. `Contains(Empty)`, `IsContainedBy((,))` and `(,).Contains(null)` returned `true`, and `Contains(T)` and a `null` set operand 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: `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)).

### Fixed

Expand Down
4 changes: 3 additions & 1 deletion docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ EF Core does for a `string` or `string[]` column, and what the methods return in
range. So `!b.Period.Overlaps(other)` keeps the rows whose `Period` is NULL, as
`!b.Name.StartsWith("a")` keeps the rows whose `Name` is NULL. The SQL adds
`b."Period" IS NULL OR NOT (…)` for a nullable column and nothing for a required one. Value set
predicates follow the same rule in a query, though in memory they throw for a `null` set.
predicates follow the same rule in a query, though in memory they throw for a `null` set. So does
`Contains(day)` when `day` is NULL, as it is for a column of a missing child in an outer join or a
nullable column read with `.Value`.
`CompareTo` keeps its own rule for a NULL column ([Sort ranges](ranges.md#sort-ranges)).

### Read bounds, merge ranges and aggregate
Expand Down
7 changes: 5 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -901,9 +901,12 @@ whether the predicate was negated or not, and a projected predicate threw when i
| `Where(b => b.Period!.Overlaps(p))` | no NULL rows | no NULL rows |
| `Select(b => b.Period!.IsEmpty())` | throws (`InvalidOperationException`) at a NULL row | `false` for it |
| `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 |

The SQL of a required column doesn't change. A nullable one gets `b."Period" IS NULL OR NOT (…)`
under a negation.
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.

- **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 @@ -9,6 +9,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)).

## [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 @@ -9,6 +9,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)).

### Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,25 +113,39 @@ private static bool TakesSet(MethodInfo method)
/// <summary>
/// A predicate with a NULL range or multirange on either side is <c>false</c>: what EF Core answers
/// for a predicate over NULL on its own types, and what the in-memory predicates answer for a null
/// (#350, #355). Each range or multirange operand becomes <c>x IS NOT NULL AND &lt;predicate&gt;</c>.
/// EF Core's nullability processing drops the check for a required column and a non-null parameter,
/// and writes <c>x IS NULL OR NOT (…)</c> under a negation, so a negated predicate keeps the NULL row.
/// (#350, #355), and so is one with a NULL element: <c>Contains(T)</c> over the optional side of an
/// outer join or a nullable column read with <c>.Value</c> (#381). Each range, multirange or element
/// operand becomes <c>x IS NOT NULL AND &lt;predicate&gt;</c>. EF Core's nullability processing drops
/// the check for a required column and a non-null parameter, and writes <c>x IS NULL OR NOT (…)</c>
/// under a negation, so a negated predicate keeps the NULL row.
/// </summary>
/// <remarks>
/// <c>CompareTo</c> never reaches this: EF Core turns <c>a.CompareTo(b) op k</c> into a comparison,
/// which keeps <c>Comparer&lt;T&gt;.Default</c>'s rule for a null (#261). Equality is EF Core's own.
/// </remarks>
private SqlExpression FalseOverNull(SqlExpression predicate, IReadOnlyList<SqlExpression> operands)
{
for (var index = operands.Count - 1; index >= 0; index--)
var unwrapped = operands.Select(Unwrap).ToList();

// The element type is the one of the range or multirange the predicate is over.
IRangeTypeDefinition? ranges = null;
foreach (var operand in unwrapped)
if (RangeTypeRegistry.TryGetByClrType(operand.Type, out ranges, out _)) break;

for (var index = unwrapped.Count - 1; index >= 0; index--)
{
var operand = Unwrap(operands[index]);
if (!RangeTypeRegistry.TryGetByClrType(operand.Type, out var definition, out var isSet))
var operand = unwrapped[index];

SqlExpression mapped;
if (RangeTypeRegistry.TryGetByClrType(operand.Type, out var definition, out var isSet))
mapped = operand.TypeMapping is null
? sqlExpressionFactory.ApplyTypeMapping(operand, isSet ? definition.RangeSetTypeMapping : definition.RangeTypeMapping)
: operand;
else if (ranges is not null && (Nullable.GetUnderlyingType(operand.Type) ?? operand.Type) == ranges.ElementClrType)
mapped = operand.TypeMapping is null ? ApplyElementMapping(operand, ranges) : operand;
else
continue;

var mapped = operand.TypeMapping is null
? sqlExpressionFactory.ApplyTypeMapping(operand, isSet ? definition.RangeSetTypeMapping : definition.RangeTypeMapping)
: operand;
predicate = sqlExpressionFactory.AndAlso(sqlExpressionFactory.IsNotNull(mapped), predicate);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests;

/// <summary>
/// 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).
/// </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.
/// </remarks>
[TestClass]
public class NullElementTests
{
private static DateOnly March(int day) => new(2026, 3, day);

public sealed class Row
{
public int Id { get; set; }

public DateRange Period { get; set; } = DateRange.Empty;

public RangeSet<DateRange, DateOnly> Blocks { get; set; } = RangeSet<DateRange, DateOnly>.Empty;

public DateOnly Start { get; set; }

public DateOnly? Day { get; set; }
}

/// <summary>A database of its own, so that the nullable element column does not change the shared schema.</summary>
private sealed class Context(string connectionString) : DbContext
{
public DbSet<Row> Rows => Set<Row>();

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<Row>().Property(r => r.Id).ValueGeneratedNever();
}

private static async Task<Context> Seeded()
{
ContainerLifecycle.RequireServer();

var name = $"null_element_{Guid.NewGuid():N}";
await using (var connection = new NpgsqlConnection(ContainerLifecycle.ServerConnectionString))
{
await connection.OpenAsync();
await using var command = new NpgsqlCommand($"CREATE DATABASE {name}", connection);
await command.ExecuteNonQueryAsync();
}

var context = new Context(new NpgsqlConnectionStringBuilder(ContainerLifecycle.ServerConnectionString) { Database = name }.ConnectionString);
await context.Database.EnsureCreatedAsync();

var period = DateRange.CreateFinite(March(1), March(10), true, true);
context.Rows.AddRange(
new Row { Id = 1, Period = period, Blocks = RangeSet<DateRange, DateOnly>.From([period]), Start = March(3), Day = March(7) },
new Row { Id = 2, Period = period, Blocks = RangeSet<DateRange, DateOnly>.From([period]), Start = March(3), Day = null });
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
return context;
}

private static async Task<int[]> Ids(IQueryable<Row> rows) => [.. await rows.Select(r => r.Id).OrderBy(id => id).ToListAsync()];

[TestMethod]
public async Task Contains_OverANullElement_IsFalse_AndItsNegationTrue()
{
await using var db = await Seeded();

CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => r.Period.Contains(r.Day!.Value))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !r.Period.Contains(r.Day!.Value))));
CollectionAssert.AreEqual(new[] { 1 }, await Ids(db.Rows.Where(r => r.Blocks.Contains(r.Day!.Value))));
CollectionAssert.AreEqual(new[] { 2 }, await Ids(db.Rows.Where(r => !r.Blocks.Contains(r.Day!.Value))));
}

[TestMethod]
public async Task Contains_OverANullElement_ProjectsFalse()
{
await using var db = await Seeded();

// 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());
}

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

// EF Core writes All as NOT EXISTS (… WHERE NOT (x @> e)); a NULL inner NOT made it true.
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)))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public sealed class Row
public RangeSet<DateRange, DateOnly>? MaybeBlocks { get; set; }

public StringSet? MaybeTags { get; set; }

public DateOnly Start { get; set; }

public DateOnly? Day { get; set; }
}

private sealed class Context : DbContext
Expand Down Expand Up @@ -82,4 +86,22 @@ public void AProjectedPredicate_IsFalseForANullColumn()
var sql = context.Rows.Select(r => r.Maybe!.IsEmpty()).ToQueryString();
StringAssert.Contains(sql, "SELECT r.\"Maybe\" IS NOT NULL AND isempty(r.\"Maybe\")");
}

/// <summary>
/// An element is NULL on the optional side of an outer join or in a nullable column read with
/// <c>.Value</c>, and <c>Contains(T)</c> over it is <c>false</c> too (#381): <c>NOT (r."Period" @&gt;
/// r."Day")</c> dropped the row EF Core keeps for its own comparisons, and a projection threw.
/// </summary>
[TestMethod]
public void ANullableElement_IsCheckedForNull()
{
Assert.AreEqual("r.\"Day\" IS NOT NULL AND r.\"Period\" @> r.\"Day\"", Where(rows => rows.Where(r => r.Period.Contains(r.Day!.Value))));
Assert.AreEqual("r.\"Day\" IS NULL OR NOT (r.\"Period\" @> r.\"Day\")", Where(rows => rows.Where(r => !r.Period.Contains(r.Day!.Value))));
Assert.AreEqual(
"r.\"MaybeBlocks\" IS NULL OR r.\"Day\" IS NULL OR NOT (r.\"MaybeBlocks\" @> r.\"Day\")",
Where(rows => rows.Where(r => !r.MaybeBlocks!.Contains(r.Day!.Value))));

// A required element keeps its SQL.
Assert.AreEqual("NOT (r.\"Period\" @> r.\"Start\")", Where(rows => rows.Where(r => !r.Period.Contains(r.Start))));
}
}
5 changes: 3 additions & 2 deletions test/oracles/claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@
"id": "E71-predicate-over-a-null-column-is-false",
"source": "docs/efcore.md#query-ranges-with-linq",
"quote": "On a nullable column, a predicate over a NULL value is `false`, and its negation `true`.",
"claim": "Over a nullable range, multirange and value set column, every discovered Boolean predicate of RangeExtensions, RangeSet and ValueSetExtensions, with the column on either side, selects in a filter, a negated filter and a projection what the same expression selects over the rows as loaded, with false for a NULL row where memory throws; a required column's SQL carries no null check, and a nullable one reads x IS NULL OR NOT (…) under a negation.",
"claim": "Over a nullable range, multirange and value set column, every discovered Boolean predicate of RangeExtensions, RangeSet and ValueSetExtensions, with the column on either side, selects in a filter, a negated filter and a projection what the same expression selects over the rows as loaded, with false for a NULL row where memory throws, and the same for Contains(T) over a NULL element (the optional side of an outer join, or a nullable column read with .Value); a required column's SQL carries no null check, and a nullable one reads x IS NULL OR NOT (…) under a negation.",
"oracle": "differential",
"surfaces": [
"RangeExtensions.Overlaps",
Expand All @@ -1151,7 +1151,8 @@
],
"heldBy": [
"ExecutedQueryTests",
"NullOperandTranslationTests"
"NullOperandTranslationTests",
"NullElementTests"
]
},
{
Expand Down
Loading