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

xuzifu666 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 e4c5b954ef [CALCITE-5101] LISTAGG function with DISTINCT and ORDER BY 
fails
e4c5b954ef is described below

commit e4c5b954ef0158f3100fd2d106408056f2d21cbf
Author: Yu Xu <[email protected]>
AuthorDate: Tue Jun 2 00:24:48 2026 +0800

    [CALCITE-5101] LISTAGG function with DISTINCT and ORDER BY fails
---
 .../AggregateExpandDistinctAggregatesRule.java     | 122 +++++++++++++++++++--
 .../enumerable/EnumerableSortedAggregateTest.java  | 100 +++++++++++++++++
 2 files changed, 210 insertions(+), 12 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/AggregateExpandDistinctAggregatesRule.java
 
b/core/src/main/java/org/apache/calcite/rel/rules/AggregateExpandDistinctAggregatesRule.java
index b25eceb38e..afb4edbf8e 100644
--- 
a/core/src/main/java/org/apache/calcite/rel/rules/AggregateExpandDistinctAggregatesRule.java
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/AggregateExpandDistinctAggregatesRule.java
@@ -19,7 +19,9 @@
 import org.apache.calcite.plan.Contexts;
 import org.apache.calcite.plan.RelOptRuleCall;
 import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.RelCollations;
+import org.apache.calcite.rel.RelFieldCollation;
 import org.apache.calcite.rel.core.Aggregate;
 import org.apache.calcite.rel.core.Aggregate.Group;
 import org.apache.calcite.rel.core.AggregateCall;
@@ -221,9 +223,13 @@ public AggregateExpandDistinctAggregatesRule(
         // one or more non-distinct aggregates
         && !nonDistinctAggCalls.isEmpty()) {
       final RelBuilder relBuilder = call.builder();
-      convertSingletonDistinct(relBuilder, aggregate, distinctCallArgLists);
-      call.transformTo(relBuilder.build());
-      return;
+      final RelBuilder result =
+          convertSingletonDistinct(relBuilder, aggregate, 
distinctCallArgLists);
+      if (result != null) {
+        call.transformTo(result.build());
+        return;
+      }
+      // If convertSingletonDistinct returns null, fall through to other 
strategies
     }
 
     // Create a list of the expressions which will yield the final result.
@@ -279,6 +285,14 @@ public AggregateExpandDistinctAggregatesRule(
     call.transformTo(relBuilder.build());
   }
 
+  private static RelCollation remapCollation(RelCollation collation,
+      Map<Integer, Integer> sourceOf) {
+    if (collation.equals(RelCollations.EMPTY)) {
+      return RelCollations.EMPTY;
+    }
+    return RelCollations.permute(collation, sourceOf);
+  }
+
   /**
    * Converts an aggregate with one distinct aggregate and one or more
    * non-distinct aggregates to multi-phase aggregates (see reference example
@@ -287,15 +301,39 @@ public AggregateExpandDistinctAggregatesRule(
    * @param relBuilder Contains the input relational expression
    * @param aggregate  Original aggregate
    * @param argLists   Arguments and filters to the distinct aggregate function
+   * @return relBuilder if conversion succeeded, or null if not applicable
    *
    */
-  private static RelBuilder convertSingletonDistinct(RelBuilder relBuilder,
+  private static @Nullable RelBuilder convertSingletonDistinct(RelBuilder 
relBuilder,
       Aggregate aggregate, Set<Pair<List<Integer>, Integer>> argLists) {
 
     // In this case, we are assuming that there is a single distinct function.
     // So make sure that argLists is of size one.
     checkArgument(argLists.size() == 1);
 
+    // Check if any DISTINCT aggregate has ORDER BY columns not in GROUP BY or 
args.
+    // If so, we cannot safely apply this optimization because it would 
violate DISTINCT
+    // semantics (adding ORDER BY columns to GROUP BY would incorrectly 
preserve duplicate
+    // values of the DISTINCT column with different ORDER BY values).
+    final ImmutableBitSet groupSet = aggregate.getGroupSet();
+    final Pair<List<Integer>, Integer> pair = 
Iterables.getOnlyElement(argLists);
+    final List<Integer> distinctArgs = ImmutableList.copyOf(pair.left);
+    final ImmutableBitSet distinctArgSet = ImmutableBitSet.of(distinctArgs);
+
+    for (AggregateCall aggCall : aggregate.getAggCallList()) {
+      if (!aggCall.isDistinct() || !aggCall.getArgList().equals(distinctArgs)) 
{
+        continue;
+      }
+      for (RelFieldCollation fc : aggCall.collation.getFieldCollations()) {
+        int colIdx = fc.getFieldIndex();
+        // Check if this ORDER BY column is in GROUP BY or in DISTINCT args
+        if (!groupSet.get(colIdx) && !distinctArgSet.get(colIdx)) {
+          // Cannot optimize: ORDER BY references a column outside GROUP BY 
and DISTINCT args
+          return null;
+        }
+      }
+    }
+
     // For example,
     //    SELECT deptno, COUNT(*), SUM(bonus), MIN(DISTINCT sal)
     //    FROM emp
@@ -315,7 +353,11 @@ private static RelBuilder 
convertSingletonDistinct(RelBuilder relBuilder,
     final ImmutableBitSet originalGroupSet = aggregate.getGroupSet();
 
     // Add the distinct aggregate column(s) to the group-by columns,
-    // if not already a part of the group-by
+    // if not already a part of the group-by.
+    // NOTE: Do NOT add ORDER BY columns to bottomGroups because that would 
break DISTINCT
+    // semantics. For example, SUM(DISTINCT sal) WITHIN GROUP (ORDER BY bonus) 
would incorrectly
+    // produce multiple rows for the same sal value if they have different 
bonus values,
+    // violating the DISTINCT guarantee. ORDER BY columns will be handled 
separately below.
     final NavigableSet<Integer> bottomGroups = new 
TreeSet<>(aggregate.getGroupSet().asList());
     for (AggregateCall aggCall : originalAggCalls) {
       if (aggCall.isDistinct()) {
@@ -360,6 +402,14 @@ private static RelBuilder 
convertSingletonDistinct(RelBuilder relBuilder,
         for (int arg : aggCall.getArgList()) {
           newArgList.add(bottomGroups.headSet(arg, false).size());
         }
+        // Remap collation field indices using the same bottomGroups formula
+        final List<RelFieldCollation> remappedFCs = new ArrayList<>();
+        for (RelFieldCollation fc : aggCall.collation.getFieldCollations()) {
+          int newIdx = bottomGroups.headSet(fc.getFieldIndex(), false).size();
+          remappedFCs.add(fc.withFieldIndex(newIdx));
+        }
+        RelCollation newCollation = 
aggCall.collation.equals(RelCollations.EMPTY)
+            ? RelCollations.EMPTY : RelCollations.of(remappedFCs);
         newCall =
             AggregateCall.create(aggCall.getParserPosition(),
                 aggCall.getAggregation(),
@@ -370,7 +420,7 @@ private static RelBuilder 
convertSingletonDistinct(RelBuilder relBuilder,
                 newArgList,
                 -1,
                 aggCall.distinctKeys,
-                aggCall.collation,
+                newCollation,
                 aggregate.hasEmptyGroup(),
                 relBuilder.peek(),
                 aggCall.getType(),
@@ -649,11 +699,14 @@ private static void 
rewriteUsingGroupingSets(RelOptRuleCall call,
           final String upperAggName = upperAggCallName(aggCall, g);
           // Each filtered grouping set emits exactly one row per group,
           // so MIN just passes that value through without re-aggregation
+          // Remap collation indices through fullGroupSet
+          final RelCollation remappedCollation =
+              remapCollationForGroupingSets(aggCall.collation, fullGroupSet);
           final AggregateCall newCall =
               AggregateCall.create(aggCall.getParserPosition(),
                   SqlStdOperatorTable.MIN, false, aggCall.isApproximate(),
                   aggCall.ignoreNulls(), aggCall.rexList, args, newFilterArg,
-                  aggCall.distinctKeys, aggCall.collation, 
aggregate.hasEmptyGroup(),
+                  aggCall.distinctKeys, remappedCollation, 
aggregate.hasEmptyGroup(),
                   relBuilder.peek(), null, upperAggName);
           upperAggCalls.add(newCall);
           ordinals.add(topGroupCount + upperAggCalls.size() - 1);
@@ -669,11 +722,14 @@ private static void 
rewriteUsingGroupingSets(RelOptRuleCall call,
               requireNonNull(filters.get(Pair.of(newGroupSet, 
aggCall.filterArg)),
                   () -> "filters.get(" + newGroupSet + ", " + 
aggCall.filterArg + ")");
           final String upperAggName = upperAggCallName(aggCall, g);
+          // Remap collation indices through fullGroupSet
+          final RelCollation remappedCollation =
+              remapCollationForGroupingSets(aggCall.collation, fullGroupSet);
           final AggregateCall newCall =
               AggregateCall.create(aggCall.getParserPosition(), 
aggCall.getAggregation(), false,
                   aggCall.isApproximate(), aggCall.ignoreNulls(),
                   aggCall.rexList, newArgList, newFilterArg,
-                  aggCall.distinctKeys, aggCall.collation,
+                  aggCall.distinctKeys, remappedCollation,
                   aggregate.hasEmptyGroup(), relBuilder.peek(), null, 
upperAggName);
           upperAggCalls.add(newCall);
           ordinals.add(topGroupCount + upperAggCalls.size() - 1);
@@ -864,6 +920,31 @@ private static int remap(ImmutableBitSet groupSet, int 
arg) {
     return arg < 0 ? -1 : groupSet.indexOf(arg);
   }
 
+  private static RelCollation remapCollationForGroupingSets(RelCollation 
collation,
+      ImmutableBitSet fullGroupSet) {
+    if (collation.equals(RelCollations.EMPTY)) {
+      return RelCollations.EMPTY;
+    }
+    // Remap each field index through the fullGroupSet.
+    // fullGroupSet contains only columns that appear in at least one grouping 
set.
+    // If an ORDER BY column is not in fullGroupSet, it means:
+    // 1. The column is not part of any grouping set combination
+    // 2. Different rows within the same logical group can have different 
values
+    // 3. Sorting by such a column would be meaningless
+    // Therefore, we safely drop ORDER BY columns not in fullGroupSet. The 
query
+    // planner should ensure ORDER BY columns are either in GROUP BY, in 
aggregation
+    // arguments, or properly scoped for consistent values within each group.
+    final List<RelFieldCollation> remappedFCs = new ArrayList<>();
+    for (RelFieldCollation fc : collation.getFieldCollations()) {
+      int originalIdx = fc.getFieldIndex();
+      int newIdx = fullGroupSet.indexOf(originalIdx);
+      if (newIdx >= 0) {
+        remappedFCs.add(fc.withFieldIndex(newIdx));
+      }
+    }
+    return remappedFCs.isEmpty() ? RelCollations.EMPTY : 
RelCollations.of(remappedFCs);
+  }
+
   private static String upperAggCallName(AggregateCall aggCall,
       int groupingSetIndex) {
     String baseName = aggCall.getName();
@@ -1013,7 +1094,7 @@ private static void doRewrite(RelBuilder relBuilder, 
Aggregate aggregate, int n,
         continue;
       }
 
-      // Re-map arguments.
+      // Re-map arguments and collation.
       final int argCount = aggCall.getArgList().size();
       final List<Integer> newArgs = new ArrayList<>(argCount);
       for (Integer arg : aggCall.getArgList()) {
@@ -1022,7 +1103,7 @@ private static void doRewrite(RelBuilder relBuilder, 
Aggregate aggregate, int n,
       final AggregateCall newAggCall =
           AggregateCall.create(aggCall.getParserPosition(), 
aggCall.getAggregation(), false,
               aggCall.isApproximate(), aggCall.ignoreNulls(), aggCall.rexList,
-              newArgs, -1, aggCall.distinctKeys, aggCall.collation,
+              newArgs, -1, aggCall.distinctKeys, 
remapCollation(aggCall.collation, sourceOf),
               aggCall.getType(), aggCall.getName());
       assert refs.get(i) == null;
       if (leftFields == null) {
@@ -1098,7 +1179,7 @@ private static void rewriteAggCalls(
         continue;
       }
 
-      // Re-map arguments.
+      // Re-map arguments and collation.
       final int argCount = aggCall.getArgList().size();
       final List<Integer> newArgs = new ArrayList<>(argCount);
       for (int j = 0; j < argCount; j++) {
@@ -1111,7 +1192,7 @@ private static void rewriteAggCalls(
           AggregateCall.create(aggCall.getParserPosition(), 
aggCall.getAggregation(), false,
               aggCall.isApproximate(), aggCall.ignoreNulls(),
               aggCall.rexList, newArgs, -1,
-              aggCall.distinctKeys, aggCall.collation,
+              aggCall.distinctKeys, remapCollation(aggCall.collation, 
sourceOf),
               aggCall.getType(), aggCall.getName());
       newAggCalls.set(i, newAggCall);
     }
@@ -1193,6 +1274,23 @@ private static RelBuilder 
createSelectDistinct(RelBuilder relBuilder,
       sourceOf.put(arg, projects.size());
       RexInputRef.add2(projects, arg, childFields);
     }
+
+    // Also project ORDER BY columns from WITHIN GROUP for DISTINCT agg calls.
+    // If the ORDER BY references a column not already in GROUP BY or argList,
+    // it must be projected here so that collation indices can be properly 
remapped.
+    for (AggregateCall aggCall : aggregate.getAggCallList()) {
+      if (!aggCall.isDistinct() || !aggCall.getArgList().equals(argList)) {
+        continue;
+      }
+      for (RelFieldCollation fc : aggCall.collation.getFieldCollations()) {
+        int col = fc.getFieldIndex();
+        if (sourceOf.get(col) == null) {
+          sourceOf.put(col, projects.size());
+          RexInputRef.add2(projects, col, childFields);
+        }
+      }
+    }
+
     relBuilder.project(projects.leftList(), projects.rightList());
 
     // Get the distinct values of the GROUP BY fields and the arguments
diff --git 
a/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableSortedAggregateTest.java
 
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableSortedAggregateTest.java
index 1945dabaed..454beb2015 100644
--- 
a/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableSortedAggregateTest.java
+++ 
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableSortedAggregateTest.java
@@ -133,6 +133,106 @@ public class EnumerableSortedAggregateTest {
             "commission=null; num_dept=1");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-5101";>[CALCITE-5101]
+   * LISTAGG(DISTINCT ...) WITHIN GROUP fails with 
ArrayIndexOutOfBoundsException</a>. */
+  @Test void listAggDistinctWithinGroupOrderByNonGroupColumn() {
+    // FAILING CASE: LISTAGG(DISTINCT ...) WITHIN GROUP (ORDER BY 
non-group-column)
+    // Previously threw: ArrayIndexOutOfBoundsException: Index 2 out of bounds 
for length 2
+    // Now fixed: ORDER BY salary is properly projected and remapped.
+    // Within each group, names are ordered by salary (ascending).
+    // deptno=10: Sebastian (7000), Bill (10000), Theodore (11500)
+    // deptno=20: Eric (8000)
+    tester(false, new HrSchema())
+        .query("select deptno, "
+            + "LISTAGG(DISTINCT name) WITHIN GROUP (ORDER BY salary) as names "
+            + "from emps group by deptno")
+        .returnsUnordered(
+            "deptno=10; names=Sebastian,Bill,Theodore",
+            "deptno=20; names=Eric");
+  }
+
+  @Test void listAggDistinctWithoutOrderBy() {
+    // WORKING CASE: LISTAGG(DISTINCT ...) without ORDER BY - always worked
+    tester(false, new HrSchema())
+        .query("select deptno, "
+            + "LISTAGG(DISTINCT name) as names "
+            + "from emps group by deptno")
+        .returnsUnordered(
+            "deptno=10; names=Bill,Sebastian,Theodore",
+            "deptno=20; names=Eric");
+  }
+
+  @Test void listAggWithoutDistinctWithinGroupOrderBy() {
+    // WORKING CASE: LISTAGG(...) WITHIN GROUP (ORDER BY non-group-column)
+    // without DISTINCT - always worked
+    tester(false, new HrSchema())
+        .query("select deptno, "
+            + "LISTAGG(name) WITHIN GROUP (ORDER BY salary) as names "
+            + "from emps group by deptno")
+        .returnsUnordered(
+            "deptno=10; names=Sebastian,Bill,Theodore",
+            "deptno=20; names=Eric");
+  }
+
+  @Test void listAggDistinctWithinGroupOrderByAggColumn() {
+    // WORKING CASE: LISTAGG(DISTINCT ...) WITHIN GROUP (ORDER BY 
aggregated-column)
+    // - always worked because the aggregated column is in the re-grouped input
+    tester(false, new HrSchema())
+        .query("select deptno, "
+            + "LISTAGG(DISTINCT name) WITHIN GROUP (ORDER BY name) as names "
+            + "from emps group by deptno")
+        .returnsUnordered(
+            "deptno=10; names=Bill,Sebastian,Theodore",
+            "deptno=20; names=Eric");
+  }
+
+  @Test void listAggMultipleDistinctWithinGroupOrderByNonGroupColumn() {
+    // Test case for multiple DISTINCT aggregates with different ORDER BY 
columns
+    // Previously threw: ArrayIndexOutOfBoundsException
+    // ORDER BY salary for first agg (not a group key), ORDER BY name for 
second
+    // This tests that collation indices are properly remapped in 
rewriteUsingGroupingSets
+    tester(false, new HrSchema())
+        .query("select deptno, "
+            + "LISTAGG(DISTINCT name) WITHIN GROUP (ORDER BY salary) as 
names_by_salary, "
+            + "LISTAGG(DISTINCT commission) WITHIN GROUP (ORDER BY name) as 
commissions_by_name "
+            + "from emps group by deptno")
+        .returnsUnordered(
+            "deptno=10; names_by_salary=Theodore,Sebastian,Bill; 
commissions_by_name=250,1000",
+            "deptno=20; names_by_salary=Eric; commissions_by_name=500");
+  }
+
+  @Test void 
groupingSetsWithDistinctAggAndCollationReferencingOutsideGroupingSets() {
+    // Test that result collation is safely reduced when ORDER BY references
+    // columns not present in all grouping sets.
+    // Why result collation can have fewer elements than input collation:
+    // - Input collation may reference {salary, deptno, name}
+    // - fullGroupSet contains only {deptno, name} (columns in some grouping 
sets)
+    // - salary is NOT in fullGroupSet (not part of any GROUPING SETS 
combination)
+    // - In GROUPING SETS ((deptno), (name), ()), salary has inconsistent 
values
+    //   within each logical group, so sorting by it would be meaningless
+    // - Result collation safely drops salary and keeps only {deptno, name}
+    // This tests that 
AggregateExpandDistinctAggregatesRule.remapCollationForGroupingSets
+    // correctly filters out columns not in fullGroupSet.
+    // The key point: this query should not throw 
ArrayIndexOutOfBoundsException.
+    tester(false, new HrSchema())
+        .query("select deptno, name, "
+            + "LISTAGG(DISTINCT salary) WITHIN GROUP (ORDER BY salary) as 
salaries "
+            + "from emps "
+            + "group by grouping sets ((deptno), (name), ())")
+        .returnsUnordered(
+            // Grouping by deptno - contains the 3 salaries from deptno=10
+            "deptno=10; name=null; salaries=10000.0,7000.0,11500.0",
+            "deptno=20; name=null; salaries=8000.0",
+            // Grouping by name - single salary per name
+            "deptno=null; name=Bill; salaries=10000.0",
+            "deptno=null; name=Eric; salaries=8000.0",
+            "deptno=null; name=Sebastian; salaries=7000.0",
+            "deptno=null; name=Theodore; salaries=11500.0",
+            // Grand total - all 4 distinct salaries
+            "deptno=null; name=null; salaries=10000.0,8000.0,7000.0,11500.0");
+  }
+
   private CalciteAssert.AssertThat tester(boolean forceDecorrelate,
                                           Object schema) {
     return CalciteAssert.that()

Reply via email to