kosiew opened a new issue, #25474:
URL: https://github.com/apache/datafusion/issues/25474

   # Related PR
   #25348
   
   ## Problem
   
   If the outer side of `NOT IN (subquery)` is an expression that can produce
   NULL, but every column it references is declared `NOT NULL`, the decorrelated
   join is not null-aware. Rows where the expression is NULL are then returned
   instead of being filtered out as UNKNOWN.
   
   Tested with a `datafusion-cli` built from `main` at `3b16a3d0ee`:
   
   ```sql
   CREATE TABLE o(x INT NOT NULL) AS VALUES (1), (2);
   CREATE TABLE on2(x INT) AS VALUES (1), (2);           -- same data, nullable 
column
   CREATE TABLE i(id INT NOT NULL) AS VALUES (1), (3);
   CREATE TABLE ie(id INT NOT NULL) AS SELECT * FROM (VALUES (1)) WHERE false;
   ```
   
   For every row below the outer expression evaluates to NULL.
   
   | Query                                                                      
    | Expected | Actual    |
   | 
------------------------------------------------------------------------------ 
| -------- | --------- |
   | `SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id 
FROM i)` | no rows  | `1, 2` ❌ |
   | `SELECT x FROM o WHERE NULLIF(x, x) NOT IN (SELECT id FROM i)`             
      | no rows  | `1, 2` ❌ |
   | `SELECT x FROM o WHERE x + CAST(NULL AS INT) NOT IN (SELECT id FROM i)`    
      | no rows  | `1, 2` ❌ |
   | `SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id 
FROM i) OR x = 99` | no rows | `1, 2` ❌ |
   | same `CASE` query over `on2` (nullable `x`)                                
      | no rows  | no rows ✅ |
   | `CASE` query against empty `ie`                                            
      | `1, 2`   | `1, 2` ✅ |
   | `SELECT x FROM o WHERE x NOT IN (SELECT CASE WHEN id > 100 THEN id END 
FROM i)`  | no rows  | no rows ✅ |
   
   The only difference between the failing `CASE` query and the `on2` control is
   the declared nullability of `x`. The expression evaluates to NULL in both.
   
   The subquery side is already handled correctly (last row). Subquery
   expressions are projected into the `__correlated_sq_N` alias, so their
   nullability reaches the schema as a column.
   
   ## Root cause
   
   `join_keys_may_be_null` in
   `datafusion/optimizer/src/decorrelate_predicate_subquery.rs` (added in 
#19635)
   decides whether `NOT IN` needs null-aware semantics by collecting the
   **columns** in the join filter and checking each field's schema nullability.
   An outer expression is never projected into a column, so its own nullability 
is
   never considered. Only the nullability of the columns it references is.
   
   Expressions that are nullable over non-nullable inputs include `CASE` without
   `ELSE`, `NULLIF`, arithmetic with a NULL operand, and scalar functions that 
can
   return NULL. For all of them, the helper returns `false` and:
   
   - `LeftAnti` path (line ~639): a plain anti join is built. The physical plan 
is
     `HashJoinExec: join_type=RightAnti, on=[(i.id, CASE WHEN ... END)]` without
     `null_aware`, so an outer row with a NULL key finds no match and is 
emitted.
   - `LeftMark` path (line ~603): the mark join is not null-aware, so the mark 
is
     `false` instead of `NULL`, and `NOT mark OR ...` keeps the row.
   
   By code history this bug predates #25348. The helper and both gates come from
   #19635. It was not bisected against an older build.
   
   ## Why it matters
   
   Silent wrong results. The pattern is realistic: `CASE`, `NULLIF` and
   `COALESCE`-like expressions over `NOT NULL` columns are common in `NOT IN`
   predicates, and schemas from Parquet or `CREATE TABLE ... NOT NULL` often
   declare columns non-nullable.
   
   ## Invariant / desired behavior
   
   For `NOT IN (subquery)`, the decision to use null-aware semantics must be
   based on whether **each side of the comparison, as an expression**, can be
   NULL. The nullability of the columns an expression references is not enough.
   A nullable expression over non-nullable columns must get the same result as
   the same values in a nullable column.
   
   ## Proposed direction
   
   *Unvalidated: not implemented or tested. Confirm with the tests below.*
   
   Replace the column walk in `join_keys_may_be_null` with expression-level
   nullability of the `IN` equality operands:
   
   - For each `a = b` conjunct produced from the `IN` predicate, evaluate
     `a.nullable(left_schema)` and `b.nullable(right_schema)`, choosing the
     schema each operand resolves against.
   - If an operand's nullability can't be determined (for example, it mixes both
     schemas), fall back to `true`. Treating a key as nullable costs a 
null-aware
     join; treating it as non-nullable gives wrong results.
   
   This is the earliest boundary where the bad decision is made, and both the
   `LeftAnti` and `LeftMark` gates call it. It should also cover #25473,
   because `Int32(NULL).nullable(..)` is `true`.
   
   Risks to check while implementing:
   
   - More queries will take the null-aware path. Confirm the physical planner
     accepts a null-aware hash join whose left key is a non-column expression. A
     non-null-aware join with such a key already plans with a `ProjectionExec`.
   - Null-aware hash joins accept a single key. Check that correlated `NOT IN` 
with
     a nullable outer expression (an equality key plus a correlation key) either
     stays correct or is kept off the null-aware path, as it is today for
     nullable columns.
   - Watch for plan changes in existing `EXPLAIN` SLTs and benchmark queries
     (TPC-H Q16 uses `NOT IN`) where operands become nullable-by-expression.
   
   ## Scope
   
   ### In
   
   - `join_keys_may_be_null` and its callers in `build_join` (the `LeftAnti` and
     `LeftMark` null-aware gates).
   - Uncorrelated `NOT IN` with a nullable outer expression over `NOT NULL`
     columns.
   
   ### Out
   
   - The constant-projection gate for NULL constants (#25473). It is fixed
     here only if this change naturally covers it.
   - Adding null-aware support for multi-key or residual-filter joins.
   - `NOT EXISTS`, which uses two-valued logic and is correct as is.
   
   ## Acceptance criteria
   
   - [ ] Every ❌ row in the table above returns the expected result.
   - [ ] Every ✅ row is unchanged, including the empty-subquery case (`1, 2`).
   - [ ] `EXPLAIN` for the `CASE` query on `o` shows a `null_aware` anti join,
         matching the `on2` plan shape.
   - [ ] Queries whose `NOT IN` operands are provably non-nullable expressions
         (for example `x + 1` over `NOT NULL x`) still plan a non-null-aware 
join.
   - [ ] No unexplained plan changes in the existing SLT `EXPLAIN` output or the
         TPC-H plans.
   
   ## Tests / verification
   
   - SLT in `datafusion/sqllogictest/test_files/null_aware_anti_join.slt`: 
`CASE`
     without `ELSE`, `NULLIF(x, x)` and `x + NULL` over a `NOT NULL` column, 
each
     compared with the same query over a nullable column, plus an empty-subquery
     control and an `EXPLAIN` assertion.
   - SLT in `datafusion/sqllogictest/test_files/null_aware_mark_join.slt`: the
     `... NOT IN (...) OR x = 99` form.
   - Optimizer unit tests in `decorrelate_predicate_subquery.rs`: null-aware 
for a
     nullable expression over a non-nullable column, and not null-aware for
     `x + 1` over non-nullable `x`.
   - `cargo test -p datafusion-optimizer`,
     `cargo test -p datafusion-sqllogictest --test sqllogictests`, which 
includes the TPC-H
     plan SLTs under `test_files/tpch`.
   
   ## Related
   
   - #19635: introduced `join_keys_may_be_null` and null-aware anti joins.
   - #25473: the same helper misses nullable constants. A fix here likely
     covers it.
   


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