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 @@ -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<TElement>` 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: a query that compares a `StringSet` or `StringSet<TElement>` with a value whose column has a collation that may not compare bytes, or with `EF.Functions.Collate(value, …)` under one, throws and names the collation. PostgreSQL compares under the collation of either side, so `Contains(g.Owner)` matched `{A}` for `"a"` on a case-insensitive `Owner`, and `Remove(g.Owner)` in `ExecuteUpdate` deleted `A` ([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)).
- EF Core: on a `YearMonthRange` and its multirange, `UpperBound()` and `Clamp` compare with a month in a query as in memory. They read the last day of the month, so on `[2026-01,2026-01]` `UpperBound() == 2026-01` was `false` and `UpperBound() > 2026-01` was `true` ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-yearmonthrange-upper-bounds-compared-in-queries)).

Expand Down
6 changes: 6 additions & 0 deletions docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ Good to know:
`pg_c_utf8` or `default`. `EF.Functions.Collate` over a string set throws the same way. A column
that something outside your model gave a collation can't be checked, so keep that collation
deterministic.
- **The value you compare with a string set brings its collation too.** PostgreSQL compares under the
collation of either side. So a query throws when it compares a string set with a value from a column
whose collation may not compare bytes, or with `EF.Functions.Collate(value, …)` under one, and the
message names the collation. That covers `Contains`, `Add`, `Remove`, `==` and the subset operators,
in `ExecuteUpdate` too. To compare such a value as memory does, write
`g.Members.Contains(EF.Functions.Collate(g.Owner, "C"))`.
- **Plain arrays keep their own mapping.** `T[]` and `List<T>` properties keep their native Npgsql
mapping, and both can sit next to value sets in one model. Scaffolding a database gives you plain
arrays, because choosing a set type is a decision you make in your model.
Expand Down
31 changes: 27 additions & 4 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -831,16 +831,20 @@ 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 string set compared with a value under a collation that may not compare bytes throws.**
The value's column or `EF.Functions.Collate` gave the comparison its collation, so `Contains(g.Owner)`
matched `{A}` for `"a"` on a case-insensitive `Owner`
([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)).
- **EF Core: a `YearMonthRange` upper bound compares with a month in a query as in memory.** On
`[2026-01,2026-01]`, `UpperBound() == 2026-01` was `false` in a query and `true` in memory
([details](#check-yearmonthrange-upper-bounds-compared-in-queries)).

The first four also shipped in 10.2.0, the next two in 10.3.0, and the last three in 10.4.0. If you
The first four also shipped in 10.2.0, the next two in 10.3.0, and the last four 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 nine.
and from 10.4.0 or later all ten.

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

Expand Down Expand Up @@ -998,8 +1002,9 @@ that can be NULL gets `CASE WHEN … IS NULL THEN NULL ELSE array_cat(…) END`.

### Check string set columns with a collation

Check `StringSet` and `StringSet<TElement>` properties configured with `UseCollation`, and queries
that call `EF.Functions.Collate` on one.
Check `StringSet` and `StringSet<TElement>` properties configured with `UseCollation`, queries that
call `EF.Functions.Collate` on one, and queries that compare one with a value from a column configured
with `UseCollation`.

PostgreSQL compares the elements of a `text[]` column with the column's collation, and memory compares
them ordinally. A deterministic collation breaks every tie by the bytes, so the two agree. A
Expand All @@ -1026,6 +1031,24 @@ 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.

The value you compare with a string set counts too, because PostgreSQL compares under the collation of
either side. With `Members` holding `{A,b}`, no collation on it, and `Owner` holding `"a"` under a
case-insensitive collation:

| Query | Memory | Before 10.4.0 |
|---|---|---|
| `Where(g => g.Members.Contains(g.Owner))` | no match | matched |
| `Where(g => g.Members.Contains(EF.Functions.Collate("a", "case_insensitive")))` | no match | matched |
| `ExecuteUpdate(… g => g.Members.Remove(g.Owner))` | — | stored `{b}` |

Now such a query throws `InvalidOperationException` and names the collation. The same five collations
are allowed on the value's column.

- **Compare with `EF.Functions.Collate(g.Owner, "C")`.** An explicit collation wins over the column's,
so the query compares bytes, as memory does. Unless the set column is collated `"C"` too, PostgreSQL
then can't use a GIN index on it for that comparison.
- **Or load the value first.** A value your code passes in has no collation.

### Check date bounds at an exclusive infinity in queries

Check EF Core queries that call `LowerBound()`, `UpperBound()`, `LowerBoundInclusive()` or `Clamp` on
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 @@ -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<TElement>` 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: a query that compares a `StringSet` or `StringSet<TElement>` with a value whose column has a collation that may not compare bytes, or with `EF.Functions.Collate(value, …)` under one, throws and names the collation. PostgreSQL compares under the collation of either side, so `Contains(g.Owner)` matched `{A}` for `"a"` on a case-insensitive `Owner`, and `Remove(g.Owner)` in `ExecuteUpdate` deleted `A` ([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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ internal static class StringSetCollation
public static bool IsStringSet(Type type)
=> type == typeof(StringSet) || type is { IsGenericType: true } && type.GetGenericTypeDefinition() == typeof(StringSet<>);

/// <summary>
/// The annotation on a property whose collation may not compare bytes, naming it, for the query guard (#507). The
/// read-optimized model a query runs on drops collations, which only migrations need.
/// </summary>
public const string ElementAnnotation = "CodoMetis:ValueRanges:Collation";

/// <summary>The collation <see cref="ElementAnnotation"/> names on a property, or <see langword="null"/>.</summary>
public static string? OfElement(IReadOnlyProperty property) => property.FindAnnotation(ElementAnnotation)?.Value as string;

public static string Remedy
=> "Remove the collation, or use \"C\" (docs/efcore.md#store-a-value-set-in-an-array-column).";
}
Expand Down Expand Up @@ -70,9 +79,16 @@ private static void Check(IConventionTypeBase type)

foreach (var property in type.GetDeclaredProperties())
{
if (!StringSetCollation.IsStringSet(property.ClrType) || property.GetCollation() is not { } collation
|| StringSetCollation.ComparesBytes(collation))
if (property.GetCollation() is not { } collation || StringSetCollation.ComparesBytes(collation))
continue;

// A value from this column compared with a string set compares under its collation too, and the query
// guard has only the read-optimized model, which keeps no collation (#507).
if (!StringSetCollation.IsStringSet(property.ClrType))
{
property.Builder.HasAnnotation(StringSetCollation.ElementAnnotation, collation);
continue;
}

throw new InvalidOperationException(
$"'{type.DisplayName()}.{property.Name}' is a {property.ClrType.ShortDisplayName()} column with the collation "
Expand Down
Loading
Loading