This is an automated email from the ASF dual-hosted git repository.

mihaibudiu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/main by this push:
     new 64b903c42d [CALCITE-7930] Correlated sub-query with ROLLUP or GROUPING 
SETS loses the () group when the sub-query input is empty
64b903c42d is described below

commit 64b903c42d0ae8d4ee4ce39cd05eb325b7cea061
Author: Mihai Budiu <[email protected]>
AuthorDate: Tue Nov 10 15:26:43 2026 -0800

    [CALCITE-7930] Correlated sub-query with ROLLUP or GROUPING SETS loses the 
() group when the sub-query input is empty
---
 .../apache/calcite/sql2rel/RelDecorrelator.java    | 48 +++++++++++-
 .../sql2rel/TopDownGeneralDecorrelator.java        | 11 ++-
 core/src/test/resources/sql/sub-query.iq           | 89 ++++++++++++++++++++++
 3 files changed, 146 insertions(+), 2 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java 
b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
index 1f5272f29d..64c84bd9dc 100644
--- a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
+++ b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
@@ -636,6 +636,42 @@ protected RexNode removeCorrelationExpr(
     return register(rel, newSort, frame.oldToNewOutputs, frame.corDefOutputs);
   }
 
+  /** Returns whether an aggregate call gives the same result for no rows
+   * and for one row of NULL values. */
+  private static boolean isCallSameForNoRowsAndNullRow(AggregateCall aggCall) {
+    final SqlAggFunction function = aggCall.getAggregation();
+    switch (function.getKind()) {
+    case SUM:
+    case SUM0:
+    case MIN:
+    case MAX:
+    case AVG:
+    case ANY_VALUE:
+    case SINGLE_VALUE:
+      // Built-in functions that give NULL (SUM0 gives 0) for both
+      return true;
+    case OTHER_FUNCTION:
+      // A user-defined function that skips NULL inputs skips the row of
+      // NULL values, so it sees no rows in both cases
+      return !aggCall.getArgList().isEmpty() && function.skipsNullInputs();
+    default:
+      // Other built-in functions can give different results. COUNT(*)
+      // gives 0 for no rows and 1 for a row. ARRAY_AGG gives an empty array
+      // for no rows, and NULL or [NULL] for a row, depending on whether it
+      // keeps NULL values.
+      return false;
+    }
+  }
+
+  /** Returns whether an Aggregate gives the same result for no input rows
+   * and for one input row of NULL values. An Aggregate with GROUP BY keys
+   * does not: it gives no group for no rows, but one group for a row. */
+  static boolean isSameForNoRowsAndNullRow(Aggregate aggregate) {
+    return aggregate.getGroupSet().isEmpty()
+        && aggregate.getAggCallList().stream()
+            .allMatch(RelDecorrelator::isCallSameForNoRowsAndNullRow);
+  }
+
   public @Nullable Frame decorrelateRel(LogicalAggregate rel, boolean 
isCorVarDefined,
       boolean parentPropagatesNullValues) {
     return decorrelateRel((Aggregate) rel, isCorVarDefined, 
parentPropagatesNullValues);
@@ -656,7 +692,17 @@ protected RexNode removeCorrelationExpr(
     assert !cm.mapRefRelToCorRef.containsKey(rel);
 
     final RelNode oldInput = rel.getInput();
-    final Frame frame = getInvoke(oldInput, isCorVarDefined, rel, 
parentPropagatesNullValues);
+    // This Aggregate is the parent of its input. The input can give no row
+    // in place of a row of NULL values only if this Aggregate gives the
+    // same result for both. Example, for an outer table t:
+    //   SELECT COUNT(*) FROM (SELECT COUNT(*) FROM s
+    //     WHERE s.k = t.k GROUP BY ROLLUP(s.a))
+    // For a row of t that matches no row of s, the () group of ROLLUP
+    // gives one row, so the result is 1, not 0.
+    final boolean aggregatePropagatesNullValues = parentPropagatesNullValues
+        && isSameForNoRowsAndNullRow(rel);
+    final Frame frame =
+        getInvoke(oldInput, isCorVarDefined, rel, 
aggregatePropagatesNullValues);
     if (frame == null) {
       // If input has not been rewritten, do not rewrite this rel.
       return null;
diff --git 
a/core/src/main/java/org/apache/calcite/sql2rel/TopDownGeneralDecorrelator.java 
b/core/src/main/java/org/apache/calcite/sql2rel/TopDownGeneralDecorrelator.java
index 445ebdbe9d..ddbc51ef0c 100644
--- 
a/core/src/main/java/org/apache/calcite/sql2rel/TopDownGeneralDecorrelator.java
+++ 
b/core/src/main/java/org/apache/calcite/sql2rel/TopDownGeneralDecorrelator.java
@@ -551,7 +551,16 @@ public RelNode unnestInternal(Project project, boolean 
allowEmptyOutputFromRewri
   }
 
   public RelNode unnestInternal(Aggregate aggregate, boolean 
allowEmptyOutputFromRewrite) {
-    RelNode newInput = unnest(aggregate.getInput(), 
allowEmptyOutputFromRewrite);
+    // The rewrite of the input can give an empty output in place of a row
+    // of NULL values only if this Aggregate gives the same result for both.
+    // Example, for an outer table t:
+    //   SELECT COUNT(*) FROM (SELECT COUNT(*) FROM s
+    //     WHERE s.k = t.k GROUP BY ROLLUP(s.a))
+    // For a row of t that matches no row of s, the () group of ROLLUP
+    // gives one row, so the result is 1, not 0.
+    final boolean allowEmptyInputFromRewrite = allowEmptyOutputFromRewrite
+        && RelDecorrelator.isSameForNoRowsAndNullRow(aggregate);
+    RelNode newInput = unnest(aggregate.getInput(), 
allowEmptyInputFromRewrite);
     UnnestedQuery inputUnnestedQuery =
         requireNonNull(mapRelToUnnestedQuery.get(aggregate.getInput()));
     builder.push(newInput);
diff --git a/core/src/test/resources/sql/sub-query.iq 
b/core/src/test/resources/sql/sub-query.iq
index 97ddb5c65d..e4cb4efad8 100644
--- a/core/src/test/resources/sql/sub-query.iq
+++ b/core/src/test/resources/sql/sub-query.iq
@@ -11242,3 +11242,92 @@ order by e.ename;
 (14 rows)
 
 !ok
+
+# [CALCITE-7930] Correlated sub-query with ROLLUP or GROUPING SETS loses the
+# () group when the sub-query input is empty
+# The next 4 queries belong to CALCITE-7930: 2 queries, each run with
+# expand false and with expand true, because the top-down decorrelator
+# fails in both modes.
+# G is the number of groups for each department. DEPT 40 has no employees,
+# so only the () group remains, and G is 1.
+!set expand false
+
+# Query 1 of 4: ROLLUP(job), expand false.
+# Validated: PostgreSQL 14 gives the same rows.
+select deptno,
+  (select count(*) from
+     (select count(*) as n from emp where emp.deptno = dept.deptno
+      group by rollup(job)) as t) as g
+from dept;
++--------+---+
+| DEPTNO | G |
++--------+---+
+|     10 | 4 |
+|     20 | 4 |
+|     30 | 4 |
+|     40 | 1 |
++--------+---+
+(4 rows)
+
+!ok
+
+# Query 2 of 4: GROUPING SETS ((job), (mgr), ()), expand false.
+# Validated: PostgreSQL 14 gives the same rows.
+select deptno,
+  (select count(*) from
+     (select count(*) as n from emp where emp.deptno = dept.deptno
+      group by grouping sets ((job), (mgr), ())) as t) as g
+from dept;
++--------+---+
+| DEPTNO | G |
++--------+---+
+|     10 | 7 |
+|     20 | 8 |
+|     30 | 6 |
+|     40 | 1 |
++--------+---+
+(4 rows)
+
+!ok
+
+!set expand true
+
+# Query 3 of 4: ROLLUP(job), expand true.
+# Validated: PostgreSQL 14 gives the same rows.
+select deptno,
+  (select count(*) from
+     (select count(*) as n from emp where emp.deptno = dept.deptno
+      group by rollup(job)) as t) as g
+from dept;
++--------+---+
+| DEPTNO | G |
++--------+---+
+|     10 | 4 |
+|     20 | 4 |
+|     30 | 4 |
+|     40 | 1 |
++--------+---+
+(4 rows)
+
+!ok
+
+# Query 4 of 4: GROUPING SETS ((job), (mgr), ()), expand true.
+# Validated: PostgreSQL 14 gives the same rows.
+select deptno,
+  (select count(*) from
+     (select count(*) as n from emp where emp.deptno = dept.deptno
+      group by grouping sets ((job), (mgr), ())) as t) as g
+from dept;
++--------+---+
+| DEPTNO | G |
++--------+---+
+|     10 | 7 |
+|     20 | 8 |
+|     30 | 6 |
+|     40 | 1 |
++--------+---+
+(4 rows)
+
+!ok
+
+!set expand false

Reply via email to