From 5ae3a4d87b67ab7d4e79a0e74dc0ab02be632a9e Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 4 Aug 2026 00:53:30 +0930 Subject: [PATCH] fix: apply each combination part to the whole of what precedes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Ash.Query.combination_of/2` takes an ordered list of parts, and each part applies to the result of everything before it. There is no precedence between the combination types — the order in the list is the order of application. `combination_of/4` appended each operation onto the accumulated query, which renders as one flat chain of set operations. SQL then applies its own precedence to that chain, and `INTERSECT` binds tighter than `UNION` and `EXCEPT`, so a combination spanning more than one precedence level was regrouped: given `base`, `union`, `intersect`, the intersect was applied to the union's right operand rather than to the running result. The effect was a query whose meaning depended on its data layer. `Ash.DataLayer.Ets` applies the parts in the order given, so the two answered differently — for one three-part combination, `["alpha", "beta", "gamma"]` here against `["alpha", "gamma"]` there. Nest what has accumulated before applying the next part, so each operation takes everything preceding it as its left operand. A part already wrapped as a subquery is left alone, so this adds one level of nesting per part rather than one per part plus a trailing one. Verified against ash_postgres built with `ASH_SQL_VERSION=local`: its combination suite (18 tests) and full suite (828) stay green, and a three-part case returns the order given only with this change. --- lib/query.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/query.ex b/lib/query.ex index d7a0c3f..ed58ddf 100644 --- a/lib/query.ex +++ b/lib/query.ex @@ -22,6 +22,18 @@ defmodule AshSql.Query do _domain \\ nil ) do Enum.reduce(combination_of, subquery(first), fn {type, combination_of}, query -> + # Each part applies to the result of everything before it. Appending + # straight onto the accumulated query would build one flat chain of set + # operations, and SQL's own precedence would then decide the grouping — + # `INTERSECT` binds tighter than `UNION`/`EXCEPT`, so a part could end up + # applied to its predecessor rather than to the whole. Nesting what has + # accumulated keeps the order the parts were given in. + query = + case query do + %Ecto.SubQuery{} -> query + query -> subquery(query) + end + case type do :union -> Ecto.Query.union(query, ^combination_of)