You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by we...@apache.org on 2026/08/07 14:22:03 UTC

(spark) branch branch-4.3 updated: [SPARK-58384][SQL] Preserve null semantics in OptimizeJoinCondition

This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.3 by this push:
     new f67d154fb179 [SPARK-58384][SQL] Preserve null semantics in OptimizeJoinCondition
f67d154fb179 is described below

commit f67d154fb1792996cf88cdf4b85b4c78542ca17c
Author: xuanyulu <35...@qq.com>
AuthorDate: Fri Aug 7 22:21:11 2026 +0800

    [SPARK-58384][SQL] Preserve null semantics in OptimizeJoinCondition
    
    ### What changes were proposed in this pull request?
    
    Restrict `OptimizeJoinCondition` to rewriting null-safe equality patterns only at the root of a join condition or beneath `AND`/`OR`.
    
    The rule no longer performs this rewrite beneath `NOT`, where the difference between `NULL` and `FALSE` is observable.
    
    ### Why are the changes needed?
    
    When exactly one operand is `NULL`, the original pattern returns `NULL`, while `<=>` returns `FALSE`.
    
    This difference does not matter at the root of a join condition, but under `NOT` it can cause Spark to incorrectly keep extra rows.
    
    ### Does this PR introduce *any* user-facing change?
    
    Yes. Join conditions containing this pattern beneath `NOT` now return the correct rows.
    
    For the SPARK-58384 reproduction, the result changes from four rows to only:
    
    ```text
    [0,10,1,22]
    ```
    
    ### How was this patch tested?
    
    Added tests covering:
    
    - No rewrite beneath `NOT`.
    - Continued rewriting beneath `AND` and `OR`.
    - The end-to-end join result with null values.
    
    Ran:
    
    ```text
    build/sbt 'catalyst/testOnly org.apache.spark.sql.catalyst.optimizer.OptimizeJoinConditionSuite'
    build/sbt 'sql/testOnly org.apache.spark.sql.DataFrameJoinSuite -- -z "SPARK-58384"'
    build/sbt 'catalyst/scalastyle' 'catalyst/Test/scalastyle' 'sql/Test/scalastyle'
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    co-authored by Codex (GPT-5)
    
    Closes #57791 from MrHappyEnding/SPARK-58384.
    
    Authored-by: xuanyulu <35...@qq.com>
    Signed-off-by: Wenchen Fan <we...@databricks.com>
    (cherry picked from commit cea566b22b3de6879f3b38e6def2cef0d417a731)
    Signed-off-by: Wenchen Fan <we...@databricks.com>
---
 .../catalyst/optimizer/OptimizeJoinCondition.scala | 28 +++++++++++++---
 .../optimizer/OptimizeJoinConditionSuite.scala     | 37 ++++++++++++++++++++++
 .../org/apache/spark/sql/DataFrameJoinSuite.scala  |  9 ++++++
 3 files changed, 70 insertions(+), 4 deletions(-)

diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinCondition.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinCondition.scala
index 7c41ebea050b..86248cf30828 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinCondition.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinCondition.scala
@@ -17,7 +17,7 @@
 
 package org.apache.spark.sql.catalyst.optimizer
 
-import org.apache.spark.sql.catalyst.expressions.{And, EqualNullSafe, EqualTo, IsNull, Or, PredicateHelper}
+import org.apache.spark.sql.catalyst.expressions.{And, EqualNullSafe, EqualTo, Expression, IsNull, Or, PredicateHelper}
 import org.apache.spark.sql.catalyst.plans.logical.{Join, LogicalPlan}
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.trees.TreePattern.{JOIN, OR}
@@ -30,7 +30,16 @@ object OptimizeJoinCondition extends Rule[LogicalPlan] with PredicateHelper {
   override def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
     _.containsPattern(JOIN), ruleId) {
     case j @ Join(_, _, _, condition, _) if condition.nonEmpty =>
-      val newCondition = condition.map(_.transformWithPruning(_.containsPattern(OR), ruleId) {
+      val newCondition = condition.map(optimizeCondition)
+      j.copy(condition = newCondition)
+  }
+
+  // Rewriting the pattern to EqualNullSafe maps NULL to FALSE, so only recurse through And/Or.
+  private def optimizeCondition(condition: Expression): Expression = {
+    if (!condition.containsPattern(OR)) {
+      condition
+    } else {
+      condition match {
         case Or(EqualTo(l, r), And(IsNull(c1), IsNull(c2)))
           if (l.semanticEquals(c1) && r.semanticEquals(c2))
             || (l.semanticEquals(c2) && r.semanticEquals(c1)) =>
@@ -39,7 +48,18 @@ object OptimizeJoinCondition extends Rule[LogicalPlan] with PredicateHelper {
           if (l.semanticEquals(c1) && r.semanticEquals(c2))
             || (l.semanticEquals(c2) && r.semanticEquals(c1)) =>
           EqualNullSafe(l, r)
-      })
-      j.copy(condition = newCondition)
+        case and @ And(left, right) =>
+          val newLeft = optimizeCondition(left)
+          val newRight = optimizeCondition(right)
+          if (newLeft.fastEquals(left) && newRight.fastEquals(right)) and
+          else And(newLeft, newRight)
+        case or @ Or(left, right) =>
+          val newLeft = optimizeCondition(left)
+          val newRight = optimizeCondition(right)
+          if (newLeft.fastEquals(left) && newRight.fastEquals(right)) or
+          else Or(newLeft, newRight)
+        case other => other
+      }
+    }
   }
 }
diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinConditionSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinConditionSuite.scala
index e7f090ec4d0d..c686eb6e680d 100644
--- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinConditionSuite.scala
+++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/OptimizeJoinConditionSuite.scala
@@ -46,4 +46,41 @@ class OptimizeJoinConditionSuite extends PlanTest {
       comparePlans(Optimize.execute(originalQuery.analyze), correctAnswer.analyze)
     })
   }
+
+  test("SPARK-58384: do not replace null-safe equality pattern under NOT") {
+    val x = testRelation.subquery("x")
+    val y = testRelation1.subquery("y")
+    val originalQuery =
+      x.join(y, Inner, Option(!($"a" === $"c" || ($"a".isNull && $"c".isNull))))
+
+    comparePlans(Optimize.execute(originalQuery.analyze), originalQuery.analyze)
+  }
+
+  test("SPARK-58384: replace null-safe equality pattern under AND and OR") {
+    val x = testRelation.subquery("x")
+    val y = testRelation1.subquery("y")
+    val pattern = $"a" === $"c" || ($"a".isNull && $"c".isNull)
+    val optimizedPattern = $"a" <=> $"c"
+    val otherCondition = $"b" === $"d"
+    val conditions = Seq(
+      (pattern && otherCondition) -> (optimizedPattern && otherCondition),
+      (pattern || otherCondition) -> (optimizedPattern || otherCondition))
+
+    conditions.foreach { case (condition, optimizedCondition) =>
+      val originalQuery = x.join(y, Inner, Option(condition))
+      val correctAnswer = x.join(y, Inner, Option(optimizedCondition))
+      comparePlans(Optimize.execute(originalQuery.analyze), correctAnswer.analyze)
+    }
+  }
+
+  test("SPARK-58384: preserve unrelated AND and OR nodes") {
+    val x = testRelation.subquery("x")
+    val y = testRelation1.subquery("y")
+    val condition = ($"a" === $"c") && (($"b" === $"d") || ($"a" === 1))
+    val originalQuery = x.join(y, Inner, Option(condition)).analyze.asInstanceOf[Join]
+
+    val optimized = OptimizeJoinCondition(originalQuery).asInstanceOf[Join]
+
+    assert(optimized.condition.get eq originalQuery.condition.get)
+  }
 }
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
index 129d6bb68676..1d93006342ab 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
@@ -630,4 +630,13 @@ class DataFrameJoinSuite extends SharedSparkSession
       checkAnswer(df1, df2)
     })
   }
+
+  test("SPARK-58384: preserve null semantics under NOT in join condition") {
+    val left = Seq((Some(0), 10), (None, 11)).toDF("a", "b").as("left")
+    val right = Seq((None, 20), (Some(0), 21), (Some(1), 22)).toDF("x", "y").as("right")
+    val condition = !(($"left.a" === $"right.x") ||
+      ($"left.a".isNull && $"right.x".isNull))
+
+    checkAnswer(left.join(right, condition), Row(0, 10, 1, 22))
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org