Skip to content

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

Closed

Description

@CaffeinatedCoder

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 own Comparer<DateRange>.Create(…) held in an IComparer variable;
  • any comparer over a value set, such as IComparer<StringSet>. Value sets are in SetTypeRegistry, not RangeTypeRegistry.

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 for IComparer<IRange<DateOnly>>, the non-generic IComparer, Comparer<DateRange>.Create(…) as IComparer, and an IComparer<StringSet>. Each must fail translation in Where and OrderBy, and give memory's answer in a final Select.
  • Translation (ClientSideOnlyTranslationTests.Predicates): the same shapes in Where and OrderBy, 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

  1. added this to the 10.3.0 milestone on Oct 25, 2026
  2. added
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    area/rangesRange unions, variants and the shape engines
    area/value-setsValue set families and wrapper arities
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    severity/lowCosmetic, message or documentation-level difference
    on Oct 25, 2026
  3. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Fixed in #453 (main) and #454 (release/10.x, a -x cherry-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-generic RunInMemory overload covers System.Collections.IComparer. On 10.x, #396's ### Changed answers row says "whatever type it is declared over" and links this issue.

    One thing the tests must avoid: a comparer held in a static readonly field reaches EF as a constant, and EF never translates Compare on 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.

    Closes acts only on main, so once both PRs merge, close this by hand.

  4. added a commit that references this issue on Oct 25, 2026
  5. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Merged on both lines; #454 was the release/10.x PR. Closed by hand, since Closes acts only on main.

  6. added a commit that references this issue on Oct 25, 2026
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 enginesarea/value-setsValue set families and wrapper aritiesbugSomething isn't workingpackage/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

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions