Repository navigation
A comparer of ranges or sets declared over IRange<T>, the non-generic IComparer or a value set still compares in the range order in SQL #452
Description
Found by a pre-release sweep of release/10.x @ 684216b (lenses: differential, new-check-vs-invariant, framework-heuristics; all three reached it); reproduced in a scratch test. main @ 7503c18 has the same code (#385).
Problem
#396 (port of #385) keeps a range comparer's Compare out of SQL. It decides by the parameter type that Compare declares, not by what is being compared. src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs, RangeComparerRewriter.VisitMethodCall:
&& RangeTypeRegistry.TryGetByClrType(compared, out _, out _) // compared = declared parameter type
&& typeof(IComparer<>).MakeGenericType(compared).IsAssignableFrom(comparer.Type)So any comparer whose declared parameter type is not a registered range or range set type is not rewritten. EF Core then reads Compare(x, y) by name as the natural order (dotnet/efcore#39402), which is exactly #396's wrong answer. Three kinds get through:
- a comparer declared over the interface,
IComparer<IRange<T>>; - the non-generic
System.Collections.IComparer. That includes the BCL's ownComparer<DateRange>.Create(…)held in anIComparervariable; - any comparer over a value set, such as
IComparer<StringSet>. Value sets are inSetTypeRegistry, notRangeTypeRegistry.
The 10.3.0 changelog row, docs/efcore.md#what-runs-where and docs/migration.md#move-rangelowerboundcomparer-out-of-queries all promise that "any other IComparer of ranges or sets" runs in memory only. The migration guide also tells readers that running each query will find every such call, because the query throws. For these comparers it doesn't throw, it answers.
Scenario
PostgreSQL 18, release/10.x @ 684216b. The rows are [03-01,03-10], [03-01,), [03-01,03-03] and [03-05,03-06], with p = [2026-03-01,2026-03-10]. The comparers compare by lower bound, except the set comparer, which compares by Count.
Query Where(r => cmp.Compare(…) == 0) |
Server | Memory | SQL |
|---|---|---|---|
IComparer<DateRange> (RangeLowerBoundComparer, the fixed path) |
throws: could not be translated | 1,2,3 | — |
IComparer<IRange<DateOnly>> |
1 | 1,2,3 | WHERE r."Period" = @p |
System.Collections.IComparer (own class) |
1 | 1,2,3 | WHERE r."Period" = @p |
Comparer<DateRange>.Create(…) as IComparer |
1 | 1,2,3 | WHERE r."Period" = @p |
IComparer<StringSet> by count, against {z} |
(none) | 1,2 | WHERE r."Tags" = @one |
a static helper Compare(DateRange, DateRange) |
throws: could not be translated | 1,2,3 | — |
In a projection, Select(r => cmp.Compare(r.Period, p)) with the IRange<DateOnly> comparer gives 0,1,-1,1 where memory gives 0,0,0,1. In an ordering the result is ORDER BY CASE WHEN … = @p THEN 0 WHEN … > @p THEN 1 …, the range order.
Change
Decide by the operands, not by the declared type. Strip the conversions from x and y (to IRange<T>, object, IComparable), then route the call through RangeComparerCall.RunInMemory whenever either operand is a registered range, range set or value set type, whatever compared is. RunInMemory then needs a form for IComparer<TDeclared> or the non-generic IComparer, such as an overload taking the comparer as object, or one generic in the declared type. Also update RangeComparerCall's summary, which says "over two ranges or two sets". It goes on main first, then a cherry-pick into release/10.x. It ships in 10.3.0 under #396's existing ### Changed answers row, extended to these comparers, with a line in docs/migration.md#changed-answers-in-1030.
Test
- Integration (
LowerBoundComparerQueryTests): rows forIComparer<IRange<DateOnly>>, the non-genericIComparer,Comparer<DateRange>.Create(…)asIComparer, and anIComparer<StringSet>. Each must fail translation inWhereandOrderBy, and give memory's answer in a finalSelect. - Translation (
ClientSideOnlyTranslationTests.Predicates): the same shapes inWhereandOrderBy, asserting "could not be translated". - Prove each one by reverting the fix (
verify-the-guard). The reproductions the sweep used are in the session's scratch copies.
Activity
CaffeinatedCoder commentedon Oct 25, 2026
Fixed in #453 (
main) and #454 (release/10.x, a-xcherry-pick). The rewriter now decides by the operands: either one, under its conversions to the declared type, is a range, a range set or a value set. A non-genericRunInMemoryoverload coversSystem.Collections.IComparer. On 10.x, #396's### Changed answersrow says "whatever type it is declared over" and links this issue.One thing the tests must avoid: a comparer held in a
static readonlyfield reaches EF as a constant, and EF never translatesCompareon a constant, so those rows fail translation with or without the fix. The test comparers are captured locals (query parameters), the case this issue reproduced. With the old declared-type check put back, the six new translation rows fail and the live filter answers instead of throwing.Closesacts only onmain, so once both PRs merge, close this by hand.Merge pull request #454 from CaffeinatedCoder/fix/452-comparer-by-ope…
CaffeinatedCoder commentedon Oct 25, 2026
Merged on both lines; #454 was the
release/10.xPR. Closed by hand, sinceClosesacts only onmain.Merge pull request #453 from CaffeinatedCoder/fix/452-comparer-by-ope…