Skip to content

v0.4.81

Choose a tag to compare

@brunolau brunolau released this 15 Sep 15:05

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 — and EXISTS ignores 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 its Subquery with no
    outerFieldRefs, so the parent never learns to join), including grouped HAVING and ORDER BY;
  • inSubquery / notInSubquery / scalar-subquery comparisons, whose getFieldRefs() reports
    only the left-hand field;
  • a scalar Subquery used 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
    grouped sql\`` 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 → users case, 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
    assertNoCorrelatedAliasShadowing
  • src/query/query-builder.ts — identity propagated into navigation mock rows; guards on the
    condition, selection and collection paths; relationForRef replacing six unguarded
    schema.relations[alias] reads; CollectionQueryBuilder.getFieldRefs surfacing outer refs; resolveWhereNavigationJoins shared by both collection render paths
  • src/query/grouped-query.ts — guards on all three harvest loops, chainId threading, shadow
    refusal
  • tests/queries/correlated-exists-nav-name-collision.test.ts,
    tests/queries/correlated-exists-outer-navigation-ref.test.ts