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:
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
Acceptance criteria
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
Related PR
#25348
Problem
If the outer side of
NOT IN (subquery)is an expression that can produceNULL, but every column it references is declared
NOT NULL, the decorrelatedjoin is not null-aware. Rows where the expression is NULL are then returned
instead of being filtered out as UNKNOWN.
Tested with a
datafusion-clibuilt frommainat3b16a3d0ee:For every row below the outer expression evaluates to NULL.
SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id FROM i)1, 2❌SELECT x FROM o WHERE NULLIF(x, x) NOT IN (SELECT id FROM i)1, 2❌SELECT x FROM o WHERE x + CAST(NULL AS INT) NOT IN (SELECT id FROM i)1, 2❌SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id FROM i) OR x = 991, 2❌CASEquery overon2(nullablex)CASEquery against emptyie1, 21, 2✅SELECT x FROM o WHERE x NOT IN (SELECT CASE WHEN id > 100 THEN id END FROM i)The only difference between the failing
CASEquery and theon2control isthe 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_Nalias, so theirnullability reaches the schema as a column.
Root cause
join_keys_may_be_nullindatafusion/optimizer/src/decorrelate_predicate_subquery.rs(added in #19635)decides whether
NOT INneeds null-aware semantics by collecting thecolumns 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
CASEwithoutELSE,NULLIF, arithmetic with a NULL operand, and scalar functions that canreturn NULL. For all of them, the helper returns
falseand:LeftAntipath (line ~639): a plain anti join is built. The physical plan isHashJoinExec: join_type=RightAnti, on=[(i.id, CASE WHEN ... END)]withoutnull_aware, so an outer row with a NULL key finds no match and is emitted.LeftMarkpath (line ~603): the mark join is not null-aware, so the mark isfalseinstead ofNULL, andNOT 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,NULLIFandCOALESCE-like expressions overNOT NULLcolumns are common inNOT INpredicates, and schemas from Parquet or
CREATE TABLE ... NOT NULLoftendeclare columns non-nullable.
Invariant / desired behavior
For
NOT IN (subquery), the decision to use null-aware semantics must bebased 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_nullwith expression-levelnullability of the
INequality operands:a = bconjunct produced from theINpredicate, evaluatea.nullable(left_schema)andb.nullable(right_schema), choosing theschema each operand resolves against.
schemas), fall back to
true. Treating a key as nullable costs a null-awarejoin; treating it as non-nullable gives wrong results.
This is the earliest boundary where the bad decision is made, and both the
LeftAntiandLeftMarkgates call it. It should also cover #25473,because
Int32(NULL).nullable(..)istrue.Risks to check while implementing:
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.NOT INwitha 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.
EXPLAINSLTs and benchmark queries(TPC-H Q16 uses
NOT IN) where operands become nullable-by-expression.Scope
In
join_keys_may_be_nulland its callers inbuild_join(theLeftAntiandLeftMarknull-aware gates).NOT INwith a nullable outer expression overNOT NULLcolumns.
Out
NOT IN (subquery)returns every row when the subquery column isNOT NULL#25473). It is fixedhere only if this change naturally covers it.
NOT EXISTS, which uses two-valued logic and is correct as is.Acceptance criteria
1, 2).EXPLAINfor theCASEquery onoshows anull_awareanti join,matching the
on2plan shape.NOT INoperands are provably non-nullable expressions(for example
x + 1overNOT NULL x) still plan a non-null-aware join.EXPLAINoutput or theTPC-H plans.
Tests / verification
datafusion/sqllogictest/test_files/null_aware_anti_join.slt:CASEwithout
ELSE,NULLIF(x, x)andx + NULLover aNOT NULLcolumn, eachcompared with the same query over a nullable column, plus an empty-subquery
control and an
EXPLAINassertion.datafusion/sqllogictest/test_files/null_aware_mark_join.slt: the... NOT IN (...) OR x = 99form.decorrelate_predicate_subquery.rs: null-aware for anullable expression over a non-nullable column, and not null-aware for
x + 1over non-nullablex.cargo test -p datafusion-optimizer,cargo test -p datafusion-sqllogictest --test sqllogictests, which includes the TPC-Hplan SLTs under
test_files/tpch.Related
join_keys_may_be_nulland null-aware anti joins.NOT IN (subquery)returns every row when the subquery column isNOT NULL#25473: the same helper misses nullable constants. A fix here likelycovers it.