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

   # Related PR
   #25348
   
   ## Problem
   
   If the outer value of `NOT IN (subquery)` is a constant that is, or can be,
   NULL, and the subquery column is declared `NOT NULL`, DataFusion returns 
every
   outer row. SQL three-valued logic says `NULL NOT IN (non-empty set)` is
   UNKNOWN, so a `WHERE` clause must return no rows.
   
   Tested with a `datafusion-cli` built from `main` at `3b16a3d0ee`:
   
   ```sql
   CREATE TABLE o(x INT NOT NULL) AS VALUES (1), (2);
   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;
   ```
   
   | Query (`SELECT x FROM o WHERE ...`)                          | Expected    
       | Actual   |
   | ------------------------------------------------------------ | 
------------------ | -------- |
   | `CAST(NULL AS INT) NOT IN (SELECT id FROM i)`                | no rows     
       | `1, 2` ❌ |
   | `NULL NOT IN (SELECT id FROM i)`                             | no rows     
       | `1, 2` ❌ |
   | `NULLIF(1, 1) NOT IN (SELECT id FROM i)`                     | no rows     
       | `1, 2` ❌ |
   | `CAST(NULL AS INT) NOT IN (SELECT id FROM i) OR x = 99`      | no rows     
       | `1, 2` ❌ |
   | `NOT (CAST(NULL AS INT) NOT IN (SELECT id FROM i))`          | no rows     
       | no rows ✅ |
   | `CAST(NULL AS INT) NOT IN (SELECT id FROM ie)` (empty set)   | `1, 2`      
       | `1, 2` ✅ |
   | `5 NOT IN (SELECT id FROM i)`                                | `1, 2`      
       | `1, 2` ✅ |
   
   The nullability of the outer table doesn't matter. The result is also wrong
   when `x` is nullable, because only `i.id` appears in the join filter.
   
   In the `SELECT` list, `CAST(NULL AS INT) NOT IN (SELECT id FROM i) AS m`
   returns `NULL` correctly. That form goes through the `rewrite_set_comparison`
   path and is not affected.
   
   ## Root cause
   
   This is a gap in the fix for #25340 (PR #25348, `edc936f38b`). It is not a
   regression from that PR: the constant case was wrong in general before it.
   
   In `build_join` 
(`datafusion/optimizer/src/decorrelate_predicate_subquery.rs`),
   the constant path projects the outer value as a column only when
   `join_keys_may_be_null(&join_filter, ...)` returns true (line ~527). That
   helper collects the **columns** in the join filter and checks their schema
   nullability. For `Int32(NULL) = __correlated_sq_1.id` the only column is
   `__correlated_sq_1.id`, which is `NOT NULL`, so the helper returns `false`,
   even though the constant itself is NULL.
   
   The same helper then gates `null_aware` on both the `LeftAnti` path
   (line ~639) and the `LeftMark` path (line ~603). So:
   
   1. The constant is not projected, and the join is not null-aware.
   2. `push_down_filter` moves the right-only filter `Int32(NULL) = i.id` into 
the
      subquery, and `simplify_expressions` folds it to `Boolean(NULL)`.
   3. The subquery becomes empty. For `LeftAnti`, every outer row survives. For
      `LeftMark` (the `... OR x = 99` form), `EXPLAIN` shows the right side as
      `EmptyRelation`, so the mark is `false` instead of `NULL`, and
      `NOT mark` is `true`.
   
   `EXPLAIN VERBOSE` shows the anti-join steps:
   
   ```text
   decorrelate_predicate_subquery:  LeftAnti Join:  Filter: Int32(NULL) = 
__correlated_sq_1.id   (no null_aware)
   push_down_filter:                LeftAnti Join:  /  Filter: Int32(NULL) = 
i.id  (inside subquery)
   simplify_expressions:            Filter: Boolean(NULL)
   ```
   
   ## Why it matters
   
   Silent wrong results: rows that SQL requires to be filtered out are returned.
   Typed NULL constants are common in generated SQL, for example parameter
   placeholders bound to NULL, `CAST(NULL AS ...)` from ORMs, and `NULLIF` or
   `CASE` over literals.
   
   ## Invariant / desired behavior
   
   For `<value> NOT IN (<uncorrelated subquery>)` with a constant `<value>`:
   
   - Null-aware semantics (projecting the constant and setting `null_aware`) 
must
     apply whenever **either** side of the comparison can be NULL. That includes
     the constant expression itself, not only the columns it references.
   - A NULL outer value against a non-empty subquery yields UNKNOWN: no row in
     `WHERE`, `NULL` as a mark.
   - A NULL outer value against an empty subquery yields TRUE. This already 
works
     and must stay that way.
   
   ## Proposed direction
   
   *Unvalidated: not implemented or tested. Confirm with the tests below.*
   
   Decide nullability of the constant from the expression, not from its column
   references. At the constant-projection gate in `build_join`, also accept
   `value.nullable(left.schema())?`:
   
   ```rust
   && (value.nullable(left.schema())?
       || join_keys_may_be_null(&join_filter, left.schema(), 
sub_query_alias.schema())?)
   ```
   
   After projection, the new `__correlated_sq_N_value` column gets its
   nullability from `value`, so the existing column-based checks at the
   `LeftAnti` and `LeftMark` `null_aware` gates should then see a nullable 
column
   without further changes. Verify that assumption rather than trusting it.
   
   If someissue is fixed by making `join_keys_may_be_null` itself use
   expression nullability, that change should cover this issue too. In that 
case,
   land both regression suites with it and close both issues.
   
   ## Scope
   
   ### In
   
   - The uncorrelated constant `NOT IN` path in `build_join`, for `LeftAnti` and
     `LeftMark`.
   - Typed NULL, untyped NULL, and nullable scalar constant expressions such as
     `NULLIF(1, 1)`.
   
   ### Out
   
   - Non-constant outer expressions that are nullable over `NOT NULL` columns 
(see
     someissue).
   - Correlated `NOT IN`. The constant path deliberately excludes it because
     null-aware hash joins accept a single key.
   - Constant-folding `NULL NOT IN (subquery)` into an emptiness check. This 
may be
     worth doing as an optimization, but the fix above is simpler.
   
   ## Acceptance criteria
   
   - [ ] Every ❌ row in the table above returns the expected result.
   - [ ] Every ✅ row is unchanged.
   - [ ] `EXPLAIN` for `CAST(NULL AS INT) NOT IN (SELECT id FROM i)` shows a
         `null_aware` `LeftAnti` join on the projected value column, and no 
filter
         pushed into the subquery.
   - [ ] The results hold with `target_partitions = 1` and `> 1`.
   
   ## Tests / verification
   
   - SLT in `datafusion/sqllogictest/test_files/null_aware_anti_join.slt`: typed
     NULL, untyped NULL and `NULLIF(1, 1)` against a `NOT NULL` subquery column,
     with both nullable and `NOT NULL` outer tables. Include the 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, plus the `SELECT`-list form as a 
control.
   - Optimizer unit test in `decorrelate_predicate_subquery.rs` asserting
     `null_aware` for a NULL constant against a non-nullable subquery column.
   - `cargo test -p datafusion-optimizer` and
     `cargo test -p datafusion-sqllogictest --test sqllogictests -- null_aware`.
   
   ## Related
   
   - #25340 / #25348: the constant `NOT IN` fix this issue extends.
   


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