Skip to content

Precompiled queries: no extensibility point for provider-defined query terminal methods (blocks NativeAOT for third-party providers) #38962

Description

@ncipollina

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:

  1. 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.
  2. 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).
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions