Repository navigation
EF Core Joins and Includes
QueryForge's EF Core surface is nothing but extension methods on IQueryable<T>, so everything EF
Core can build — Join, navigation properties, Include/ThenInclude, AsSplitQuery — composes
with it. This page covers how, and the three interactions that are not obvious.
public static Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IQueryable<TModel> source, Query query, CancellationToken ct = default);QueryForge never replaces your query; it appends ApplyFilter → ApplySort → ApplyPaging → ApplyProjection and executes the result. EF Core sees one expression tree and emits one statement.
Because ExpressionCompiler.ResolveProperty<TModel> resolves readable instance properties of
TModel, one sentence predicts every behaviour below:
A column is queryable if and only if it is a direct property of
TModel.
Which is why the shape you hand to ToQueryResultAsync matters more than anything else on this page.
The pattern for any endpoint where the client drives the query.
public class OrderRow // named type, parameterless ctor, settable properties
{
public int OrderId { get; set; }
public string Number { get; set; } = "";
public string? CustomerName { get; set; } // from the join
public string? Tier { get; set; } // from the join
public decimal Total { get; set; }
public DateTime PlacedOn { get; set; }
}
app.MapPost("/api/orders/query", async (Query query, AppDbContext db, ITenant tenant) =>
{
var rows = db.Orders
.Where(o => o.TenantId == tenant.Id) // survives everything the client sends
.Select(o => new OrderRow
{
OrderId = o.OrderId,
Number = o.Number,
CustomerName = o.Customer.Name, // EF turns this into a JOIN
Tier = o.Customer.Tier,
Total = o.Total,
PlacedOn = o.PlacedOn
});
return await rows.ToQueryResultAsync<OrderRow>(query);
});TModel is now OrderRow, so CustomerName is a genuine column — filterable, sortable, groupable
and projectable:
{ "criteria": { "groups": [ { "conditions": [
{ "columnName": "CustomerName", "operator": 2, "value": "acme" } ] } ] },
"sortColumns": [ { "columnName": "Tier" } ],
"groupByColumns": [ { "columnName": "CustomerName" } ] }All of it is pushed down into the same statement — the LIKE lands on Customers.Name in SQL, not
in memory:
SELECT o.OrderId, o.Number, c.Name, c.Tier, o.Total, o.PlacedOn
FROM Orders o INNER JOIN Customers c ON c.Id = o.CustomerId
WHERE o.TenantId = @__tenant AND c.Name LIKE @__p0
ORDER BY (CASE WHEN c.Tier IS NULL THEN 0 ELSE 1 END), c.Tier
OFFSET 0 ROWS FETCH NEXT 12 ROWS ONLYThe CASE is QueryForge's null-rank key, pinning nulls first ascending so every engine agrees. See
Query Semantics.
var rows = db.Orders.Join(db.Customers,
o => o.CustomerId,
c => c.Id,
(o, c) => new OrderRow // ← named, NOT anonymous
{
OrderId = o.OrderId, CustomerName = c.Name, Total = o.Total
});BuildProjection opens with:
if (selectColumns.Count == 0 || typeof(TModel).GetConstructor(Type.EmptyTypes) is null)
return null;An anonymous type has no parameterless constructor, so the projection is skipped and
selectColumns is silently ignored. Filtering and sorting still work — they only need readable
properties — so it fails quietly in exactly one dimension. Use a named DTO.
var rows = db.Orders
.GroupJoin(db.Customers, o => o.CustomerId, c => c.Id, (o, cs) => new { o, cs })
.SelectMany(x => x.cs.DefaultIfEmpty(), (x, c) => new OrderRow
{
OrderId = x.o.OrderId,
CustomerName = c != null ? c.Name : null,
Total = x.o.Total
});EF Core 10's operator does the same more directly:
db.Orders.LeftJoin(db.Customers, o => o.CustomerId, c => c.Id,
(o, c) => new OrderRow { OrderId = o.OrderId, CustomerName = c.Name, Total = o.Total });Unmatched rows now carry null, which QueryForge treats as first-class:
{"columnName":"CustomerName","operator":0,"value":null} becomes IS NULL and returns exactly the
unmatched orders.
Declare left-joined value-type columns as nullable — int?, decimal?, DateTime?. Null
placement is pinned by an extra ordering term that BuildNullRank emits only for nullable types, and
for a value type that means Nullable<T>. Reference types including string are always nullable to
the CLR, so they get the term either way and need nothing special.
await db.Orders
.Include(o => o.OrderItems).ThenInclude(i => i.Product)
.Include(o => o.Payments)
.AsSplitQuery()
.AsNoTracking()
.ToQueryResultAsync<Order>(query);This works, and SelectColumns is handled for you: ApplyProjection walks the query's expression
chain, finds the Include, and skips the projection rather than applying it.
That behaviour exists because the alternative is silent data loss. EF Core drops every Include from
a query whose final shape is a constructed instance rather than the tracked entity, so projecting
would return rows with empty navigations — a 200 response, no warning, and the included data
simply gone. The projection is the input that cannot be honoured, so it is the one that gets dropped,
which is the rule the rest of QueryForge follows for an input it cannot apply.
The detection walks the source chain, so it still finds the include through the AsSplitQuery,
AsNoTracking, Where and OrderBy that follow it, and through ThenInclude.
It stops at a Select you wrote yourself, because that projection has already cost you the includes —
so Pattern A keeps SelectColumns working even if an Include appears earlier in the chain:
db.Orders
.Include(o => o.Lines) // already moot: the Select below drops it
.Select(o => new OrderRow { /* … */ })
.ToQueryResultAsync<OrderRow>(query); // selectColumns still narrows OrderRowThe trade-off to know: on an
Includeendpoint,selectColumnsdoes nothing. If you need a narrowed payload and related data, use Pattern A.
What is queryable here: Order's own scalar properties. "OrderItems.Product.Name" is a dotted path,
does not resolve, and is dropped.
Two sibling collection includes produce a cartesian product — 10 orders × 5 items × 3 payments = 150
rows to carry 18 entities. AsSplitQuery() issues three statements instead.
| Single query (default) | Split query | |
|---|---|---|
| Round trips | one | one per collection include |
| Row duplication | cartesian across sibling collections | none |
| Consistency | one snapshot | rows can change between statements unless in a transaction |
| Best for | one include, or a single-level chain | several collection includes, or large collections |
Set it globally if that suits the application:
options.UseSqlServer(cs, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
// then .AsSingleQuery() per query to opt back outApplyPaging emits Skip/Take on the root query, and EF then fetches children by the root keys in
separate statements. If the root ordering is not deterministic, those statements can disagree about
which page they are on — you can get children belonging to parents you did not return.
So with a split query, a unique tie-breaker stops being advice and becomes required:
query.SortColumns = [.. query.SortColumns, new SortDescriptor("OrderId")];With a single query an unstable sort is merely flaky; see Cross-Provider Parity.
columnName |
Resolves? | Behaviour |
|---|---|---|
"Total" — scalar |
yes | filter, sort, group, project |
"CustomerName" — DTO property from a join |
yes | the reason Pattern A exists |
"Customer.Name" — dotted path |
no | dropped |
"Customer" — reference navigation |
as a filter only | sort and group drop it |
"OrderItems" — collection navigation |
as a filter only | sort and group drop it |
ResolveProperty answers "is this a property of the entity", which is right for a filter and wrong
for an ordering — so sorting and grouping go through ResolveOrderableProperty, which additionally
requires the column to be a single value. Both kinds of navigation are excluded, for different
reasons:
- A collection navigation cannot be translated at all.
OrderBy(c => c.Orders)throwsInvalidOperationException, so before this was guarded a caller could take an endpoint down with one column name. - A reference navigation does translate — EF Core orders by the related key — which is worse in
the grouping case than a failure would be. The group key becomes the whole related entity, so
HierarchyNode.Keywould serialize every column of it, including ones the caller was never offered.
Filtering is deliberately left alone: a value that cannot convert to an entity type already fails to
bind and drops the condition, and Equals against null is a meaningful, translatable test for whether
a related row exists.
{ "columnName": "Customer", "operator": 1, "value": null }is a legitimate "has a customer" filter.
The guard stops a crash and a leak; it does not make every column a good idea to expose. Allow-list what a caller may sort and group by — it also keeps sorts on columns you have indexed:
query.Validate(rules =>
{
rules.Sort(c => c.Allow("OrderId", "Number", "PlacedOn", "Total", "CustomerName"));
rules.GroupBy(c => c.Allow("Status", "CustomerName"));
}, QueryValidationMode.SilentStrip);| Pattern A — flat DTO | Pattern B — Include
|
|
|---|---|---|
| Client filters or sorts on joined data | ✅ | ❌ root scalars only |
| Client needs nested objects back | ❌ flat shape | ✅ |
SelectColumns honoured |
✅ | ❌ skipped, by design |
AsSplitQuery relevant |
no — no collections | ✅ |
| Fetches only the needed columns | ✅ | ❌ whole entities |
| Best for | data grids, exports, search | detail views, "load one order fully" |
For a QueryForge endpoint — arbitrary filters arriving from a client — Pattern A is almost always
right. Include suits detail endpoints where the shape is fixed and the client is not driving the
query.
Everything on this page composes after whatever restriction you apply first, so the pattern in Recipes is unaffected:
await db.Orders
.Where(o => o.TenantId == tenant.Id)
.Include(o => o.Lines)
.AsNoTracking()
.ToQueryResultAsync<Order>(query);QueryForge · part of the PepperX Ecosystem · MIT licensed · packages 2.0.0, .NET 10
Foundations
- Getting Started
- Architecture
- Query Model
- Query Semantics
- Results and Metadata
- JSON Contract
- Fluent Builders
Behaviour
Providers
- Dapper Provider
- Dapper: Generated SQL
- Dapper: Dialects
- EF Core Provider
- EF Core: Joins & Includes
- In-Memory Provider
Practice
Reference