Context
We maintain LayeredCraft/dynamodb-efcore-provider, an open-source EF Core provider for Amazon DynamoDB. We're actively adding EF Core precompiled-query and NativeAOT support (tracking work: PR #318), and have hit a hard blocker that we believe is a genuine EF Core extensibility gap rather than something a provider can work around on its own.
Summary
EF Core's precompiled-query discovery mechanism (Microsoft.EntityFrameworkCore.Query.Internal.QueryLocator) recognizes query-terminal method calls via a closed, hardcoded set of method names, gated by checking that the method's declaring type is exactly Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions, System.Linq.Queryable, or System.Linq.Enumerable. There is no way for a database provider to register its own terminal operator (an extension method it defines, analogous to FirstOrDefaultAsync/ToListAsync but provider-specific) so that it participates in precompiled-query generation. Under NativeAOT, a query whose terminal isn't discovered this way is silently skipped at build time and then throws at runtime the first time it executes, because there is no generated interceptor and no JIT fallback available.
Concrete motivating example
DynamoDB pagination is expressed via a provider-defined terminal:
var firstPage = await query.ToPageAsync(pageSize, null, cancellationToken);
var secondPage = await query.ToPageAsync(pageSize, firstPage.NextToken, cancellationToken);
ToPageAsync needs to be a distinct terminal (not ToListAsync) because DynamoDB pagination requires returning both the materialized items and a continuation token (NextToken) in a single result — ToListAsync() alone can't carry that second value out.
- The provider already translates and executes
ToPageAsync(...) correctly at normal runtime — see DynamoQueryableMethodTranslatingExpressionVisitor.Translate(), which special-cases ToPageAsync() as a top-level terminal and produces a DynamoPagingExpression shaped-query result. This machinery is unaffected by precompilation — it's shared with normal query execution.
- The provider's own
IPrecompiledQueryCodeGenerator implementation (DynamoPrecompiledQueryCodeGenerator) is fully wired up and already successfully precompiles other provider-defined fluent operators (Limit(n), WithNextToken(...), including parameterized/runtime-varying arguments) — see the generation tests in PrecompiledQueryGenerationTests.cs.
ToPageAsync specifically never reaches our generator at all, because QueryLocator never identifies it as a query root in the first place.
Root cause (traced via decompilation of Microsoft.EntityFrameworkCore.Design.dll)
QueryLocator.VisitInvocationExpression is a hardcoded switch on the invoked method's name:
switch (text) // text == identifier.Text, e.g. "ToListAsync"
{
case "ToArrayAsync":
case "ToDictionaryAsync":
case "ToHashSetAsync":
case "ToListAsync":
num = 4;
goto IL_088a; // requires IsOnEfQueryableExtensions()
case "AverageAsync":
case "FirstAsync":
// ... more EF-defined async terminals
if (!IsOnEfQueryableExtensions()) break;
goto IL_089e;
// ... Queryable/Enumerable-declared terminals similarly gated
}
where IsOnEfQueryableExtensions() / IsOnQueryable() / IsOnEnumerable() resolve the invoked method's symbol and compare ContainingType.OriginalDefinition by identity against Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions, System.Linq.Queryable, and System.Linq.Enumerable respectively. A method that isn't in this name list — or that is named identically to a recognized terminal but declared on any other type, including a provider's own extension class — falls through to ((CSharpSyntaxWalker)this).VisitInvocationExpression(invocation): ordinary tree traversal, not query-root registration. No error, no diagnostic — the call is simply invisible to the precompiler.
We also confirmed there is no way to work around this from a provider:
QueryLocator is constructed directly in PrecompiledQueryCodeGenerator's constructor — _queryLocator = new QueryLocator(); — as a private readonly field. It is not DI-injected and there is no virtual factory method a provider's IPrecompiledQueryCodeGenerator subclass could override to substitute a different locator.
QueryLocator.VisitInvocationExpression and QueryLocator.LocateQueries are public virtual, so a provider could subclass QueryLocator — but the actual validation logic it would need to call after recognizing a candidate (ProcessQueryCandidate, IsDbContext, IsQueryable, IsDbSet) is all private, so a subclass can't participate in or reuse it.
- Because
_queryLocator itself is private and non-substitutable, the only way to make a provider-defined terminal discoverable today is to bypass QueryLocator entirely: reimplement an equivalent CSharpSyntaxWalker from scratch (duplicating the DbContext/DbSet-chain resolution and the terminal-name recognition for the other, EF-defined terminals a consumer might also use in the same file, so their detection isn't regressed), then feed the result into PrecompiledQueryCodeGenerator.ProcessSyntaxTree (which is protected virtual and reachable).
We consider that workaround unsafe to ship: QueryLocator (like the rest of Microsoft.EntityFrameworkCore.Query.Internal) is explicitly documented as an internal API "not subject to the same compatibility standards as public APIs... may be changed or removed without notice in any release." A provider-side reimplementation would have to track its exact behavior across every EF Core release with no compile-time signal when it silently diverges.
Expected behavior
A database provider should have a supported way to declare "this provider-defined method is a query-terminal operation; pass it through the normal precompiled-query pipeline" — without reimplementing EF Core's internal Roslyn-based query-location logic.
Actual behavior
- EF Core 10 (10.0.12) and EF Core 11 (11.0.0-rc.1.26425.128): identical behavior in both — confirmed via
DynamoPrecompiledQueryCodeGenerator.GeneratePrecompiledQueries returning 0 errors and 0 generated files for a query ending in ToPageAsync(...) (not a translation error — the call is never even located as a candidate).
- Under a normal (non-NativeAOT) published app, this is invisible: EF Core falls back to its runtime query-compilation path, and
ToPageAsync(...) works correctly.
- Under
PublishAot=true, a query with no generated interceptor has no fallback. We confirmed empirically that it throws at the first call site that reaches it:
System.InvalidOperationException: Query wasn't precompiled and dynamic code isn't supported with NativeAOT.
dotnet publish succeeds; the application builds and ships; the failure only surfaces the first time a real user (or an automated smoke test) exercises the pagination code path. This prevents the provider from supporting this terminal under NativeAOT, even though its translation and execution pipeline already supports the operation.
The provider's IPrecompiledQueryCodeGenerator already successfully handles provider-defined fluent query operators such as Limit and WithNextToken once the enclosing query is discovered through a recognized EF terminal. The missing capability is allowing a provider-defined terminal itself to establish that query root.
Possible extensibility directions (not prescribing a specific implementation)
Any of the following would unblock us; we'd defer to EF Core maintainers on which fits the codebase best:
- Allow a provider (e.g., via its
IPrecompiledQueryCodeGenerator or a related design-time service) to register additional terminal method names/symbols that QueryLocator should treat as query roots.
- Make query-root discovery itself injectable/replaceable (e.g., resolve
QueryLocator through DI, or make its construction go through a protected virtual factory method on PrecompiledQueryCodeGenerator).
- Expose a provider callback invoked while
QueryLocator is walking a syntax tree, asked "is this invocation a query candidate?", so providers can opt a call in without reimplementing chain validation themselves.
Ideally, once a provider-defined invocation is recognized as a query root, it should flow into that provider's own IPrecompiledQueryCodeGenerator exactly as EF-defined terminals do today, requiring no further changes on the discovery side.
Offer to validate
We're happy to validate any proposed mechanism against our provider as a real, external, non-trivial third-party test case — we already have full precompiled-query and NativeAOT test coverage in place (PrecompiledQueryGenerationTests.cs, NativeAOT smoke app) that this would let us extend to cover ToPageAsync end to end.
Happy to provide a minimal standalone repro project if useful, separate from the full provider.
Context
We maintain
LayeredCraft/dynamodb-efcore-provider, an open-source EF Core provider for Amazon DynamoDB. We're actively adding EF Core precompiled-query and NativeAOT support (tracking work: PR #318), and have hit a hard blocker that we believe is a genuine EF Core extensibility gap rather than something a provider can work around on its own.Summary
EF Core's precompiled-query discovery mechanism (
Microsoft.EntityFrameworkCore.Query.Internal.QueryLocator) recognizes query-terminal method calls via a closed, hardcoded set of method names, gated by checking that the method's declaring type is exactlyMicrosoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions,System.Linq.Queryable, orSystem.Linq.Enumerable. There is no way for a database provider to register its own terminal operator (an extension method it defines, analogous toFirstOrDefaultAsync/ToListAsyncbut provider-specific) so that it participates in precompiled-query generation. Under NativeAOT, a query whose terminal isn't discovered this way is silently skipped at build time and then throws at runtime the first time it executes, because there is no generated interceptor and no JIT fallback available.Concrete motivating example
DynamoDB pagination is expressed via a provider-defined terminal:
ToPageAsyncneeds to be a distinct terminal (notToListAsync) because DynamoDB pagination requires returning both the materialized items and a continuation token (NextToken) in a single result —ToListAsync()alone can't carry that second value out.ToPageAsync(...)correctly at normal runtime — seeDynamoQueryableMethodTranslatingExpressionVisitor.Translate(), which special-casesToPageAsync()as a top-level terminal and produces aDynamoPagingExpressionshaped-query result. This machinery is unaffected by precompilation — it's shared with normal query execution.IPrecompiledQueryCodeGeneratorimplementation (DynamoPrecompiledQueryCodeGenerator) is fully wired up and already successfully precompiles other provider-defined fluent operators (Limit(n),WithNextToken(...), including parameterized/runtime-varying arguments) — see the generation tests inPrecompiledQueryGenerationTests.cs.ToPageAsyncspecifically never reaches our generator at all, becauseQueryLocatornever identifies it as a query root in the first place.Root cause (traced via decompilation of
Microsoft.EntityFrameworkCore.Design.dll)QueryLocator.VisitInvocationExpressionis a hardcodedswitchon the invoked method's name:where
IsOnEfQueryableExtensions()/IsOnQueryable()/IsOnEnumerable()resolve the invoked method's symbol and compareContainingType.OriginalDefinitionby identity againstMicrosoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions,System.Linq.Queryable, andSystem.Linq.Enumerablerespectively. A method that isn't in this name list — or that is named identically to a recognized terminal but declared on any other type, including a provider's own extension class — falls through to((CSharpSyntaxWalker)this).VisitInvocationExpression(invocation): ordinary tree traversal, not query-root registration. No error, no diagnostic — the call is simply invisible to the precompiler.We also confirmed there is no way to work around this from a provider:
QueryLocatoris constructed directly inPrecompiledQueryCodeGenerator's constructor —_queryLocator = new QueryLocator();— as aprivate readonlyfield. It is not DI-injected and there is no virtual factory method a provider'sIPrecompiledQueryCodeGeneratorsubclass could override to substitute a different locator.QueryLocator.VisitInvocationExpressionandQueryLocator.LocateQueriesarepublic virtual, so a provider could subclassQueryLocator— but the actual validation logic it would need to call after recognizing a candidate (ProcessQueryCandidate,IsDbContext,IsQueryable,IsDbSet) is allprivate, so a subclass can't participate in or reuse it._queryLocatoritself is private and non-substitutable, the only way to make a provider-defined terminal discoverable today is to bypassQueryLocatorentirely: reimplement an equivalentCSharpSyntaxWalkerfrom scratch (duplicating the DbContext/DbSet-chain resolution and the terminal-name recognition for the other, EF-defined terminals a consumer might also use in the same file, so their detection isn't regressed), then feed the result intoPrecompiledQueryCodeGenerator.ProcessSyntaxTree(which isprotected virtualand reachable).We consider that workaround unsafe to ship:
QueryLocator(like the rest ofMicrosoft.EntityFrameworkCore.Query.Internal) is explicitly documented as an internal API "not subject to the same compatibility standards as public APIs... may be changed or removed without notice in any release." A provider-side reimplementation would have to track its exact behavior across every EF Core release with no compile-time signal when it silently diverges.Expected behavior
A database provider should have a supported way to declare "this provider-defined method is a query-terminal operation; pass it through the normal precompiled-query pipeline" — without reimplementing EF Core's internal Roslyn-based query-location logic.
Actual behavior
DynamoPrecompiledQueryCodeGenerator.GeneratePrecompiledQueriesreturning0errors and0generated files for a query ending inToPageAsync(...)(not a translation error — the call is never even located as a candidate).ToPageAsync(...)works correctly.PublishAot=true, a query with no generated interceptor has no fallback. We confirmed empirically that it throws at the first call site that reaches it:dotnet publishsucceeds; the application builds and ships; the failure only surfaces the first time a real user (or an automated smoke test) exercises the pagination code path. This prevents the provider from supporting this terminal under NativeAOT, even though its translation and execution pipeline already supports the operation.The provider's
IPrecompiledQueryCodeGeneratoralready successfully handles provider-defined fluent query operators such asLimitandWithNextTokenonce the enclosing query is discovered through a recognized EF terminal. The missing capability is allowing a provider-defined terminal itself to establish that query root.Possible extensibility directions (not prescribing a specific implementation)
Any of the following would unblock us; we'd defer to EF Core maintainers on which fits the codebase best:
IPrecompiledQueryCodeGeneratoror a related design-time service) to register additional terminal method names/symbols thatQueryLocatorshould treat as query roots.QueryLocatorthrough DI, or make its construction go through a protected virtual factory method onPrecompiledQueryCodeGenerator).QueryLocatoris walking a syntax tree, asked "is this invocation a query candidate?", so providers can opt a call in without reimplementing chain validation themselves.Ideally, once a provider-defined invocation is recognized as a query root, it should flow into that provider's own
IPrecompiledQueryCodeGeneratorexactly as EF-defined terminals do today, requiring no further changes on the discovery side.Offer to validate
We're happy to validate any proposed mechanism against our provider as a real, external, non-trivial third-party test case — we already have full precompiled-query and NativeAOT test coverage in place (
PrecompiledQueryGenerationTests.cs, NativeAOT smoke app) that this would let us extend to coverToPageAsyncend to end.Happy to provide a minimal standalone repro project if useful, separate from the full provider.