Skip to content

fix: keep a comparer out of SQL by what it compares, not its declared type (#452) - #453

Merged
CaffeinatedCoder merged 2 commits into
mainfrom
fix/452-comparer-by-operand
Oct 25, 2026
Merged

CaffeinatedCoder merged 2 commits into
mainfrom
fix/452-comparer-by-operand

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

Closes #452

Problem

#385 keeps a range comparer's Compare out of SQL, but it decided by the parameter type Compare declares. A comparer declared over IRange<T>, the non-generic System.Collections.IComparer (including Comparer<DateRange>.Create(…) held as one) and any comparer of value sets declare a type RangeTypeRegistry doesn't hold, so EF Core still read the call as the natural order. With p = [2026-03-01,2026-03-10], Where(r => cmp.Compare(r.Period, p) == 0) became WHERE r."Period" = @p and returned one row where memory returns three. The changelog row, docs/efcore.md#what-runs-where and the migration guide all promise that every IComparer of ranges or sets fails translation.

Change

  • ValueRangesQueryExpressionInterceptor.RangeComparerRewriter: rewrites when either operand is a range, range set or value set. It checks the operand itself and each conversion on its way to the declared type, and reaches a sealed variant through its union. The comparer must still implement IComparer<TDeclared> or, for Compare(object, object), the non-generic IComparer.
  • RangeComparerCall: a RunInMemory(IComparer, object?, object?) overload with the same name, so EF's error message still shows RangeComparerCall.RunInMemory.
  • docs/efcore.md (What runs where) and docs/migration.md (Move RangeLowerBoundComparer out of queries) name the three kinds. Claim E31 says the operands decide.
  • The CHANGELOG.md row already says "any other IComparer of ranges or sets", so it is unchanged here. The 10.x cherry-pick links this issue from 10.x: RangeLowerBoundComparer.Compare in a query runs in the range order (port of #385) #396's row, as pr-hygiene.sh requires for a silent PR into a minor.

Tests

  • ClientSideOnlyTranslationTests: Where and OrderBy rows for an IComparer<IRange<DateOnly>>, a non-generic IComparer and an IComparer<StringSet>.
  • LowerBoundComparerQueryTests (live PostgreSQL 16): the same comparers, plus Comparer<DateRange>.Create as IComparer and a comparer class of our own. Each fails translation in Where and OrderBy with RangeComparerCall.RunInMemory in the message. In a final Select each gives memory's answer; for value sets the test adds a Tags column and compares by count against {z}.
  • Reverted: with the old declared-type check put back, the six new translation rows fail, the live filter query returns rows instead of throwing, and the projection reads the NULL row's Compare from SQL. That proves the tests catch the bug.
  • A trap the tests 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 with or without the fix. The comparers in both tests are captured locals, so they become query parameters, which is the case the review reproduced.
  • EF unit tests (330), the conventions tests (417), and TranslationSupportProbeTests, CapturedCollectionProbeTests, ClientSideOnlyProjectionTests and LowerBoundComparerQueryTests live all pass. The seed lower-bound-comparer-runs-as-sql.patch still applies.

🤖 Generated with Claude Code

… type (#452)

#385 rewrote comparer.Compare(x, y) into RangeComparerCall.RunInMemory only when the
declared parameter type was a registered range or range-set type. A comparer declared
over IRange<T>, the non-generic IComparer (Comparer<T>.Create held as one) and a
comparer of value sets declared some other type, so EF Core still read the call as
the natural order: Where(cmp.Compare(r.Period, p) == 0) became WHERE "Period" = @p
and selected one row where memory selects three.

The rewriter now looks at the operands under their conversions to the declared type
(and a sealed variant through its union), and RunInMemory gains an overload for the
non-generic IComparer. The test comparers are captured locals: EF Core leaves Compare
on a constant comparer untranslated in any case, so a static field would pass with
or without the fix.

Found-By: pre-release sweep of release/10.x (differential lens)
Defect-Class: declared-type dispatch

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/low Cosmetic, message or documentation-level difference labels Oct 25, 2026
@CaffeinatedCoder
CaffeinatedCoder merged commit af2165e into main Oct 25, 2026
14 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/452-comparer-by-operand branch October 25, 2026 19:53
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/low Cosmetic, message or documentation-level difference 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 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

1 participant