MaxGekk opened a new pull request, #59573:
URL: https://github.com/apache/spark/pull/59573

   ### What changes were proposed in this pull request?
   
   Whole-stage codegen of a filter condition (`FilterExec`) and of the `FILTER` 
clause of an aggregate function (`AggregateCodegenSupport`) generates each 
top-level conjunct separately and drops the row as soon as one of them is FALSE 
**or NULL** (`if (isNull || !value) continue;`). The interpreted path evaluates 
the whole condition with `And.eval`, which stops only at FALSE. So when an 
earlier conjunct is NULL and a later one raises on that row, the query fails 
with codegen off and returns rows with it on.
   
   This PR changes the generated code so that only a FALSE conjunct stops the 
evaluation:
   
   - `GeneratePredicateHelper` gets three helpers: `nullConjunctFlag` (a fresh 
flag name, or `None`), `conjunctCheck` (the per-conjunct check) and 
`withNullConjunctFlag` (declares the flag before the checks and drops the row 
after them if it is set).
   - A NULL conjunct sets the flag, and the remaining conjuncts are still 
evaluated: `if (isNull) { flag = true; } else if (!value) continue;`. The row 
is dropped after all of them: `if (flag) continue;`.
   - The flag is generated only when some conjunct can be NULL after binding. 
The `IsNotNull` checks are never NULL and keep their early `continue`, so the 
tightened-nullability binding of the later conjuncts, and of the CSE 
precomputes, stays safe.
   - Both code paths are changed: `generatePredicateCode`, which the aggregate 
`FILTER` clause also uses, and the CSE branch of `FilterExec.doConsume`, which 
the code comments require to stay in step with it.
   - New legacy config `spark.sql.legacy.filterShortCircuitOnNullConjunct` 
(internal, default `false`, version 4.4.0). Set to `true`, it restores the 
previous codegen behavior. The interpreted path is unchanged in both modes.
   
   Join conditions and `MergeRowsExec` generate the whole condition through 
`And.doGenCode`, which already evaluates the right side after a NULL left side, 
so they are not changed.
   
   #### SQL standard
   
   - 7.11 `<where clause>`, GR 2 (and 10.9 `<aggregate function>`, GR 4a, for 
`<filter clause>`): the search condition is effectively evaluated for each row, 
and the result keeps the rows for which it is True.
   - 6.35 `<boolean value expression>`, Table 11: Unknown AND True is Unknown, 
Unknown AND False is False. After an Unknown conjunct the result of the search 
condition still depends on the remaining ones.
   - 9075-1 "Rule evaluation order" (SQL-92 3.3.4.4): only the *inessential* 
parts, those not needed to determine the result of the expression or search 
condition, may be skipped, and whether their exceptions are raised is 
implementation-dependent. After a NULL conjunct the rest is essential. The 
order of evaluation is implementation-dependent too, but in every order the 
raising conjunct is either evaluated first or is still essential, so the 
exception is raised. Only a FALSE conjunct makes the rest inessential.
   
   #### Other DBMSes
   
   Table `t(a INT, b INT, y)` with rows `(NULL, 1, <raises>)` and `(0, 1, 
<ok>)`. `<E>` raises on the first row:
   - `y + 1 > 0` with `y` at the maximum of its type: `INT`, or `BIGINT` on 
MySQL and MariaDB, which widen `INT + INT`;
   - `10 / y > 0` with `y = 0` on Oracle, whose `NUMBER` does not overflow at 
32 bits;
   - `abs(y) > 0` with `y` at the minimum `BIGINT` on SQLite, where `+` 
switches to floating point.
   
   The images and credentials are those of `connector/docker-integration-tests`.
   
   | Query | PostgreSQL 18.2 | MySQL 9.6.0 | MariaDB 12.2.2 | SQL Server 2022 | 
Oracle Free 23.26 | DB2 11.5.9 | H2 2.3.232 | Derby 10.16 | SQLite 3.46 |
   |---|---|---|---|---|---|---|---|---|---|
   | `WHERE NOT (a > 0 AND b > 0) AND <E>` | skip | skip | skip | **raise** | 
skip | **raise** | **raise** | skip | skip |
   | `WHERE <E> AND NOT (a > 0 AND b > 0)` | raise | raise | raise | raise | 
**skip** | raise | raise | **skip** | raise |
   | `WHERE a > 0 AND <E>` | skip | skip | skip | skip | skip | **raise** | 
**raise** | skip | skip |
   | `WHERE b < 0 AND <E>` (FALSE first) | - | - | - | - | - | raise | - | - | 
- |
   | `count(a) FILTER (WHERE NOT (a > 0 AND b > 0) AND <E>)` | **raise** | n/a 
| n/a | n/a | **raise** | n/a | **raise** | n/a | skip |
   
   "-" means no error and an empty result; "n/a" means the `FILTER` clause is 
not supported.
   
   - In WHERE, most engines drop the row at the NULL conjunct. Oracle and Derby 
do it even when the raising conjunct is written first, so they reorder.
   - DB2 and H2 raise. SQL Server raises for the JIRA's shape but not for a 
plain `a > 0` first.
   - For the aggregate `FILTER` clause, PostgreSQL, Oracle and H2 evaluate the 
whole `AND` and raise, as this PR does.
   
   We considered the opposite fix: make the interpreted path stop at the first 
NULL conjunct too. It matches most engines' WHERE behavior, but it departs from 
the standard's text and from what Spark already does with codegen off. That 
path is kept available through the legacy config.
   
   ### Why are the changes needed?
   
   The same query must not raise with whole-stage codegen off and return rows 
with it on (SPARK-60315). Reproducer (ANSI mode):
   
   ```sql
   CREATE TABLE t USING parquet AS
     SELECT * FROM VALUES (NULL, 1, 2147483647), (0, 1, 0) AS v(a, b, y);
   
   SET spark.sql.codegen.wholeStage=true;
   SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0;
   -- before: 0
   -- after:  [ARITHMETIC_OVERFLOW] overflow. ... SQLSTATE: 22003
   
   SET spark.sql.codegen.wholeStage=false;
   SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0;
   -- before and after: [ARITHMETIC_OVERFLOW] overflow. ... SQLSTATE: 22003
   ```
   
   The same happens in non-ANSI mode with year-month interval arithmetic, and 
with the aggregate `FILTER` clause.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes. With whole-stage codegen on, an error raised by a later conjunct on a 
row where an earlier conjunct is NULL now fails the query, as it already did 
with codegen off. A row can still be skipped before the filter, for example by 
Parquet row-group statistics, and then no error is raised.
   
   The later conjuncts also run on more rows than before:
   - a nondeterministic conjunct such as `rand(seed)` can return different 
values on the remaining rows;
   - a side-effecting UDF is called on rows that are then dropped;
   - an expensive later conjunct costs more on rows where an earlier, nullable 
conjunct is NULL.
   
   Rows whose earlier conjunct is FALSE are not affected. Setting 
`spark.sql.legacy.filterShortCircuitOnNullConjunct=true` restores the previous 
behavior. The migration guide has an entry for this.
   
   ### How was this patch tested?
   
   Four new tests in `WholeStageCodegenSuite`. Each runs with whole-stage 
codegen on and off, and with the legacy config on and off; it expects the row 
to be skipped only with codegen on and the legacy config set, and the error in 
every other combination:
   
   - the ANSI reproducer, and a variant with a common `y + 1` that takes the 
CSE path of `FilterExec` (checked from the generated code, with 
`subexpressionElimination.filterExec.enabled` on and off);
   - the non-ANSI year-month interval reproducer 
(`INTERVAL_ARITHMETIC_OVERFLOW`);
   - a NULL conjunct followed by a FALSE one, which must not raise; it also 
checks that no flag is generated when no conjunct can be NULL;
   - the aggregate `FILTER` clause.
   
   The tests use a Parquet table with filter pushdown off, so the filter is 
neither evaluated on a local relation by the optimizer nor skipped by row-group 
statistics.
   
   Also ran `WholeStageCodegenSuite`, `SQLQuerySuite`, `DataFrameSuite`, 
`DataFrameAggregateSuite` and `SQLQueryTestSuite` locally. The only failure is 
`udtf/udtf.sql - Python UDTFs`, whose golden output differs from the local 
Python's `ValueError` message (`'input' is not in list` vs `list.index(x): x 
not in list`), which is unrelated to this change.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Claude Opus 5.5)
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


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