Repository navigation
RangeLowerBoundComparer.Compare runs as SQL in the range order: EF Core rewrites any Compare(a, b) to the natural comparison #385
Description
Problem
RangeLowerBoundComparer<TRange, T>.Instance.Compare(a, b) compares two ranges by their lower bound in memory (docs/ranges.md#sort-ranges-the-way-a-set-does). Inside an EF Core query it runs as SQL and compares the whole range instead, so it answers differently without an error. EF Core rewrites any Compare(a, b) call as the natural comparison of its operands, whatever the comparer does. For a range that is the range order (=, <, > on daterange), not the lower-bound order.
Measured 2026-10-24 on main (EF Core 11 RC2, Npgsql 11 RC), PostgreSQL 16, with p = [2026-03-01,2026-03-10] and comparer = RangeLowerBoundComparer<DateRange, DateOnly>.Instance:
| Row | Period |
Memory comparer.Compare(Period, p) |
SQL |
|---|---|---|---|
| 1 | [2026-03-01,2026-03-10] |
0 | 0 |
| 2 | [2026-03-01,) |
0 | 1 |
| 3 | [2026-03-01,2026-03-03] |
0 | -1 |
| 4 | [2026-03-05,2026-03-06] |
1 | 1 |
Select(r => comparer.Compare(r.Period, p))→CASE WHEN r."Period" = @p THEN 0 WHEN r."Period" > @p THEN 1 WHEN r."Period" < @p THEN -1 END.Where(r => comparer.Compare(r.Period, p) == 0)→WHERE r."Period" = @p: row 1 only, where memory selects rows 1, 2 and 3.OrderBy(r => comparer.Compare(r.Period, p))orders by the sameCASE.- The same through
IComparer<DateRange>.
EF Core does this for any comparer. A comparer that reverses int order, Compare(x, y) => y.CompareTo(x), becomes WHERE r."Id" > 2 for Compare(r.Id, 2) > 0 (rows 3 and 4, where memory answers row 1). So the root cause is EF Core's, but this package's public comparer is where a consumer meets it. The package promotes the comparer for sorting and documents nothing about queries.
Documented-decision check. docs/efcore.md#what-runs-where doesn't list the comparer in either table. docs/ranges.md presents it as an in-memory sorting helper. No waiver, candidate or closed issue mentions it.
Found by TranslationSupportProbeTests (#384), for every range type (13 cells), and confirmed by hand.
Change
One of:
- Refuse it: the query interceptor rewrites a call of
RangeLowerBoundComparer<,>.Compareinto one EF Core cannot translate (or throws with a message), so a filter fails loudly and a projection runs in memory, as for the other in-memory members. - Translate it: compare
lower_inf,lowerandlower_incin SQL, the order the comparer defines.
Either way, docs/efcore.md#what-runs-where lists it. Reporting EF Core's general rewrite upstream (dotnet/efcore) is separate.
Done when
The candidates registered for TranslationSupportProbeTests against this issue report candidate fixed, and test/oracles/translation-support.json classifies the member as the fix leaves it.
Activity
CaffeinatedCoder commentedon Oct 24, 2026
Reported upstream as dotnet/efcore#39402: EF Core's
ComparisonTranslatortranslates anyCompare(a, b)by name, so a comparer's ordering is ignored on every provider (reproduced withStringComparer.OrdinalIgnoreCaseand a reversingIComparer<int>on EF Core 10.0.13 and 11.0.0-rc.2, SQLite and Npgsql). The fix here stays this package's to make forRangeLowerBoundComparereither way.CaffeinatedCoder commentedon Oct 24, 2026
It reproduces on EF Core 10. Measured 2026-10-24 on
release/10.x(9af5842), PostgreSQL 16, on EF Core 10.0.0 and on 10.0.13 with Npgsql 10.0.3, with the four rows above. MemoryComparegives0,0,0,1. SQL gives0,1,-1,1, the sameCASEandWHERE r."Period" = @pas on 11.Where(… == 0)selects row 1 where memory selects rows 1, 2 and 3, also throughIComparer<DateRange>. So 10.x consumers can meet it today. The 10.x port is #396.Severity stays
severity/low, judged on consumer impact alone. The upstream origin doesn't lower it. The impact is the worst kind, silently selecting different rows. What keeps it low is how a query reaches it: someone has to call anIComparer'sCompareinside a query expression. No doc shows that, and the comparer is documented as a sort helper for lists. Compare #387, which is alsolowbut is reached from any outer join.Release decision: deferred from 10.2.0, documented in it. The fix needs a choice between refusing the call and translating the lower-bound order, and either one should land on
mainfirst, in 11.0.0. 10.2.0 names it underdocs/efcore.md#known-differences-in-1020with the workaround (compare loaded rows), in #397 / PR #398. #396 carries the fix to the next 10.x minor and removes that entry.CaffeinatedCoder commentedon Oct 25, 2026
Committed for 10.3.0 through its port #396, which this issue blocks (#408, widened 2026-10-25). The fix lands here on
mainfirst, and #396 cherry-picks it intorelease/10.x.Plan for the choice in Change: refuse the call. A filter then fails loudly and a final projection runs in memory, as the other in-memory members do. Translating the lower-bound order (
lower_inf,lower,lower_inc) is a new translation and would need its own parity list from #408 (empty, both unbounded sides, theinfinityextremes, NULL operands). It can follow later as an addition, if anyone needs the comparer in SQL.