Skip to content

fix: run a Compare over ranges in a query as the method the code called (#464) - #466

Merged
CaffeinatedCoder merged 1 commit into
mainfrom
fix/464-compare-original-method
Oct 25, 2026
Merged

CaffeinatedCoder merged 1 commit into
mainfrom
fix/464-compare-original-method

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

Closes #464

Problem

#385 and #452 keep a Compare(x, y) over ranges or sets out of SQL, because EF Core reads any int Compare(T, T) as the natural order (dotnet/efcore#39402). The rewrite replaced the call with RangeComparerCall.RunInMemory, and that called IComparer<T>.Compare instead of the method the code called. An outside pre-tag review of release/10.x found the first case below; the reproduction found the other two. On PostgreSQL 16, with p = [2026-03-01,2026-03-09]:

Call Before Memory
split.Compare(r.Period, p) in a Select: public Compare and explicit IComparer<T>.Compare differ 0, from the explicit implementation 1
helper.Compare(r.Period, p) on a class that implements no IComparer runs in SQL: CASE WHEN r."Period" = @p THEN 0 …, in Select and in Where its own answer
static LowerBound.Compare(r.Period, p) the same as the row above its own answer

No error in any of them. The docs promise that a comparer's Compare over ranges runs in memory only.

Change

  • RangeComparerCall: RunInMemory<TComparer, T>(comparer, x, y, int call) and RunInMemory<T>(x, y, int call) run a compiled delegate of the call as written. The delegate sits in a registry, one per method and receiver type, and the query holds only its int id. EF Core rejects a delegate constant in a client projection because the cached query would capture it, but it accepts a constant of a type it maps. A filter or an ordering still fails translation, and the message still names RangeComparerCall.RunInMemory.
  • RangeComparerRewriter: rewrites any int Compare(T, T) with a range, range set or value set operand, static or instance, whatever type declares it. That is exactly the set of calls EF Core's ComparisonTranslator takes. The IComparer/IComparer<T> lookup and the non-generic overload are gone.
  • docs/efcore.md (What runs where), docs/migration.md, claim E31 and the changelog rows (root and EF Core) cover the static Compare, a Compare on a class that isn't a comparer, and the method that runs.

Tests

  • ClientSideOnlyTranslationTests: Where and OrderBy rows for a Compare on a class that isn't a comparer, and for a static Compare.
  • LowerBoundComparerQueryTests (live): the projection test agrees with memory for all three cases (the split comparer's explicit implementation compares in the range order), and the filter/ordering test expects RunInMemory translation failures for them.
  • Reverted (the old RangeComparerCall and rewriter back, the new tests kept): 4 translation rows fail; the live filter query returns rows instead of throwing; the live projection gets server 0,1,-1,1,-1 against memory 0,0,0,1,-1 for the split comparer.
  • EF unit tests (366), NodaTime EF tests (103) and conventions (417) pass on the .NET 11 SDK RC2. Live, LowerBoundComparerQueryTests, ClientSideOnlyProjectionTests, TranslationSupportProbeTests and CapturedCollectionProbeTests pass on PostgreSQL 16. The seed lower-bound-comparer-runs-as-sql.patch still applies.

🤖 Generated with Claude Code

…ed (#464)

RangeComparerRewriter replaced comparer.Compare(x, y) with a call to IComparer<T>.Compare,
so a class whose public Compare differs from its explicit implementation answered the
explicit one's 0 where memory answers 1. It also required an instance whose type
implements IComparer, so a Compare on a class that isn't a comparer, or a static one, went
to EF Core unchanged and ran in SQL in the range order, in filters as well as projections.

The rewrite now hands RangeComparerCall.RunInMemory a compiled delegate of the call as
written, held in a registry and referenced by an int id: EF Core refuses a delegate
constant in a client projection. Filters and orderings still fail translation, and
the error still names RangeComparerCall.RunInMemory.

Found-By: RV
Defect-Class: SQL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder CaffeinatedCoder added this to the 10.3.0 milestone Oct 25, 2026
@CaffeinatedCoder CaffeinatedCoder added bug Something isn't working silent Answers wrongly without throwing — fixes go in the next major, never deferred area/ranges Range unions, variants and the shape engines area/value-sets Value set families and wrapper arities package/efcore Affects CodoMetis.ValueRanges.EFCore.PostgreSQL severity/medium Inconsistency or failure in a narrow scenario labels Oct 25, 2026
@CaffeinatedCoder
CaffeinatedCoder merged commit 5cf64ce into main Oct 25, 2026
14 of 16 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/464-compare-original-method branch October 25, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ranges Range unions, variants and the shape engines area/value-sets Value set families and wrapper arities bug Something isn't working package/efcore Affects CodoMetis.ValueRanges.EFCore.PostgreSQL severity/medium Inconsistency or failure in a narrow scenario silent Answers wrongly without throwing — fixes go in the next major, never deferred

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A Compare over ranges in a query runs another method, or runs in SQL, when it isn't IComparer's own

1 participant