Josh Rosen created SPARK-58384:
----------------------------------

             Summary: OptimizeJoinCondition rewrites the null-safe-equality 
pattern under NOT, returning wrong rows
                 Key: SPARK-58384
                 URL: https://issues.apache.org/jira/browse/SPARK-58384
             Project: Spark
          Issue Type: Improvement
          Components: SQL
    Affects Versions: 4.0.0
            Reporter: Josh Rosen


This is a report of a correctness bug present in Spark 4.0.0 and later 
(introduced by SPARK-47810).

The {{OptimizeJoinCondition}} optimizer rule rewrites 
{{    (l = r) OR (l IS NULL AND r IS NULL)}} 
to 
         {{l <=> r}}
inside join conditions.

The two expressions agree on whether they evaluate to TRUE, but differ in 
three-valued logic: when exactly one side is NULL, the OR-pattern evaluates to 
NULL while {{l <=> r}} evaluates to FALSE.

At the top level of a join condition the difference is unobservable (filter 
conditions evaluating to NULL and FALSE both reject the row), but the rule 
applies the rewrite via an unrestricted {{transform}} over the entire filter 
condition tree, including under {{{}NOT{}}}, where the difference _is_ 
observable: {{NOT(NULL) = NULL}} (row rejected) vs {{NOT(FALSE) = TRUE}} (row 
accepted).

Writing {{P}} for the original pattern {{(l = r) OR (l IS NULL AND r IS NULL)}} 
(a join keeps a row only when its condition is TRUE; NULL and FALSE both 
reject):
||l||r||P||l <=> r||NOT(P)||NOT(l <=> r)||row kept under NOT?||
|0|0|TRUE|TRUE|FALSE|FALSE|no / no|
|0|1|FALSE|FALSE|TRUE|TRUE|yes / yes|
|0|NULL|*NULL*|*FALSE*|*NULL*|*TRUE*|*no / yes – WRONG*|
|NULL|NULL|TRUE|TRUE|FALSE|FALSE|no / no|
h3. Repro
{code:java}
CREATE TABLE tl(a INT, b INT) USING parquet;
INSERT INTO tl VALUES (0, 10), (NULL, 11);

CREATE TABLE tr(x INT, y INT) USING parquet;
INSERT INTO tr VALUES (NULL, 20), (0, 21), (1, 22);

SELECT * FROM tl JOIN tr ON NOT (tl.a = tr.x OR (tl.a IS NULL AND tr.x IS 
NULL));
-- outputs: [0,10,null,20], [null,11,0,21], [null,11,1,22], [0,10,1,22]
-- WRONG (expected: only [0,10,1,22]) {code}
Every pair with exactly one NULL side must be excluded, but the join keeps 
those pairs because the condition was rewritten:
{code:java}
== Analyzed Logical Plan ==
Join Inner, NOT ((a#20 = x#22) OR (isnull(a#20) AND isnull(x#22)))
== Optimized Logical Plan ==
Join Inner, NOT (a#20 <=> x#22) {code}
h3. Fix

To fix this: the rewrite is only sound at positions where nothing can 
distinguish NULL from FALSE, i.e. positions reachable from the condition root 
through AND/OR only, never under NOT and not in value positions of conditional 
expressions.

The pre-existing [{{ReplaceNullWithFalseInPredicate}} rule 
|https://github.com/apache/spark/blob/5ce51a624421acceec378e0170da24a8fc2a96eb/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceNullWithFalseInPredicate.scala#L31-L53]performs
 a structurally identical NULL-vs-FALSE substitution and restricts itself to 
exactly those positions by explicit recursion (its scaladoc calls out NOT as 
the hazard); {{OptimizeJoinCondition}} should mirror that traversal instead of 
an unrestricted {{{}transform{}}}.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to