Implicit navigation queries
A declared relationship turns a navigation member into a query construct: a reference member can be used as a scalar chain or a presence check, and a collection member exposes exactly four direct terminals (
Any,Count,LongCountand theCountproperty). nextorm expands the navigation on the server as a correlated subquery or aLEFT JOIN; it never reads the CLR object graph and never loads a collection lazily.Count/Count()materialise as a checkedint(a 64-bit count narrowed only at materialisation), whileLongCount()stays 64-bit end to end.
Prerequisites: Relationships and single-query loading (JoinInto) · Entities and metadata · Joins
Overview
A navigation query is available only when the relationship is declared in the mapping metadata:
HasMany/HasOne/HasOneToOne/HasManyThrough or the [Relationship]
attribute. Conventions over foreign-key names are not used and a member that is not part of a declared
relationship is never treated as a navigation.
With the relationship declared, two navigation shapes are supported:
- a reference navigation (
c.Parent) — scalar member chains, presence checks and whole-reference projection; - a collection navigation (
p.Children) — the four direct terminals below, plus theAsEntityBuilder<T>adapter.
Everything is metadata-driven: on the in-memory provider the registered datasets and relationship metadata are authoritative, so the result matches the SQL providers even when the CLR object graph is empty or inconsistent.
A navigation query computes a scalar/cardinality or projects one declared reference or collection — it
never materializes a nested object graph. To fill a nested collection graph (level 2 and deeper), use
LoadWith with a child query that declares its own LoadWith (see
Nested graphs); to return the per-parent collections
inside a projected result, use a collection-valued projection slot.
Reference navigation
Scalar chains
A member chain through a reference navigation is translated to the related source. nextorm emits a
LEFT JOIN for the path and reads the member from the joined alias:
var rows = ctx.From<Child>()
.Where(c => c.Id == id)
.Select(c => new { c.Id, ParentName = c.Parent!.Name })
.ToList();
// select c.Id, p.Name
// from child as c
// left join parent as p on c.ParentId = p.Id
The reference expansion is always a LEFT JOIN — a relationship carries no mandatory flag, and an
INNER join would silently drop a child whose foreign key dangles. A dangling foreign key therefore
produces NULL and c.Parent!.Name materialises as null.
A chain may cross more than one reference hop (c.Parent!.Parent!.Name): nextorm emits one LEFT JOIN
per hop and the absence propagates at every hop, so a missing intermediate parent stays absent instead of
re-appearing through a default key. Self-references (a self-referencing entity's n.Parent!.Name) and two
reference paths that happen to share one CLR type (o.Buyer, o.Seller) are supported too: each declared
path (root + member path + lexical scope) binds its own alias and paths are never merged by CLR type.
Reusing the same path in Where, Select and OrderBy still joins it once.
Presence checks
c.Parent == null / c.Parent != null test whether the related row actually exists, using the
non-nullable identifying principal key of the relationship — not merely the foreign-key value. A child
whose foreign key points at a missing parent is treated as absent:
var withParent = ctx.From<Child>().Where(c => c.Parent != null).ToList();
var withoutParent = ctx.From<Child>().Where(c => c.Parent == null).ToList();
A presence check may span more than one reference hop (c.Parent!.Parent != null,
c.Parent!.Parent == null): an absent row at the first, an intermediate or the final hop makes the
whole chain read as absent, and ==/!= stay mutually consistent. The same holds on the in-memory
provider, where the chain is walked over the registered datasets rather than the CLR graph.
Whole-reference projection
Projecting the reference itself yields null for an absent row and a fully materialised entity for a
present one on every provider, including in-memory:
var rows = ctx.From<Child>()
.Select(c => new { c.Id, Parent = c.Parent })
.ToList();
var present = rows[0].Parent; // the principal entity, or
var absent = rows[1].Parent; // null when the foreign key dangles
// select c.Id, p.Id, p.Age, p.Name
// from child as c
// left join parent as p on c.ParentId = p.Id
The in-memory provider resolves the entity (or null) from the mapped foreign/principal keys against the
registered dataset — it never reads the receiver's populated CLR navigation graph, so an inconsistent graph
still yields the FK-correct entity. Projecting a collection reached through an absent reference yields an
empty collection, not a failure.
Null contract
A nullable scalar derived from a reference follows the SQL null semantics of the LEFT JOIN:
var rows = ctx.From<Child>()
.Select(c => new
{
c.Id,
Age = (int?)c.Parent!.Age, // SQL NULL when the parent is absent
AgeOrDefault = (int?)c.Parent!.Age ?? 0,
})
.ToList();
An unlifted non-nullable scalar (c.Parent!.Age typed as int) whose parent can be absent is
rejected during preparation with QueryPreparationException,
naming the navigation path and the result type, instead of silently reading default(int). Lift the member
to a nullable type or supply an explicit fallback (?? 0).
Null compensation is consistent across predicates and bool/value projections, including the inverted forms:
c.Parent!.Name != "x", its negation !(c.Parent!.Name == "x"), a nullable operand such as
c.Parent!.Score == null, and a disjunction that also admits rows without a parent
(c.IsActive || c.Parent!.Name == "x"). A child whose foreign key dangles takes part with SQL NULL
semantics — it is included in !=/negated/OR comparisons — rather than being silently dropped or
compared against a default value.
References inside a correlated predicate
A reference navigation can also be used inside a correlated subquery — an EXISTS/IN body or a
correlated scalar subquery — where the hop is resolved on the outer query and the nested body compares
against the joined alias:
var inner = ctx.From<Child>();
var hop1 = ctx.From<Parent>()
.Where(p => SqlFunctions.Sql.exists(inner.Where(c => c.ParentId == p.Id && c.GroupCode == p.Group!.Code)))
.Select(p => p.Id)
.ToList();
// select t1.id
// from parent as t1
// left join group as t2 on t1.group_id = t2.id
// where exists(select * from child as t3 where (t3.parent_id = t1.id and t3.group_code = t2.code))
The expansion belongs to the outer command: its join is emitted once on the outer query and the
joined alias is registered as an outer reference of the nested command, so the correlated body reads
t2.code instead of carrying the navigation as a free parameter. A deeper chain
(c.Group!.Root!.Label, hop 2+) adds one LEFT JOIN per hop on the outer command and reads the deepest
alias inside the body — hop depth is not a special case:
var hop2 = ctx.From<Parent>()
.Where(p => SqlFunctions.Sql.exists(inner.Where(c => c.ParentId == p.Id && c.GroupCode == p.Group!.Root!.Label)))
.Select(p => p.Id)
.ToList();
// ... left join group as t2 on t1.group_id = t2.id
// left join root as t3 on t2.root_id = t3.id
// ... exists(select * from child as t4 where (t4.parent_id = t1.id and t4.group_code = t3.label))
All three reference forms work in this position:
- a scalar comparison against a reference member (
c.GroupCode == p.Group!.Code); - a presence check on the bare reference (
p.Group != null/p.Group == null, rendered ast2.id is not null/t2.id is nullon the joined principal key); - a scalar projection by the correlated subquery (
.Select(c => p.Group!.Code)) — a scalar reference value, not whole-object eager loading.
The same reference path used by two correlated predicates of one command reuses one joined alias (one
LEFT JOIN, both correlated bodies reading it). Null semantics follow the outer LEFT JOIN: a dangling
foreign key produces a NULL joined key, so the correlated comparison is unknown and the row does not
match; a nullable foreign key with a NULL value never matches either; and an absent row at an
intermediate hop of a 2+ chain makes the whole hop absent. A presence check inside the body tests the
non-nullable identifying principal key of the deepest hop, exactly as it does outside a correlated
predicate, so !=/== and a nullable-FK chain stay mutually consistent.
This is a predicate/scalar form: it adds no collection capability and no new join form, and it
never falls back to a per-parent APPLY/LATERAL. The emitted shape is a portable LEFT JOIN plus a
correlated EXISTS/scalar subquery on every SQL provider:
| Provider | Reference hop inside a correlated predicate | Disposition |
|---|---|---|
| SQLite | yes | SQL shape + live (incl. no APPLY) |
| PostgreSQL | yes | SQL shape + shared live |
| SQL Server | yes | SQL shape + shared live |
| MySQL | yes | SQL shape + shared live |
| MariaDB | yes | SQL shape; live deferred (trigger: dedicated live harness or MySQL divergence) |
| ClickHouse | yes | SQL shape (no APPLY); live deferred (trigger: correlated-EXISTS live fixture or shape change) |
ClickHouse's collection-through-reference limitation is unchanged: the reference hop itself is supported, and a collection reached through a reference stays rejected. The in-memory provider is not part of this matrix — the form is a server-side predicate translation.
Collection navigation
A declared collection supports exactly four parameterless terminals. They lower to a correlated
subquery over the related source — never to an OUTER join that would multiply the parent rows:
| Form | Result | Lowering |
|---|---|---|
p.Children.Any() |
bool |
correlated EXISTS |
p.Children.Count() |
int |
correlated 64-bit COUNT_BIG; checked narrowing to int only at materialisation |
p.Children.LongCount() |
long |
correlated 64-bit COUNT_BIG end to end (no narrowing) |
p.Children.Count (property) |
int |
same as Count() |
var rows = ctx.From<Parent>()
.Select(p => new
{
p.Id,
HasChildren = p.Children.Any(),
ChildCount = p.Children.Count(),
ChildCount64 = p.Children.LongCount(),
ChildCountProperty = p.Children.Count,
})
.ToList();
An empty collection yields false/0/0/0. Count() and the Count property are evaluated at
64-bit inside the database: SQL emits the wide COUNT_BIG count with no narrowing cast, so a
predicate, a boolean projection or arithmetic over the count compares/combines the true wide
value and is always exact — it never truncates or wraps. The checked narrowing to int happens only
where an int is materialised — a scalar projection or an int-typed projection member — and throws an
un-wrapped OverflowException on every provider when the count
exceeds Int32.MaxValue. LongCount() returns a true 64-bit count end to end (no narrowing). The
in-memory provider follows the same boundary: Count() and the Count property match Enumerable.Count
and throw OverflowException for every consumer, while LongCount() stays 64-bit.
Cardinality and duplicates
The direct terminals report the cardinality of the declared relation:
- a one-to-many collection counts the matching child rows;
- a many-to-many collection counts the matching junction rows whose mapped child exists — a junction row whose child is missing (dangling link) is excluded, so it is not counted and does not make the collection non-empty;
- duplicate junction rows that reference the same existing child each count — they are not deduplicated
and no implicit
DISTINCTis applied; - a many-to-many collection whose junction has only dangling links yields
false/0/0/0.
Adapter: AsEntityBuilder<T>
The collection navigation can be adapted to a full EntityBuilder<T>
with AsEntityBuilder<T>, then used with the same direct terminals:
ctx.From<Parent>()
.Select(p => new
{
p.Id,
Has = p.Children.AsEntityBuilder().Any(), // T inferred from ICollection<Child>
Count = p.Children.AsEntityBuilder<Child>().Count(),
});
The adapter is a marker: it must appear inside a query expression tree, where the translator resolves
the receiver to the declared navigation path. Calling it as an ordinary method — outside an expression
tree, including on a captured collection — always throws
NotSupportedException; it never returns a null or default marker.
A captured IEnumerable is an ordinary in-memory collection, not a navigation.
The public surface also provides a reference overload, AsEntityBuilder<T>(object?), which must be
called with an explicit generic argument so that it never steals collection inference
(c.Parent!.AsEntityBuilder<Parent>()). It supplies reference semantics on SQL and in-memory:
Any() tests presence, and Count()/LongCount() return 1 when the reference is present and 0 when
it is absent — exactly like c.Parent != null. Projecting the adapted marker itself (rather than one
of those terminals) and composing LINQ operators after the adapter remain unsupported and fail closed.
The adapter is a query surface: it answers presence and count inside a query. Filling a navigation on
already materialized parents is the job of LoadWith: its declared-metadata form
(LoadWith(navigation) / LoadWith(navigation, childQuery)) reads the same declared relationship this
page translates and returns entities with the navigation populated (see
Declared-metadata form). A collection-valued Select slot and the
AsEntityBuilder … Where/OrderBy/Limit/Select(...).ToList() composition are a separate projection surface:
they are delivered, with their own v1 limits, in Collection-valued projection slots
— a projection returns the per-parent collections inside the result shape and is not a loading alternative to
LoadWith. The prohibitions below describe the query-construct adapter (Any/Count/LongCount); the
projection adapter follows the rules of its own page.
Providers
| Provider | Reference chains / presence | Whole-reference | Collection terminals | In-memory |
|---|---|---|---|---|
| SQLite | yes | yes | yes | yes |
| PostgreSQL | yes | yes | yes | yes |
| SQL Server | yes | yes | yes | yes |
| MySQL / MariaDB | yes | yes | yes | yes |
| ClickHouse | yes | yes | yes | yes |
| In-memory | yes | yes | yes | — |
On ClickHouse a reference-navigation query needs real SQL NULLs on the unmatched side of the outer
join. nextorm injects join_use_nulls = 1 query-locally for a reference-expansion query only — never
as a session or global setting — and rejects an explicit conflicting join_use_nulls = 0 before
execution.
A collection reached through a reference (child.Parent.Children) is not available on ClickHouse.
This is an accepted provider limitation (approved), not a defect: the engine cannot correlate a
subquery on a joined source, so nextorm rejects the shape with an explicit
NotSupportedException ("cannot correlate a subquery on a joined
source") instead of emitting SQL the engine fails on. Query the collection from its own source or use an
explicit join.
On the in-memory provider the registered datasets and the declared metadata are authoritative: a populated
navigation property is never read, and the result matches the SQL providers even for an empty or
inconsistent CLR graph. If a declared navigation's related type has no registered dataset/mapping,
preparation throws BuildSqlCommandException naming the table
(Table name is not registered) — the ordinary missing-source diagnostic, never a silent empty/null
result.
The generated SQL, the plan key and the returned rows are identical across a cold plan, a warm cache hit
and an uncached (prepared) run. A navigation query does not disable the shared .Any()/.Count() plan
cache.
Limitations and rejected forms
Implicit navigation is a deliberately narrow surface. The following are rejected with
NotSupportedException (or, for the unlifted scalar, a
QueryPreparationException) and never fall back to
client-side evaluation:
- Predicate overloads of
Any/Count/LongCount(p.Children.Any(c => ...),p.Children.Count(c => ...),p.Children.LongCount(c => ...)). - Direct LINQ composition on a collection navigation —
Where,OrderBy/OrderByDescending,Select,Skip,Take,Distinct,GroupBy,GroupJoin,SelectMany, joins, set operations (Union/Except/Intersect) and materialisation (ToArray/ToList). Compose them through theAsEntityBuilder<T>adapter of a collection-valued projection slot instead (supported subset only). - Extra terminals —
First/First(predicate),Single/SingleOrDefault,Last/LastOrDefault,Contains,Sum,Averageand other aggregates. - Captured or outside markers — a captured enumerable is not a navigation, and
AsEntityBuilderoutside a query expression always throws. - Undeclared navigations — a reference- or collection-typed member with no declared relationship is not inferred from a convention.
- Navigation in grouping/paging modifier keys —
GROUP BY,DISTINCT ON,LIMIT BYand extreme-row keys do not evaluate navigation (use explicit joins there). - Temp-table / table-valued (TVP) navigation — navigation over a temp-table/TVP source is
deferred: a lazy temp-table read source (
ctx.From(tempTableSource)) is an untypedTableAliaswith no navigation surface, so a navigation member cannot be read over it. The trigger is a navigation source combined with a temp-table/TVP path. Navigation declared before the temp-table boundary is still expanded into the materialisingCREATE TEMPORARY TABLE ... AS SELECT; build a navigation over the temp-table rows from an explicit join or from the mapped source instead. - Adapter LINQ composition outside a projection slot — chaining query operators after
AsEntityBuilder<T>is not a query construct: as a navigation query the adapter recognises only the direct terminals. The one composition that is delivered is the collection-valued projection slot, where the adapter continues into a per-parentWhere/OrderBy/ThenBy/Limit/Selectpipeline terminated byToList()/ToArray(). - Bare reference-adapter projection — projecting the reference marker itself
(
Select(c => new { X = c.Parent!.AsEntityBuilder<Parent>() })) is not a column and is rejected; use theAny/Count/LongCountterminals instead. - Not a loading surface — the adapter and the composition forms above never fill a navigation.
Loading a declared navigation is done with
LoadWithon the parent query, whose declared-metadata form reads the same declared relationship. A collection-valued projection slot is the third, projection-shaped form: it returns the per-parent collection inside the projected result instead of filling the entity's navigation.
Composite relationship keys
A relationship may span several components. The reference, collection and many-to-many SQL routes
correlate on every declared component, in declaration order — not on one column and not on the
first component alone. Per-component null semantics follow SQL: any null foreign-key component yields
no relationship (a presence check reports the related row as absent, and a scalar chain yields null),
and there is no null == null match — a null foreign-key component never matches a null principal
component. Components are paired positionally, and the component lists come from the relationship
metadata (see Ordered key selectors).
This composite scope is the SQL routes only. The in-memory provider keeps its previous boundary: a
composite relationship key is still refused by its navigation path with NotSupportedException (the
declaration and the metadata are provider-neutral, but no component-wise in-memory matching is added).
Use a SQL provider for composite implicit navigation.
For the explicit, fully-featured relationship surface use JoinInto and
the explicit join operators; see Limitations for the general boundaries.
See also
- Relationships and single-query loading (
JoinInto) — declaring the metadata this page builds on. - Nested graphs in eager loading — the level 2+
LoadWithgraph, a different surface from a navigation query. - Collection-valued projection slots — the same adapter as a per-parent projection pipeline.
- Per-parent paging in eager loading — where a child
Take/Skipis applied independently per parent. - Joins — the explicit join surface.
- Subqueries — the correlated forms nextorm renders.
- Entities and metadata — keys and column mapping.
- Limitations and out-of-scope features.