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

xiangfu0 pushed a commit to branch xiangfu0/agg-rewrite/02-array-agg
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to 
refs/heads/xiangfu0/agg-rewrite/02-array-agg by this push:
     new 1a5b828a80a Limit early inference to ARRAY_AGG and preserve union 
column semantics
1a5b828a80a is described below

commit 1a5b828a80a0a7682d5711c65e6e1a00732f131d
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Oct 27 13:37:02 2026 -0700

    Limit early inference to ARRAY_AGG and preserve union column semantics
    
    Keep MODE CASE-to-FILTER conversion intact. Append aggregate literals above 
unions and lift an existing column only when all branches project the same 
literal. Reject DISTINCT ARRAY_AGG explicitly in SSE instead of silently 
dropping DISTINCT.
---
 .../apache/pinot/sql/parsers/CalciteSqlParser.java |  4 +++
 .../AggregateFunctionRewriteOptimizerTest.java     |  9 +++++
 .../PinotAggregateTypeArgumentRewriteRule.java     | 18 +++++++---
 .../pinot/calcite/rel/rules/PinotRuleUtils.java    | 38 ++++++++++++----------
 .../org/apache/pinot/query/QueryEnvironment.java   |  2 +-
 .../query/ApproximateAggregateRewriteTest.java     | 12 +++++++
 .../src/test/resources/queries/AggregatePlans.json | 19 ++++++-----
 .../src/test/resources/queries/UDFAggregates.json  |  5 +++
 8 files changed, 75 insertions(+), 32 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
index 274b6be95cf..9039445b360 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
@@ -1242,6 +1242,10 @@ public class CalciteSqlParser {
 
   private static Expression compileFunctionExpression(SqlBasicCall 
functionNode) {
     SqlKind functionKind = functionNode.getKind();
+    if (functionKind == SqlKind.ARRAY_AGG && 
functionNode.getFunctionQuantifier() != null
+        && "DISTINCT".equals(functionNode.getFunctionQuantifier().toString())) 
{
+      throw new SqlCompilationException("Function 'ARRAY_AGG' on DISTINCT is 
not supported.");
+    }
     boolean negated = false;
     String canonicalName;
     switch (functionKind) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
index fc9828c9f33..3e324ce47f3 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
@@ -25,6 +25,7 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.exception.BadQueryRequestException;
 import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.apache.pinot.sql.parsers.SqlCompilationException;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
@@ -109,6 +110,14 @@ public class AggregateFunctionRewriteOptimizerTest {
             + "FROM testTable");
   }
 
+  @Test
+  public void testArrayAggRejectsDistinctKeyword() {
+    for (String arguments : new String[]{"intCol", "intCol, false", "intCol, 
'INT'"}) {
+      expectThrows(SqlCompilationException.class,
+          () -> CalciteSqlParser.compileToPinotQuery("SELECT 
ARRAY_AGG(DISTINCT " + arguments + ") FROM testTable"));
+    }
+  }
+
   @Test
   public void testArrayAggRejectsUnsupportedColumn() {
     PinotQuery query = CalciteSqlParser.compileToPinotQuery("SELECT 
ARRAY_AGG(mapCol) FROM testTable");
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
index 332f92ced98..324d10fb5c9 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
@@ -64,20 +64,27 @@ public class PinotAggregateTypeArgumentRewriteRule extends 
RelOptRule {
           ColumnDataType.BYTES, ColumnDataType.UUID);
 
   public static final PinotAggregateTypeArgumentRewriteRule INSTANCE =
-      new PinotAggregateTypeArgumentRewriteRule(true, 
PinotRuleUtils.PINOT_REL_FACTORY, null);
+      new PinotAggregateTypeArgumentRewriteRule(true, false, 
PinotRuleUtils.PINOT_REL_FACTORY, null);
   /// Leaves ANY_VALUE without the data type argument.
   public static final PinotAggregateTypeArgumentRewriteRule WITHOUT_ANY_VALUE =
-      new PinotAggregateTypeArgumentRewriteRule(false, 
PinotRuleUtils.PINOT_REL_FACTORY,
+      new PinotAggregateTypeArgumentRewriteRule(false, false, 
PinotRuleUtils.PINOT_REL_FACTORY,
           "PinotAggregateTypeArgumentRewriteRuleWithoutAnyValue");
 
+  /// Runs before CASE-to-FILTER without changing other aggregation functions.
+  public static final PinotAggregateTypeArgumentRewriteRule ARRAY_AGG_ONLY =
+      new PinotAggregateTypeArgumentRewriteRule(false, true, 
PinotRuleUtils.PINOT_REL_FACTORY,
+          "PinotAggregateTypeArgumentRewriteRuleArrayAggOnly");
+
+  private final boolean _arrayAggOnly;
   private final boolean _rewriteAnyValue;
 
   // Matches with RelOptRule operands, like the other aggregate rewrite rules, 
instead of a RelRule config.
   @SuppressWarnings("deprecation")
-  private PinotAggregateTypeArgumentRewriteRule(boolean rewriteAnyValue, 
RelBuilderFactory factory,
-      @Nullable String description) {
+  private PinotAggregateTypeArgumentRewriteRule(boolean rewriteAnyValue, 
boolean arrayAggOnly,
+      RelBuilderFactory factory, @Nullable String description) {
     super(operand(LogicalAggregate.class, any()), factory, description);
     _rewriteAnyValue = rewriteAnyValue;
+    _arrayAggOnly = arrayAggOnly;
   }
 
   @Override
@@ -142,6 +149,9 @@ public class PinotAggregateTypeArgumentRewriteRule extends 
RelOptRule {
       }
       return List.of(getDataTypeArgument(name, dataType.getSqlTypeName()));
     }
+    if (_arrayAggOnly) {
+      return null;
+    }
     if (name.equals(AggregationFunctionType.MODE.name())) {
       // BOOLEAN and TIMESTAMP already return their own type, cast from the 
DOUBLE result.
       return argList.size() == 1 && 
SqlTypeName.CHAR_TYPES.contains(getSqlTypeName(input, argList.get(0)))
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
index 2658d210d51..7091c3f7cd9 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
@@ -89,22 +89,10 @@ public class PinotRuleUtils {
     }
   }
 
-  /// Appends aggregate literal arguments without hiding literals already 
projected beneath a union. If a union has
-  /// no projected literals, keeps one project above it; otherwise recurses 
through nested unions to their projects.
+  /// Appends literal arguments in one project, retaining a union column as a 
literal only when every branch projects
+  /// that same literal. Branch-specific constants remain input references.
   public static RelNode appendAggregateLiterals(RelNode input, List<RexNode> 
literals) {
-    return appendAggregateLiterals(input, literals, false);
-  }
-
-  private static RelNode appendAggregateLiterals(RelNode input, List<RexNode> 
literals, boolean insideUnion) {
     RelNode rel = unboxRel(input);
-    if (rel instanceof Union && (insideUnion || hasProjectedLiterals(rel))) {
-      Union union = (Union) rel;
-      List<RelNode> inputs = new ArrayList<>(union.getInputs().size());
-      for (RelNode unionInput : union.getInputs()) {
-        inputs.add(appendAggregateLiterals(unionInput, literals, true));
-      }
-      return union.copy(union.getTraitSet(), inputs);
-    }
     if (rel instanceof Project) {
       Project project = (Project) rel;
       List<RexNode> projects = new ArrayList<>(project.getProjects());
@@ -114,18 +102,32 @@ public class PinotRuleUtils {
     RexBuilder rexBuilder = input.getCluster().getRexBuilder();
     List<RexNode> projects = new 
ArrayList<>(input.getRowType().getFieldCount() + literals.size());
     for (int i = 0; i < input.getRowType().getFieldCount(); i++) {
-      projects.add(rexBuilder.makeInputRef(input, i));
+      RexLiteral literal = rel instanceof Union ? commonLiteral(rel, i) : null;
+      projects.add(literal != null ? literal : rexBuilder.makeInputRef(input, 
i));
     }
     projects.addAll(literals);
     return LogicalProject.create(input, List.of(), projects, (List<String>) 
null, Set.of());
   }
 
-  private static boolean hasProjectedLiterals(RelNode input) {
+  @Nullable
+  private static RexLiteral commonLiteral(RelNode input, int index) {
     RelNode rel = unboxRel(input);
     if (rel instanceof Project) {
-      return ((Project) rel).getProjects().stream().anyMatch(project -> 
project instanceof RexLiteral);
+      RexNode expression = ((Project) rel).getProjects().get(index);
+      return expression instanceof RexLiteral ? (RexLiteral) expression : null;
+    }
+    if (rel instanceof Union) {
+      RexLiteral common = null;
+      for (RelNode branch : rel.getInputs()) {
+        RexLiteral literal = commonLiteral(branch, index);
+        if (literal == null || (common != null && !common.equals(literal))) {
+          return null;
+        }
+        common = literal;
+      }
+      return common;
     }
-    return rel instanceof Union && 
rel.getInputs().stream().anyMatch(PinotRuleUtils::hasProjectedLiterals);
+    return null;
   }
 
   public static boolean isExchange(RelNode rel) {
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index 44501f7d439..95e9b4f102e 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -613,7 +613,7 @@ public class QueryEnvironment {
       // Supplies the data type argument before AggregateCaseToFilter, which 
rewrites only one-argument calls: its
       // ARRAY_AGG(expression) FILTER (WHERE condition) form of ARRAY_AGG(CASE 
WHEN ...) fails at runtime.
       if 
(relOptRule.toString().equals(CommonConstants.Broker.PlannerRuleNames.AGGREGATE_CASE_TO_FILTER))
 {
-        
hepProgramBuilder.addRuleInstance(PinotAggregateTypeArgumentRewriteRule.WITHOUT_ANY_VALUE);
+        
hepProgramBuilder.addRuleInstance(PinotAggregateTypeArgumentRewriteRule.ARRAY_AGG_ONLY);
       }
       hepProgramBuilder.addRuleInstance(relOptRule);
     }
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
index cea95cc294d..f580c196b6f 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/ApproximateAggregateRewriteTest.java
@@ -130,6 +130,18 @@ public class ApproximateAggregateRewriteTest extends 
QueryEnvironmentTestBase {
     }
   }
 
+  @Test
+  public void testApproximateParamsKeepBranchSpecificUnionColumns() {
+    QueryEnvironment env = newQueryEnvironment(true, "threshold=17", 
"threshold=23");
+    String query = "EXPLAIN PLAN FOR SELECT DISTINCTCOUNT(x), COVARPOP(x, y) 
FROM "
+        + "(SELECT col3 x, 1.0 y FROM a UNION ALL SELECT col3, CAST(col7 AS 
DOUBLE) FROM b)";
+    String plan = env.compile(query, 
CalciteSqlParser.compileToSqlNodeAndOptions(query))
+        .explain(RANDOM_REQUEST_ID_GEN.nextLong(), null).getExplainPlan();
+    String leaf = plan.lines().filter(line -> 
line.contains("aggType=[LEAF]")).findFirst().orElse("");
+    assertTrue(leaf.contains("_UTF-8'threshold=17'"), plan);
+    assertFalse(leaf.contains("1.0"), plan);
+  }
+
   /// `AggregateCaseToFilterRule` turns `COUNT(DISTINCT CASE WHEN c THEN x 
END)` into `COUNT(DISTINCT x) FILTER (WHERE
   /// c)`, so the rows that fail `c` add nothing to the count. It only matches 
a single-argument call, so the rewrite
   /// has to run after it: with the parameters appended first, the CASE stays 
and its NULL counts as one more value.
diff --git a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json 
b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
index fe7e4caf100..e8442d6d3a0 100644
--- a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
+++ b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
@@ -374,20 +374,21 @@
         ]
       },
       {
-        "description": "The data type argument goes into every input of a 
union, next to the other literal arguments",
+        "description": "The data type argument stays above a union alongside 
literals common to every branch",
         "sql": "EXPLAIN PLAN FOR SELECT ARRAY_AGG(x), PERCENTILE(x, 50), 
ARRAY_AGG(DISTINCT x, 'INT') FROM (SELECT a.col3 x FROM a UNION ALL SELECT 
b.col3 FROM b)",
         "output": [
           "Execution Plan",
           "\nPinotLogicalAggregate(group=[{}], agg#0=[ARRAYAGG($0, 
_UTF-8'INT')], agg#1=[PERCENTILE($1, 50)], agg#2=[ARRAYAGG($2, _UTF-8'INT', 
true)], aggType=[FINAL])",
           "\n  PinotLogicalExchange(distribution=[hash])",
           "\n    PinotLogicalAggregate(group=[{}], agg#0=[ARRAYAGG($0, 
_UTF-8'INT')], agg#1=[PERCENTILE($0, 50)], agg#2=[ARRAYAGG($0, _UTF-8'INT', 
true)], aggType=[LEAF])",
-          "\n      LogicalUnion(all=[true])",
-          "\n        PinotLogicalExchange(distribution=[single])",
-          "\n          LogicalProject($f0=[$2], $f1=[50], $f2=[_UTF-8'INT'], 
$f3=[_UTF-8'INT'])",
-          "\n            PinotLogicalTableScan(table=[[default, a]])",
-          "\n        PinotLogicalExchange(distribution=[single])",
-          "\n          LogicalProject($f0=[$2], $f1=[50], $f2=[_UTF-8'INT'], 
$f3=[_UTF-8'INT'])",
-          "\n            PinotLogicalTableScan(table=[[default, b]])",
+          "\n      LogicalProject($f0=[$0], $f1=[50], $f2=[_UTF-8'INT'], 
$f3=[_UTF-8'INT'])",
+          "\n        LogicalUnion(all=[true])",
+          "\n          PinotLogicalExchange(distribution=[single])",
+          "\n            LogicalProject(x=[$2], $f1=[50], $f2=[_UTF-8'INT'])",
+          "\n              PinotLogicalTableScan(table=[[default, a]])",
+          "\n          PinotLogicalExchange(distribution=[single])",
+          "\n            LogicalProject(x=[$2], $f1=[50], $f2=[_UTF-8'INT'])",
+          "\n              PinotLogicalTableScan(table=[[default, b]])",
           "\n"
         ]
       },
@@ -399,7 +400,7 @@
           "\nPinotLogicalAggregate(group=[{}], agg#0=[FIRSTWITHTIME($0, $0, 
_UTF-8'INT')], agg#1=[ARRAYAGG($1, _UTF-8'INT')], aggType=[FINAL])",
           "\n  PinotLogicalExchange(distribution=[hash])",
           "\n    PinotLogicalAggregate(group=[{}], agg#0=[FIRSTWITHTIME($0, 
$1, _UTF-8'INT')], agg#1=[ARRAYAGG($0, _UTF-8'INT')], aggType=[LEAF])",
-          "\n      LogicalProject($f0=[$0], $f1=[$1], $f2=[_UTF-8'INT'])",
+          "\n      LogicalProject($f0=[$0], $f1=[$1], $f2=[_UTF-8'INT'], 
$f3=[_UTF-8'INT'])",
           "\n        LogicalUnion(all=[true])",
           "\n          PinotLogicalExchange(distribution=[single])",
           "\n            LogicalProject(col3=[$2], ts=[$7])",
diff --git a/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json 
b/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
index c405e55daa2..f1ca915df48 100644
--- a/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
+++ b/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
@@ -45,6 +45,11 @@
       }
     },
     "queries": [
+      {"sql": "SELECT MODE(CASE WHEN int_col > 3 THEN string_col END) FROM 
{tbl}", "outputs": [["c"]]},
+      {"sql": "SELECT /*+ aggOptions(is_skip_leaf_stage_group_by='true') */ 
bool_col, MODE(CASE WHEN int_col > 3 THEN string_col END) FROM {tbl} GROUP BY 
bool_col", "outputs": [[true, "c"], [false, "c"]]},
+      {"sql": "SET inferAnyValueType=true; SELECT src, ANY_VALUE(x) FROM 
(SELECT 'a' src, 1 x FROM {tbl} UNION ALL SELECT 'b', 2 FROM {tbl}) GROUP BY 
src", "outputs": [["a", 1], ["b", 2]]},
+      {"sql": "SELECT LASTWITHTIME(x, t) FROM (SELECT int_col x, 
CAST('2019-01-01 00:00:00' AS TIMESTAMP) t FROM {tbl} UNION ALL SELECT int_col, 
timestamp_col FROM {tbl})", "outputs": [[101]]},
+      {"sql": "SELECT /*+ aggOptions(is_skip_leaf_stage_group_by='true') */ 
src, k, MODE(s) FROM (SELECT 'a' src, int_col k, string_col s FROM {tbl} UNION 
ALL SELECT 'b', int_col, string_col FROM {tbl}) WHERE k = 2 GROUP BY src, k", 
"outputs": [["a", 2, "a"], ["b", 2, "a"]]},
       {
         "sql": "SELECT FIRSTWITHTIME(int_col, timestamp_col, 'INT'), 
FIRSTWITHTIME(string_col, timestamp_col, 'STRING'), first_with_time(bool_col, 
CAST(long_col AS TIMESTAMP), 'BOOLEAN') FROM {tbl}",
         "outputs": [[2, "a", true]]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to