Skip to content

fix(optimizer)!: decorrelation when non-EQ keys are present - #8317

Merged
georgesittas merged 5 commits into
mainfrom
jo/fix_non_eq_key_decorrelation
Sep 8, 2026
Merged

fix(optimizer)!: decorrelation when non-EQ keys are present#8317
georgesittas merged 5 commits into
mainfrom
jo/fix_non_eq_key_decorrelation

Conversation

@georgesittas

@georgesittas georgesittas commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

The way I came across the issues fixed by this PR is that I spent some time reading decorrelate again, motivated by this PR. I found that we had a condition "if at least one EQ condition exists, proceed with decorrelation", which seemed counter-intuitive, so I did some back-and-forth with Claude, which uncovered bugs.

Comment on lines 32 to +33
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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 139 to +140
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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +206 to 207
if not eq_count or (len(keys) > eq_count and not isinstance(parent_predicate, exp.Exists)):
return

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines -215 to +221
if key == value.this:
if key == value.this and isinstance(predicate, exp.EQ):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NULL

This 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.

Comment on lines -267 to +290
alias = exp.column(list(key_aliases.values())[0], table_alias)
alias = exp.column(next(key_aliases[key] for key in group_by), table_alias)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needed to happen because the first key may not be in an EQ, so we'd get an invalid join key.

Comment on lines +268 to +269
# Multiple keys are collected as one struct per row, so that all of their predicates are
# checked against the same row below

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This comment was marked as resolved.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

SQLGlot Integration Test Results

✅ All tests passed

Comparing:

  • this branch (sqlglot:jo/fix_non_eq_key_decorrelation @ sqlglot 7adf855)
  • baseline (main @ sqlglot 1d753d7)

Overall

main: 182937 total, 163862 passed (pass rate: 89.6%)

sqlglot:jo/fix_non_eq_key_decorrelation: 170743 total, 152710 passed (pass rate: 89.4%)

Transitions:
No change

Dialect pair changes: 0 previous results not found, 3 current results not found

✅ All tests passed

Comment thread sqlglot/optimizer/unnest_subqueries.py Outdated
Comment thread sqlglot/optimizer/unnest_subqueries.py
@georgesittas
georgesittas merged commit 106ad25 into main Sep 8, 2026
8 checks passed
@georgesittas
georgesittas deleted the jo/fix_non_eq_key_decorrelation branch September 8, 2026 15:49
georgesittas added a commit that referenced this pull request Sep 8, 2026
…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).
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.

3 participants