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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to