From 778dd5edb82fed301ea15faa346e43892ef5a1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Sun, 30 Aug 2026 07:12:08 +0200 Subject: [PATCH] fix: allow a correlated scalar subquery to group without aggregating A correlated scalar subquery must return at most one row per set of outer values. `check_aggregation_in_scalar_subquery` required an aggregate expression for that, and rejected select o.id, (select k from dim where dim.k = o.k group by k) from o with "Correlated scalar subquery must be aggregated to return at most one row". The aggregate expression is not what makes the subquery scalar. The grouping is. An empty GROUP BY returns one row, and a GROUP BY on only correlated columns returns at most one group per set of outer values. The function already checks the latter, so the aggregate check is redundant and only rejects valid queries. These subqueries decorrelate to a plain LEFT JOIN like any other, so no changes are needed outside the check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgqwvctZdvR1ZCz2hbkEJC --- .../expr/src/logical_plan/invariants.rs | 10 ++++---- .../sqllogictest/test_files/subquery.slt | 25 +++++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/datafusion/expr/src/logical_plan/invariants.rs b/datafusion/expr/src/logical_plan/invariants.rs index f36653694c21..263573ad3865 100644 --- a/datafusion/expr/src/logical_plan/invariants.rs +++ b/datafusion/expr/src/logical_plan/invariants.rs @@ -362,11 +362,11 @@ fn check_aggregation_in_scalar_subquery( inner_plan: &LogicalPlan, agg: &Aggregate, ) -> Result<()> { - if agg.aggr_expr.is_empty() { - return plan_err!( - "Correlated scalar subquery must be aggregated to return at most one row" - ); - } + // The grouping is what makes the subquery scalar, not the aggregation. An + // empty GROUP BY returns one row, and a GROUP BY on only correlated columns + // returns at most one row per set of outer values. So subqueries without + // aggregate expressions are allowed as long as the GROUP BY check below + // passes. if !agg.group_expr.is_empty() { let correlated_exprs = get_correlated_expressions(inner_plan)?; let inner_subquery_cols = diff --git a/datafusion/sqllogictest/test_files/subquery.slt b/datafusion/sqllogictest/test_files/subquery.slt index 2a022b947d0f..546eaffa58bb 100644 --- a/datafusion/sqllogictest/test_files/subquery.slt +++ b/datafusion/sqllogictest/test_files/subquery.slt @@ -446,9 +446,30 @@ SELECT t1_id, (SELECT t3_int FROM t3 WHERE t3.t3_id = t1.t1_id) as t3_int from t 44 3 -#non_aggregated_correlated_scalar_subquery -statement error DataFusion error: Invalid \(non-executable\) plan after Analyzer\ncaused by\nError during planning: Correlated scalar subquery must be aggregated to return at most one row +# A GROUP BY on only correlated columns returns at most one row per outer row, +# so no aggregate function is needed to make the subquery scalar. +#grouped_correlated_scalar_subquery_without_aggregate +query II rowsort SELECT t1_id, (SELECT t2_int FROM t2 WHERE t2.t2_int = t1_int group by t2_int) as t2_int from t1 +---- +11 1 +22 NULL +33 3 +44 NULL + +query TT +explain SELECT t1_id, (SELECT t2_int FROM t2 WHERE t2.t2_int = t1_int group by t2_int) as t2_int from t1 +---- +logical_plan +01)Projection: t1.t1_id, __scalar_sq_1.t2_int AS t2_int +02)--Left Join: t1.t1_int = __scalar_sq_1.t2_int +03)----TableScan: t1 projection=[t1_id, t1_int] +04)----SubqueryAlias: __scalar_sq_1 +05)------Aggregate: groupBy=[[t2.t2_int]], aggr=[[]] +06)--------TableScan: t2 projection=[t2_int] + +# A GROUP BY on a non-correlated column is still rejected, see +# `aggregated_correlated_scalar_subquery_with_extra_group_by_columns` below. #non_aggregated_correlated_scalar_subquery_with_limit statement error DataFusion error: Invalid \(non-executable\) plan after Analyzer\ncaused by\nError during planning: Correlated scalar subquery must be aggregated to return at most one row