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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -186,13 +290,24 @@ trait GeneratePredicateHelper extends PredicateHelper {
     // This has the property of not doing redundant IsNotNull checks and 
taking better advantage of
     // short-circuiting, not loading attributes until they are needed.
     // This is very perf sensitive.
+    // With the NULL-conjunct flag, once a predicate that can be NULL is 
generated, the IsNotNull
+    // checks that precede a later predicate in the condition are generated 
before it, see
+    // `precedingNotNullChecks`, and a check on an expression that is written 
after the predicate
+    // is not generated before it, see the nested checks below.
     // NOTE: `FilterExec.doConsume`'s CSE branch inlines the same interleaving 
so it can
     // emit CSE state precomputes between the IsNotNull checks and each 
otherPred body.
     // Any change to this algorithm must be mirrored there (and vice versa).
     // TODO: revisit this. We can consider reordering predicates as well.
     val generatedIsNotNullChecks = new Array[Boolean](notNullPreds.length)
     val extraIsNotNullAttrs = mutable.Set[Attribute]()
-    val generated = otherPreds.map { c =>
+    var nullableSeen = false
+    val generated = otherPreds.zip(boundOtherPreds).zipWithIndex.map { case 
((c, bound), k) =>
+      val afterNullable = nullFlag.isDefined && nullableSeen

Review Comment:
   **`afterNullable` is decided at codegen time, so the moved checks and the 
skipped nested checks also apply to rows where no conjunct is NULL.** Once some 
conjunct can be NULL after binding, `precedingNotNullChecks` and the 
nested-branch skip (L334; L530 and L560 in the CSE branch) are in the generated 
code for every row. So with the default config, rows without any NULL conjunct 
also behave differently from 4.3, in both directions, always toward the 
codegen-off result. With ANSI on and Parquet filter pushdown off:
   
   - On `withNullConjunctTable(Int.MaxValue)`, `SELECT a FROM t WHERE cast(s AS 
INT) IS NOT NULL AND (a > 0 OR b > 0) AND y < 0` returns no rows on master and 
with the legacy config, because `y < 0` is FALSE before the leftover cast check 
runs. Now it raises `CAST_INVALID_INPUT`: on the first row the `OR` is TRUE 
(`NULL OR TRUE`), and `isnotnull(cast(s AS INT))` is generated before `y < 0`. 
Codegen off raises too.
   - On a row `(a, b, s) = (0, 1, 'x')`, `WHERE NOT (a > 0 AND b > 0) AND 
coalesce(b, cast(s AS INT)) < 0 AND cast(s AS INT) IS NOT NULL` raised 
`CAST_INVALID_INPUT` on master and still does with the legacy config, but now 
returns no rows, as codegen off does.
   
   The migration guide ("on a row after one of them evaluates to `NULL`" ... 
"on that row"), the legacy config doc (`SQLConf.scala:1751`) and the 
user-facing section of the PR description ("Rows whose earlier conjunct is 
FALSE are not affected") describe only rows where a conjunct is NULL. Also, the 
SPARK-60377 example under "Not fixed here" in the PR description, which becomes 
the commit message, now raises only with the legacy config; `WHERE coalesce(b, 
cast(s AS INT)) < 0 AND cast(s AS INT) IS NOT NULL` on `(b, s) = (1, 'x')` 
still shows it with the default config.
   
   Could you add a sentence to the migration guide and the legacy config doc 
saying that, once a condition has a conjunct that can be NULL, the `IS NOT 
NULL` checks after that conjunct are evaluated in the order of the condition on 
every row, not only on rows where a conjunct is NULL, update that example, and 
pin the first query in a test?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -137,47 +137,151 @@ trait GeneratePredicateHelper extends PredicateHelper {
       condition: Expression,
       inputAttrs: Seq[Attribute],
       inputExprCode: Seq[ExprCode]): String = {
-    val (notNullPreds, otherPreds) = 
splitConjunctivePredicates(condition).partition {
-      case IsNotNull(a) => isNullIntolerant(a) && 
a.references.subsetOf(AttributeSet(inputAttrs))
-      case _ => false
-    }
+    val (notNullPreds, otherPreds, precedingNotNulls) =
+      splitNotNullPreds(condition, AttributeSet(inputAttrs))
     val nonNullAttrExprIds = 
notNullPreds.flatMap(_.references).distinct.map(_.exprId)
     val outputAttrs = outputWithNullability(inputAttrs, nonNullAttrExprIds)
     generatePredicateCode(
-      ctx, inputAttrs, inputExprCode, outputAttrs, notNullPreds, otherPreds,
+      ctx, inputAttrs, inputExprCode, notNullPreds, otherPreds,
+      otherPreds.map(BindReferences.bindReference(_, outputAttrs)), 
precedingNotNulls,
       nonNullAttrExprIds)
   }
 
+  /**
+   * Splits `condition` into the `IsNotNull` checks on null-intolerant 
expressions over
+   * `inputAttrs`, the other conjuncts, and for each of the other conjuncts 
the number of the
+   * checks that precede it in `condition`. The conjuncts keep their order 
within each group, so
+   * the checks before the k-th other conjunct are the first 
`precedingNotNulls(k)` checks.
+   */
+  protected def splitNotNullPreds(
+      condition: Expression,
+      inputAttrs: AttributeSet): (Seq[Expression], Seq[Expression], Seq[Int]) 
= {
+    val notNullPreds = mutable.ArrayBuffer.empty[Expression]
+    val otherPreds = mutable.ArrayBuffer.empty[Expression]
+    val precedingNotNulls = mutable.ArrayBuffer.empty[Int]
+    splitConjunctivePredicates(condition).foreach {
+      case p @ IsNotNull(a) if isNullIntolerant(a) && 
a.references.subsetOf(inputAttrs) =>
+        notNullPreds += p
+      case p =>
+        otherPreds += p
+        precedingNotNulls += notNullPreds.length
+    }
+    (notNullPreds.toSeq, otherPreds.toSeq, precedingNotNulls.toSeq)
+  }
+
+  /**
+   * Returns a fresh name for the flag that records a conjunct evaluated to 
NULL, or None if the
+   * row is dropped at the first NULL conjunct (the legacy behavior) or none 
of `conjuncts`, bound
+   * with the nullability they are generated with, can be NULL.
+   *
+   * A row is kept only when the whole condition is TRUE, so both FALSE and 
NULL drop it. Only
+   * a FALSE conjunct decides the result of the AND on its own, though: after 
a NULL one, the
+   * remaining conjuncts must still be evaluated, as in `And.eval`, so that an 
error they raise
+   * on the row is raised (SPARK-60315). The flag drops the row once all of 
them are evaluated.
+   */
+  protected def nullConjunctFlag(
+      ctx: CodegenContext,
+      conjuncts: Seq[Expression]): Option[String] = {
+    if (conf.legacyFilterShortCircuitOnNullConjunct || 
!conjuncts.exists(_.nullable)) {

Review Comment:
   **The flag, and with it the hoisted checks, is generated whenever a conjunct 
can be NULL, even when nothing after it can raise an error.** This condition 
looks only at nullability, so any `OR`, `IN`, `CASE` or `coalesce` over 
nullable columns turns it on. After that conjunct, `precedingNotNullChecks` 
generates all pending checks before the next conjunct, including every 
`isnotnull(...)` that `InferFiltersFromConstraints` prepends, and on the 
non-CSE path each check loads its column value through 
`evaluateRequiredVariables` (L269). That code runs on every row that reaches 
the next conjunct, not only on rows where a conjunct is NULL.
   
   For example, on a Parquet table `t(a INT, b INT, c INT, d DECIMAL(38,10))`, 
`WHERE (a > 5 OR b > 5) AND c = 1 AND d = 2` becomes `isnotnull(c) AND 
isnotnull(d) AND (a > 5 OR b > 5) AND c = 1 AND d = 2`. Master decodes `d` only 
for rows with `c = 1`, while this PR decodes it, allocating a `BigInteger` and 
a `BigDecimal`, on every row where the `OR` is TRUE or NULL. No conjunct there 
can raise, so neither the flag nor the hoisting changes any result. This is the 
cost that d6036f470d7 (the SPARK-56032 follow-up for TPC-DS q28) removed from 
the CSE prologue, and it goes beyond the risk noted in the first review 
("limited to plain-column null checks"), because the values are decoded too and 
common `OR`/`IN` filters trigger it.
   
   Could you gate both with `ExprUtils.canEvaluateUnconditionally`, applied to 
the unbound expressions? Return `None` here unless a conjunct after the first 
nullable one, or one of the `IsNotNull` checks, cannot be evaluated 
unconditionally, and in `precedingNotNullChecks` hoist before the k-th conjunct 
only if it or one of the pending checks cannot. `precedingNotNulls` never 
decreases, so the next conjunct that can raise still gets every check that 
precedes it, and the legacy path stays as it is.



##########
docs/sql-migration-guide.md:
##########
@@ -51,6 +51,7 @@ license: |
 - Since Spark 4.4, the value columns of `UNPIVOT` and `Dataset.unpivot` merge 
the nested nullability (array element, map value, and struct field nullability) 
of all unpivoted values. Previously, a value column took the nested nullability 
of the first listed value, so a `NULL` array element, map value, or struct 
field of a later value could be returned as a non-`NULL` value such as `0`, and 
`IS NULL` on it returned `false`. A view created by an earlier Spark version 
over such an `UNPIVOT` may have recorded the non-nullable nested type in its 
schema and can fail to read; recreate it with `CREATE OR REPLACE VIEW`. To 
restore the previous behavior, set 
`spark.sql.legacy.unpivot.useFirstValueNestedNullability` to `true`.
 - Since Spark 4.4, when `spark.sql.parquet.fieldId.read.enabled` is true, a 
Parquet read fails with `PARQUET_FIELD_REQUESTED_MORE_THAN_ONCE` if two or more 
requested fields of a struct (or of the top-level schema) resolve to the same 
Parquet field. This happens when one requested field is matched by field ID and 
another by name, or when two requested fields have the same field ID. In Spark 
4.3 and earlier, such a read returned wrong values without an error: with the 
vectorized reader one of the fields read as non-null default values (for 
example `0`) or uninitialized data, and with the parquet-mr reader 
(`spark.sql.parquet.enableVectorizedReader` set to `false`) one of the fields 
read as `null`. To read the Parquet field more than once, request it once and 
refer to the resulting column more than once in the query.
 - Since Spark 4.4, when `spark.sql.groupByOrdinal` is true (the default), the 
pipe `AGGREGATE` operator rejects an aliased integer literal in its `GROUP BY` 
clause, such as `TABLE t |> AGGREGATE sum(x) AS s GROUP BY 2 AS grp`, with 
`UNSUPPORTED_FEATURE.PIPE_OPERATOR_AGGREGATE_UNSUPPORTED_CASE`, because a bare 
integer literal in the same position is an ordinal. Previously the aliased 
literal was grouped as a constant. Bare ordinals and aliases on other grouping 
expressions are not affected. To restore the previous behavior, set 
`spark.sql.legacy.allowAliasedPipeAggregateOrdinals` to `true`.
+- Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates the remaining 
top-level conjuncts of the condition on a row after one of them evaluates to 
`NULL`, as the SQL standard requires, and does so in the order they appear in 
the condition, as Spark already did with whole-stage codegen disabled. Only a 
`FALSE` conjunct stops the evaluation. The one exception is an `IS NOT NULL` 
check on a column that a later conjunct references: it is still evaluated 
before that conjunct, even when it is written after it, and can drop the row 
before an error the conjunct raises. A row whose condition is `NULL` is still 
dropped, but an error raised by a later conjunct on that row, such as 
`ARITHMETIC_OVERFLOW` in ANSI mode, now fails the query. For example, `SELECT a 
FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0` can fail with 
`ARITHMETIC_OVERFLOW` on a row where `a` is `NULL`, `b` is `1`, and `y` is 
`2147483647`, un
 less the row is dropped earlier by a `FALSE` conjunct or `IS NOT NULL` check 
that precedes `y + 1 > 0` in the condition, or skipped before the filter, for 
example by data source filter pushdown such as Parquet row group statistics. 
Previously the query skipped such a row with whole-stage codegen enabled. The 
later conjuncts also run on more rows than before, so a nondeterministic 
conjunct such as `rand(seed)` can return different values on the remaining 
rows, and a side-effecting user-defined function is called on rows that are 
dropped. To restore the previous behavior, set 
`spark.sql.legacy.filterShortCircuitOnNullConjunct` to `true`.

Review Comment:
   Minor: "as Spark already did with whole-stage codegen disabled. Only a 
`FALSE` conjunct stops the evaluation." does not hold when the conjuncts after 
a FALSE one share a subexpression. With whole-stage codegen off, `FilterExec` 
uses `Predicate.create`, and `GeneratePredicate` evaluates the common 
subexpressions before the condition (`GeneratePredicate.scala:63-64`); the 
generated projection for an aggregate `FILTER` does the same. So on 
`withNullConjunctTable(Int.MaxValue)` with ANSI on and Parquet filter pushdown 
off, `SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND b < 0 AND y + 1 > 0 AND y 
+ 1 > -1` returns no rows with codegen on, whatever the legacy and filterExec 
CSE configs, and raises `ARITHMETIC_OVERFLOW` with codegen off. This is the 
pre-existing difference you mentioned in the review thread, and it also happens 
without the NULL conjunct, so it is not a regression. Could you list it under 
"Not fixed here" in the PR description, or file a JIRA, and qualify this 
sentence?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -451,10 +597,11 @@ case class FilterExec(condition: Expression, child: 
SparkPlan)
           case (p, i) if !generatedIsNotNullChecks(i) => genNotNull(p)
         }.mkString("\n")
 
-        (inputVarsEvalCode, predCode + "\n" + leftoverNotNull)
+        (inputVarsEvalCode, withNullConjunctFlag(predCode + "\n" + 
leftoverNotNull, nullFlag))
       } else {
         ("", generatePredicateCode(
-          ctx, child.output, input, output, notNullPreds, otherPreds, 
notNullAttributes))
+          ctx, child.output, input, notNullPreds, otherPreds, boundOtherPreds,

Review Comment:
   Nit: this call now passes `boundOtherPreds` to the non-CSE path, where it 
decides the NULL-conjunct flag and supplies the bodies of the conjuncts, but 
the comment on `boundOtherPreds` (L381-383) still says it is "shared between 
the CSE gate in `doConsume` and the CSE codegen itself". Could you update it, 
e.g. "shared by the CSE gate in `doConsume` and both predicate code paths (the 
CSE branch and `generatePredicateCode`)"?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -2814,4 +2815,274 @@ class WholeStageCodegenSuite extends SharedSparkSession
       }
     }
   }
+
+  // A Parquet table, so that the filter is not evaluated by the optimizer on 
a local relation.
+  // On the first row `NOT (a > 0 AND b > 0)` is NULL, and no `isnotnull(a)` 
is inferred from it.
+  // The second row passes every condition of the tests below, except `b < 0`. 
The rows can be
+  // written to different files, so the filter is not pushed down, which would 
skip the first
+  // row's file by its statistics.
+  private def withNullConjunctTable(y: Int)(f: => Unit): Unit = {
+    withTable("t") {
+      sql("CREATE TABLE t USING parquet AS " +
+        s"SELECT * FROM VALUES (NULL, 1, $y, NULL, 'x'), (0, 1, 0, 1, '1') AS 
v(a, b, y, x, s)")
+      f
+    }
+  }
+
+  // A common subexpression after the NULL conjunct takes the CSE path of 
FilterExec.
+  private val nullConjunctCSEQuery =
+    "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND y + 1 > -1"
+
+  private def checkArithmeticOverflow(query: String): Unit = {
+    checkError(
+      exception = intercept[SparkArithmeticException] {
+        sql(query).collect()
+      },
+      condition = "ARITHMETIC_OVERFLOW",
+      parameters = Map(
+        "message" -> "overflow",
+        "alternative" -> " Use 'try_add' to tolerate overflow and return NULL 
instead.",
+        "config" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
+      context = ExpectedContext(
+        fragment = "y + 1",
+        start = query.indexOf("y + 1"),
+        stop = query.indexOf("y + 1") + 4))
+  }
+
+  // Runs `f` with whole-stage codegen on and off and with the legacy behavior 
on and off, and
+  // passes it whether the filter drops a row at its first NULL conjunct.
+  private def withNullConjunctConfs(otherConfs: (String, String)*)(f: Boolean 
=> Unit): Unit = {
+    for (codegen <- Seq(true, false); legacy <- Seq(true, false)) {
+      withSQLConf(otherConfs ++ Seq(
+          SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "false",
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+          SQLConf.LEGACY_FILTER_SHORT_CIRCUIT_ON_NULL_CONJUNCT.key -> 
legacy.toString): _*) {
+        withClue(s"wholeStage=$codegen, legacy=$legacy: ") {
+          f(codegen && legacy)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one") {
+    withNullConjunctTable(Int.MaxValue) {
+      // The CSE path evaluates the common `y + 1` once, the non-CSE path at 
each use. Both
+      // generate the flag that keeps the row's evaluation going after a NULL 
conjunct.
+      def cseQueryCode(filterExecCSE: Boolean): String = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = 
codegenString(sql(nullConjunctCSEQuery).queryExecution.executedPlan)
+          assert(code.contains("nullConjunct"))
+          code
+        }
+      }
+      assert("addExact".r.findAllIn(cseQueryCode(filterExecCSE = true)).length 
<
+        "addExact".r.findAllIn(cseQueryCode(filterExecCSE = false)).length)
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        Seq(
+          "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0",
+          nullConjunctCSEQuery
+        ).foreach { query =>
+          withNullConjunctConfs(
+              SQLConf.ANSI_ENABLED.key -> "true",
+              SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+            skipsAtNull =>
+              if (skipsAtNull) {
+                checkAnswer(sql(query), Row(0))
+              } else {
+                checkArithmeticOverflow(query)
+              }
+          }
+        }
+      }
+    }
+    // A NULL conjunct from OR. The second row does not pass it.
+    withNullConjunctTable(Int.MaxValue) {
+      val query = "SELECT a FROM t WHERE (a > 0 OR b < 0) AND y + 1 > 0"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Seq.empty)
+        } else {
+          checkArithmeticOverflow(query)
+        }
+      }
+    }
+    // Without an error, the CSE path drops the first row after its last 
conjunct is evaluated.
+    withNullConjunctTable(0) {
+      withNullConjunctConfs(
+          SQLConf.ANSI_ENABLED.key -> "true",
+          SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> "true") 
{ _ =>
+        checkAnswer(sql(nullConjunctCSEQuery), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one in 
non-ANSI mode") {
+    // Year-month interval arithmetic raises on overflow in every mode.
+    withNullConjunctTable(178956970) {
+      val condition = "NOT (a > 0 AND b > 0) AND " +
+        "make_ym_interval(y, 0) + INTERVAL '1' YEAR > INTERVAL '0' YEAR"
+      val query = s"SELECT a FROM t WHERE $condition"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "false") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Row(0))
+        } else {
+          checkError(
+            exception = intercept[SparkArithmeticException] {
+              sql(query).collect()
+            },
+            condition = "INTERVAL_ARITHMETIC_OVERFLOW.WITH_SUGGESTION",
+            parameters = Map("functionName" -> toSQLId("try_add")))
+        }
+        // A check on an expression, not on a column, that precedes the NULL 
conjunct:
+        // `cast(s AS INT)` is NULL on the first row, so the check is FALSE 
and drops the row
+        // before the overflowing conjunct, as with codegen off.
+        checkAnswer(
+          sql(s"SELECT a FROM t WHERE cast(s AS INT) IS NOT NULL AND 
$condition"), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter drops a row with a NULL conjunct and stops at a 
FALSE one") {
+    withNullConjunctTable(Int.MaxValue) {
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { _ =>
+        // The first row is dropped after its last conjunct is evaluated.
+        checkAnswer(sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y > 
-1"), Row(0))
+        // FALSE decides the AND, so the overflowing conjunct after it is not 
evaluated.
+        checkAnswer(
+          sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND b < 0 AND y + 1 
> 0"),
+          Seq.empty)
+      }
+      // `b > 0` cannot be NULL after the inferred `isnotnull(b)`, so no flag 
is generated.
+      val df = sql("SELECT a FROM t WHERE b > 0 AND y > -1")
+      checkAnswer(df, Seq(Row(null), Row(0)))
+      
assert(!codegenString(df.queryExecution.executedPlan).contains("nullConjunct"))
+    }
+  }
+
+  test("SPARK-60315: a filter checks IS NOT NULL before the conjuncts it 
precedes") {
+    withNullConjunctTable(Int.MaxValue) {
+      // `x` is NULL on the first row. With codegen off, a FALSE 
`isnotnull(x)` drops the row
+      // before a conjunct after it in the condition is evaluated. With 
codegen on, the check is
+      // generated just before the first conjunct that references `x`, or 
after all of them, so
+      // after a NULL conjunct it has to be generated before the conjuncts it 
precedes.
+      //
+      // The common `b * 2` takes the CSE path of FilterExec. The overflowing 
`y + 1` is not
+      // common because `GeneratePredicate` (codegen off) evaluates common 
subexpressions first.
+      val cseQuery = "SELECT a FROM t WHERE x IS NOT NULL AND NOT (a > 0 AND b 
> 0) AND " +
+        "y + 1 > 0 AND b * 2 > 0 AND b * 2 > -1"
+      def multiplyExactCount(filterExecCSE: Boolean): Int = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = codegenString(sql(cseQuery).queryExecution.executedPlan)
+          "multiplyExact".r.findAllIn(code).length
+        }
+      }
+      assert(multiplyExactCount(filterExecCSE = true) < 
multiplyExactCount(filterExecCSE = false))
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        withNullConjunctConfs(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+          skipsAtNull =>
+            // `isnotnull(x)` is inferred from `x > 0` and precedes the 
overflowing conjunct.
+            checkAnswer(
+              sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 
AND x > 0"),
+              Row(0))
+            // A user-written check that no other conjunct references.
+            checkAnswer(
+              sql("SELECT a FROM t WHERE x IS NOT NULL AND NOT (a > 0 AND b > 
0) AND y + 1 > 0"),
+              Row(0))
+            // The same with a common subexpression.
+            checkAnswer(sql(cseQuery), Row(0))
+            // A check written after the overflowing conjunct stays after it.
+            val checkAfter =
+              "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND x 
IS NOT NULL"

Review Comment:
   Minor: this query, like the inferred-check one at L2993, has no common 
subexpression, so it takes `generatePredicateCode` even when `filterExecCSE` is 
true. Only `cseQuery`, whose check is written first, runs the preceding checks 
through the CSE branch of `FilterExec.doConsume`. If a later change to that 
branch emitted the pending column checks right after the first nullable 
conjunct (the shape that cloud-fan's first review warned would bring back the 
original inconsistency), `... AND y + 1 > 0 AND x IS NOT NULL AND b * 2 > 0 AND 
b * 2 > -1` would return `[0]` with codegen on and raise with it off, and the 
suite would still pass; the new expression-check test only catches hoisting 
that includes the expression check. Could you also check `checkAfter + " AND b 
* 2 > 0 AND b * 2 > -1"` with the same `skipsAtNull` expectation? With 
`precedingNotNulls = [2, 2, 3, 3]`, it would pin the per-conjunct placement as 
well.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -373,29 +503,43 @@ case class FilterExec(condition: Expression, child: 
SparkPlan)
         // Emit an IsNotNull check, binding against child.output so the inner 
expression
         // is evaluated with its original nullability (the 
tightened-nullability `output`
         // is only appropriate inside otherPreds, where the required checks 
have fired).
-        // The `bound.nullable` gate below is defensive -- `IsNotNull` always 
produces a
-        // non-nullable Boolean, so this branch is structurally unreachable 
today; it
-        // mirrors the non-CSE helper's shape to keep future refactors safe.
+        // Passing `bound.nullable` to `conjunctCheck` is defensive -- 
`IsNotNull` always
+        // produces a non-nullable Boolean, so its NULL handling is 
unreachable here; the
+        // non-CSE helper generates its IsNotNull checks the same way.
         def genNotNull(pred: Expression): String = {
           val bound = BindReferences.bindReference(pred, child.output)
           val evaluated = evaluateRequiredVariables(child.output, input, 
pred.references)
           val ev = ExpressionCanonicalizer.execute(bound).genCode(ctx)
-          val nullCheck = if (bound.nullable) s"${ev.isNull} || " else ""
           s"""
              |$evaluated
              |${ev.code}
-             |if (${nullCheck}!${ev.value}) continue;
+             |${conjunctCheck(ev, bound.nullable, nullFlag)}

Review Comment:
   Nit: with a flag, passing `nullFlag` here turns the "defensive" NULL 
handling of a check into `if (isNull) { flag = true; } else if (!value) 
continue;`. So if a check could ever be NULL, it would no longer drop the row 
before the later conjuncts and CSE precomputes that are bound with its column 
non-nullable, which is what invariant (c) above is about. Master's `if (isNull 
|| !value) continue;` was really defensive, and `genNotNull` in 
`generatePredicateCode` (L277, L283) has the same issue. Could you pass `None` 
for the `IsNotNull` checks in both helpers? The generated code does not change, 
since `IsNotNull` is never nullable.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -2814,4 +2815,274 @@ class WholeStageCodegenSuite extends SharedSparkSession
       }
     }
   }
+
+  // A Parquet table, so that the filter is not evaluated by the optimizer on 
a local relation.
+  // On the first row `NOT (a > 0 AND b > 0)` is NULL, and no `isnotnull(a)` 
is inferred from it.
+  // The second row passes every condition of the tests below, except `b < 0`. 
The rows can be
+  // written to different files, so the filter is not pushed down, which would 
skip the first
+  // row's file by its statistics.
+  private def withNullConjunctTable(y: Int)(f: => Unit): Unit = {
+    withTable("t") {
+      sql("CREATE TABLE t USING parquet AS " +
+        s"SELECT * FROM VALUES (NULL, 1, $y, NULL, 'x'), (0, 1, 0, 1, '1') AS 
v(a, b, y, x, s)")
+      f
+    }
+  }
+
+  // A common subexpression after the NULL conjunct takes the CSE path of 
FilterExec.
+  private val nullConjunctCSEQuery =
+    "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND y + 1 > -1"
+
+  private def checkArithmeticOverflow(query: String): Unit = {
+    checkError(
+      exception = intercept[SparkArithmeticException] {
+        sql(query).collect()
+      },
+      condition = "ARITHMETIC_OVERFLOW",
+      parameters = Map(
+        "message" -> "overflow",
+        "alternative" -> " Use 'try_add' to tolerate overflow and return NULL 
instead.",
+        "config" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
+      context = ExpectedContext(
+        fragment = "y + 1",
+        start = query.indexOf("y + 1"),
+        stop = query.indexOf("y + 1") + 4))
+  }
+
+  // Runs `f` with whole-stage codegen on and off and with the legacy behavior 
on and off, and
+  // passes it whether the filter drops a row at its first NULL conjunct.
+  private def withNullConjunctConfs(otherConfs: (String, String)*)(f: Boolean 
=> Unit): Unit = {
+    for (codegen <- Seq(true, false); legacy <- Seq(true, false)) {
+      withSQLConf(otherConfs ++ Seq(
+          SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "false",
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+          SQLConf.LEGACY_FILTER_SHORT_CIRCUIT_ON_NULL_CONJUNCT.key -> 
legacy.toString): _*) {
+        withClue(s"wholeStage=$codegen, legacy=$legacy: ") {
+          f(codegen && legacy)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one") {
+    withNullConjunctTable(Int.MaxValue) {
+      // The CSE path evaluates the common `y + 1` once, the non-CSE path at 
each use. Both
+      // generate the flag that keeps the row's evaluation going after a NULL 
conjunct.
+      def cseQueryCode(filterExecCSE: Boolean): String = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = 
codegenString(sql(nullConjunctCSEQuery).queryExecution.executedPlan)
+          assert(code.contains("nullConjunct"))
+          code
+        }
+      }
+      assert("addExact".r.findAllIn(cseQueryCode(filterExecCSE = true)).length 
<
+        "addExact".r.findAllIn(cseQueryCode(filterExecCSE = false)).length)
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        Seq(
+          "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0",
+          nullConjunctCSEQuery
+        ).foreach { query =>
+          withNullConjunctConfs(
+              SQLConf.ANSI_ENABLED.key -> "true",
+              SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+            skipsAtNull =>
+              if (skipsAtNull) {
+                checkAnswer(sql(query), Row(0))
+              } else {
+                checkArithmeticOverflow(query)
+              }
+          }
+        }
+      }
+    }
+    // A NULL conjunct from OR. The second row does not pass it.
+    withNullConjunctTable(Int.MaxValue) {
+      val query = "SELECT a FROM t WHERE (a > 0 OR b < 0) AND y + 1 > 0"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Seq.empty)
+        } else {
+          checkArithmeticOverflow(query)
+        }
+      }
+    }
+    // Without an error, the CSE path drops the first row after its last 
conjunct is evaluated.
+    withNullConjunctTable(0) {
+      withNullConjunctConfs(
+          SQLConf.ANSI_ENABLED.key -> "true",
+          SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> "true") 
{ _ =>
+        checkAnswer(sql(nullConjunctCSEQuery), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one in 
non-ANSI mode") {
+    // Year-month interval arithmetic raises on overflow in every mode.
+    withNullConjunctTable(178956970) {
+      val condition = "NOT (a > 0 AND b > 0) AND " +
+        "make_ym_interval(y, 0) + INTERVAL '1' YEAR > INTERVAL '0' YEAR"
+      val query = s"SELECT a FROM t WHERE $condition"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "false") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Row(0))
+        } else {
+          checkError(
+            exception = intercept[SparkArithmeticException] {
+              sql(query).collect()
+            },
+            condition = "INTERVAL_ARITHMETIC_OVERFLOW.WITH_SUGGESTION",
+            parameters = Map("functionName" -> toSQLId("try_add")))
+        }
+        // A check on an expression, not on a column, that precedes the NULL 
conjunct:
+        // `cast(s AS INT)` is NULL on the first row, so the check is FALSE 
and drops the row
+        // before the overflowing conjunct, as with codegen off.
+        checkAnswer(
+          sql(s"SELECT a FROM t WHERE cast(s AS INT) IS NOT NULL AND 
$condition"), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter drops a row with a NULL conjunct and stops at a 
FALSE one") {
+    withNullConjunctTable(Int.MaxValue) {
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { _ =>
+        // The first row is dropped after its last conjunct is evaluated.
+        checkAnswer(sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y > 
-1"), Row(0))
+        // FALSE decides the AND, so the overflowing conjunct after it is not 
evaluated.
+        checkAnswer(
+          sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND b < 0 AND y + 1 
> 0"),
+          Seq.empty)
+      }
+      // `b > 0` cannot be NULL after the inferred `isnotnull(b)`, so no flag 
is generated.
+      val df = sql("SELECT a FROM t WHERE b > 0 AND y > -1")
+      checkAnswer(df, Seq(Row(null), Row(0)))
+      
assert(!codegenString(df.queryExecution.executedPlan).contains("nullConjunct"))

Review Comment:
   Nit: when the plan has no whole-stage codegen subtree, `codegenString` 
returns only "Found 0 WholeStageCodegen subtrees.", so this assertion would 
also pass if the filter were not in whole-stage codegen. The other `FilterExec` 
codegen tests here guard with 
`plan.exists(_.isInstanceOf[WholeStageCodegenExec])`, and `genCode` (L889) 
asserts it. Could you use 
`assert(genCode(df).forall(!_.body.contains("nullConjunct")))` instead?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -201,56 +316,64 @@ trait GeneratePredicateHelper extends PredicateHelper {
         if (idx != -1 && !generatedIsNotNullChecks(idx)) {
           generatedIsNotNullChecks(idx) = true
           // Use the child's output. The nullability is what the child 
produced.
-          genPredicate(notNullPreds(idx), inputExprCode, inputAttrs)
+          genNotNull(notNullPreds(idx))
         } else if (nonNullAttrExprIds.contains(r.exprId) && 
!extraIsNotNullAttrs.contains(r)) {

Review Comment:
   Nit: when `precedingNotNullChecks` has already generated the direct 
`IsNotNull(c)` for a column, this branch adds a synthetic `IsNotNull(c)` for 
the same column right before the conjunct that references it, e.g. for `c` in 
`(a = 1 OR b = 2) AND c = 5`. It is harmless at runtime, but it adds code to 
the per-row loop and goes against "not doing redundant IsNotNull checks" 
(L290). Master already does this for the second conjunct that references a 
column, e.g. `c <= hi` of a `BETWEEN`. Could you guard this branch with `idx == 
-1` (and `ni == -1` at L552 in the CSE branch)? A synthetic check is only 
needed for a column that only a check on an expression covers. If the generated 
code without a flag has to stay identical to master, `idx == -1 || 
!afterNullable` works too.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -373,29 +503,43 @@ case class FilterExec(condition: Expression, child: 
SparkPlan)
         // Emit an IsNotNull check, binding against child.output so the inner 
expression
         // is evaluated with its original nullability (the 
tightened-nullability `output`
         // is only appropriate inside otherPreds, where the required checks 
have fired).
-        // The `bound.nullable` gate below is defensive -- `IsNotNull` always 
produces a
-        // non-nullable Boolean, so this branch is structurally unreachable 
today; it
-        // mirrors the non-CSE helper's shape to keep future refactors safe.
+        // Passing `bound.nullable` to `conjunctCheck` is defensive -- 
`IsNotNull` always
+        // produces a non-nullable Boolean, so its NULL handling is 
unreachable here; the
+        // non-CSE helper generates its IsNotNull checks the same way.
         def genNotNull(pred: Expression): String = {
           val bound = BindReferences.bindReference(pred, child.output)
           val evaluated = evaluateRequiredVariables(child.output, input, 
pred.references)
           val ev = ExpressionCanonicalizer.execute(bound).genCode(ctx)
-          val nullCheck = if (bound.nullable) s"${ev.isNull} || " else ""
           s"""
              |$evaluated
              |${ev.code}
-             |if (${nullCheck}!${ev.value}) continue;
+             |${conjunctCheck(ev, bound.nullable, nullFlag)}
            """.stripMargin
         }
 
         val generatedIsNotNullChecks = new Array[Boolean](notNullPreds.length)
         val extraIsNotNullAttrs = mutable.Set[Attribute]()
+        var nullableSeen = false
 
         val predCode: String = {
           val parts = new StringBuilder
           ctx.withSubExprEliminationExprs(subExprs.states) {
             otherPreds.zip(boundOtherPreds).zipWithIndex.foreach {
               case ((orig, bound), idx) =>
+                val afterNullable = nullFlag.isDefined && nullableSeen
+                precedingNotNullChecks(
+                    notNullPreds, precedingNotNulls, idx, afterNullable,
+                    generatedIsNotNullChecks).foreach { p =>
+                  // The inner expression of a check is evaluated with no CSE 
state, as below.

Review Comment:
   Nit: this block (L534-540) is a copy of the one below (L566-572), and its 
only explanation is "as below"; why a check is generated without the CSE state 
is not written down in the main code. Could you move it into a local helper 
next to `genNotNull`, e.g. `genNotNullWithoutCSE`, with a short comment on the 
reason? The calls stay in the same order, so the generated code does not change.



##########
docs/sql-migration-guide.md:
##########
@@ -51,6 +51,7 @@ license: |
 - Since Spark 4.4, the value columns of `UNPIVOT` and `Dataset.unpivot` merge 
the nested nullability (array element, map value, and struct field nullability) 
of all unpivoted values. Previously, a value column took the nested nullability 
of the first listed value, so a `NULL` array element, map value, or struct 
field of a later value could be returned as a non-`NULL` value such as `0`, and 
`IS NULL` on it returned `false`. A view created by an earlier Spark version 
over such an `UNPIVOT` may have recorded the non-nullable nested type in its 
schema and can fail to read; recreate it with `CREATE OR REPLACE VIEW`. To 
restore the previous behavior, set 
`spark.sql.legacy.unpivot.useFirstValueNestedNullability` to `true`.
 - Since Spark 4.4, when `spark.sql.parquet.fieldId.read.enabled` is true, a 
Parquet read fails with `PARQUET_FIELD_REQUESTED_MORE_THAN_ONCE` if two or more 
requested fields of a struct (or of the top-level schema) resolve to the same 
Parquet field. This happens when one requested field is matched by field ID and 
another by name, or when two requested fields have the same field ID. In Spark 
4.3 and earlier, such a read returned wrong values without an error: with the 
vectorized reader one of the fields read as non-null default values (for 
example `0`) or uninitialized data, and with the parquet-mr reader 
(`spark.sql.parquet.enableVectorizedReader` set to `false`) one of the fields 
read as `null`. To read the Parquet field more than once, request it once and 
refer to the resulting column more than once in the query.
 - Since Spark 4.4, when `spark.sql.groupByOrdinal` is true (the default), the 
pipe `AGGREGATE` operator rejects an aliased integer literal in its `GROUP BY` 
clause, such as `TABLE t |> AGGREGATE sum(x) AS s GROUP BY 2 AS grp`, with 
`UNSUPPORTED_FEATURE.PIPE_OPERATOR_AGGREGATE_UNSUPPORTED_CASE`, because a bare 
integer literal in the same position is an ordinal. Previously the aliased 
literal was grouped as a constant. Bare ordinals and aliases on other grouping 
expressions are not affected. To restore the previous behavior, set 
`spark.sql.legacy.allowAliasedPipeAggregateOrdinals` to `true`.
+- Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates the remaining 
top-level conjuncts of the condition on a row after one of them evaluates to 
`NULL`, as the SQL standard requires, and does so in the order they appear in 
the condition, as Spark already did with whole-stage codegen disabled. Only a 
`FALSE` conjunct stops the evaluation. The one exception is an `IS NOT NULL` 
check on a column that a later conjunct references: it is still evaluated 
before that conjunct, even when it is written after it, and can drop the row 
before an error the conjunct raises. A row whose condition is `NULL` is still 
dropped, but an error raised by a later conjunct on that row, such as 
`ARITHMETIC_OVERFLOW` in ANSI mode, now fails the query. For example, `SELECT a 
FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0` can fail with 
`ARITHMETIC_OVERFLOW` on a row where `a` is `NULL`, `b` is `1`, and `y` is 
`2147483647`, un
 less the row is dropped earlier by a `FALSE` conjunct or `IS NOT NULL` check 
that precedes `y + 1 > 0` in the condition, or skipped before the filter, for 
example by data source filter pushdown such as Parquet row group statistics. 
Previously the query skipped such a row with whole-stage codegen enabled. The 
later conjuncts also run on more rows than before, so a nondeterministic 
conjunct such as `rand(seed)` can return different values on the remaining 
rows, and a side-effecting user-defined function is called on rows that are 
dropped. To restore the previous behavior, set 
`spark.sql.legacy.filterShortCircuitOnNullConjunct` to `true`.

Review Comment:
   Minor: "The one exception" is narrower than what the code does. On 
`withNullConjunctTable(Int.MaxValue)` with ANSI on and Parquet filter pushdown 
off:
   
   1. A check on an expression also makes its columns non-nullable, so a 
synthetic `isnotnull(x)` is generated before the first later conjunct that 
references `x` (`basicPhysicalOperators.scala:320-322`). `SELECT count(*) 
FILTER (WHERE NOT (a > 0 AND b > 0) AND y + coalesce(x, 1) > 0 AND -x IS NOT 
NULL) FROM t` returns 1 with whole-stage codegen on and raises 
`ARITHMETIC_OVERFLOW` with it off; an aggregate `FILTER` gets no inferred 
checks.
   2. The moved check also runs before the conjuncts between that conjunct and 
its own position, so it can drop the row before an error raised by a conjunct 
that does not reference the column. `WHERE NOT (a > 0 AND b > 0) AND (x > 0 OR 
b > 0) AND y + 1 > 0 AND x IS NOT NULL` drops the first row with codegen on, 
while codegen off evaluates the `OR` (TRUE) and fails at `y + 1 > 0`. The 
`precedingNotNullChecks` scaladoc ("unless the conjunct raises an error on the 
row first") has the same gap.
   3. The first conjunct that can be NULL keeps the nested hoisting, since 
`afterNullable` is false there. `WHERE NOT (a > 0 AND coalesce(b, cast(s AS 
INT)) > 0) AND b < 0 AND cast(s AS INT) IS NOT NULL` raises 
`CAST_INVALID_INPUT` on the first row, whose first conjunct is NULL, with 
codegen on, and returns no rows with codegen off.
   
   All three behave as on master, so this is only about the text. Could you 
describe the exception more broadly (a check on a column, or on an expression 
over it, runs before the first later conjunct that references the column and 
every conjunct up to its own position) and mention the first nullable conjunct, 
or say "One exception"? The "Without a NULL conjunct" of the SPARK-60377 bullet 
in the PR description could cover case 3 too.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -2814,4 +2815,274 @@ class WholeStageCodegenSuite extends SharedSparkSession
       }
     }
   }
+
+  // A Parquet table, so that the filter is not evaluated by the optimizer on 
a local relation.
+  // On the first row `NOT (a > 0 AND b > 0)` is NULL, and no `isnotnull(a)` 
is inferred from it.
+  // The second row passes every condition of the tests below, except `b < 0`. 
The rows can be
+  // written to different files, so the filter is not pushed down, which would 
skip the first
+  // row's file by its statistics.
+  private def withNullConjunctTable(y: Int)(f: => Unit): Unit = {
+    withTable("t") {
+      sql("CREATE TABLE t USING parquet AS " +
+        s"SELECT * FROM VALUES (NULL, 1, $y, NULL, 'x'), (0, 1, 0, 1, '1') AS 
v(a, b, y, x, s)")
+      f
+    }
+  }
+
+  // A common subexpression after the NULL conjunct takes the CSE path of 
FilterExec.
+  private val nullConjunctCSEQuery =
+    "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND y + 1 > -1"
+
+  private def checkArithmeticOverflow(query: String): Unit = {
+    checkError(
+      exception = intercept[SparkArithmeticException] {
+        sql(query).collect()
+      },
+      condition = "ARITHMETIC_OVERFLOW",
+      parameters = Map(
+        "message" -> "overflow",
+        "alternative" -> " Use 'try_add' to tolerate overflow and return NULL 
instead.",
+        "config" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
+      context = ExpectedContext(
+        fragment = "y + 1",
+        start = query.indexOf("y + 1"),
+        stop = query.indexOf("y + 1") + 4))
+  }
+
+  // Runs `f` with whole-stage codegen on and off and with the legacy behavior 
on and off, and
+  // passes it whether the filter drops a row at its first NULL conjunct.
+  private def withNullConjunctConfs(otherConfs: (String, String)*)(f: Boolean 
=> Unit): Unit = {
+    for (codegen <- Seq(true, false); legacy <- Seq(true, false)) {
+      withSQLConf(otherConfs ++ Seq(
+          SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "false",
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+          SQLConf.LEGACY_FILTER_SHORT_CIRCUIT_ON_NULL_CONJUNCT.key -> 
legacy.toString): _*) {
+        withClue(s"wholeStage=$codegen, legacy=$legacy: ") {
+          f(codegen && legacy)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one") {
+    withNullConjunctTable(Int.MaxValue) {
+      // The CSE path evaluates the common `y + 1` once, the non-CSE path at 
each use. Both
+      // generate the flag that keeps the row's evaluation going after a NULL 
conjunct.
+      def cseQueryCode(filterExecCSE: Boolean): String = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = 
codegenString(sql(nullConjunctCSEQuery).queryExecution.executedPlan)
+          assert(code.contains("nullConjunct"))
+          code
+        }
+      }
+      assert("addExact".r.findAllIn(cseQueryCode(filterExecCSE = true)).length 
<
+        "addExact".r.findAllIn(cseQueryCode(filterExecCSE = false)).length)
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        Seq(
+          "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0",
+          nullConjunctCSEQuery
+        ).foreach { query =>
+          withNullConjunctConfs(
+              SQLConf.ANSI_ENABLED.key -> "true",
+              SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+            skipsAtNull =>
+              if (skipsAtNull) {
+                checkAnswer(sql(query), Row(0))
+              } else {
+                checkArithmeticOverflow(query)
+              }
+          }
+        }
+      }
+    }
+    // A NULL conjunct from OR. The second row does not pass it.
+    withNullConjunctTable(Int.MaxValue) {
+      val query = "SELECT a FROM t WHERE (a > 0 OR b < 0) AND y + 1 > 0"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Seq.empty)
+        } else {
+          checkArithmeticOverflow(query)
+        }
+      }
+    }
+    // Without an error, the CSE path drops the first row after its last 
conjunct is evaluated.
+    withNullConjunctTable(0) {
+      withNullConjunctConfs(
+          SQLConf.ANSI_ENABLED.key -> "true",
+          SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> "true") 
{ _ =>
+        checkAnswer(sql(nullConjunctCSEQuery), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one in 
non-ANSI mode") {
+    // Year-month interval arithmetic raises on overflow in every mode.
+    withNullConjunctTable(178956970) {
+      val condition = "NOT (a > 0 AND b > 0) AND " +
+        "make_ym_interval(y, 0) + INTERVAL '1' YEAR > INTERVAL '0' YEAR"
+      val query = s"SELECT a FROM t WHERE $condition"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "false") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Row(0))
+        } else {
+          checkError(
+            exception = intercept[SparkArithmeticException] {
+              sql(query).collect()
+            },
+            condition = "INTERVAL_ARITHMETIC_OVERFLOW.WITH_SUGGESTION",
+            parameters = Map("functionName" -> toSQLId("try_add")))
+        }
+        // A check on an expression, not on a column, that precedes the NULL 
conjunct:
+        // `cast(s AS INT)` is NULL on the first row, so the check is FALSE 
and drops the row
+        // before the overflowing conjunct, as with codegen off.
+        checkAnswer(
+          sql(s"SELECT a FROM t WHERE cast(s AS INT) IS NOT NULL AND 
$condition"), Row(0))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter drops a row with a NULL conjunct and stops at a 
FALSE one") {
+    withNullConjunctTable(Int.MaxValue) {
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { _ =>
+        // The first row is dropped after its last conjunct is evaluated.
+        checkAnswer(sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y > 
-1"), Row(0))
+        // FALSE decides the AND, so the overflowing conjunct after it is not 
evaluated.
+        checkAnswer(
+          sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND b < 0 AND y + 1 
> 0"),
+          Seq.empty)
+      }
+      // `b > 0` cannot be NULL after the inferred `isnotnull(b)`, so no flag 
is generated.
+      val df = sql("SELECT a FROM t WHERE b > 0 AND y > -1")
+      checkAnswer(df, Seq(Row(null), Row(0)))
+      
assert(!codegenString(df.queryExecution.executedPlan).contains("nullConjunct"))
+    }
+  }
+
+  test("SPARK-60315: a filter checks IS NOT NULL before the conjuncts it 
precedes") {
+    withNullConjunctTable(Int.MaxValue) {
+      // `x` is NULL on the first row. With codegen off, a FALSE 
`isnotnull(x)` drops the row
+      // before a conjunct after it in the condition is evaluated. With 
codegen on, the check is
+      // generated just before the first conjunct that references `x`, or 
after all of them, so
+      // after a NULL conjunct it has to be generated before the conjuncts it 
precedes.
+      //
+      // The common `b * 2` takes the CSE path of FilterExec. The overflowing 
`y + 1` is not
+      // common because `GeneratePredicate` (codegen off) evaluates common 
subexpressions first.
+      val cseQuery = "SELECT a FROM t WHERE x IS NOT NULL AND NOT (a > 0 AND b 
> 0) AND " +
+        "y + 1 > 0 AND b * 2 > 0 AND b * 2 > -1"
+      def multiplyExactCount(filterExecCSE: Boolean): Int = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = codegenString(sql(cseQuery).queryExecution.executedPlan)
+          "multiplyExact".r.findAllIn(code).length
+        }
+      }
+      assert(multiplyExactCount(filterExecCSE = true) < 
multiplyExactCount(filterExecCSE = false))
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        withNullConjunctConfs(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+          skipsAtNull =>
+            // `isnotnull(x)` is inferred from `x > 0` and precedes the 
overflowing conjunct.
+            checkAnswer(
+              sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 
AND x > 0"),
+              Row(0))
+            // A user-written check that no other conjunct references.
+            checkAnswer(
+              sql("SELECT a FROM t WHERE x IS NOT NULL AND NOT (a > 0 AND b > 
0) AND y + 1 > 0"),
+              Row(0))
+            // The same with a common subexpression.
+            checkAnswer(sql(cseQuery), Row(0))
+            // A check written after the overflowing conjunct stays after it.
+            val checkAfter =
+              "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND x 
IS NOT NULL"
+            if (skipsAtNull) {
+              checkAnswer(sql(checkAfter), Row(0))
+            } else {
+              checkArithmeticOverflow(checkAfter)
+            }
+        }
+      }
+      // The aggregate FILTER clause goes through the same code generation.
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { _ =>
+        checkAnswer(
+          sql("SELECT count(1) FILTER (WHERE x IS NOT NULL AND NOT (a > 0 AND 
b > 0) AND " +
+            "y + 1 > 0) FROM t"),
+          Row(1))
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter checks IS NOT NULL on an expression after the 
conjunct it follows") {
+    withNullConjunctTable(Int.MaxValue) {
+      // `cast(s AS INT)` raises on the first row. With codegen on, a check on 
an expression is
+      // generated just before the first conjunct that contains the expression 
(SPARK-51356),
+      // even when it is written after that conjunct. After a NULL conjunct, 
it has to stay after
+      // the conjunct, as with codegen off: `coalesce(b, cast(s AS INT)) < 0` 
is FALSE on the row
+      // and drops it before the check raises, while `coalesce(b, cast(s AS 
INT)) < 2` is TRUE and
+      // the check raises after it. The second row passes the latter condition.
+      //
+      // The common `b * 2` takes the CSE path of FilterExec.
+      def query(conjunct: String, cse: Boolean): String = {
+        val common = if (cse) "b * 2 > 0 AND b * 2 > -1 AND " else ""
+        s"SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND $common$conjunct AND 
" +
+          "cast(s AS INT) IS NOT NULL"
+      }
+      def multiplyExactCount(filterExecCSE: Boolean): Int = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val plan = sql(query("coalesce(b, cast(s AS INT)) < 0", cse = 
true)).queryExecution
+          "multiplyExact".r.findAllIn(codegenString(plan.executedPlan)).length
+        }
+      }
+      assert(multiplyExactCount(filterExecCSE = true) < 
multiplyExactCount(filterExecCSE = false))
+
+      Seq(true, false).foreach { cse =>
+        withNullConjunctConfs(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
cse.toString) {
+          skipsAtNull =>
+            checkAnswer(sql(query("coalesce(b, cast(s AS INT)) < 0", cse)), 
Seq.empty)
+            val raising = query("coalesce(b, cast(s AS INT)) < 2", cse)
+            if (skipsAtNull) {
+              checkAnswer(sql(raising), Row(0))
+            } else {
+              checkError(
+                exception = intercept[SparkNumberFormatException] {
+                  sql(raising).collect()
+                },
+                condition = "CAST_INVALID_INPUT",
+                parameters = Map(
+                  "expression" -> "'x'",
+                  "sourceType" -> "\"STRING\"",
+                  "targetType" -> "\"INT\"",
+                  "ansiConfig" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
+                context = ExpectedContext(
+                  fragment = "cast(s AS INT)",
+                  start = raising.lastIndexOf("cast(s AS INT)"),
+                  stop = raising.lastIndexOf("cast(s AS INT)") + 13))
+            }
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: an aggregate FILTER evaluates the conjuncts after a NULL 
one") {
+    withNullConjunctTable(Int.MaxValue) {
+      val query = "SELECT count(a) FILTER (WHERE NOT (a > 0 AND b > 0) AND y + 
1 > 0) FROM t"

Review Comment:
   Minor (pre-existing, not from this PR): the aggregate `FILTER` clause also 
differs between codegen on and off in the opposite direction. 
`AggregateCodegenSupport.scala:237-238` eliminates the common subexpressions of 
the update expressions without their `FILTER` and evaluates them before the `do 
{ ... } while (false)` of each `FILTER` (L270-272 and L297-305 there; the 
with-keys paths of `HashAggregateExec` do the same), while codegen off 
evaluates `If(filter, updateExpr, buffer)` 
(`AggregationIterator.scala:166-169`). So with ANSI on, `SELECT avg(y + 1) 
FILTER (WHERE b < 0) FROM t` on this table raises `ARITHMETIC_OVERFLOW` with 
whole-stage codegen on, because `Average` uses `y + 1` twice, and returns NULL 
with it off, on master too. `spark.sql.subexpressionElimination.enabled=false` 
does not turn this off. The tests here use `count(a)` and `count(1)`, which 
have no common subexpression. Could you add it to "Not fixed here" or file a 
JIRA?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -186,13 +290,24 @@ trait GeneratePredicateHelper extends PredicateHelper {
     // This has the property of not doing redundant IsNotNull checks and 
taking better advantage of
     // short-circuiting, not loading attributes until they are needed.
     // This is very perf sensitive.
+    // With the NULL-conjunct flag, once a predicate that can be NULL is 
generated, the IsNotNull
+    // checks that precede a later predicate in the condition are generated 
before it, see
+    // `precedingNotNullChecks`, and a check on an expression that is written 
after the predicate
+    // is not generated before it, see the nested checks below.
     // NOTE: `FilterExec.doConsume`'s CSE branch inlines the same interleaving 
so it can

Review Comment:
   Nit (follow-up): as cloud-fan noted in the first review, this ordering is 
mirrored by hand in the CSE branch of `FilterExec.doConsume`, and this PR adds 
two rules to both copies (the preceding checks at L305-308 and L529-541, and 
the nested skip at L334 and L560). SPARK-60329 and SPARK-60377 will each need 
the same pair of edits, and a mismatch shows only for filters with a non-cheap 
common subexpression, which is why three of the new tests add `y + 1 > -1` or 
`b * 2 > 0 AND b * 2 > -1` and count `addExact` or `multiplyExact`. Earlier 
CSE-only bugs came from the same split, e.g. the one fixed by a4399f0c6e7, 
which added this NOTE. Could a follow-up compute the order once, as a list of 
checks and conjuncts that both emitters consume, ideally before SPARK-60329 and 
SPARK-60377?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -2814,4 +2815,274 @@ class WholeStageCodegenSuite extends SharedSparkSession
       }
     }
   }
+
+  // A Parquet table, so that the filter is not evaluated by the optimizer on 
a local relation.
+  // On the first row `NOT (a > 0 AND b > 0)` is NULL, and no `isnotnull(a)` 
is inferred from it.
+  // The second row passes every condition of the tests below, except `b < 0`. 
The rows can be

Review Comment:
   Nit: the second row also fails `(a > 0 OR b < 0)` (L2903) and `coalesce(b, 
cast(s AS INT)) < 0` (L3051), not only `b < 0`. Could you list them, or say 
"unless a test says otherwise"?



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