xiangfu0 commented on code in PR #20001:
URL: https://github.com/apache/pinot/pull/20001#discussion_r4352451191


##########
pinot-query-planner/src/main/java/org/apache/pinot/query/validate/PinotTypeCoercion.java:
##########
@@ -108,7 +134,65 @@ public boolean binaryComparisonCoercion(SqlCallBinding 
binding) {
     return super.binaryComparisonCoercion(binding);
   }
 
+  @Override
+  public boolean inOperationCoercion(SqlCallBinding binding) {
+    SqlKind kind = binding.getOperator().getKind();
+    if ((kind == SqlKind.IN || kind == SqlKind.NOT_IN) && binding.operand(1) 
instanceof SqlNodeList
+        && isMultiValueColumn(binding, 0)
+        && isScalarValueListRejectingNull(binding, (SqlNodeList) 
binding.operand(1))) {
+      return coerceMultiValueComparison(binding, 0, () -> 
inOperationCoercion(binding));
+    }
+    return super.inOperationCoercion(binding);
+  }
+
+  /// Returns whether the operand at the given index is a reference to a 
multi-value (ARRAY type) column.
+  private static boolean isMultiValueColumn(SqlCallBinding binding, int index) 
{
+    return SqlTypeUtil.isArray(binding.getOperandType(index)) && 
isColumnReference(binding.operand(index));
+  }
+
+  /// Returns whether all values of the `IN` list are non-array values, and 
throws on a `NULL` value: Calcite types it
+  /// as the array type of the column, and the leaf stage cannot evaluate it 
in a multi-value predicate.
+  private static boolean isScalarValueListRejectingNull(SqlCallBinding 
binding, SqlNodeList values) {
+    for (SqlNode value : values) {
+      if (SqlUtil.isNullLiteral(value, false)) {
+        throw QueryErrorCode.QUERY_VALIDATION.asException(
+            "NULL is not supported in an IN list compared to multi-value 
column: " + binding.operand(0));

Review Comment:
   Small asymmetry worth a note: a NULL in an `IN` list errors here, but `mvCol 
= NULL` on the binary path is silently wrapped and pushed down as `col = NULL` 
(always no-match). Both are defensible, but one form erroring while the sibling 
form is accepted may surprise users. Consider a one-line comment explaining the 
difference, and maybe a `mvCol = NULL` test so the accepted behavior is pinned.



##########
pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java:
##########
@@ -172,6 +174,42 @@ public Void visitUnnest(UnnestNode node, Boolean 
isIntermediateStage) {
     return null;
   }
 
+  /// Comparing a multi-value column to a value (e.g. `mvCol = 'a'`) is 
planned as a comparison on `ARRAY_TO_MV`. As
+  /// the query does not contain `ARRAY_TO_MV` in this case, the error 
explains the comparison instead.
+  private QueryException arrayToMvNotSupported(String location, List<? extends 
RexExpression> expressions,
+      String hint) {
+    if (containsMultiValueComparison(expressions)) {
+      return new QueryException(QueryErrorCode.QUERY_PLANNING,
+          "Comparing a multi-value column to a value (e.g. mvCol = 'a' or 
mvCol IN ('a', 'b')) is not supported in "
+              + location + ". It is only supported in a WHERE clause filter 
that can be pushed down to the table "
+              + "scan. Elsewhere, use an arrayContains function instead, e.g. 
arrayContainsString(mvCol, 'a')");

Review Comment:
   Nit: for an INT MV column the only suggested example is 
`arrayContainsString`, which won't type-check. Since `arrayContainsInt` exists, 
a user on an INT column has to guess the right variant. Fine to leave as-is 
given you intentionally softened this to one example, but mentioning both (or 
picking by element type) would be friendlier.



##########
pinot-query-runtime/src/test/resources/queries/MultiValueFilters.json:
##########
@@ -0,0 +1,370 @@
+{
+  "mv_filter": {
+    "comment": "Comparisons of a multi-value column to a value. As in the 
single-stage engine, = and IN match a row if any value matches, <> and NOT IN 
if no value matches. They are only supported in filters pushed down to the leaf 
stage.",
+    "tables": {
+      "orders": {
+        "schema": [
+          {
+            "name": "order_id",
+            "type": "STRING"
+          },
+          {
+            "name": "customer_id",
+            "type": "STRING"
+          },
+          {
+            "name": "tags",
+            "type": "STRING",
+            "isSingleValue": false
+          },
+          {
+            "name": "codes",
+            "type": "INT",
+            "isSingleValue": false
+          }
+        ],
+        "inputs": [
+          [
+            "o1",
+            "c1",
+            [
+              "urgent",
+              "vip"
+            ],
+            [
+              1,
+              2
+            ]
+          ],
+          [
+            "o2",
+            "c1",
+            [
+              "urgent"
+            ],
+            [
+              2
+            ]
+          ],
+          [
+            "o3",
+            "c2",
+            [
+              "urgent"
+            ],
+            [
+              3
+            ]
+          ],
+          [
+            "o4",
+            "c2",
+            [
+              "vip"
+            ],
+            [
+              4,
+              5
+            ]
+          ],
+          [
+            "o5",
+            "c3",
+            [
+              "normal"
+            ],
+            [
+              5
+            ]
+          ]
+        ]
+      },
+      "customers": {
+        "schema": [
+          {
+            "name": "customer_id",
+            "type": "STRING"
+          },
+          {
+            "name": "name",
+            "type": "STRING"
+          }
+        ],
+        "inputs": [
+          [
+            "c1",
+            "Alice"
+          ],
+          [
+            "c2",
+            "Bob"
+          ],
+          [
+            "c3",
+            "Charlie"
+          ]
+        ]
+      }
+    },
+    "queries": [
+      {
+        "description": "Equality on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags = 'urgent' ORDER BY 
order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o2"
+          ],
+          [
+            "o3"
+          ]
+        ]
+      },
+      {
+        "description": "Equality with the value on the left side",
+        "sql": "SELECT order_id FROM {orders} WHERE 'vip' = tags ORDER BY 
order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o4"
+          ]
+        ]
+      },
+      {
+        "description": "IN on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags IN ('vip', 'normal') 
ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o4"
+          ],
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "IN with a large value list on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags IN ('v0', 'v1', 'v2', 
'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12', 'v13', 'v14', 
'v15', 'v16', 'v17', 'v18', 'v19', 'v20', 'v21', 'v22', 'v23', 'v24', 'normal') 
ORDER BY order_id",
+        "outputs": [
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "Two equalities on the same MV column are not folded to 
false",
+        "sql": "SELECT order_id FROM {orders} WHERE tags = 'urgent' AND tags = 
'vip' ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ]
+        ]
+      },
+      {
+        "description": "Equality and IN on an INT MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE codes = 5 OR codes IN (1, 
3) ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o3"
+          ],
+          [
+            "o4"
+          ],
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "MV equality pushed below a join",
+        "sql": "SELECT o.order_id, c.name FROM {orders} o JOIN {customers} c 
ON o.customer_id = c.customer_id WHERE o.tags = 'urgent' ORDER BY o.order_id",
+        "outputs": [
+          [
+            "o1",
+            "Alice"
+          ],
+          [
+            "o2",
+            "Alice"
+          ],
+          [
+            "o3",
+            "Bob"
+          ]
+        ]
+      },
+      {
+        "description": "MV IN pushed below a join with an aggregate above it",
+        "sql": "SELECT c.name, COUNT(*) FROM {orders} o JOIN {customers} c ON 
o.customer_id = c.customer_id WHERE o.tags IN ('vip', 'normal') GROUP BY c.name 
ORDER BY c.name",
+        "outputs": [
+          [
+            "Alice",
+            1
+          ],
+          [
+            "Bob",
+            1
+          ],
+          [
+            "Charlie",
+            1
+          ]
+        ]
+      },
+      {
+        "description": "Not-equals on an MV column matches rows with no equal 
value",

Review Comment:
   Coverage gap (low): every input row has a non-empty, non-null MV. The `<>` / 
`NOT IN` "no value matches" complement is most error-prone on an empty or NULL 
MV row (where `= 'x'` is false and `<> 'x'` should be true). An extra row like 
`["o6","c3",[],[6]]` (or a null MV) extending the `<>` / `NOT IN` expected 
outputs would lock down that boundary — especially under null-handling-enabled.



##########
pinot-query-planner/src/main/java/org/apache/pinot/query/planner/validation/ArrayToMvValidationVisitor.java:
##########
@@ -172,6 +174,42 @@ public Void visitUnnest(UnnestNode node, Boolean 
isIntermediateStage) {
     return null;
   }
 
+  /// Comparing a multi-value column to a value (e.g. `mvCol = 'a'`) is 
planned as a comparison on `ARRAY_TO_MV`. As
+  /// the query does not contain `ARRAY_TO_MV` in this case, the error 
explains the comparison instead.
+  private QueryException arrayToMvNotSupported(String location, List<? extends 
RexExpression> expressions,
+      String hint) {
+    if (containsMultiValueComparison(expressions)) {
+      return new QueryException(QueryErrorCode.QUERY_PLANNING,
+          "Comparing a multi-value column to a value (e.g. mvCol = 'a' or 
mvCol IN ('a', 'b')) is not supported in "
+              + location + ". It is only supported in a WHERE clause filter 
that can be pushed down to the table "
+              + "scan. Elsewhere, use an arrayContains function instead, e.g. 
arrayContainsString(mvCol, 'a')");

Review Comment:
   Nit: for an INT MV column the only suggested example is 
`arrayContainsString`, which won't type-check. Since `arrayContainsInt` exists, 
a user on an INT column has to guess the right variant. Fine to leave as-is 
given you intentionally softened this to one example, but mentioning both (or 
picking by element type) would be friendlier.



##########
pinot-query-planner/src/main/java/org/apache/pinot/query/validate/PinotTypeCoercion.java:
##########
@@ -108,7 +134,65 @@ public boolean binaryComparisonCoercion(SqlCallBinding 
binding) {
     return super.binaryComparisonCoercion(binding);
   }
 
+  @Override
+  public boolean inOperationCoercion(SqlCallBinding binding) {
+    SqlKind kind = binding.getOperator().getKind();
+    if ((kind == SqlKind.IN || kind == SqlKind.NOT_IN) && binding.operand(1) 
instanceof SqlNodeList
+        && isMultiValueColumn(binding, 0)
+        && isScalarValueListRejectingNull(binding, (SqlNodeList) 
binding.operand(1))) {
+      return coerceMultiValueComparison(binding, 0, () -> 
inOperationCoercion(binding));
+    }
+    return super.inOperationCoercion(binding);
+  }
+
+  /// Returns whether the operand at the given index is a reference to a 
multi-value (ARRAY type) column.
+  private static boolean isMultiValueColumn(SqlCallBinding binding, int index) 
{
+    return SqlTypeUtil.isArray(binding.getOperandType(index)) && 
isColumnReference(binding.operand(index));
+  }
+
+  /// Returns whether all values of the `IN` list are non-array values, and 
throws on a `NULL` value: Calcite types it
+  /// as the array type of the column, and the leaf stage cannot evaluate it 
in a multi-value predicate.
+  private static boolean isScalarValueListRejectingNull(SqlCallBinding 
binding, SqlNodeList values) {
+    for (SqlNode value : values) {
+      if (SqlUtil.isNullLiteral(value, false)) {
+        throw QueryErrorCode.QUERY_VALIDATION.asException(
+            "NULL is not supported in an IN list compared to multi-value 
column: " + binding.operand(0));

Review Comment:
   Small asymmetry worth a note: a NULL in an `IN` list errors here, but `mvCol 
= NULL` on the binary path is silently wrapped and pushed down as `col = NULL` 
(always no-match). Both are defensible, but one form erroring while the sibling 
form is accepted may surprise users. Consider a one-line comment explaining the 
difference, and maybe a `mvCol = NULL` test so the accepted behavior is pinned.



##########
pinot-query-runtime/src/test/resources/queries/MultiValueFilters.json:
##########
@@ -0,0 +1,370 @@
+{
+  "mv_filter": {
+    "comment": "Comparisons of a multi-value column to a value. As in the 
single-stage engine, = and IN match a row if any value matches, <> and NOT IN 
if no value matches. They are only supported in filters pushed down to the leaf 
stage.",
+    "tables": {
+      "orders": {
+        "schema": [
+          {
+            "name": "order_id",
+            "type": "STRING"
+          },
+          {
+            "name": "customer_id",
+            "type": "STRING"
+          },
+          {
+            "name": "tags",
+            "type": "STRING",
+            "isSingleValue": false
+          },
+          {
+            "name": "codes",
+            "type": "INT",
+            "isSingleValue": false
+          }
+        ],
+        "inputs": [
+          [
+            "o1",
+            "c1",
+            [
+              "urgent",
+              "vip"
+            ],
+            [
+              1,
+              2
+            ]
+          ],
+          [
+            "o2",
+            "c1",
+            [
+              "urgent"
+            ],
+            [
+              2
+            ]
+          ],
+          [
+            "o3",
+            "c2",
+            [
+              "urgent"
+            ],
+            [
+              3
+            ]
+          ],
+          [
+            "o4",
+            "c2",
+            [
+              "vip"
+            ],
+            [
+              4,
+              5
+            ]
+          ],
+          [
+            "o5",
+            "c3",
+            [
+              "normal"
+            ],
+            [
+              5
+            ]
+          ]
+        ]
+      },
+      "customers": {
+        "schema": [
+          {
+            "name": "customer_id",
+            "type": "STRING"
+          },
+          {
+            "name": "name",
+            "type": "STRING"
+          }
+        ],
+        "inputs": [
+          [
+            "c1",
+            "Alice"
+          ],
+          [
+            "c2",
+            "Bob"
+          ],
+          [
+            "c3",
+            "Charlie"
+          ]
+        ]
+      }
+    },
+    "queries": [
+      {
+        "description": "Equality on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags = 'urgent' ORDER BY 
order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o2"
+          ],
+          [
+            "o3"
+          ]
+        ]
+      },
+      {
+        "description": "Equality with the value on the left side",
+        "sql": "SELECT order_id FROM {orders} WHERE 'vip' = tags ORDER BY 
order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o4"
+          ]
+        ]
+      },
+      {
+        "description": "IN on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags IN ('vip', 'normal') 
ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o4"
+          ],
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "IN with a large value list on a STRING MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE tags IN ('v0', 'v1', 'v2', 
'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12', 'v13', 'v14', 
'v15', 'v16', 'v17', 'v18', 'v19', 'v20', 'v21', 'v22', 'v23', 'v24', 'normal') 
ORDER BY order_id",
+        "outputs": [
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "Two equalities on the same MV column are not folded to 
false",
+        "sql": "SELECT order_id FROM {orders} WHERE tags = 'urgent' AND tags = 
'vip' ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ]
+        ]
+      },
+      {
+        "description": "Equality and IN on an INT MV column",
+        "sql": "SELECT order_id FROM {orders} WHERE codes = 5 OR codes IN (1, 
3) ORDER BY order_id",
+        "outputs": [
+          [
+            "o1"
+          ],
+          [
+            "o3"
+          ],
+          [
+            "o4"
+          ],
+          [
+            "o5"
+          ]
+        ]
+      },
+      {
+        "description": "MV equality pushed below a join",
+        "sql": "SELECT o.order_id, c.name FROM {orders} o JOIN {customers} c 
ON o.customer_id = c.customer_id WHERE o.tags = 'urgent' ORDER BY o.order_id",
+        "outputs": [
+          [
+            "o1",
+            "Alice"
+          ],
+          [
+            "o2",
+            "Alice"
+          ],
+          [
+            "o3",
+            "Bob"
+          ]
+        ]
+      },
+      {
+        "description": "MV IN pushed below a join with an aggregate above it",
+        "sql": "SELECT c.name, COUNT(*) FROM {orders} o JOIN {customers} c ON 
o.customer_id = c.customer_id WHERE o.tags IN ('vip', 'normal') GROUP BY c.name 
ORDER BY c.name",
+        "outputs": [
+          [
+            "Alice",
+            1
+          ],
+          [
+            "Bob",
+            1
+          ],
+          [
+            "Charlie",
+            1
+          ]
+        ]
+      },
+      {
+        "description": "Not-equals on an MV column matches rows with no equal 
value",

Review Comment:
   Coverage gap (low): every input row has a non-empty, non-null MV. The `<>` / 
`NOT IN` "no value matches" complement is most error-prone on an empty or NULL 
MV row (where `= 'x'` is false and `<> 'x'` should be true). An extra row like 
`["o6","c3",[],[6]]` (or a null MV) extending the `<>` / `NOT IN` expected 
outputs would lock down that boundary — especially under null-handling-enabled.



-- 
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