fix(optimizer)!: decorrelation when non-EQ keys are present - #8317
Conversation
| SELECT * FROM x WHERE x.a < (SELECT SUM(y.a) AS a FROM y WHERE y.a = x.a and y.a = x.b and y.b <> x.d); | ||
| SELECT * FROM x LEFT JOIN (SELECT SUM(y.a) AS a, y.a AS _u_1, ARRAY_AGG(y.b) AS _u_2 FROM y WHERE TRUE AND TRUE AND TRUE GROUP BY y.a) AS _u_0 ON _u_0._u_1 = x.a AND _u_0._u_1 = x.b WHERE (x.a < _u_0.a AND ARRAY_ANY(_u_0._u_2, _x -> _x <> x.d)); | ||
| SELECT * FROM x WHERE x.a < (SELECT SUM(y.a) AS a FROM y WHERE y.a = x.a AND y.a = x.b AND y.b <> x.d); |
There was a problem hiding this comment.
WITH x AS (
SELECT * FROM (VALUES (2, 2, 1, 3)) AS t(a, b, c, d)
), y AS (
SELECT * FROM (VALUES (2, 3, 0, 0), (2, 1, 0, 0)) AS t(a, b, c, d)
)
-- original, returns [] because the sum is evaluated over (2, 1, 0, 0) and produces 2 which is equal to x.a
SELECT x.a, x.b, x.c, x.d FROM x
WHERE x.a < (SELECT SUM(y.a) AS a FROM y WHERE y.a = x.a AND y.a = x.b AND y.b <> x.d);
-- optimized (main), returns [(2, 2, 1, 3)] because the sum is evaluated over both y rows, producing 4
-- which passes the x.a < _u_0.a check and array_agg produces [3, 1] and 1 passes _x <> x.d
SELECT x.a, x.b, x.c, x.d FROM x
LEFT JOIN (
SELECT SUM(y.a) AS a, y.a AS _u_1, ARRAY_AGG(y.b) AS _u_2
FROM y WHERE TRUE AND TRUE AND TRUE GROUP BY y.a
) AS _u_0 ON _u_0._u_1 = x.a AND _u_0._u_1 = x.b
WHERE (x.a < _u_0.a AND ARRAY_ANY(_u_0._u_2, _x -> _x <> x.d));The optimized form in the PR matches the original query's output: y.b <> x.d should exclude the row with b = 3, leaving a sum of 2, so that 2 < 2 is false.
| SELECT (SELECT MAX(t2.c1) AS c1 FROM t2 WHERE t2.c2 = t1.c2 AND t2.c3 <= TRUNC(t1.c3)) AS c FROM t1; | ||
| SELECT _u_0.c1 AS c FROM t1 LEFT JOIN (SELECT MAX(t2.c1) AS c1, t2.c2 AS _u_1, MAX(t2.c3) AS _u_2 FROM t2 WHERE TRUE AND TRUE GROUP BY t2.c2) AS _u_0 ON _u_0._u_1 = t1.c2 WHERE _u_0._u_2 <= TRUNC(t1.c3); | ||
| SELECT (SELECT MAX(t2.c1) AS c1 FROM t2 WHERE t2.c2 = t1.c2 AND t2.c3 <= TRUNC(t1.c3)) AS c FROM t1; |
There was a problem hiding this comment.
WITH t1 AS (
SELECT * FROM (VALUES (1, 1, 5), (2, 2, 5)) AS t(c1, c2, c3)
), t2 AS (
SELECT * FROM (VALUES (10, 1, 3), (20, 1, 9)) AS t(c1, c2, c3)
)
-- original, returns [(10,), (NULL,)] because y: (10, 1, 3), x: (1, 1, 5) satisfy the predicates, producing a max
-- of 10, and y: (20, 1, 9) doesn't satisfy t2.c3 <= trunc(t1.c3), so max is evaluated over {}, producing null
SELECT (SELECT MAX(t2.c1) AS c1 FROM t2 WHERE t2.c2 = t1.c2 AND t2.c3 <= TRUNC(t1.c3)) AS c
FROM t1;
-- optimized (main), returns [] because the rhs of the join produces (20, 1, 9) and the outer filter is
-- not satisfied, since 9 > 5
SELECT _u_0.c1 AS c FROM t1
LEFT JOIN (
SELECT MAX(t2.c1) AS c1, t2.c2 AS _u_1, MAX(t2.c3) AS _u_2
FROM t2 WHERE TRUE AND TRUE GROUP BY t2.c2
) AS _u_0 ON _u_0._u_1 = t1.c2
WHERE _u_0._u_2 <= TRUNC(t1.c3);The optimized form in the PR again preserves the original query's output.
| if not eq_count or (len(keys) > eq_count and not isinstance(parent_predicate, exp.Exists)): | ||
| return |
There was a problem hiding this comment.
This makes sure non-EXISTS filters over decorrelated subqueries are left as-is to avoid the issues I mentioned in the changed tests. For EXISTS filters we only care about row existence, which is easier to answer, so we continue.
| if key == value.this: | ||
| if key == value.this and isinstance(predicate, exp.EQ): |
There was a problem hiding this comment.
A non-EQ predicate whose key happens to match the projection would be routed to the GROUP BY path and then silently dropped, e.g.:
SELECT x.a FROM x WHERE EXISTS (SELECT y.b AS b FROM y WHERE y.a = x.a AND y.b > x.b)Here, the projection y.b matches the key y.b from y.b > x.b, so without this we'd end up with:
SELECT x.a FROM x
LEFT JOIN (SELECT y.a AS _u_1, y.b AS b FROM y WHERE TRUE AND TRUE GROUP BY y.a, y.b) AS _u_0
ON _u_0._u_1 = x.a
WHERE NOT _u_0._u_1 IS NULLThis doesn't even have the y.b > x.b filter, and also groups by both a and b, which is wrong because it duplicates outer rows.
| alias = exp.column(list(key_aliases.values())[0], table_alias) | ||
| alias = exp.column(next(key_aliases[key] for key in group_by), table_alias) |
There was a problem hiding this comment.
This needed to happen because the first key may not be in an EQ, so we'd get an invalid join key.
| # Multiple keys are collected as one struct per row, so that all of their predicates are | ||
| # checked against the same row below |
There was a problem hiding this comment.
Without aggregating structs instead of plain keys, predicates over non-EQ keys could end up being satisfied across different rows, resulting in incorrect results.
For example, given:
WITH x AS (
SELECT * FROM (VALUES (1, 5, 15)) AS t(a, b, c)
), y AS (
SELECT * FROM (VALUES (1, 1, 10), (1, 9, 20)) AS t(a, b, c)
)This query produces no results, because every row in y satisfies one inequality and fails the other:
SELECT x.a FROM x WHERE EXISTS (SELECT 1 FROM y WHERE y.a = x.a AND y.b > x.b AND y.c < x.c);If we simply aggregated the keys in separate arrays, we'd get this, which produces [(1,)]:
SELECT x.a FROM x
LEFT JOIN (
SELECT y.a AS _u_1, ARRAY_AGG(y.b) AS _u_2, ARRAY_AGG(y.c) AS _u_3
FROM y WHERE TRUE AND TRUE AND TRUE GROUP BY y.a
) AS _u_0 ON _u_0._u_1 = x.a
WHERE NOT _u_0._u_1 IS NULL
-- _u_2: [1, 9], _u_3: [10, 20]
AND ARRAY_ANY(_u_0._u_2, _x -> _x > x.b)
AND ARRAY_ANY(_u_0._u_3, _x -> _x < x.c);As the comment points out, the struct is constructed to make sure the comparison happens over values appearing in the same original row in y.
SQLGlot Integration Test Results✅ All tests passedComparing:
Overallmain: 182937 total, 163862 passed (pass rate: 89.6%) sqlglot:jo/fix_non_eq_key_decorrelation: 170743 total, 152710 passed (pass rate: 89.4%) Transitions: Dialect pair changes: 0 previous results not found, 3 current results not found ✅ All tests passed |
…ueries Unnesting an EXISTS subquery with its own GROUP BY appended the join key to the existing grouping, so the derived table was no longer unique on it and the LEFT JOIN emitted one outer row per group. A plain GROUP BY, DISTINCT or ORDER BY can't change whether the subquery returns rows (we already bail earlier for ORDER BY + LIMIT), so they're dropped before decorrelating. Since HAVING and QUALIFY do filter the groups, and grouping sets yield a row even on empty input, those subqueries are no longer unnested. See also: #8317 (comment).
The way I came across the issues fixed by this PR is that I spent some time reading
decorrelateagain, motivated by this PR. I found that we had a condition "if at least oneEQcondition exists, proceed with decorrelation", which seemed counter-intuitive, so I did some back-and-forth with Claude, which uncovered bugs.