Repository navigation
v0.4.81
v0.4.81
Fix: the correlated-subquery misbinding, closed at the root and across every builder path
v0.4.80 fixed one path and shipped with a known limitation. This release closes the root cause
and the remaining paths. Upgrade past 0.4.80 if you correlate subqueries at all.
The defect. A builder resolved a field reference's __tableAlias into a JOIN by looking the
alias up in its own schema.relations, a map keyed by navigation PROPERTY NAME. When a
subquery's inner table declared a navigation named identically to the outer table's alias, the
builder read the correlation as one of its own navigations, joined a second copy of the outer
table into the subquery, and bound the predicate to that inner copy — comparing the inner row
with itself, true for every row, no SQL error and no type error.
The root cause v0.4.80 missed
v0.4.80 guarded the consumers of chain identity but never checked the producers.
ReferenceQueryBuilder minted navigation refs with no identity at all:
| ref | alias | __chainId before |
after |
|---|---|---|---|
outer plain column l.name |
library |
1 | 1 |
inner own column s.label |
shelf |
2 | 2 |
inner navigation s.library.name |
library |
(none) | 2 |
outer navigation l.city.name |
city |
(none) | 1 |
So a correlation written THROUGH a navigation was anonymous and indistinguishable from the
inner table's own navigation of the same name — invisible to every guard, on every path.
mintReferenceMockRow now propagates the identity of the row a navigation hangs off.
That variant is also wider than the one v0.4.80 described: it does not need singular table
names. It only needs the outer and inner tables to share a navigation property name (city on
both), which the plural-table convention does nothing to prevent. The v0.4.80 note claiming
navigation refs already inherited their root's chain was simply wrong.
Paths fixed since v0.4.80
Each was reproduced with a failing test before being fixed:
| path | shape that misbound |
|---|---|
| subquery SELECT list | six inline relations[alias] → joins.push sites in the select-clause renderer, bypassing the guarded harvesters |
| collection lambda WHERE + selector | exists(l.shelves!.where(s => eq(s.label, l.name))) |
| grouped correlated subquery | …groupBy(…).select(…).asSubquery() inside exists(...) — for a correlation on a plain outer COLUMN; outer NAVIGATIONS are a known limitation below |
grouped projection sql\`` fragment |
the fragment carries no id, so the refs inside it needed their own screen |
| correlation through an outer navigation | eq(s.label, l.city!.name) — the root cause above |
CollectionQueryBuilder.getFieldRefs() also stopped returning a hardcoded []. It now reports
the refs belonging to an ENCLOSING query, so the parent joins what the correlation needs;
without that, l.city!.name inside a collection lambda emitted a "city"."name" with no
FROM-clause entry to bind to.
The unrenderable shape is refused
Correlating to an outer table AND joining a navigation of your own under the same alias wants
one identifier for two tables in one scope. There is no correct SQL, so it is refused at build
time with a message naming the alias and the ways out. assertNoCorrelatedAliasShadowing in
query-utils.ts is the single definition, applied to:
- the standalone subquery WHERE,
- the standalone subquery SELECT list (the colliding navigation can be named only in the
projection, which the WHERE-time check cannot see — andEXISTSignores the select list, so
nothing else would have caught it), - the grouped path, so
.groupBy()is not a quiet bypass, - both collection render paths — the inline
exists()/ aggregate form and the
projection /count()form, which reach it through the shared where-navigation resolution
described next.
Collection WHERE navigations are resolved on both render paths
CollectionQueryBuilder renders two ways: inline (exists(), aggregates) and as a CTE or
lateral for a collection in a PROJECTION. Only the first resolved the joins needed by
navigations used in the collection's own where; the second derived joins from its SELECTOR,
which says nothing about the where-clause.
So l.shelves!.where(s => eq(s.city!.name, 'Rural')) in a projection referenced city without
joining it. Under lateral the outer FROM is in scope, so it silently bound to the OUTER row's
city and filtered on the wrong table; under cte / temptable it failed with
missing FROM-clause entry. It was even load-bearing on an unrelated line — projecting
l.city!.name alongside is what put a city join in the outer query for it to latch onto, so
adding a projection column flipped a loud failure into a wrong answer.
Both paths now share one resolution step, so they answer the same question and reach the same
refusal.
Known limitations
All of these fail loudly with missing FROM-clause entry for table "<alias>". None
misbinds — but none is fixed either, so a correlation reached through an outer NAVIGATION is
not yet supported in:
- a grouped subquery (
GroupedSelectQueryBuilder.asSubquery()builds itsSubquerywith no
outerFieldRefs, so the parent never learns to join), including groupedHAVINGandORDER BY; inSubquery/notInSubquery/ scalar-subquery comparisons, whosegetFieldRefs()reports
only the left-hand field;- a scalar
Subqueryused in the OUTER query's projection.
Correlate on a plain key column instead, or traverse the navigation in the outer query and
compare against the resulting column.
Behaviour changes worth knowing
1. cte / temptable + an outer column in a collection lambda now errors.
db.libraries.select(l => ({
shelves: l.shelves!.select(s => ({ label: s.label, outer: l.name })).toList(),
}));Those strategies materialise the collection as an UNCORRELATED CTE, so the parent row is not in
scope inside it. The previous answer was correct only by accident and only in colliding
schemas — the join it leaned on was the very name-collision this release removes, and on a
schema where the child's parent-named navigation uses a different FK it returned another
parent's value outright. Use 'lateral', which correlates and handles this shape, or lift the
outer column out of the collection projection.
2. UPDATE … RETURNING with a collection projection naming an outer navigation now errors.
Previously it returned the INNER row's value for a field asking for the outer one's; it now
fails loudly. Same remedy as above.
Coverage
tests/queries/correlated-exists-nav-name-collision.test.ts— thirteen cases on a
singular-table schema (library+shelf.library): standalone EXISTS and its exact NOT
EXISTS complement, an outer reference in the SELECT, the navigation-collection form
(unaffected — the regression guard), both collection-lambda shapes, the grouped subquery, the
groupedsql\`` fragment, all four shadow refusals, and plain navigation traversal still
working.tests/queries/correlated-exists-outer-navigation-ref.test.ts(new) — the outer-navigation
correlation, on a schema where outer and inner share a navigation property name. Its fixture
makes correct and defective answers disjoint (['Central']vs['Annex']), so neither
can be reached by accident. Also covers the collection-in-a-projection path filtering on its
own same-named navigation.tests/queries/correlated-standalone-exists.test.ts— a discriminating twin of the
posts→userscase, plus a note on the original that it does not discriminate.
Unchanged on purpose
DataContext.detectNavigationInReturning and its query-builder.ts twin analyse the RETURNING
clause of a root DML statement. A collection lambda in a RETURNING clause can carry an outer
reference into that analysis, but the result is a loud missing FROM-clause entry, never a
misbind — and createMockEntity stamps no chain id, so the discriminator would have nothing to
read there. Left alone rather than guarded speculatively.
Files Changed
src/query/query-utils.ts—isForeignChainRef(moved here, generalised) and
assertNoCorrelatedAliasShadowingsrc/query/query-builder.ts— identity propagated into navigation mock rows; guards on the
condition, selection and collection paths;relationForRefreplacing six unguarded
schema.relations[alias]reads;CollectionQueryBuilder.getFieldRefssurfacing outer refs;resolveWhereNavigationJoinsshared by both collection render pathssrc/query/grouped-query.ts— guards on all three harvest loops,chainIdthreading, shadow
refusaltests/queries/correlated-exists-nav-name-collision.test.ts,
tests/queries/correlated-exists-outer-navigation-ref.test.ts