Skip to content

fix: error instead of wrong rows for a LATERAL with a HAVING over an ungrouped aggregate - #26874

Merged
jayzhan211 merged 2 commits into
apache:mainfrom
raghavyadav01:fix/lateral-having-error
Nov 8, 2026
Merged

jayzhan211 merged 2 commits into
apache:mainfrom
raghavyadav01:fix/lateral-having-error

Conversation

@raghavyadav01

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

  • A LATERAL subquery over an aggregate without GROUP BY, with a HAVING, returns wrong rows today instead of an error:
CREATE TABLE a(k INT) AS VALUES (1), (2), (3);
CREATE TABLE b(k INT, v INT) AS VALUES (1, 10), (1, 20), (2, 30);
SELECT a.k, s.c FROM a, LATERAL (SELECT count(*) AS c FROM b WHERE b.k = a.k HAVING count(*) > 1) s;
-- main: (1, 2), (2, NULL), (3, NULL)
-- DuckDB: (1, 2)
  • The existing check only refuses a HAVING that PullUpCorrelatedExpr pulls up, which happens when it is true on an empty group. Two cases get past it:
    • a HAVING that is false on an empty group stays above the aggregate. The rewrite turns the inner join into an outer join, so an outer row whose row the HAVING removes comes back with NULLs
    • a HAVING that reads an outer column is moved into the join condition, and the count bug handling turns its unmatched rows into a count of 0. This is wrong for a left lateral join too ((2, 0) where the answer is (2, NULL))

What changes are included in this PR?

  • DecorrelateLateralJoin looks for a HAVING over an ungrouped aggregate in the original subquery, and leaves the join correlated (so it errors) when the join is inner, or when the HAVING reads an outer column.
  • A filter below the aggregate is not a HAVING and is not affected, and neither is a left lateral join with a HAVING that reads only the subquery.

What is the testing strategy for this PR?

  • New lateral_join.slt cases:
    • inner join with HAVING count(*) > 1 and with HAVING max(t2.id) > 15: now an error
    • inner and left join with HAVING count(*) > t1.id: now an error
    • left join with HAVING count(*) > 1, and an inner join with GROUP BY ... HAVING: still decorrelated, with the same rows as before
  • Without the change, the four error cases return wrong rows instead of failing.
  • I compared 20 lateral aggregate shapes against DuckDB on main and on this branch. The 8 that return wrong rows on main now error, the 8 that return correct rows still return the same rows, and the 4 that already errored still do.
  • Optimizer unit tests and the full sqllogictest suite pass.

Are there any user-facing changes?

  • Queries that returned wrong rows now fail with OuterReferenceColumn. Every query that returned correct rows still does.

🤖 Generated with Claude Code

…ungrouped aggregate

PullUpCorrelatedExpr only pulls up a HAVING that is true on an empty group,
and the lateral rewrite already refuses that one. A HAVING that is false on
an empty group stayed above the aggregate, so the inner join turned outer
kept the outer rows it removes, with NULLs. A HAVING that reads an outer
column moved into the join condition, where the count bug handling made its
unmatched rows a count of 0, for a left join too. Leave both correlated so
the query errors.

Refs apache#21198
@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Oct 29, 2026
@raghavyadav01

Copy link
Copy Markdown
Contributor Author

@neilconway @alamb when you have a moment, this one is ready for a look. It makes the LATERAL + HAVING shape from #21198 error instead of returning wrong rows, as discussed there.

@jayzhan211
jayzhan211 self-requested a review November 8, 2026 03:48

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@raghavyadav01 thanks for taking this on! One change is needed before merge: the new gate also turns four queries that return correct rows in the 55.2.0 release into errors. The inline comment has a narrower condition that keeps all of your error cases, plus tests for it.

// - A HAVING that reads an outer column is moved into the join condition,
// where the count bug handling turns its unmatched rows into a count of 0,
// for a left lateral join too.
if let Some(having) = having_over_ungrouped_aggregate(subquery_plan)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The gate also turns queries that work in the 55.2.0 release into errors. Without a correlated WHERE, the aggregate isn't grouped by the correlation columns and keeps one row per outer row, so a HAVING that reads an outer column is just a join filter. Four such queries match DuckDB on 55.2.0 and main, and error on this branch, for example:

SELECT t1.id, sub.cnt FROM t1, LATERAL (SELECT count(*) AS cnt FROM t2 HAVING count(*) > t1.id) AS sub;
-- 55.2.0, main and DuckDB: (1, 3), (2, 3); this PR: error
SELECT t1.id, sub.cnt FROM t1 LEFT JOIN LATERAL (SELECT count(*) AS cnt FROM t2 HAVING count(*) > t1.id) AS sub ON true;
-- 55.2.0, main and DuckDB: (1, 3), (2, 3), (3, NULL); this PR: error

Gating on pull_up.pulled_up_scalar_agg (so after the pull-up) keeps all your error cases and brings these back. lateral_join.slt passes with it and with the tests below, and both tests fail on the current head. Could you apply it and add the tests?

     let subquery_plan = subquery.subquery.as_ref();
     let original_join_filter = join.filter.clone();
 
+    // Walk the subquery plan bottom-up, extracting correlated filter
+    // predicates into join conditions and converting ungrouped aggregates
+    // into group-by aggregates keyed on the correlation columns.
+    let mut pull_up = PullUpCorrelatedExpr::new().with_need_handle_count_bug(true);
+    let rewritten_subquery = subquery_plan.clone().rewrite(&mut pull_up).data()?;
+    if !pull_up.can_pull_up {
+        return Ok(Transformed::no(LogicalPlan::Join(join)));
+    }
+
     // A HAVING over an ungrouped aggregate can remove the aggregate's only
     // row. The rewrite below handles neither case correctly, so leave the join
     // correlated and let the query error instead of returning wrong rows.
@@
-    if let Some(having) = having_over_ungrouped_aggregate(subquery_plan)
+    if pull_up.pulled_up_scalar_agg
+        && let Some(having) = having_over_ungrouped_aggregate(subquery_plan)
         && (original_join_type == JoinType::Inner || having.predicate.contains_outer())
     {
         return Ok(Transformed::no(LogicalPlan::Join(join)));
     }
 
-    // Walk the subquery plan bottom-up, extracting correlated filter
-    // predicates into join conditions and converting ungrouped aggregates
-    // into group-by aggregates keyed on the correlation columns.
-    let mut pull_up = PullUpCorrelatedExpr::new().with_need_handle_count_bug(true);
-    let rewritten_subquery = subquery_plan.clone().rewrite(&mut pull_up).data()?;
-    if !pull_up.can_pull_up {
-        return Ok(Transformed::no(LogicalPlan::Join(join)));
-    }
-
Tests for lateral_join.slt (both error on the current head, pass with the change)
# Without a correlated WHERE the aggregate keeps its single row for every
# outer row, so a HAVING that reads an outer column is a plain join filter.
query II
SELECT t1.id, sub.cnt
FROM t1, LATERAL (
    SELECT count(*) AS cnt FROM t2 HAVING count(*) > t1.id
) AS sub
ORDER BY t1.id;
----
1 3
2 3

query II
SELECT t1.id, sub.cnt
FROM t1 LEFT JOIN LATERAL (
    SELECT count(*) AS cnt FROM t2 HAVING count(*) > t1.id
) AS sub ON true
ORDER BY t1.id;
----
1 3
2 3
3 NULL

The other two queries that changed: max(t2.id) ... WHERE t2.id > 10 HAVING max(t2.id) > t1.id * 10 (55.2.0 and main: (1, 30), (2, 30)) and count(*) ... WHERE t2.id > 100 HAVING count(*) >= t1.id - 1 (55.2.0 and main: (1, 0)). Both match DuckDB on 55.2.0 and main, and error on this branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks @jayzhan211, good catch. Applied the pulled_up_scalar_agg gate, added both tests (they fail on the old head and pass now), and merged the two Filter arms.

Comment on lines +345 to +348
LogicalPlan::Filter(filter) if feeds_ungrouped_aggregate(&filter.input) => {
Some(filter)
}
LogicalPlan::Filter(filter) => having_over_ungrouped_aggregate(&filter.input),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional cleanup: the second Filter arm always returns None. If a filter further down fed the aggregate, feeds_ungrouped_aggregate(&filter.input) would already be true for this one, so the two arms can be one:

Suggested change
LogicalPlan::Filter(filter) if feeds_ungrouped_aggregate(&filter.input) => {
Some(filter)
}
LogicalPlan::Filter(filter) => having_over_ungrouped_aggregate(&filter.input),
LogicalPlan::Filter(filter) => {
feeds_ungrouped_aggregate(&filter.input).then_some(filter)
}

Without a correlated WHERE the ungrouped aggregate keeps one row per outer
row, so a HAVING that reads an outer column is a plain join filter and
those queries worked before. Suggested by jayzhan211.
@jayzhan211
jayzhan211 added this pull request to the merge queue Nov 8, 2026
@jayzhan211

Copy link
Copy Markdown
Contributor

Thanks @raghavyadav01 !

Merged via the queue into apache:main with commit a16d10d Nov 8, 2026
43 of 44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) v57.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants