dotnet / dotnet/efcore

Compiled query with a parameterized temporal point-in-time throws ArgumentException from ExpressionTreeFuncletizer

Open
#38,851 0 comments 0 reactions 0 assignees View on GitHub
area-compiled-query area-query area-temporal-tables
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

## Summary

`EF.CompileQuery` / `EF.CompileAsyncQuery` over a SQL Server temporal entity throws
`ArgumentException` during parameter extraction — but **only** when the temporal
operator's point-in-time is a compiled-query parameter. Constant point-in-times and
`TemporalAll()` work fine.

```
System.ArgumentException: Expression of type 'System.Linq.IQueryable`1[City]' cannot be
used for parameter of type 'Microsoft.EntityFrameworkCore.DbSet`1[City]' of method
'IQueryable`1[City] TemporalAsOf[City](DbSet`1[City], System.DateTime)' (Parameter 'arg0')
at ExpressionTreeFuncletizer.VisitMethodCall(MethodCallExpression) in ExpressionTreeFuncletizer.cs:1180
at ExpressionTreeFuncletizer.ExtractParameters(...) in ExpressionTreeFuncletizer.cs:185
```

## Root cause

`SqlServerDbSetExtensions.TemporalAsOf` is declared with `DbSet` as its first
parameter:

```csharp
public static IQueryable TemporalAsOf(this DbSet source, DateTime utcPointInTime)
```

When the `DateTime` argument is a compiled-query parameter, the method call becomes
`ContainsEvaluatable`, so `ExpressionTreeFuncletizer` processes argument 0 (`c.Cities`)
as an evaluatable root. `ProcessEvaluatableRoot` then inlines it:

```csharp
// ExpressionTreeFuncletizer.cs, ProcessEvaluatableRoot
switch (value)
{
case IQueryable { Expression: var innerExpression }:
return Visit(innerExpression); // <-- DbSet becomes an IQueryable-typed root

case Expression innerExpression when !isContextAccessor:
return Visit(innerExpression);
}
```

`methodCall.Update(...)` then cannot rebuild the call, because the rebuilt argument is
typed `IQueryable` while the parameter is declared `DbSet`.

EF already knows about this failure mode. `VisitMember` deliberately declines to inline
`DbSet`-typed members for exactly this reason:

```csharp
// Note that we only do this when the MemberExpression is typed as IQueryable/IOrderedQueryable;
// this notably excludes DbSet captured variables integrated directly into the query, as that also
// evaluates e.g. context.Order in context.Order.FromSql(), which fails.
```

That guard is bypassed when the *parent* processes the member as an evaluatable root
instead. Note also that the adjacent `case Expression` arm has an `isContextAccessor`
guard which the `IQueryable` arm does not.

## Repro matrix

Verified against unmodified `main`, SQL Server 2025, using the existing
`TemporalGearsOfWarQuerySqlServerFixture`:

| Compiled query | Result |
| --- | --- |
| `TemporalAsOf(asOf)` — point-in-time as a compiled-query parameter | **ArgumentException** |
| `TemporalFromTo(a, b)` — parameterized | **ArgumentException** |
| `TemporalBetween(a, b)` — parameterized | **ArgumentException** |
| `TemporalContainedIn(a, b)` — parameterized | **ArgumentException** |
| same, via sync `EF.CompileQuery` rather than `EF.CompileAsyncQuery` | **ArgumentException** |
| `TemporalAsOf(new DateTime(2020, 1, 1))` — constant | passes |
| `TemporalAsOf(capturedLocal)` — closure, not a lambda parameter | passes |
| `TemporalAll()` — takes no value argument | passes |
| `FromSqlRaw(...)` — same `DbSet` first-parameter shape | passes |
| no operator | passes |

So **all four value-taking temporal operators** are affected, under **both** compiled-query
entry points. Only `TemporalAll()`, which takes no argument, escapes.

The two passing workarounds are not workarounds in practice: a constant and a captured
local are both baked into the compiled query, so the point-in-time cannot vary between
invocations — which is the only reason to compile the query in the first place.

```csharp
[Fact]
public async Task Compiled_query_with_TemporalAsOf_parameterized()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileAsyncQuery(
(GearsOfWarContext c, DateTime asOf) => c.Cities.TemporalAsOf(asOf).Select(x => x.Name));

_ = await compiled(context, new DateTime(2020, 1, 1)).ToListAsync();
}
```

## Why it cannot simply be made to work

`SqlServerQuerySqlGenerator` writes the point in time into the SQL as a **literal**:

```csharp
case TemporalOperationType.AsOf:
var pointInTime = (DateTime)tableExpression.FindAnnotation(SqlServerAnnotationNames.TemporalAsOfPointInTime)!.Value!;
Sql.Append("AS OF ")
.Append(_typeMappingSource.GetMapping(typeof(DateTime)).GenerateSqlLiteral(pointInTime));
```

A compiled query caches one SQL string and reuses it across invocations, so a point in time
that varies per invocation has nowhere to go. Supporting it properly would mean emitting
`FOR SYSTEM_TIME` against a SQL parameter — a feature, not a bug fix.

So the bug here is narrower and worth fixing on its own: **an unsupported scenario reports
an internal `ArgumentException` about expression types instead of a guided EF error.**

## Proposed fix

Two parts; the first alone is not sufficient, and the second cannot substitute for the first
(`QueryRootProcessor` runs in the query preprocessor, i.e. after funcletization).

1. **`ExpressionTreeFuncletizer.VisitMethodCall`** — when a rebuilt argument is no longer
assignable to a parameter declared `DbSet`, keep the original argument. A query root
needs no inlining; it already is the root. This mirrors the existing `VisitMember` guard
and is scoped to `DbSet<>` parameters so nothing else changes behaviour.

2. **`SqlServerQueryableMethodTranslatingExpressionVisitor.VisitMethodCall`** — a
`SqlServerDbSetExtensions` call that survives to translation can only mean it was never
executed, i.e. a compiled query. Throw a guided error naming the operator instead of
falling through to a generic "could not be translated".

A branch implementing this is available with 9 tests: the five failing shapes now report the
guided error, and the four supported shapes (constant, captured variable, `TemporalAll`,
non-temporal) still work. Verified against the full SQL Server functional suite.

Happy to open it as a PR if the team would like this shape — or, if the preference is to
support a parameterized `FOR SYSTEM_TIME` instead, treat this as the bug report for the
misleading exception and I will file the feature request separately.

## Impact

Compiled queries cannot be used with a runtime-varying point-in-time — the common case
for temporal "as of" queries. The constant-valued workaround defeats the purpose.

Contributor guide

Open the contributing guide

Research direction

Start with ExpressionTreeFuncletizer.VisitMethodCall and its ProcessEvaluatableRoot handling, then inspect SqlServerQueryableMethodTranslatingExpressionVisitor.VisitMethodCall. Run the temporal compiled-query cases using TemporalGearsOfWarQuerySqlServerFixture and the existing SQL Server functional suite. Done means the affected parameterized operators report the guided error while the four supported shapes continue to pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.