Skip to content

RangeLowerBoundComparer.Compare runs as SQL in the range order: EF Core rewrites any Compare(a, b) to the natural comparison #385

Open

Description

@CaffeinatedCoder

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 same CASE.
  • 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<,>.Compare into 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, lower and lower_inc in 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

  1. added this to the 11.0.0 milestone on Oct 24, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    found-by/oracleFound by the generated-oracle machinery (claims × API surfaces)
    area/rangesRange unions, variants and the shape engines
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    severity/lowCosmetic, message or documentation-level difference
    on Oct 24, 2026
  3. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Reported upstream as dotnet/efcore#39402: EF Core's ComparisonTranslator translates any Compare(a, b) by name, so a comparer's ordering is ignored on every provider (reproduced with StringComparer.OrdinalIgnoreCase and a reversing IComparer<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 for RangeLowerBoundComparer either way.

  4. CaffeinatedCoder commented on Oct 24, 2026

    @CaffeinatedCoder
    OwnerAuthor

    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. Memory Compare gives 0,0,0,1. SQL gives 0,1,-1,1, the same CASE and WHERE r."Period" = @p as on 11. Where(… == 0) selects row 1 where memory selects rows 1, 2 and 3, also through IComparer<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 an IComparer's Compare inside a query expression. No doc shows that, and the comparer is documented as a sort helper for lists. Compare #387, which is also low but 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 main first, in 11.0.0. 10.2.0 names it under docs/efcore.md#known-differences-in-1020 with the workaround (compare loaded rows), in #397 / PR #398. #396 carries the fix to the next 10.x minor and removes that entry.

  5. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Committed for 10.3.0 through its port #396, which this issue blocks (#408, widened 2026-10-25). The fix lands here on main first, and #396 cherry-picks it into release/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, the infinity extremes, NULL operands). It can follow later as an addition, if anyone needs the comparer in SQL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/rangesRange unions, variants and the shape enginesbugSomething isn't workingfound-by/oracleFound by the generated-oracle machinery (claims × API surfaces)package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLseverity/lowCosmetic, message or documentation-level differencesilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions