You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by ya...@apache.org on 2026/10/21 18:49:31 UTC

(pinot) branch master updated: Push ARRAY_TO_MV filters below joins again in MSE (#19973)

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

yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new e574c52eff6 Push ARRAY_TO_MV filters below joins again in MSE (#19973)
e574c52eff6 is described below

commit e574c52eff6fc6c1544e8270f465565c8e51e03b
Author: Yash Mayya <ya...@gmail.com>
AuthorDate: Wed Oct 21 11:49:24 2026 -0700

    Push ARRAY_TO_MV filters below joins again in MSE (#19973)
---
 .../pinot/calcite/rel/rules/PinotRuleUtils.java    | 18 +++----
 .../pinot/calcite/sql/fun/PinotOperatorTable.java  | 32 ++++++++-----
 .../validation/ArrayToMvValidationVisitor.java     |  3 +-
 .../calcite/rel/rules/PinotRuleUtilsTest.java      | 19 ++++++++
 .../src/test/resources/queries/JoinPlans.json      | 17 +++++++
 .../resources/queries/ValidationErrorPlan.json     |  5 ++
 .../src/test/resources/queries/Joins.json          | 56 ++++++++++++++++++++++
 7 files changed, 128 insertions(+), 22 deletions(-)

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 bb7f98a24c4..6bbb888ebab 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
@@ -42,7 +42,6 @@ import org.apache.calcite.rex.RexCall;
 import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.rex.RexVisitorImpl;
 import org.apache.calcite.rex.RexWindowBound;
 import org.apache.calcite.rex.RexWindowBounds;
@@ -54,6 +53,7 @@ import org.apache.calcite.tools.RelBuilder;
 import org.apache.calcite.tools.RelBuilderFactory;
 import org.apache.calcite.util.Util;
 import org.apache.pinot.calcite.rel.hint.PinotHintStrategyTable;
+import org.apache.pinot.calcite.sql.fun.PinotOperatorTable;
 import org.apache.pinot.common.function.sql.PinotSqlFunction;
 
 
@@ -147,13 +147,17 @@ public class PinotRuleUtils {
   /// An expression must be clear of three axes of variability:
   ///
   /// - [SqlOperator#isDeterministic()] -- `false` for `rand()`, `UUID_V4`, `UUID_V7` and Calcite's own `RAND` /
-  ///   `RAND_INTEGER`. Delegated to `RexUtil#isDeterministic` so this half tracks upstream automatically.
+  ///   `RAND_INTEGER`.
   /// - [SqlOperator#isDynamicFunction()] -- Calcite's own "fold once per query, never re-evaluate" marker, used by
   ///   `CURRENT_TIMESTAMP` and friends.
   /// - [PinotSqlFunction#isVolatile()] -- Pinot's equivalent marker, `true` for `FunctionVolatility.VOLATILE`
   ///   functions such as `now()`, `ago()` and `stageId()`. These deliberately stay `isDeterministic() == true` so that
   ///   [PinotEvaluateLiteralRule] can still fold them once at plan time, which is precisely why
-  ///   `RexUtil#isDeterministic` alone does not catch them.
+  ///   [SqlOperator#isDeterministic()] alone does not catch them.
+  ///
+  /// [PinotOperatorTable#ARRAY_TO_MV] is the one exception. It is marked non-deterministic (and so volatile) only to
+  /// stop Calcite from simplifying MV predicates such as `ARRAY_TO_MV(col) = 'a' AND ARRAY_TO_MV(col) = 'b'` to
+  /// `false`. Its value depends only on its row, so its own flags are ignored here. Its operands are still checked.
   ///
   /// Relocating an expression that fails this check changes how many times, and in what context, it is evaluated --
   /// which changes query results. `FunctionVolatility.STABLE` deliberately passes: it is constant within a single
@@ -162,16 +166,14 @@ public class PinotRuleUtils {
   /// Note this is a predicate that callers must apply; it is not enforced globally. Only [PinotFilterJoinRule]
   /// consults it today, so other rules that relocate expressions can still move volatile ones.
   public static boolean isRelocatable(RexNode node) {
-    if (!RexUtil.isDeterministic(node)) {
-      return false;
-    }
     try {
       node.accept(new RexVisitorImpl<Void>(true) {
         @Override
         public Void visitCall(RexCall call) {
           SqlOperator operator = call.getOperator();
-          if (operator.isDynamicFunction()
-              || (operator instanceof PinotSqlFunction && ((PinotSqlFunction) operator).isVolatile())) {
+          boolean isVariable = !operator.isDeterministic() || operator.isDynamicFunction()
+              || (operator instanceof PinotSqlFunction && ((PinotSqlFunction) operator).isVolatile());
+          if (isVariable && operator != PinotOperatorTable.ARRAY_TO_MV) {
             throw Util.FoundOne.NULL;
           }
           return super.visitCall(call);
diff --git a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
index c6750013e74..c0bcb0996e3 100644
--- a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
+++ b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
@@ -126,6 +126,24 @@ public class PinotOperatorTable implements SqlOperatorTable {
           InferTypes.VARCHAR_1024,
           OperandTypes.ANY);
 
+  /// Placeholder for special functions to handle MV.
+  ///
+  /// NOTE: ARRAY_TO_MV is not deterministic.
+  /// We need to explicitly set it as not deterministic in order to do not let Calcite optimize expressions like
+  /// `ARRAY_TO_MV(RandomAirports) = 'MFR' and ARRAY_TO_MV(RandomAirports) = 'GTR'` as `false`.
+  /// If the function were deterministic, its value would never be MFR and GTR at the same time, so Calcite is
+  /// smart enough to know there is no value that satisfies the condition.
+  /// In fact what ARRAY_TO_MV does is just to trick Calcite typesystem, but then what the leaf stage executor
+  /// receives is `RandomAirports = 'MFR' and RandomAirports = 'GTR'`, which in the V1 semantics means:
+  /// true if and only if RandomAirports contains a value equal to 'MFR' and RandomAirports contains a value equal
+  /// to 'GTR'.
+  ///
+  /// [org.apache.pinot.calcite.rel.rules.PinotRuleUtils#isRelocatable] ignores this flag, so ARRAY_TO_MV filters are
+  /// still pushed below joins.
+  public static final PinotSqlFunction ARRAY_TO_MV =
+      new PinotSqlFunction("ARRAY_TO_MV", opBinding -> opBinding.getOperandType(0).getComponentType(),
+          OperandTypes.ARRAY, false);
+
   /// This list includes the supported standard [SqlOperator]s defined in [SqlStdOperatorTable].
   /// NOTE: The operator order follows the same order as defined in [SqlStdOperatorTable] for easier search.
   ///       Some operators are commented out and re-declared in [#STANDARD_OPERATORS_WITH_ALIASES].
@@ -305,19 +323,7 @@ public class PinotOperatorTable implements SqlOperatorTable {
       new PinotSqlFunction("IN_SUBQUERY", ReturnTypes.BOOLEAN_NULLABLE,
           OperandTypes.family(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER)),
 
-      // Placeholder for special functions to handle MV
-      // NOTE:
-      // ARRAY_TO_MV is not deterministic.
-      // We need to explicitly set it as not deterministic in order to do not let Calcite optimize expressions like
-      // `ARRAY_TO_MV(RandomAirports) = 'MFR' and ARRAY_TO_MV(RandomAirports) = 'GTR'` as `false`.
-      // If the function were deterministic, its value would never be MFR and GTR at the same time, so Calcite is
-      // smart enough to know there is no value that satisfies the condition.
-      // In fact what ARRAY_TO_MV does is just to trick Calcite typesystem, but then what the leaf stage executor
-      // receives is `RandomAirports = 'MFR' and RandomAirports = 'GTR'`, which in the V1 semantics means:
-      // true if and only if RandomAirports contains a value equal to 'MFR' and RandomAirports contains a value equal
-      // to 'GTR'
-      new PinotSqlFunction("ARRAY_TO_MV", opBinding -> opBinding.getOperandType(0).getComponentType(),
-          OperandTypes.ARRAY, false),
+      ARRAY_TO_MV,
 
       // SqlStdOperatorTable.COALESCE without rewrite
       new SqlFunction("COALESCE", SqlKind.COALESCE,
diff --git a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java
index 6653f1f22fc..246a9aa2119 100644
--- a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java
+++ b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java
@@ -115,7 +115,8 @@ public class ArrayToMvValidationVisitor implements PlanNodeVisitor<Void, Boolean
     if (containsArrayToMv(node.getProjects())) {
       throw new QueryException(QueryErrorCode.QUERY_PLANNING,
           "Function 'ArrayToMv' is not supported in PROJECT " + (isIntermediateStage ? "Intermediate Stage"
-              : "Leaf Stage"));
+              : "Leaf Stage") + ". To get one row for each value of a multi-value column, use "
+              + "'CROSS JOIN UNNEST(mvCol) AS t(val)' instead");
     }
     node.getInputs().forEach(e -> e.visit(this, isIntermediateStage));
     return null;
diff --git a/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
index 30fba46e27f..5447ac59dd2 100644
--- a/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
+++ b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
@@ -40,6 +40,7 @@ import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertSame;
 import static org.testng.Assert.assertTrue;
 
 
@@ -156,4 +157,22 @@ public class PinotRuleUtilsTest {
     RexNode nested = call(SqlStdOperatorTable.PLUS, literal(1), call(registryFunction("stageId"), literal(0)));
     assertFalse(PinotRuleUtils.isRelocatable(nested));
   }
+
+  /// `ARRAY_TO_MV` is flagged non-deterministic only to stop Calcite from folding MV predicates, so it must still be
+  /// relocatable (otherwise MV filters stay above joins and fail validation). Its operands must still be checked.
+  @Test
+  public void testArrayToMvIsRelocatable() {
+    SqlOperator arrayToMv = resolvedOperator("arrayToMv");
+    assertSame(arrayToMv, PinotOperatorTable.ARRAY_TO_MV, "isRelocatable matches ARRAY_TO_MV by identity");
+    assertFalse(arrayToMv.isDeterministic(), "ARRAY_TO_MV is expected to be flagged non-deterministic");
+    RexNode mvCol = _rexBuilder.makeInputRef(
+        _typeFactory.createArrayType(_typeFactory.createSqlType(SqlTypeName.VARCHAR), -1), 0);
+    RexNode predicate = _rexBuilder.makeCall(SqlStdOperatorTable.EQUALS,
+        _rexBuilder.makeCall(_typeFactory.createSqlType(SqlTypeName.VARCHAR), arrayToMv, List.of(mvCol)),
+        _rexBuilder.makeLiteral("a"));
+    assertTrue(PinotRuleUtils.isRelocatable(predicate));
+
+    assertFalse(PinotRuleUtils.isRelocatable(call(arrayToMv, call(registryFunction("rand")))));
+    assertFalse(PinotRuleUtils.isRelocatable(call(arrayToMv, call(registryFunction("stageId"), literal(0)))));
+  }
 }
diff --git a/pinot-query-planner/src/test/resources/queries/JoinPlans.json b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
index b68b0d4a0a5..3e5f6e90631 100644
--- a/pinot-query-planner/src/test/resources/queries/JoinPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
@@ -882,6 +882,23 @@
           "\n        PinotLogicalTableScan(table=[[default, b]])",
           "\n"
         ]
+      },
+      {
+        "description": "ARRAY_TO_MV WHERE filter is pushed below the join together with the other conjuncts",
+        "sql": "EXPLAIN PLAN FOR SELECT a.col1, e.col3 FROM a JOIN e ON a.col1 = e.col1 WHERE e.col2 = 'x' AND ARRAY_TO_MV(e.mcol1) = 'a'",
+        "output": [
+          "Execution Plan",
+          "\nLogicalProject(col1=[$0], col3=[$2])",
+          "\n  LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+          "\n    PinotLogicalExchange(distribution=[hash[0]])",
+          "\n      LogicalProject(col1=[$0])",
+          "\n        PinotLogicalTableScan(table=[[default, a]])",
+          "\n    PinotLogicalExchange(distribution=[hash[0]])",
+          "\n      LogicalProject(col1=[$0], col3=[$2])",
+          "\n        LogicalFilter(condition=[AND(=($1, _UTF-8'x'), =(ARRAY_TO_MV($7), _UTF-8'a'))])",
+          "\n          PinotLogicalTableScan(table=[[default, e]])",
+          "\n"
+        ]
       }
     ]
   },
diff --git a/pinot-query-planner/src/test/resources/queries/ValidationErrorPlan.json b/pinot-query-planner/src/test/resources/queries/ValidationErrorPlan.json
index 494f1cf138f..c7cf334b334 100644
--- a/pinot-query-planner/src/test/resources/queries/ValidationErrorPlan.json
+++ b/pinot-query-planner/src/test/resources/queries/ValidationErrorPlan.json
@@ -16,6 +16,11 @@
         "sql": "SET usePhysicalOptimizer=true; EXPLAIN PLAN FOR SELECT SUM(a.col3) as sumCol3, arrayToMv(e.mcol1), a.col2  FROM a JOIN e on a.col1=e.col1 GROUP BY arrayToMv(e.mcol1), a.col2",
         "expectedException": ".*'ArrayToMv' is not supported.*"
       },
+      {
+        "description": "arrayToMV in a PROJECT error suggests CROSS JOIN UNNEST",
+        "sql": "EXPLAIN PLAN FOR SELECT arrayToMv(e.mcol1), a.col2 FROM a JOIN e ON a.col1 = e.col1",
+        "expectedException": ".*'ArrayToMv' is not supported in PROJECT.*CROSS JOIN UNNEST.*"
+      },
       {
         "description": "Select * with negative limit -1",
         "sql": "EXPLAIN PLAN FOR SELECT * FROM d LIMIT -1",
diff --git a/pinot-query-runtime/src/test/resources/queries/Joins.json b/pinot-query-runtime/src/test/resources/queries/Joins.json
index 50ecf1cc976..f523163a2b9 100644
--- a/pinot-query-runtime/src/test/resources/queries/Joins.json
+++ b/pinot-query-runtime/src/test/resources/queries/Joins.json
@@ -108,5 +108,61 @@
         "sql": "SELECT c.name, o.total_amount FROM {customers} c JOIN (SELECT customer_id, SUM(amount) as total_amount FROM {orders} GROUP BY customer_id) o ON c.customer_id = o.customer_id ORDER BY c.name"
       }
     ]
+  },
+  "join_with_mv_filter": {
+    "comment": "ARRAY_TO_MV filters in WHERE must be pushed below the join to the leaf stage, the only stage that can run them. An MV predicate matches a row if any value matches.",
+    "tables": {
+      "orders": {
+        "schema": [
+          {"name": "order_id", "type": "STRING"},
+          {"name": "customer_id", "type": "STRING"},
+          {"name": "status", "type": "STRING"},
+          {"name": "tags", "type": "STRING", "isSingleValue": false}
+        ],
+        "inputs": [
+          ["o1", "c1", "open", ["urgent", "vip"]],
+          ["o2", "c1", "open", ["urgent"]],
+          ["o3", "c2", "closed", ["urgent"]],
+          ["o4", "c2", "open", ["vip"]],
+          ["o5", "c3", "open", ["normal"]]
+        ]
+      },
+      "customers": {
+        "schema": [
+          {"name": "customer_id", "type": "STRING"},
+          {"name": "name", "type": "STRING"}
+        ],
+        "inputs": [
+          ["c1", "Alice"],
+          ["c2", "Bob"],
+          ["c3", "Charlie"]
+        ]
+      }
+    },
+    "queries": [
+      {
+        "description": "ARRAY_TO_MV equality with another conjunct on the same side",
+        "sql": "SELECT o.order_id, c.name FROM {orders} o JOIN {customers} c ON o.customer_id = c.customer_id WHERE o.status = 'open' AND ARRAY_TO_MV(o.tags) = 'urgent' ORDER BY o.order_id",
+        "outputs": [
+          ["o1", "Alice"],
+          ["o2", "Alice"]
+        ]
+      },
+      {
+        "description": "Two ARRAY_TO_MV equalities on the same column are not folded to false",
+        "sql": "SELECT o.order_id, c.name FROM {orders} o JOIN {customers} c ON o.customer_id = c.customer_id WHERE ARRAY_TO_MV(o.tags) = 'urgent' AND ARRAY_TO_MV(o.tags) = 'vip' ORDER BY o.order_id",
+        "outputs": [
+          ["o1", "Alice"]
+        ]
+      },
+      {
+        "description": "ARRAY_TO_MV IN filter with a filter on the other side and an aggregate above the join",
+        "sql": "SELECT c.name, COUNT(*) FROM {orders} o JOIN {customers} c ON o.customer_id = c.customer_id WHERE ARRAY_TO_MV(o.tags) IN ('urgent', 'vip') AND c.name <> 'Charlie' GROUP BY c.name ORDER BY c.name",
+        "outputs": [
+          ["Alice", 2],
+          ["Bob", 2]
+        ]
+      }
+    ]
   }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org