Query Compiler Pipeline
This page explains how EF Core Projectables integrates with EF Core's internal query compilation pipeline, and the differences between Full and Limited compatibility modes.
EF Core's Query Pipeline (Background)
When you execute a LINQ query against a DbContext, EF Core runs it through a multi-stage pipeline:
LINQ Expression (IQueryable)
↓
QueryCompiler.Execute()
↓
Query Translation Preprocessor
↓
Query Translator (LINQ → SQL model)
↓
SQL Generator
↓
SQL + Parameters → DatabaseProjectables hooks into this pipeline at different points depending on the selected compatibility mode.
Full Compatibility Mode
In Full mode, expansion happens before the query reaches EF Core's pipeline:
LINQ Expression
↓
CustomQueryCompiler.Execute() / CreateCompiledQuery()
↓ ← [Projectables expansion happens HERE]
ProjectableExpressionReplacer.Replace()
↓
Expanded LINQ Expression
↓
(Delegated to the original EF Core QueryCompiler)
↓
Standard EF Core pipeline...
↓
SQLCustomQueryCompiler
The CustomQueryCompiler class wraps EF Core's default QueryCompiler. It overrides all execution entry points:
public override TResult Execute<TResult>(Expression query)
=> _decoratedQueryCompiler.Execute<TResult>(Expand(query));
public override TResult ExecuteAsync<TResult>(Expression query, CancellationToken cancellationToken)
=> _decoratedQueryCompiler.ExecuteAsync<TResult>(Expand(query), cancellationToken);
public override Func<QueryContext, TResult> CreateCompiledQuery<TResult>(Expression query)
=> _decoratedQueryCompiler.CreateCompiledQuery<TResult>(Expand(query));The Expand() method calls ProjectableExpressionReplacer.Replace() on the raw expression before passing it downstream.
Query Cache Implications
Because expansion happens before EF Core sees the query, the expanded expression is what gets compiled and cached. This means:
- EF Core's query cache works on the expanded expression.
- Two queries that differ only in which projectable member they call will produce different cache keys, even if the expanded SQL is the same.
- Each unique LINQ query shape goes through expansion on every execution — there is no caching of the expansion step itself.
Limited Compatibility Mode
In Limited mode, expansion happens inside EF Core's query translation preprocessor:
LINQ Expression
↓
EF Core QueryCompiler (default)
↓
CustomQueryTranslationPreprocessor.Process()
↓ ← [Projectables expansion happens HERE]
ProjectableExpressionReplacer (via ExpandProjectables() extension)
↓
Expanded expression (now stored in EF Core's query cache)
↓
Standard EF Core query translator...
↓
SQLCustomQueryTranslationPreprocessor
This class wraps EF Core's default QueryTranslationPreprocessor and overrides the Process() method:
public override Expression Process(Expression query)
=> _decoratedPreprocessor.Process(query.ExpandProjectables());ExpandProjectables() is an extension method on Expression that runs the ProjectableExpressionReplacer over the expression tree.
Query Cache Benefits
Because the expansion happens inside EF Core's own preprocessing step, EF Core compiles the resulting expanded expression and stores it in its query cache. On subsequent executions with the same query shape:
- EF Core computes the cache key from the original (unexpanded) query.
- It finds the cached compiled query.
- It executes the cached query directly — no expansion needed.
This is why Limited mode can outperform both Full mode and vanilla EF Core for repeated queries.
Dynamic Parameter Caveat
The downside of Limited mode is that EF Core's query cache key is based on the original LINQ expression. If your projectable member captures external state (a closure variable that changes between calls), the cache may not distinguish between calls with different values.
Safe with Limited mode:
// The threshold is a query parameter — EF Core handles it correctly
dbContext.Orders.Where(o => o.ExceedsThreshold(threshold))Potentially unsafe with Limited mode:
// If GetCurrentUserRegion() returns a different value per call
// and the result is baked into the expression tree at expansion time
// (not captured as a standard EF Core parameter), this may be stale.
dbContext.Orders.Where(o => o.Region == GetCurrentUserRegion())How Expansion Works
In both modes, the core expansion logic is in ProjectableExpressionReplacer:
- Visit the expression tree — The replacer inherits from
ExpressionVisitorand recursively visits every node. - Detect projectable calls — For each
MemberExpression(property access) orMethodCallExpression, it checks if the member has a[ProjectableAttribute]. - Load the generated expression — Uses
ProjectionExpressionResolverto find the auto-generated companion class and invoke itsExpression()factory method via reflection. - Cache the resolved expression — The resolved
LambdaExpressionis cached in a per-replacer dictionary to avoid redundant reflection calls within the same query expansion. - Substitute arguments — Uses
ExpressionArgumentReplacerto replace the lambda's parameters with the actual arguments from the call site. - Recurse — The substituted expression body is itself visited, expanding any nested projectable calls.
Registering the Infrastructure
Both modes use the same EF Core extension mechanism. ProjectionOptionsExtension implements IDbContextOptionsExtension and registers the appropriate services:
// Full mode — registers CustomQueryCompiler
services.AddScoped<IQueryCompiler, CustomQueryCompiler>();
// Limited mode — registers CustomQueryTranslationPreprocessorFactory
services.AddScoped<IQueryTranslationPreprocessorFactory,
CustomQueryTranslationPreprocessorFactory>();The CustomConventionSetPlugin also registers the ProjectablePropertiesNotMappedConvention, which ensures EF Core's model builder ignores [Projectable] properties (they are computed — not mapped to database columns).
Query Filters
The ProjectablesExpandQueryFiltersConvention handles the case where global query filters reference projectable members. It ensures that query filters are also expanded when Projectables is active.