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:
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:
- The constant is not projected, and the join is not null-aware.
push_down_filter moves the right-only filter Int32(NULL) = i.id into the
subquery, and simplify_expressions folds it to Boolean(NULL).
- 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:
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())?:
&& (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 #25474 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
Acceptance criteria
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
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 everyouter row. SQL three-valued logic says
NULL NOT IN (non-empty set)isUNKNOWN, so a
WHEREclause must return no rows.Tested with a
datafusion-clibuilt frommainat3b16a3d0ee:SELECT x FROM o WHERE ...)CAST(NULL AS INT) NOT IN (SELECT id FROM i)1, 2❌NULL NOT IN (SELECT id FROM i)1, 2❌NULLIF(1, 1) NOT IN (SELECT id FROM i)1, 2❌CAST(NULL AS INT) NOT IN (SELECT id FROM i) OR x = 991, 2❌NOT (CAST(NULL AS INT) NOT IN (SELECT id FROM i))CAST(NULL AS INT) NOT IN (SELECT id FROM ie)(empty set)1, 21, 2✅5 NOT IN (SELECT id FROM i)1, 21, 2✅The nullability of the outer table doesn't matter. The result is also wrong
when
xis nullable, because onlyi.idappears in the join filter.In the
SELECTlist,CAST(NULL AS INT) NOT IN (SELECT id FROM i) AS mreturns
NULLcorrectly. That form goes through therewrite_set_comparisonpath and is not affected.
Root cause
This is a gap in the fix for #25340 (PR #25348,
edc936f38b). It is not aregression 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). Thathelper collects the columns in the join filter and checks their schema
nullability. For
Int32(NULL) = __correlated_sq_1.idthe only column is__correlated_sq_1.id, which isNOT NULL, so the helper returnsfalse,even though the constant itself is NULL.
The same helper then gates
null_awareon both theLeftAntipath(line ~639) and the
LeftMarkpath (line ~603). So:push_down_filtermoves the right-only filterInt32(NULL) = i.idinto thesubquery, and
simplify_expressionsfolds it toBoolean(NULL).LeftAnti, every outer row survives. ForLeftMark(the... OR x = 99form),EXPLAINshows the right side asEmptyRelation, so the mark isfalseinstead ofNULL, andNOT markistrue.EXPLAIN VERBOSEshows the anti-join steps: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, andNULLIForCASEover literals.Invariant / desired behavior
For
<value> NOT IN (<uncorrelated subquery>)with a constant<value>:null_aware) mustapply whenever either side of the comparison can be NULL. That includes
the constant expression itself, not only the columns it references.
WHERE,NULLas a mark.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 acceptvalue.nullable(left.schema())?:After projection, the new
__correlated_sq_N_valuecolumn gets itsnullability from
value, so the existing column-based checks at theLeftAntiandLeftMarknull_awaregates should then see a nullable columnwithout further changes. Verify that assumption rather than trusting it.
If #25474 is fixed by making
join_keys_may_be_nullitself useexpression nullability, that change should cover this issue too. In that case,
land both regression suites with it and close both issues.
Scope
In
NOT INpath inbuild_join, forLeftAntiandLeftMark.NULLIF(1, 1).Out
NOT NULLcolumns (seeWrong results:
NOT IN (subquery)ignores NULLs from a nullable outer expression overNOT NULLcolumns #25474).NOT IN. The constant path deliberately excludes it becausenull-aware hash joins accept a single key.
NULL NOT IN (subquery)into an emptiness check. This may beworth doing as an optimization, but the fix above is simpler.
Acceptance criteria
EXPLAINforCAST(NULL AS INT) NOT IN (SELECT id FROM i)shows anull_awareLeftAntijoin on the projected value column, and no filterpushed into the subquery.
target_partitions = 1and> 1.Tests / verification
datafusion/sqllogictest/test_files/null_aware_anti_join.slt: typedNULL, untyped NULL and
NULLIF(1, 1)against aNOT NULLsubquery column,with both nullable and
NOT NULLouter tables. Include the empty-subquerycontrol and an
EXPLAINassertion.datafusion/sqllogictest/test_files/null_aware_mark_join.slt: the... NOT IN (...) OR x = 99form, plus theSELECT-list form as a control.decorrelate_predicate_subquery.rsassertingnull_awarefor a NULL constant against a non-nullable subquery column.cargo test -p datafusion-optimizerandcargo test -p datafusion-sqllogictest --test sqllogictests -- null_aware.Related
NOT IN (subquery)with a constant value ignores NULLs in the subquery #25348: the constantNOT INfix this issue extends.