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 @@ -20,6 +20,7 @@ Some calls answer differently: [Changed answers in 10.4.0](https://github.com/Ca
- 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 ([#496](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/496), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).
- 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 ([#502](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/502), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).
- 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` ([#511](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/511), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).
- 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, because the database compares under it: `Contains(g.Owner)` matched `{A}` for `"a"` on a case-insensitive `Owner`, and `Remove(g.Owner)` in `ExecuteUpdate` deleted `A` ([#512](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/512), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).

### Added

Expand Down
6 changes: 6 additions & 0 deletions docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,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
23 changes: 21 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,8 +991,9 @@ Each section below says how to find the calls whose answer changed. 11.0.0 makes

### 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 @@ -1019,6 +1020,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 @@ -10,6 +10,7 @@ Some calls answer differently: [Changed answers in 10.4.0](https://github.com/Ca
### Changed answers

- 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 ([#496](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/496), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).
- 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, because the database compares under it: `Contains(g.Owner)` matched `{A}` for `"a"` on a case-insensitive `Owner`, and `Remove(g.Owner)` in `ExecuteUpdate` deleted `A` ([#512](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/512), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).
- EF Core: on a `DateRange` and its multirange, `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, and on `(-infinity,2026-01-01)` `LowerBound()` returned 0001-01-01 ([#502](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/502), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)).

### 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