Skip to content

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

Merged
AndriySvyryd merged 2 commits into
dotnet:mainfrom
buvinghausen:fix/compiled-query-temporal-dbset-argument
Oct 27, 2026
Merged

AndriySvyryd merged 2 commits into
dotnet:mainfrom
buvinghausen:fix/compiled-query-temporal-dbset-argument

Conversation

@buvinghausen

@buvinghausen buvinghausen commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor
  • 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.

@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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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.

@AndriySvyryd
AndriySvyryd marked this pull request as draft September 9, 2026 23:37
@AndriySvyryd

Copy link
Copy Markdown
Member

Marking as draft until it's updated

buvinghausen and others added 2 commits October 24, 2026 20:24
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
- Move the coverage into TemporalGearsOfWarQuerySqlServerTest instead of a
  dedicated fixture; delete TemporalCompiledQuerySqlServerFixture/Test.
- Assert the full guided message for every operator, sync and async.
- Reword the message to cover point-in-time and range operators and to
  name both EF.CompileQuery and EF.CompileAsyncQuery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@buvinghausen
buvinghausen force-pushed the fix/compiled-query-temporal-dbset-argument branch from 1d3aa61 to c710e66 Compare October 25, 2026 00:38
@buvinghausen
buvinghausen marked this pull request as ready for review October 25, 2026 00:39
@buvinghausen

Copy link
Copy Markdown
Contributor Author

@AndriySvyryd sorry for the delay on this one — I had some things come up and it sat longer than I meant it to.

Updated and rebased on main:

  • Moved the coverage into TemporalGearsOfWarQuerySqlServerTest (against Cities) and deleted the dedicated fixture.
  • Every guided-error test now asserts the full resource string, across sync CompileQuery and async CompileAsyncQuery and all four value-taking operators; the still-supported shapes (constant, captured variable, TemporalAll) assert their SQL.
  • Reworded the message to cover both point-in-time and range operators and to name both EF.CompileQuery and EF.CompileAsyncQuery.

All Temporal* SQL Server query tests pass locally.

Separately, I'd like to take on #26457 (per-fragment temporal for table splitting) for .NET 12. I'll post a design proposal there before writing any code.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The DbSet<T> fallback may regress runtime-parameterized supported extensions and needs a targeted fix and regression test.

No open issues 🎉

2 resolved since last review
1 file not reviewed
  • src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs: Generated file

Review effort: Lite

@AndriySvyryd AndriySvyryd left a comment

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.

Thanks for your contribution!

@AndriySvyryd
AndriySvyryd merged commit 3d3f730 into dotnet:main Oct 27, 2026
23 of 24 checks passed
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