Skip to content

Report a guided error for temporal operators in compiled queries - #38852

Open
buvinghausen wants to merge 1 commit into
dotnet:mainfrom
buvinghausen:fix/compiled-query-temporal-dbset-argument
Open

Report a guided error for temporal operators in compiled queries#38852
buvinghausen wants to merge 1 commit into
dotnet:mainfrom
buvinghausen:fix/compiled-query-temporal-dbset-argument

Conversation

@buvinghausen

Copy link
Copy Markdown
  • I've read the guidelines for contributing and seen the walkthrough
  • I've posted a comment on an issue with a detailed description of how I am planning to contribute and got approval from a member of the team
  • The code builds and tests pass locally (also verified by our automated build checks)
  • Commit messages follow this format
  • Tests for the changes have been added (for bug fixes / features)
  • Code follows the same patterns and style as existing code in this repo

On the second box: I filed #38851 with the full analysis and offered to contribute this, but
have not waited for team approval before opening the PR. Happy to close this and discuss the
shape on the issue first if you'd prefer — the analysis there stands on its own either way.

Fixes #38851

The bug

A temporal operator whose point in time is a compiled-query parameter throws out of parameter
extraction, before anything provider-specific runs:

System.ArgumentException: Expression of type 'IQueryable`1[City]' cannot be used for parameter
of type 'DbSet`1[City]' of method 'IQueryable`1[City] TemporalAsOf[City](DbSet`1[City], DateTime)'
   at ExpressionTreeFuncletizer.VisitMethodCall(...)

Affects all four value-taking temporal operators (TemporalAsOf, TemporalFromTo,
TemporalBetween, TemporalContainedIn) under both EF.CompileQuery and
EF.CompileAsyncQuery. TemporalAll() and constant/captured point-in-times are unaffected.

Why this reports an error rather than making it work

SqlServerQuerySqlGenerator writes the point in time into the SQL as a literal via
GenerateSqlLiteral. A compiled query caches one SQL string and reuses it across invocations,
so a value that varies per invocation has nowhere to go. Supporting that would mean emitting
FOR SYSTEM_TIME against a SQL parameter — a feature, and a separate discussion.

What is clearly a bug is that an unsupported scenario surfaces as an internal ArgumentException
about expression types instead of a guided EF error. That is what this fixes.

The change

ExpressionTreeFuncletizer.VisitMethodCall — when a rebuilt argument is no longer assignable
to a parameter declared DbSet<T>, keep the original argument. Evaluating a DbSet inlines its
query root, which is typed IQueryable<T>; that is right for Queryable.Count(IQueryable<T>) but
cannot be rebuilt into a DbSet<T> parameter. A query root needs no inlining — it already is the
root. VisitMember already declines to inline DbSet-typed members for exactly this reason
(its comment cites FromSql), but that guard is bypassed when the parent processes the member as
an evaluatable root. Scoped to DbSet<> parameters so nothing else changes behaviour.

SqlServerQueryableMethodTranslatingExpressionVisitor.VisitMethodCall — a
SqlServerDbSetExtensions call that survives to translation was never executed, which means a
compiled query. Report which operator and why.

Both parts are needed: the funcletizer throws before the preprocessor runs, so a
QueryRootProcessor cannot substitute for the first, and without the second the query falls
through to a generic "could not be translated".

Tests

TemporalCompiledQuerySqlServerTest, 9 tests on a dedicated store:

  • the five previously-throwing shapes now report the guided error
  • constant point-in-time, captured variable, TemporalAll(), and non-temporal compiled queries
    all still work

Verified locally against SQL Server 2025: EFCore.Tests 6985/0,
EFCore.SqlServer.FunctionalTests 50,862/0 failed.

🤖 Generated with Claude Code

A temporal operator whose point in time is a compiled-query parameter threw
ArgumentException out of ExpressionTreeFuncletizer, complaining that
IQueryable<T> did not fit a DbSet<T> parameter.

- ExpressionTreeFuncletizer.VisitMethodCall: when a rebuilt argument is no
  longer assignable to a parameter declared DbSet<T>, keep the original
  argument. Evaluating a DbSet inlines its query root, which is typed
  IQueryable<T>, and that cannot be rebuilt into a DbSet<T> parameter. A
  query root needs no inlining -- it already is the root. VisitMember
  already declines to inline DbSet-typed members for this reason, citing
  FromSql, but that guard is bypassed when the parent processes the member
  as an evaluatable root. Scoped to DbSet<> parameters so nothing else
  changes behaviour.
- SqlServerQueryableMethodTranslatingExpressionVisitor.VisitMethodCall: a
  SqlServerDbSetExtensions call that survives to translation was never
  executed, which means a compiled query. Report which operator, and why,
  instead of falling through to a generic "could not be translated".
- The scenario cannot be supported as-is: SqlServerQuerySqlGenerator writes
  the point in time into the SQL as a literal, and a compiled query reuses
  one cached SQL string, so a per-invocation value has nowhere to go.
  Constant and captured-variable point-in-times, and TemporalAll, are
  unaffected and keep working.

Fixes dotnet#38851
@buvinghausen
buvinghausen requested a review from a team as a code owner August 23, 2026 23:29
@AndriySvyryd AndriySvyryd added this to the 12.0.0 milestone Aug 24, 2026
@AndriySvyryd
AndriySvyryd requested a lite review from Copilot August 24, 2026 19:43
@AndriySvyryd AndriySvyryd self-assigned this Aug 24, 2026
Comment on lines +8 to +9
public class TemporalCompiledQuerySqlServerTest(TemporalCompiledQuerySqlServerFixture fixture)
: IClassFixture<TemporalCompiledQuerySqlServerFixture>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this coverage to TemporalGearsOfWarQuerySqlServerTest instead of creating a new fixture

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the SQL Server temporal query experience under EF.CompileQuery / EF.CompileAsyncQuery by preventing an internal ArgumentException during funcletization and instead surfacing a guided, provider-specific error when a temporal point-in-time/range varies per invocation (which can’t be represented in cached compiled-query SQL).

Changes:

  • Adjust ExpressionTreeFuncletizer.VisitMethodCall to avoid inlining DbSet<T>-typed arguments into IQueryable<T> when that would make MethodCallExpression.Update(...) fail for DbSet<T> parameters.
  • Add a SQL Server translation-time guard that throws a guided InvalidOperationException for temporal operators that reach translation (compiled-query expression-tree scenario).
  • Add new functional tests + fixture/store to validate the guided error and ensure supported shapes still work.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/EFCore.SqlServer.FunctionalTests/Query/TemporalCompiledQuerySqlServerTest.cs New functional tests covering guided error behavior for temporal operators under compiled queries.
test/EFCore.SqlServer.FunctionalTests/Query/TemporalCompiledQuerySqlServerFixture.cs New dedicated fixture/context/entity/store setup for the compiled temporal tests.
src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs Prevents DbSet<T> argument inlining from breaking method call rebuild for APIs declared with DbSet<T> parameters.
src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs Throws a guided SQL Server error when a temporal DbSet extension call reaches translation (compiled query scenario).
src/EFCore.SqlServer/Properties/SqlServerStrings.resx Adds a new user-facing error message resource for the compiled-query temporal limitation.
src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs Updates the generated accessor for the new resource string.
Files not reviewed (1)
  • src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs: Generated file
Suppressed comments (1)

test/EFCore.SqlServer.FunctionalTests/Query/TemporalCompiledQuerySqlServerTest.cs:70

  • This sync CompileQuery test also uses a substring match on the exception message. Since the feature change is specifically about producing a guided error message, the test should assert the full resource string for stability and to ensure the correct message is produced.
        var exception = Assert.Throws<InvalidOperationException>(() => compiled(context, PointInTime).ToList());

        Assert.Contains("compiled query", exception.Message);
    }

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +34 to +57
[Theory]
[InlineData("FromTo")]
[InlineData("Between")]
[InlineData("ContainedIn")]
public async Task Temporal_range_operators_with_query_parameters_report_a_guided_error(string op)
{
using var context = fixture.CreateContext();

Func<TemporalCompiledQueryContext, DateTime, DateTime, IAsyncEnumerable<string?>> compiled = op switch
{
"FromTo" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalFromTo(a, b).Select(x => x.Name)),
"Between" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalBetween(a, b).Select(x => x.Name)),
"ContainedIn" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalContainedIn(a, b).Select(x => x.Name)),
_ => throw new ArgumentOutOfRangeException(nameof(op)),
};

var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await compiled(context, PointInTime, LaterPointInTime).ToListAsync());

Assert.Contains("compiled query", exception.Message);
}
Comment on lines +385 to +387
<data name="TemporalOperatorRequiresConstantArgumentInCompiledQuery" xml:space="preserve">
<value>The temporal operator '{operatorName}' cannot be used in a compiled query with an argument that varies per invocation. The point in time is written into the SQL as a literal, and a compiled query reuses a single SQL string, so the argument must be a constant. Either use a constant point in time, or execute the query without EF.CompileQuery.</value>
</data>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants