dotnet / dotnet/efcore

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

Open
#38,962 0 comments 0 reactions 1 assignee Claimed by @AndriySvyryd View on GitHub
area-aot needs-design
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

## Context

We maintain [`LayeredCraft/dynamodb-efcore-provider`](https://github.com/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](https://github.com/LayeredCraft/dynamodb-efcore-provider/pull/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:

```csharp
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()`](https://github.com/LayeredCraft/dynamodb-efcore-provider/blob/24d33dc/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoQueryableMethodTranslatingExpressionVisitor.cs#L69), 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`](https://github.com/LayeredCraft/dynamodb-efcore-provider/blob/24d33dc/tests/EntityFrameworkCore.DynamoDb.AotTests/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:

```csharp
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`](https://github.com/LayeredCraft/dynamodb-efcore-provider/blob/24d33dc/tests/EntityFrameworkCore.DynamoDb.AotTests/PrecompiledQueryGenerationTests.cs), [NativeAOT smoke app](https://github.com/LayeredCraft/dynamodb-efcore-provider/blob/24d33dc/testapps/EntityFrameworkCore.DynamoDb.NativeAotSmoke/Program.cs)) 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.