From a7653b911f0bb29724a6a4152a6e893e8fe30c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 25 Oct 2026 21:47:57 +0100 Subject: [PATCH] fix: run a Compare over ranges in a query as the method the code called (#464) RangeComparerRewriter replaced comparer.Compare(x, y) with a call to IComparer.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 --- CHANGELOG.md | 2 +- docs/efcore.md | 2 +- docs/migration.md | 9 ++- .../CHANGELOG.md | 2 +- .../Query/RangeComparerCall.cs | 69 +++++++++++++++---- .../ValueRangesQueryExpressionInterceptor.cs | 34 ++++----- .../LowerBoundComparerQueryTests.cs | 38 +++++++++- .../ClientSideOnlyTranslationTests.cs | 16 +++++ test/oracles/claims.json | 2 +- 9 files changed, 136 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3b02c..223369d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11. Not - EF Core: `Contains(T)` on a range or multirange is `false` in a query when the element is NULL: a column of a missing child in an outer join, or a nullable column read with `.Value`. Its negation dropped the row, and a projection threw ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - EF Core: `CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd` over a NULL element are NULL in a query, as EF Core's own `AddDays` is. They built `empty` or `(,)`, so `Contains(v)` selected a row without the element ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - EF Core: a value set `Union` with a NULL set, or `Remove` with a NULL set or element, is NULL in a query, as `Where(… == null)` already said. A `Select` loaded the other set instead, for a nullable column, a missing outer-join child or a `null` parameter ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-value-set-union-and-remove-over-null-in-queries)). -- EF Core: `RangeLowerBoundComparer.Compare`, and any other `IComparer` of ranges or sets, runs in memory only in a query. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)). +- EF Core: any `Compare(x, y)` over ranges or sets, `RangeLowerBoundComparer.Compare` and every other `IComparer` included, runs in memory only in a query, as the method called. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([#464](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/464), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)). ### Added diff --git a/docs/efcore.md b/docs/efcore.md index 1e90354..159f7ff 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -938,7 +938,7 @@ translation, and in the final projection EF runs them in memory instead: | The value set indexer, `set[0]` | Subscripting reads the stored array, which is canonical only for rows this library wrote ([Rows written by other tools](#rows-written-by-other-tools)). PostgreSQL arrays also start at 1, and an index past the end is `NULL` where memory throws. | | `Values` on a value set | It is the loaded array itself. In a query, use the set's own members, such as `Contains` and `Count`. | | `Count` and the indexer on a `RangeSet` | Not translated. PostgreSQL has no function for either, and each would be a subquery over `unnest(x)`. | -| `Compare` on `RangeLowerBoundComparer`, `RangeSet.LowerBoundComparer` and any other `IComparer` of ranges or sets | EF Core reads every `Compare(x, y)` as the natural order of `x` and `y`, whatever the comparer. In SQL the lower-bound comparer would compare in the range order. What counts is what you compare, not the type the comparer is declared over: an `IComparer>`, a non-generic `IComparer` and a comparer of value sets run in memory too. For the range order in SQL, call `x.CompareTo(y)`. | +| `Compare` on `RangeLowerBoundComparer`, `RangeSet.LowerBoundComparer`, any other `IComparer` of ranges or sets, and any other `Compare(x, y)` over them | EF Core reads every `Compare(x, y)` as the natural order of `x` and `y`, whatever the comparer. In SQL the lower-bound comparer would compare in the range order. What counts is what you compare, not the type the comparer is declared over: an `IComparer>`, a non-generic `IComparer` and a comparer of value sets run in memory too. So do a `Compare` method on a class that isn't a comparer and a static `Compare`. The query runs the method you called, so a class whose public `Compare` differs from its `IComparer.Compare` gets the public one, as in memory. For the range order in SQL, call `x.CompareTo(y)`. | | `From` and `Parse` on any range or set | They build a value in .NET. Given values your code passes in, EF Core runs them before the query and sends the result as a parameter. | | `ToInstantRange`, `ToInterval`, `ToLocalDateRange`, `ToDateInterval` and `ToYearMonthRange` (NodaTime package) | They convert in .NET: between NodaTime's `Interval` or `DateInterval` and a range type, or between `LocalDateRange` and `YearMonthRange`. | diff --git a/docs/migration.md b/docs/migration.md index f27f940..a6b2d12 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -996,10 +996,13 @@ in SQL in that order. Now it runs in memory only, like the other members under `Comparer.Default.Compare` runs in memory too. Once the comparer is a query parameter, a query can't tell one comparer from another. So do comparers declared over another type, when what they compare is a range or a set: an `IComparer>`, a non-generic `IComparer` (such as -`Comparer.Create(…)` held as one) and an `IComparer`. +`Comparer.Create(…)` held as one) and an `IComparer`. The same goes for any +other method named `Compare` that takes two ranges or two sets, such as a static helper or one on a +class that isn't a comparer. The query runs the method you called, so a class whose public `Compare` +differs from its `IComparer.Compare` gets the public one, as in memory. -- **Search your queries for `Compare(`.** Each call on a comparer of ranges or sets in a `Where` or - an `OrderBy` now throws, so running the query finds it. +- **Search your queries for `Compare(`.** Each `Compare` over ranges or sets in a `Where` or an + `OrderBy` now throws, so running the query finds it. - **Filter and sort by the lower bound itself.** `LowerBound()` and `LowerBoundInclusive()` run as SQL, so `Where(r => r.Period.LowerBound() == day)` and `OrderBy(r => r.Period.LowerBound())` work. An ordering puts the rows without a lower bound where PostgreSQL puts NULL, last ascending diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index 49aeaf5..3847a8b 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -16,7 +16,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11, EF - EF Core: `Contains(T)` on a range or multirange is `false` in a query when the element is NULL: a column of a missing child in an outer join, or a nullable column read with `.Value`. Its negation dropped the row, and a projection threw ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - EF Core: `CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd` over a NULL element are NULL in a query, as EF Core's own `AddDays` is. They built `empty` or `(,)`, so `Contains(v)` selected a row without the element ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - EF Core: a value set `Union` with a NULL set, or `Remove` with a NULL set or element, is NULL in a query, as `Where(… == null)` already said. A `Select` loaded the other set instead, for a nullable column, a missing outer-join child or a `null` parameter ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-value-set-union-and-remove-over-null-in-queries)). -- EF Core: `RangeLowerBoundComparer.Compare`, and any other `IComparer` of ranges or sets, runs in memory only in a query. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)). +- EF Core: any `Compare(x, y)` over ranges or sets, `RangeLowerBoundComparer.Compare` and every other `IComparer` included, runs in memory only in a query, as the method called. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([#464](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/464), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)). ### Added diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/RangeComparerCall.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/RangeComparerCall.cs index 4289f93..70e9298 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/RangeComparerCall.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/RangeComparerCall.cs @@ -1,31 +1,72 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; using System.Reflection; namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// -/// The form an 's or 's Compare -/// over a range, a range set or a value set takes in a query -/// once has seen it: a call no translator knows, so -/// it runs in memory or not at all (#385). +/// The form a Compare(x, y) over a range, a range set or a value set takes in a query once +/// has seen it: a call no translator knows, so it +/// runs in memory or not at all (#385). /// /// /// EF Core reads any Compare(x, y) as the natural order of its operands, so /// ran in SQL in the range order instead of by lower /// bound. Under this name EF Core fails to translate the call in a filter or an ordering, and its message -/// shows RangeComparerCall.RunInMemory(…); in a final projection it calls the comparer on the rows -/// it loaded, which is the answer the same code gives in memory. +/// shows RangeComparerCall.RunInMemory(…); in a final projection it runs the call on the rows it +/// loaded, which is the answer the same code gives in memory. The call runs as written: the method the +/// code called, not , which a class may implement differently, and +/// which a Compare on a class that is no comparer, or a static one, does not have (#464). /// internal static class RangeComparerCall { - /// 's own . - public static int RunInMemory(IComparer comparer, T x, T y) => comparer.Compare(x, y); + /// The call stands for, on . + public static int RunInMemory(TComparer comparer, T x, T y, int call) => ((Func)Calls[call])(comparer, x, y); - /// 's own . - public static int RunInMemory(System.Collections.IComparer comparer, object? x, object? y) => comparer.Compare(x, y); + /// The static call stands for. + public static int RunInMemory(T x, T y, int call) => ((Func)Calls[call])(x, y); - internal static readonly MethodInfo RunInMemoryMethod = - typeof(RangeComparerCall).GetMethods().Single(method => method is { Name: nameof(RunInMemory), IsGenericMethodDefinition: true }); + private static readonly MethodInfo InstanceMethod = + typeof(RangeComparerCall).GetMethods().Single(method => method is { Name: nameof(RunInMemory) } && method.GetGenericArguments().Length == 2); - internal static readonly MethodInfo RunInMemoryNonGenericMethod = - typeof(RangeComparerCall).GetMethod(nameof(RunInMemory), [typeof(System.Collections.IComparer), typeof(object), typeof(object)])!; + private static readonly MethodInfo StaticMethod = + typeof(RangeComparerCall).GetMethods().Single(method => method is { Name: nameof(RunInMemory) } && method.GetGenericArguments().Length == 1); + + // The query holds the call as a number: EF Core refuses a delegate constant in a client projection, + // since it would be captured by the cached query, and accepts a constant of a type it maps. One + // delegate per method and receiver type; a race may compile one twice, and the extra is never used. + private static readonly ConcurrentDictionary<(MethodInfo Method, Type? Receiver), int> Ids = new(); + + private static readonly ConcurrentDictionary Calls = new(); + + private static int _lastId; + + /// + /// called on ( for a + /// static method) with and , as a RunInMemory call. + /// + public static MethodCallExpression Of(MethodInfo method, Expression? comparer, Expression x, Expression y) + { + var compared = method.GetParameters()[0].ParameterType; + var call = Expression.Constant(Ids.GetOrAdd((method, comparer?.Type), static key => + { + var id = Interlocked.Increment(ref _lastId); + Calls[id] = Compile(key.Method, key.Receiver); + return id; + })); + + return comparer is null + ? Expression.Call(StaticMethod.MakeGenericMethod(compared), x, y, call) + : Expression.Call(InstanceMethod.MakeGenericMethod(comparer.Type, compared), comparer, x, y, call); + } + + private static Delegate Compile(MethodInfo method, Type? receiverType) + { + var (x, y) = (Expression.Parameter(method.GetParameters()[0].ParameterType), Expression.Parameter(method.GetParameters()[1].ParameterType)); + if (receiverType is null) + return Expression.Lambda(Expression.Call(method, x, y), x, y).Compile(); + + var receiver = Expression.Parameter(receiverType); + return Expression.Lambda(Expression.Call(receiver, method, x, y), receiver, x, y).Compile(); + } } diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs index 425fe40..a97fe5f 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs @@ -27,9 +27,9 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// read-only collection — see . /// /// -/// A sixth keeps an over ranges or sets, -/// among them, out of SQL, where EF Core would read it as the -/// natural order — see . +/// A sixth keeps a Compare(x, y) over ranges or sets, an 's and +/// 's among them, out of SQL, where EF Core would read it as +/// the natural order — see . /// /// /// The value set refusals — a server-computed Union consumed by an order- or @@ -363,9 +363,10 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } /// - /// Turns comparer.Compare(x, y) over a range, a range set or a value set into - /// , which EF Core cannot translate: a filter or an - /// ordering then fails translation, and a final projection runs the comparer in memory (#385). + /// Turns comparer.Compare(x, y) or a static Compare(x, y) over a range, a range set or a + /// value set into , which EF Core cannot + /// translate: a filter or an ordering then fails translation, and a final projection runs the call in + /// memory (#385). /// /// /// @@ -391,28 +392,29 @@ protected override Expression VisitMethodCall(MethodCallExpression node) /// (#452). So a call is rewritten when either operand, under the conversions to the declared type, is a /// range, a range set or a value set. /// + /// + /// Nor does the declaring type decide whether a call is rewritten, or which method runs. EF Core + /// translates every int Compare(T, T), static or not, a comparer's or not, and a class may + /// implement explicitly and differently from its public + /// Compare. Calling in place of the method the code called + /// answered the explicit implementation's 0 where memory answered 1, and a Compare on a class + /// that is no comparer, or a static one, was not rewritten and ran in SQL in the range order (#464). So + /// runs the call as written. + /// /// private sealed class RangeComparerRewriter : ExpressionVisitor { public static readonly RangeComparerRewriter Instance = new(); protected override Expression VisitMethodCall(MethodCallExpression node) - => node is { Object: { } comparer, Method: { Name: nameof(IComparer<>.Compare) } method, Arguments: [var x, var y] } + => node is { Method: { Name: nameof(IComparer<>.Compare) } method, Arguments: [var x, var y] } && method.ReturnType == typeof(int) && method.GetParameters() is [{ ParameterType: var compared }, { ParameterType: var other }] && compared == other && (IsRangeOrSet(x) || IsRangeOrSet(y)) - && RunInMemory(comparer.Type, compared) is { } runInMemory - ? Expression.Call(runInMemory, Visit(comparer), Visit(x), Visit(y)) + ? RangeComparerCall.Of(method, node.Object is { } comparer ? Visit(comparer) : null, Visit(x), Visit(y)) : base.VisitMethodCall(node); - private static MethodInfo? RunInMemory(Type comparerType, Type compared) - => typeof(IComparer<>).MakeGenericType(compared).IsAssignableFrom(comparerType) - ? RangeComparerCall.RunInMemoryMethod.MakeGenericMethod(compared) - : compared == typeof(object) && typeof(System.Collections.IComparer).IsAssignableFrom(comparerType) - ? RangeComparerCall.RunInMemoryNonGenericMethod - : null; - // The operand and every conversion it passes through on its way to the declared parameter type: // a column converted to IRange, object or IComparable is still a range. private static bool IsRangeOrSet(Expression operand) diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LowerBoundComparerQueryTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LowerBoundComparerQueryTests.cs index 395f4b3..dd660a8 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LowerBoundComparerQueryTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LowerBoundComparerQueryTests.cs @@ -18,7 +18,10 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; /// cannot tell apart once it is a parameter; goes the same way. A NULL /// column sorts first, as the comparer says. The operands decide, not the type Compare declares: a /// comparer over , the non-generic and a -/// comparer of value sets each still compared in the range order (#452). By count, {z} is equal to +/// comparer of value sets each still compared in the range order (#452). Nor the declaring type: the call +/// runs as written, so a public Compare beside a different explicit +/// answers as itself, and a Compare on a class that is no +/// comparer, or a static one, runs in memory too (#464). By count, {z} is equal to /// rows 1 and 2, where SQL's = selected none. /// [TestClass] @@ -84,6 +87,13 @@ async Task Agree(string label, Expression> compare) await Agree("Comparer.Create as IComparer", r => created.Compare(r.Period, p)); await Agree("own IComparer", r => ownNonGeneric.Compare(r.Period, p)); await Agree("IComparer.Compare", r => byCount.Compare(r.Tags, oneTag)); + + // The call runs as written (#464): the public Compare, not the explicit IComparer.Compare that + // answers in the range order, and a Compare on a class that is no comparer, or a static one. + var (split, helper) = (new SplitComparer(), new LowerBoundHelper()); + await Agree("public Compare beside an explicit IComparer.Compare", r => split.Compare(r.Period, p)); + await Agree("Compare on a class that is no comparer", r => helper.Compare(r.Period, p)); + await Agree("static Compare", r => LowerBound.Compare(r.Period, p)); } [TestMethod] @@ -98,6 +108,7 @@ public async Task Compare_InAFilterOrAnOrdering_FailsTranslation() // As locals, so each comparer is a query parameter. EF Core leaves a Compare on a constant comparer // (a static field) untranslated in any case, and the range order needs a parameter (#452). var (overInterface, created, ownNonGeneric, byCount, oneTag) = (OverInterface, Created, OwnNonGeneric, ByCount, OneTag); + var (split, helper) = (new SplitComparer(), new LowerBoundHelper()); IQueryable[] queries = [ @@ -113,6 +124,11 @@ public async Task Compare_InAFilterOrAnOrdering_FailsTranslation() context.Rows.OrderBy(r => ownNonGeneric.Compare(r.Period, p)), context.Rows.Where(r => byCount.Compare(r.Tags, oneTag) == 0), context.Rows.OrderBy(r => byCount.Compare(r.Tags, oneTag)), + context.Rows.Where(r => split.Compare(r.Period, p) == 0), + context.Rows.Where(r => helper.Compare(r.Period, p) == 0), + context.Rows.OrderBy(r => helper.Compare(r.Period, p)), + context.Rows.Where(r => LowerBound.Compare(r.Period, p) == 0), + context.Rows.OrderBy(r => LowerBound.Compare(r.Period, p)), ]; foreach (var query in queries) @@ -147,6 +163,26 @@ private sealed class LowerBoundObjectComparer : System.Collections.IComparer public int Compare(object? x, object? y) => RangeLowerBoundComparer.Instance.Compare((DateRange?)x, (DateRange?)y); } + // Its public Compare orders by lower bound; IComparer.Compare, implemented apart, in the range order. + private sealed class SplitComparer : IComparer + { + public int Compare(DateRange? x, DateRange? y) => RangeLowerBoundComparer.Instance.Compare(x, y); + + int IComparer.Compare(DateRange? x, DateRange? y) => Comparer.Default.Compare(x, y); + } + + // EF Core reads a Compare by its name and signature alone, on a class that implements no IComparer + // and on a static one. + private sealed class LowerBoundHelper + { + public int Compare(DateRange? x, DateRange? y) => RangeLowerBoundComparer.Instance.Compare(x, y); + } + + private static class LowerBound + { + public static int Compare(DateRange? x, DateRange? y) => RangeLowerBoundComparer.Instance.Compare(x, y); + } + private sealed class ComparerContext(string connectionString) : DbContext { public DbSet Rows => Set(); diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClientSideOnlyTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClientSideOnlyTranslationTests.cs index 77f7946..f38866d 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClientSideOnlyTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ClientSideOnlyTranslationTests.cs @@ -43,6 +43,11 @@ public sealed class ClientSideOnlyTranslationTests ["OrderBy(IComparer.Compare)", (Func>)(db => { var comparer = NonGeneric; return db.Bookings.OrderBy(b => comparer.Compare(b.Period, June)); })], ["IComparer.Compare", (Func>)(db => { var comparer = OverValueSets; return db.Bookings.Where(b => comparer.Compare(b.Tags, OneTag) == 0); })], ["OrderBy(IComparer.Compare)", (Func>)(db => { var comparer = OverValueSets; return db.Bookings.OrderBy(b => comparer.Compare(b.Tags, OneTag)); })], + // Nor the declaring type: a Compare on a class that is no comparer, and a static one, ran in SQL in the range order (#464). + ["NotAComparer.Compare", (Func>)(db => { var helper = new ByLowerBound(); return db.Bookings.Where(b => helper.Compare(b.Period, June) == 0); })], + ["OrderBy(NotAComparer.Compare)", (Func>)(db => { var helper = new ByLowerBound(); return db.Bookings.OrderBy(b => helper.Compare(b.Period, June)); })], + ["static Compare", (Func>)(db => db.Bookings.Where(b => LowerBound.Compare(b.Period, June) == 0))], + ["OrderBy(static Compare)", (Func>)(db => db.Bookings.OrderBy(b => LowerBound.Compare(b.Period, June)))], ]; private static readonly IComparer> OverInterface = @@ -56,6 +61,17 @@ public sealed class ClientSideOnlyTranslationTests private static readonly DateRange June = DateRange.Month(2026, 6); + // Compare methods EF Core reads by name and signature alone: on a class that implements no IComparer, and static. + private sealed class ByLowerBound + { + public int Compare(DateRange x, DateRange y) => RangeLowerBoundComparer.Instance.Compare(x, y); + } + + private static class LowerBound + { + public static int Compare(DateRange x, DateRange y) => RangeLowerBoundComparer.Instance.Compare(x, y); + } + [TestMethod] [DynamicData(nameof(Predicates))] public void ClientSideOnlyOperation_InWhere_FailsTranslation(string operation, Func> query) diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f1166a9..d4a5499 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -583,7 +583,7 @@ "id": "E31-range-client-side-only-operations", "source": "docs/efcore.md#what-runs-where", "quote": "**Runs in memory only.** These compute the right answer in memory. In a predicate they fail translation, and in the final projection EF runs them in memory instead:", - "claim": "Length (on every range type but Int32Range and Int64Range), Values(), Values(step), Split, Clamp (on every range type but Int32Range, Int64Range and DecimalRange), Shift, Expand, ToRangeSet()/ToInt32Set(), the set indexer and Compare on RangeLowerBoundComparer, RangeSet.LowerBoundComparer and any IComparer of ranges or sets (decided by the operands, so also a comparer declared over IRange, the non-generic IComparer and a comparer of value sets) each throw an EF translation exception inside a Where (an OrderBy for the comparer) and evaluate on the client with the in-memory answer inside a Select.", + "claim": "Length (on every range type but Int32Range and Int64Range), Values(), Values(step), Split, Clamp (on every range type but Int32Range, Int64Range and DecimalRange), Shift, Expand, ToRangeSet()/ToInt32Set(), the set indexer and Compare on RangeLowerBoundComparer, RangeSet.LowerBoundComparer and any IComparer of ranges or sets (decided by the operands, so also a comparer declared over IRange, the non-generic IComparer and a comparer of value sets), and any other Compare(x, y) over ranges or sets (a static one, or one on a class that is no comparer, running the method called rather than IComparer.Compare) each throw an EF translation exception inside a Where (an OrderBy for the comparer) and evaluate on the client with the in-memory answer inside a Select.", "oracle": "exception", "surfaces": [ "*Range.Length",