Jackie-Jiang commented on code in PR #19489:
URL: https://github.com/apache/pinot/pull/19489#discussion_r3994548230


##########
pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java:
##########
@@ -46,48 +56,69 @@ public void optimize(PinotQuery pinotQuery, @Nullable 
Schema schema) {
       return;
     }
 
-    // Only perform auto rewrite when enabled through query option.
-    if (pinotQuery.getQueryOptions() == null || 
!Boolean.parseBoolean(pinotQuery.getQueryOptions().get(
-        
CommonConstants.Broker.Request.QueryOptionKey.AUTO_REWRITE_AGGREGATION_TYPE))) {
-      return;
-    }
+    boolean autoRewrite = pinotQuery.getQueryOptions() != null && 
Boolean.parseBoolean(pinotQuery.getQueryOptions().get(
+        
CommonConstants.Broker.Request.QueryOptionKey.AUTO_REWRITE_AGGREGATION_TYPE));
 
     List<Expression> selectList = pinotQuery.getSelectList();
     if (selectList != null) {
       for (Expression expression : selectList) {
-        maybeRewriteAggregateFunction(expression, schema);
+        maybeRewriteAggregateFunction(expression, schema, autoRewrite);
       }
     }
 
     List<Expression> groupByList = pinotQuery.getGroupByList();
     if (groupByList != null) {
       for (Expression expression : groupByList) {
-        maybeRewriteAggregateFunction(expression, schema);
+        maybeRewriteAggregateFunction(expression, schema, autoRewrite);
       }
     }
 
     List<Expression> orderByList = pinotQuery.getOrderByList();
     if (orderByList != null) {
       for (Expression expression : orderByList) {
-        maybeRewriteAggregateFunction(expression, schema);
+        maybeRewriteAggregateFunction(expression, schema, autoRewrite);
       }
     }
 
-    maybeRewriteAggregateFunction(pinotQuery.getFilterExpression(), schema);
-    maybeRewriteAggregateFunction(pinotQuery.getHavingExpression(), schema);
+    maybeRewriteAggregateFunction(pinotQuery.getFilterExpression(), schema, 
autoRewrite);
+    maybeRewriteAggregateFunction(pinotQuery.getHavingExpression(), schema, 
autoRewrite);
   }
 
-  private void maybeRewriteAggregateFunction(@Nullable Expression expression, 
Schema schema) {
+  private void maybeRewriteAggregateFunction(@Nullable Expression expression, 
Schema schema, boolean autoRewrite) {
     if (expression == null || !expression.isSetFunctionCall()) {
       return;
     }
 
     Function function = expression.getFunctionCall();
+    List<Expression> operands = function.getOperands();
+    for (Expression operand : operands) {
+      // Infer arguments within scalar expressions while preserving the 
existing top-level variant rewrites.
+      maybeRewriteAggregateFunction(operand, schema, false);
+    }
     String functionName = function.getOperator();
     if (!AggregationFunctionType.isAggregationFunction(functionName)) {
       return;
     }
 
+    List<String> inferredArguments = 
AggregationFunctionType.getAggregationFunctionType(functionName)
+        .inferArguments(operands.size(), i -> {
+          ColumnDataType type = getOperandType(operands.get(i), schema);
+          if (type == null || type.isArray() || type == ColumnDataType.OBJECT) 
{
+            return DataType.UNKNOWN;
+          }
+          return type == ColumnDataType.MAP ? DataType.MAP : type.toDataType();
+        });

Review Comment:
   **[Critical] Gate automatic timestamp inference during rollout**
   
   Existing `MODE(timestampCol)` queries now gain a third `TIMESTAMP` argument 
unconditionally, even when `autoRewriteAggregationType=false`. During a 
broker-first upgrade, older servers reject these previously valid queries with 
`Mode expects at most 2 arguments, got: 3`. Existing timestamp `AVG` calls also 
become invalid on a fully upgraded cluster.
   
   Please gate automatic conversion behind an initially disabled rollout option 
and preserve legacy behavior until it is enabled after server upgrades. 
Explicit typed calls can remain available for the new behavior.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java:
##########
@@ -133,4 +164,96 @@ private void maybeRewriteAggregateFunction(@Nullable 
Expression expression, Sche
       }
     }
   }
+
+  @Nullable
+  private static ColumnDataType getOperandType(Expression operand, Schema 
schema) {
+    if (operand.isSetIdentifier()) {
+      FieldSpec fieldSpec = 
schema.getFieldSpecFor(operand.getIdentifier().getName());
+      return fieldSpec != null
+          ? ColumnDataType.fromDataType(fieldSpec.getDataType(), 
fieldSpec.isSingleValueField())
+          : null;
+    }
+    if (operand.isSetLiteral()) {
+      return 
RequestUtils.getLiteralTypeAndValue(operand.getLiteral()).getLeft();
+    }
+    if (!operand.isSetFunctionCall()) {
+      return null;
+    }
+    Function function = operand.getFunctionCall();
+    List<Expression> arguments = function.getOperands();
+    String name = FunctionRegistry.canonicalize(function.getOperator());
+    switch (name) {
+      case "cast":
+        return literalType(arguments, 1);
+      case "jsonextractscalar":
+      case "jsonextractscalarfast":
+      case "jsonextractscalarfirstmatch":
+      case "jsonextractscalarfory":
+        return literalType(arguments, 2);
+      case "case":
+        // CASE stores alternating condition/result pairs followed by an 
optional ELSE result.
+        ColumnDataType resultType = ColumnDataType.UNKNOWN;
+        for (int i = 1; i < arguments.size(); i += 2) {
+          resultType = commonType(resultType, getOperandType(arguments.get(i), 
schema));
+        }
+        if (arguments.size() % 2 == 1) {
+          resultType = commonType(resultType, 
getOperandType(arguments.get(arguments.size() - 1), schema));
+        }
+        return resultType;
+      case "datetimeconvert":
+        if (arguments.size() < 3 || !arguments.get(2).isSetLiteral()
+            || !arguments.get(2).getLiteral().isSetStringValue()) {
+          return null;
+        }
+        DateTimeFieldSpec.TimeFormat format =
+            new 
DateTimeFormatSpec(arguments.get(2).getLiteral().getStringValue()).getTimeFormat();
+        return format == DateTimeFieldSpec.TimeFormat.EPOCH || format == 
DateTimeFieldSpec.TimeFormat.TIMESTAMP
+            ? ColumnDataType.LONG
+            : ColumnDataType.STRING;
+      default:
+        ColumnDataType[] argumentTypes = new ColumnDataType[arguments.size()];
+        for (int i = 0; i < arguments.size(); i++) {
+          argumentTypes[i] = getOperandType(arguments.get(i), schema);
+          if (argumentTypes[i] == null) {
+            return null;
+          }
+        }
+        FunctionInfo functionInfo = FunctionRegistry.lookupFunctionInfo(name, 
argumentTypes);
+        return functionInfo != null ? 
FunctionUtils.getColumnDataType(functionInfo.getMethod().getReturnType()) : 
null;

Review Comment:
   **[Major] Infer COALESCE from its native transform**
   
   `MODE(COALESCE(stringCol, ''))` falls back to the registry function's 
`Object` return type, although the native single-stage 
`CoalesceTransformFunction` returns `STRING` for these operands. MODE therefore 
remains configured as `DOUBLE` and cannot serialize or finalize its string 
frequency map.
   
   Please infer polymorphic native functions from their operands using the 
execution implementation's typing rules. A targeted optimizer test expecting 
the appended `'MIN', 'STRING'` arguments fails for this expression.



##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java:
##########
@@ -40,6 +51,58 @@ Object[] scenarios() {
     };
   }
 
+  @DataProvider
+  Object[] typedScenarios() {
+    return new Object[]{new Scenario(DataType.STRING, true), new 
Scenario(DataType.STRING, false),
+        new Scenario(DataType.TIMESTAMP, true), new 
Scenario(DataType.TIMESTAMP, false)};
+  }
+
+  @Test(dataProvider = "typedScenarios")
+  void typedModeMergesCountsAndHandlesNulls(Scenario scenario) {
+    String type = scenario._dataType.name();
+    String first = scenario._dataType == DataType.STRING ? "apple" : 
"2026-09-03 10:11:12.123";
+    String shared = scenario._dataType == DataType.STRING ? "banana" : 
"2026-09-04 10:11:12.456";
+    String last = scenario._dataType == DataType.STRING ? "cherry" : 
"2026-09-05 10:11:12.789";
+    // Each instance has a different local mode; the shared value wins only 
after merging full counts.
+    scenario.getDeclaringTable(true)

Review Comment:
   **[Critical coverage gap] Test disabled null handling**
   
   Every new typed execution test enables null handling; the scenario booleans 
vary dictionary encoding. Please add `STRING`/`TIMESTAMP` cases with null 
handling disabled, covering mixed-null, all-null, empty, and grouped inputs 
where stored defaults participate in MODE. A data provider over type, 
dictionary encoding, and null-handling mode would cover the relevant 
combinations.
   
   The repository's testing policy classifies missing coverage of either 
null-handling mode as critical. This is a coverage finding; no disabled-mode 
runtime failure was demonstrated.



##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java:
##########
@@ -40,6 +51,58 @@ Object[] scenarios() {
     };
   }
 
+  @DataProvider
+  Object[] typedScenarios() {
+    return new Object[]{new Scenario(DataType.STRING, true), new 
Scenario(DataType.STRING, false),
+        new Scenario(DataType.TIMESTAMP, true), new 
Scenario(DataType.TIMESTAMP, false)};
+  }
+
+  @Test(dataProvider = "typedScenarios")
+  void typedModeMergesCountsAndHandlesNulls(Scenario scenario) {
+    String type = scenario._dataType.name();
+    String first = scenario._dataType == DataType.STRING ? "apple" : 
"2026-09-03 10:11:12.123";
+    String shared = scenario._dataType == DataType.STRING ? "banana" : 
"2026-09-04 10:11:12.456";
+    String last = scenario._dataType == DataType.STRING ? "cherry" : 
"2026-09-05 10:11:12.789";
+    // Each instance has a different local mode; the shared value wins only 
after merging full counts.
+    scenario.getDeclaringTable(true)
+        .onFirstInstance("myField", first, first, first, shared, shared, 
"null")
+        .andOnSecondInstance("myField", last, last, last, shared, shared, 
"null")
+        .whenQuery("select mode(myField, 'MIN', '" + type + "') as mode from 
testTable")

Review Comment:
   **[Major] Exercise ordinary MODE through the single-stage broker**
   
   These execution tests manually supply the internal type argument, while the 
optimizer tests check only rewritten expressions. Please add a 
`CustomDataQueryClusterIntegrationTest` case using ordinary `MODE(stringCol)`, 
`MODE(timestampCol)`, and scalar expressions through broker rewriting and 
result reduction. Assert both values and result types for aggregation, grouped 
queries, and post-aggregation expressions without supplying the third argument.
   
   The distributed resource tests cover the separate multi-stage planning path, 
so they do not exercise this single-stage gap.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java:
##########
@@ -133,4 +164,96 @@ private void maybeRewriteAggregateFunction(@Nullable 
Expression expression, Sche
       }
     }
   }
+
+  @Nullable
+  private static ColumnDataType getOperandType(Expression operand, Schema 
schema) {
+    if (operand.isSetIdentifier()) {
+      FieldSpec fieldSpec = 
schema.getFieldSpecFor(operand.getIdentifier().getName());
+      return fieldSpec != null
+          ? ColumnDataType.fromDataType(fieldSpec.getDataType(), 
fieldSpec.isSingleValueField())
+          : null;
+    }
+    if (operand.isSetLiteral()) {
+      return 
RequestUtils.getLiteralTypeAndValue(operand.getLiteral()).getLeft();
+    }
+    if (!operand.isSetFunctionCall()) {
+      return null;
+    }
+    Function function = operand.getFunctionCall();
+    List<Expression> arguments = function.getOperands();
+    String name = FunctionRegistry.canonicalize(function.getOperator());
+    switch (name) {
+      case "cast":
+        return literalType(arguments, 1);
+      case "jsonextractscalar":
+      case "jsonextractscalarfast":
+      case "jsonextractscalarfirstmatch":
+      case "jsonextractscalarfory":
+        return literalType(arguments, 2);
+      case "case":
+        // CASE stores alternating condition/result pairs followed by an 
optional ELSE result.
+        ColumnDataType resultType = ColumnDataType.UNKNOWN;
+        for (int i = 1; i < arguments.size(); i += 2) {
+          resultType = commonType(resultType, getOperandType(arguments.get(i), 
schema));
+        }
+        if (arguments.size() % 2 == 1) {
+          resultType = commonType(resultType, 
getOperandType(arguments.get(arguments.size() - 1), schema));
+        }
+        return resultType;
+      case "datetimeconvert":
+        if (arguments.size() < 3 || !arguments.get(2).isSetLiteral()
+            || !arguments.get(2).getLiteral().isSetStringValue()) {
+          return null;
+        }
+        DateTimeFieldSpec.TimeFormat format =
+            new 
DateTimeFormatSpec(arguments.get(2).getLiteral().getStringValue()).getTimeFormat();
+        return format == DateTimeFieldSpec.TimeFormat.EPOCH || format == 
DateTimeFieldSpec.TimeFormat.TIMESTAMP
+            ? ColumnDataType.LONG
+            : ColumnDataType.STRING;
+      default:
+        ColumnDataType[] argumentTypes = new ColumnDataType[arguments.size()];
+        for (int i = 0; i < arguments.size(); i++) {
+          argumentTypes[i] = getOperandType(arguments.get(i), schema);
+          if (argumentTypes[i] == null) {
+            return null;
+          }
+        }
+        FunctionInfo functionInfo = FunctionRegistry.lookupFunctionInfo(name, 
argumentTypes);
+        return functionInfo != null ? 
FunctionUtils.getColumnDataType(functionInfo.getMethod().getReturnType()) : 
null;
+    }
+  }
+
+  @Nullable
+  private static ColumnDataType commonType(@Nullable ColumnDataType left, 
@Nullable ColumnDataType right) {
+    if (left == null || right == null) {
+      return null;
+    }
+    if (left == ColumnDataType.UNKNOWN) {
+      return right;
+    }
+    return right == ColumnDataType.UNKNOWN || left == right ? left : null;

Review Comment:
   **[Critical] Match CASE timestamp coercion rules**
   
   For `MODE(CASE WHEN longCol = 0 THEN timestampCol ELSE '1970-01-01 
00:00:00.000' END)`, this method rejects `TIMESTAMP` combined with a `STRING` 
literal. `CaseTransformFunction` accepts that literal as `TIMESTAMP`, so MODE 
silently retains `DOUBLE` output containing epoch milliseconds instead of 
preserving `TIMESTAMP`, potentially losing precision beyond 2^53.
   
   Please apply CASE's literal coercion rules during inference. A targeted 
optimizer test expecting the appended `'MIN', 'TIMESTAMP'` arguments fails for 
this expression.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java:
##########
@@ -719,6 +824,25 @@ public Double extractFinalResult(Double2LongOpenHashMap 
intermediateResult) {
     }
   }
 
+  @Nullable
+  private Comparable<?> extractComparableFinalResult(@Nullable Map<?, Long> 
counts) {
+    Comparable mode = null;
+    long maxCount = 0;
+    if (counts != null) {
+      for (Map.Entry<?, Long> entry : counts.entrySet()) {
+        Comparable value = (Comparable) entry.getKey();
+        long count = entry.getValue();
+        if (mode == null || count > maxCount || (count == maxCount
+            && (_multiModeReducerType == MultiModeReducerType.MIN
+                ? value.compareTo(mode) < 0 : value.compareTo(mode) > 0))) {

Review Comment:
   **[Minor] Separate multiline ternary branches**
   
   Please move `: value.compareTo(mode) > 0` onto its own line to follow the 
repository's multiline ternary convention.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to