dongjoon-hyun commented on code in PR #59004:
URL: https://github.com/apache/spark/pull/59004#discussion_r4150313712


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilters.scala:
##########
@@ -281,9 +281,19 @@ private[sql] object OrcFilters extends OrcFiltersBase {
           .isNull(dataTypeMap(name).fieldName, getType(name)).end())
 
       case In(name, values) if dataTypeMap.contains(name) =>

Review Comment:
   **1. Correctness: the comparison leaves still hit the same NPE.**
   
   This fixes only the `In` leaf. `EqualTo`, `EqualNullSafe`, `LessThan`, 
`LessThanOrEqual`, `GreaterThan`, and `GreaterThanOrEqual` (L245-L273) still 
call `castLiteralValue(value, ...)` without a null check, so a null value 
throws an NPE for integral, float, double, decimal, and ANSI interval columns 
(L166-L181).
   
   This is reachable in the default configuration. Since SPARK-43402, 
`FileSourceScanExec` pushes filters that contain a scalar subquery and replaces 
the subquery with its result at runtime, and that result is `NULL` for an empty 
table:
   
   ```sql
   CREATE TABLE t1(c1 INT) USING ORC;
   INSERT INTO t1 VALUES (1), (2), (3);
   CREATE TABLE t2(c2 INT) USING ORC;
   SELECT * FROM t1 WHERE c1 > (SELECT max(c2) FROM t2);
   ```
   
   Both before and after this PR, the query fails with `FAILED_READ_FILE`, 
caused by a `NullPointerException` at 
`OrcFilters$.castLiteralValue(OrcFilters.scala:167)`, and `=`, `!=`, `<=>`, 
`<`, `<=`, and `>=` behave the same. With 
`spark.sql.files.ignoreCorruptFiles=true`, `WHERE c1 = (SELECT max(c2) FROM t2) 
OR c1 = 1` silently returns no rows instead of `1`. The DSv2 `OrcScan` does not 
push runtime scalar-subquery filters, so only V1 and `spark.sql.orc.impl=hive` 
are affected.
   
   Parquet fixed the same scenario in SPARK-47120 with `value != null` guards. 
Could you add the same guard to these leaves (`EqualNullSafe(name, null)` could 
push `isNull` instead), or file a separate JIRA? An ORC version of the 
SPARK-47120 test needs an integral, decimal, or double column. A `DATE` column 
does not reproduce it, because a null date literal passes through 
`castLiteralValue`.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcQuerySuite.scala:
##########
@@ -72,6 +72,35 @@ case class Person(name: String, age: Int, contacts: 
Seq[Contact])
 abstract class OrcQueryTest extends OrcTest {
   import testImplicits._
 
+  test("SPARK-59605: ORC IN filter with NULL matches with pushdown on and 
off") {
+    withTempPath { dir =>
+      val path = dir.getCanonicalPath
+      spark.sql(
+        "SELECT * FROM VALUES " +
+          "(CAST(NULL AS INT), 'null'), (1, 'a'), (2, 'b'), (3, 'c') AS t(id, 
label)")
+        .write.orc(path)
+
+      Seq("true", "false").foreach { pushdown =>
+        withSQLConf(SQLConf.ORC_FILTER_PUSHDOWN_ENABLED.key -> pushdown) {
+          checkAnswer(
+            spark.read.orc(path)
+              .where("id IN (1, 3, NULL)")
+              .select("id", "label")
+              .orderBy("id", "label"),
+            Seq(Row(1, "a"), Row(3, "c")))
+
+          // NOT IN with NULL: under three-valued logic every row evaluates to 
FALSE or NULL,
+          // never TRUE, so the result is empty.
+          checkAnswer(
+            spark.read.orc(path)
+              .where("id NOT IN (1, 3, NULL)")

Review Comment:
   **3. Tests: this `NOT IN` check never reaches ORC.**
   
   The idea from the earlier review is right, but 
`ReplaceNullWithFalseInPredicate` (SPARK-34692) rewrites `NOT (id IN (1, 3, 
NULL))` to `false`, and `PruneFilters` then replaces the scan with an empty 
`LocalRelation`. So neither the ORC reader nor `OrcFilters` runs here in 
`OrcV1QuerySuite`, `OrcV2QuerySuite`, or `HiveOrcQuerySuite`, with pushdown on 
or off, and this block passes without the fix too. On 4.3.0 the optimized plan 
is `LocalRelation <empty>`.
   
   Excluding the rule lets the query reach the `Not(In)` pushdown that the code 
comment describes:
   
   ```scala
             withSQLConf(SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
                 ReplaceNullWithFalseInPredicate.ruleName) {
               val df = spark.read.orc(path)
               checkAnswer(df.where("id NOT IN (1, 3, NULL)"), Seq.empty[Row])
               checkAnswer(df.where("id NOT IN (1, 3, NULL) OR label = 'b'"), 
Row(2, "b"))
             }
   ```
   
   Without the fix, both queries fail with the NPE at `OrcFilters.scala:284` in 
all three suites. With the fix, they return the expected answers. The second 
query has a non-empty answer, so it would also catch a pushed `SearchArgument` 
that prunes too much. `ReplaceNullWithFalseInPredicate` needs an import from 
`org.apache.spark.sql.catalyst.optimizer`.
   
   In the default configuration, this shape reaches ORC through a runtime 
scalar subquery over a real table, such as `id NOT IN ((SELECT min(v) FROM 
empty_table), 1)`, in V1 and in the Hive implementation.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcQuerySuite.scala:
##########
@@ -72,6 +72,35 @@ case class Person(name: String, age: Int, contacts: 
Seq[Contact])
 abstract class OrcQueryTest extends OrcTest {
   import testImplicits._
 
+  test("SPARK-59605: ORC IN filter with NULL matches with pushdown on and 
off") {
+    withTempPath { dir =>
+      val path = dir.getCanonicalPath
+      spark.sql(
+        "SELECT * FROM VALUES " +
+          "(CAST(NULL AS INT), 'null'), (1, 'a'), (2, 'b'), (3, 'c') AS t(id, 
label)")
+        .write.orc(path)
+
+      Seq("true", "false").foreach { pushdown =>
+        withSQLConf(SQLConf.ORC_FILTER_PUSHDOWN_ENABLED.key -> pushdown) {
+          checkAnswer(
+            spark.read.orc(path)
+              .where("id IN (1, 3, NULL)")

Review Comment:
   **5. Tests: this check cannot observe the pushdown.**
   
   `checkAnswer` compares the result after Spark re-applies the filter, and 
with this data layout (two files, `{NULL, 1}` and `{2, 3}`, with 
`SharedSparkSession`; one file with TestHive) the correct `SearchArgument` `IN 
(1, 3)` prunes nothing. So this check catches the NPE, but not a change that 
stops pushing an `IN` with a NULL. The unit test covers `OrcFilters` itself, 
not the planner path.
   
   The "SPARK-10623 Enable ORC PPD" test in this class already has a 
`stripSparkFilter`-based `checkPredicate` over ten single-row files. One line 
there checks both the NPE and the pruning:
   
   ```scala
           checkPredicate($"a".isin(3, 5, 7, null), List(3, 5, 7).map(Row(_, 
null)))
   ```
   
   Without the fix it fails with the NPE, and with the fix the ORC reader 
returns only the three matching rows, in all three suites.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilterSuite.scala:
##########
@@ -730,6 +730,26 @@ class OrcFilterSuite extends OrcTest with 
SharedSparkSession {
     ).toImmutableArraySeq).isEmpty)
   }
 
+  test("SPARK-59605: IN filter with a NULL literal drops the NULL and pushes 
the rest") {
+    import org.apache.spark.sql.sources._
+    val schema = StructType(Array(StructField("a", IntegerType, nullable = 
true)))
+
+    def sarg(filter: Filter): Option[String] =
+      OrcFilters.createFilter(schema, Array(filter).toImmutableArraySeq)
+        .map(_.asInstanceOf[SearchArgumentImpl].toOldString)
+
+    // A NULL literal is inert: IN (1, 3, NULL) pushes the same SearchArgument 
as IN (1, 3).
+    assert(sarg(In("a", Array[Any](1, 3, null))) === sarg(In("a", 
Array[Any](1, 3))))
+    assert(sarg(In("a", Array[Any](1, 3, null))).isDefined)
+
+    // IN (NULL) has no non-null value to push, so no SearchArgument is 
generated.
+    assert(sarg(In("a", Array[Any](null))).isEmpty)
+
+    // Not(IN (1, 3, NULL)) still builds and matches Not(IN (1, 3)); Not(IN 
(NULL)) pushes nothing.
+    assert(sarg(Not(In("a", Array[Any](1, 3, null)))) === sarg(Not(In("a", 
Array[Any](1, 3)))))

Review Comment:
   **6. Tests: this assertion also passes when both sides are `None`.**
   
   Unlike the `IN` pair (L743), there is no `isDefined` check here, so a change 
that stopped converting `Not(In(...))` would still pass as `None === None`, 
although the comment says it "still builds". Pinning the expected strings, as 
the other tests in this suite do, covers both with one assertion each:
   
   ```scala
       assert(sarg(In("a", Array[Any](1, 3, null))) === Some("leaf-0 = (IN a 1 
3), expr = leaf-0"))
       assert(sarg(Not(In("a", Array[Any](1, 3, null)))) ===
         Some("leaf-0 = (IN a 1 3), expr = (not leaf-0)"))
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilters.scala:
##########
@@ -281,9 +281,19 @@ private[sql] object OrcFilters extends OrcFiltersBase {
           .isNull(dataTypeMap(name).fieldName, getType(name)).end())
 
       case In(name, values) if dataTypeMap.contains(name) =>
-        val castedValues = values.map(v => castLiteralValue(v, 
dataTypeMap(name).fieldType))
-        Some(builder.startAnd().in(dataTypeMap(name).fieldName, getType(name),
-          castedValues.map(_.asInstanceOf[AnyRef]): _*).end())
+        // SPARK-59605: a NULL in the IN list is inert (`x IN (.., NULL)` 
matches the same rows as
+        // IN on the non-null values), and castLiteralValue would NPE on a 
null literal. Drop NULLs;
+        // if none remain (e.g. `IN (NULL)`) there is nothing safe to push. 
Under Not(In) the pushed
+        // NOT(IN(non-nulls)) is over-inclusive but safe: Spark re-applies the 
full predicate.
+        val nonNullValues = values.filter(_ != null)
+        if (nonNullValues.isEmpty) {

Review Comment:
   **7. Behavior change: an empty `IN` list.**
   
   This branch also catches `In(name, Array())`, which has no NULL. Before this 
PR, ORC's `BuilderImpl.in` threw `IllegalArgumentException: Can't create in 
expression with no arguments`. For example, with `OptimizeIn` excluded through 
`spark.sql.optimizer.excludedRules`, `spark.read.orc(path).where($"id".isin())` 
failed in the task for V1 and in the optimizer for V2. Now the filter is simply 
not pushed. That is an improvement, but the description says "No change for 
`IN` lists without a `NULL`". Could you mention it there and add 
`assert(sarg(In("a", Array.empty[Any])).isEmpty)`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilters.scala:
##########
@@ -281,9 +281,19 @@ private[sql] object OrcFilters extends OrcFiltersBase {
           .isNull(dataTypeMap(name).fieldName, getType(name)).end())
 
       case In(name, values) if dataTypeMap.contains(name) =>
-        val castedValues = values.map(v => castLiteralValue(v, 
dataTypeMap(name).fieldType))
-        Some(builder.startAnd().in(dataTypeMap(name).fieldName, getType(name),
-          castedValues.map(_.asInstanceOf[AnyRef]): _*).end())
+        // SPARK-59605: a NULL in the IN list is inert (`x IN (.., NULL)` 
matches the same rows as
+        // IN on the non-null values), and castLiteralValue would NPE on a 
null literal. Drop NULLs;
+        // if none remain (e.g. `IN (NULL)`) there is nothing safe to push. 
Under Not(In) the pushed
+        // NOT(IN(non-nulls)) is over-inclusive but safe: Spark re-applies the 
full predicate.
+        val nonNullValues = values.filter(_ != null)
+        if (nonNullValues.isEmpty) {
+          None
+        } else {
+          val fieldType = dataTypeMap(name).fieldType

Review Comment:
   **11. Nit: the null check could move into the case guard.**
   
   `fieldType` seems to be hoisted only for the line length under the extra 
nesting; `dataTypeMap(name)` is still looked up again for `fieldName` and 
inside `getType`. With the check in the guard, this branch keeps the shape of 
its siblings, and an empty or all-NULL list falls through to `case _ => None`:
   
   ```scala
         case In(name, values) if dataTypeMap.contains(name) && values.exists(_ 
!= null) =>
           val castedValues =
             values.filter(_ != null).map(v => castLiteralValue(v, 
dataTypeMap(name).fieldType))
           Some(builder.startAnd().in(dataTypeMap(name).fieldName, 
getType(name),
             castedValues.map(_.asInstanceOf[AnyRef]): _*).end())
   ```



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilterSuite.scala:
##########
@@ -730,6 +730,26 @@ class OrcFilterSuite extends OrcTest with 
SharedSparkSession {
     ).toImmutableArraySeq).isEmpty)
   }
 
+  test("SPARK-59605: IN filter with a NULL literal drops the NULL and pushes 
the rest") {
+    import org.apache.spark.sql.sources._
+    val schema = StructType(Array(StructField("a", IntegerType, nullable = 
true)))

Review Comment:
   **4. Tests: only `IntegerType` is covered.**
   
   For string, boolean, date, timestamp, `TIMESTAMP_NTZ`, `TIME`, and the 
nanosecond timestamp types, `castLiteralValue` returns the null unchanged, so 
there was no NPE on the Spark side before this PR. ORC accepted the null 
literal and built `leaf-0 = (IN s a null)`, then failed to compare it with the 
row group statistics, logged `NullPointerException when evaluating predicate. 
Skipping ORC PPD.`, and stopped pruning. On 4.3.0, an ORC file whose string 
column has only `'x'` and `'y'` is skipped for `s IN ('a', 'b')` but read for 
`s IN ('a', 'b', NULL)`. With `orc.sarg.to.filter=true`, `IN ('x', NULL)` on a 
string, boolean, date, or `TIMESTAMP_NTZ` column even fails the query.
   
   This PR fixes these types too, but nothing pins it; a change that dropped 
NULLs only for numeric types would still pass. How about adding a string case?
   
   ```scala
       val stringSchema = StructType(Array(StructField("s", StringType, 
nullable = true)))
       assert(OrcFilters.createFilter(stringSchema, Seq(In("s", Array[Any]("a", 
null))))
         .map(_.asInstanceOf[SearchArgumentImpl].toOldString) ===
         Some("leaf-0 = (IN s a), expr = leaf-0"))
   ```
   
   The code comment at L285 ("castLiteralValue would NPE on a null literal") 
could mention this second failure mode as well.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcQuerySuite.scala:
##########
@@ -72,6 +72,35 @@ case class Person(name: String, age: Int, contacts: 
Seq[Contact])
 abstract class OrcQueryTest extends OrcTest {
   import testImplicits._
 
+  test("SPARK-59605: ORC IN filter with NULL matches with pushdown on and 
off") {
+    withTempPath { dir =>
+      val path = dir.getCanonicalPath
+      spark.sql(
+        "SELECT * FROM VALUES " +
+          "(CAST(NULL AS INT), 'null'), (1, 'a'), (2, 'b'), (3, 'c') AS t(id, 
label)")
+        .write.orc(path)
+
+      Seq("true", "false").foreach { pushdown =>
+        withSQLConf(SQLConf.ORC_FILTER_PUSHDOWN_ENABLED.key -> pushdown) {
+          checkAnswer(
+            spark.read.orc(path)
+              .where("id IN (1, 3, NULL)")
+              .select("id", "label")
+              .orderBy("id", "label"),

Review Comment:
   **10. Nit: `select` and `orderBy` are not needed.**
   
   The files have only `id` and `label`, so `.select("id", "label")` is a 
no-op. `checkAnswer` already sorts both sides when the plan has no `Sort`, so 
`.orderBy` only adds a range-partitioned sort (6 jobs per `checkAnswer` instead 
of 2 on 4.3.0). The `NOT IN` query below uses neither. Also, the `"false"` 
iteration never calls `OrcFilters`; the other pushdown tests in this file run 
only with pushdown enabled.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcFilters.scala:
##########
@@ -281,9 +281,19 @@ private[sql] object OrcFilters extends OrcFiltersBase {
           .isNull(dataTypeMap(name).fieldName, getType(name)).end())
 
       case In(name, values) if dataTypeMap.contains(name) =>
-        val castedValues = values.map(v => castLiteralValue(v, 
dataTypeMap(name).fieldType))
-        Some(builder.startAnd().in(dataTypeMap(name).fieldName, getType(name),
-          castedValues.map(_.asInstanceOf[AnyRef]): _*).end())
+        // SPARK-59605: a NULL in the IN list is inert (`x IN (.., NULL)` 
matches the same rows as
+        // IN on the non-null values), and castLiteralValue would NPE on a 
null literal. Drop NULLs;
+        // if none remain (e.g. `IN (NULL)`) there is nothing safe to push. 
Under Not(In) the pushed

Review Comment:
   **8. Comment: "there is nothing safe to push".**
   
   `x IN (NULL)` is never TRUE, with or without a surrounding `NOT`, so pushing 
a constant would be safe. The actual reason is that no values remain, and ORC 
rejects an empty `IN` list. Maybe "if none remain (e.g. `IN (NULL)`), there is 
nothing to push". Note that the optimizer folds SQL `id IN (NULL)` before it 
gets here; this branch is reached through the `Filter` API or a runtime scalar 
subquery such as `id IN ((SELECT min(v) FROM empty_table))`.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/orc/OrcQuerySuite.scala:
##########
@@ -72,6 +72,35 @@ case class Person(name: String, age: Int, contacts: 
Seq[Contact])
 abstract class OrcQueryTest extends OrcTest {
   import testImplicits._
 
+  test("SPARK-59605: ORC IN filter with NULL matches with pushdown on and 
off") {

Review Comment:
   **12. Nit: test placement.**
   
   This becomes the first test of `OrcQueryTest`, before "Read/write All 
Types", while the related pushdown tests come later ("SPARK-10623 Enable ORC 
PPD", "SPARK-27160 ..."). Keeping it in `OrcQueryTest` is right, because 
`HiveOrcQuerySuite` runs it too, but it would be easier to find next to those 
tests.



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