Skip to content

fix: don't drop the tail of a multi-hop exists path with an unanchored predicate - #259

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
barnabasJ:fix/multi-hop-exists-constant-predicate
Sep 2, 2026
Merged

fix: don't drop the tail of a multi-hop exists path with an unanchored predicate#259
zachdaniel merged 1 commit into
ash-project:mainfrom
barnabasJ:fix/multi-hop-exists-constant-predicate

Conversation

@barnabasJ

@barnabasJ barnabasJ commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The bug

An exists aggregate or exists/2 expression over a multi-hop relationship path wrongly returns true when there are zero matching related rows, whenever the predicate contains no refs:

  • unfiltered exists aggregates: exists :has_policy?, [:service_line, :policies]
  • constant predicates: expr(exists(service_line.policies, true))

Joins inside the exists subquery are derived from the refs in the predicate (and are left joins). A predicate with no refs never joins the remaining path, so the query degrades to "the first relationship exists":

-- exists(bucket.items, true), before:
exists((SELECT 1 FROM buckets st0 WHERE outer.bucket_id = st0.id))  -- items never queried

Relatedly, a null-satisfiable predicate (e.g. exists(bucket.items, is_nil(name))) is satisfied by the null-extended rows of the left joins.

The fix

Anchor the subquery filter with not is_nil(primary_key) at every hop of the remaining path. The anchors are a no-op for real rows, force the joins to materialize, and exclude null-extended rows:

-- exists(bucket.items, true), after:
exists((SELECT 1 FROM buckets st0
        LEFT JOIN items st1 ON st0.id = st1.bucket_id
        WHERE st1.id IS NOT NULL AND outer.bucket_id = st0.id))

Anchoring only the last hop would not be enough: no_attributes? relationships join with on: true, so their rows join even when an earlier hop of the chain is null-extended.

Tests

Regression tests live in the companion ash_postgres PR (ash-project/ash_postgres#843), since exercising this end-to-end needs a real data layer: zero-row multi-hop exists, three-hop chains broken at each level, a no_attributes? hop after a broken middle hop, and predicate scoping. The full ash_postgres suite passes against this branch (ASH_SQL_VERSION=local), including all pre-existing exists/aggregate tests.

🤖 Generated with Claude Code

…d predicate

Joins inside an exists subquery are derived from the refs in the
predicate and are left joins. A predicate with no refs (`exists(a.bs,
true)`, or an unfiltered exists aggregate over a multi-hop path) never
joined the remaining path, so the exists degraded to "the first
relationship exists" and wrongly returned true with zero related rows.
A null-satisfiable predicate (e.g. `exists(a.bs, is_nil(name))`) was
satisfied by the null-extended rows of the left joins.

Anchor the subquery filter with `not is_nil(primary_key)` at every hop
of the remaining path, which is a no-op for real rows and excludes both
failure modes. Anchoring only the last hop would not be enough: a
no_attributes? relationship joins with `on: true`, so its rows join
even when an earlier hop of the chain is null-extended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zachdaniel
zachdaniel merged commit d2c8d79 into ash-project:main Sep 2, 2026
25 of 26 checks passed
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants