From 15d62ee0ce4f25a2dfa72731ba705743b3f0a6b6 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Tue, 1 Sep 2026 16:15:26 +0200 Subject: [PATCH] fix: don't drop the tail of a multi-hop exists path with an unanchored 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 --- lib/expr.ex | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/expr.ex b/lib/expr.ex index b7a9601..9fca125 100644 --- a/lib/expr.ex +++ b/lib/expr.ex @@ -2922,6 +2922,29 @@ defmodule AshSql.Expr do end) end) + # Joins for `rest` are derived from the refs in the filter and are left + # joins, so a predicate with no refs (e.g. `exists(a.bs, true)`) drops the + # remaining path entirely, and a null-satisfiable predicate is satisfied + # by null-extended rows. Requiring a non-nil primary key at every hop + # (not just the last: `no_attributes?` hops join with `on: true`) + # excludes both while being a no-op for real rows. + filter = + rest + |> Enum.scan([], fn rel_name, prefix -> prefix ++ [rel_name] end) + |> Enum.reduce(filter, fn prefix, filter -> + with target when not is_nil(target) <- + Ash.Resource.Info.related(first_relationship.destination, prefix), + [pk | _] <- Ash.Resource.Info.primary_key(target) do + Ash.Query.BooleanExpression.optimized_new( + :and, + filter, + Ash.Expr.expr(not is_nil(^Ash.Expr.ref(prefix, pk))) + ) + else + _ -> filter + end + end) + query = if first_relationship.type == :many_to_many do put_in(query.__ash_bindings__[:lateral_join_bindings], [:join_source])