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/06/15 00:44:01 UTC

(spark) branch master updated: [SPARK-56395][SQL][FOLLOWUP] Fix RewriteNearestByJoin nullability for LEFT OUTER

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 62e4e166698f [SPARK-56395][SQL][FOLLOWUP] Fix RewriteNearestByJoin nullability for LEFT OUTER
62e4e166698f is described below

commit 62e4e166698f95bcbeb16687ceaa61a3a8f14dbc
Author: Wenchen Fan <we...@databricks.com>
AuthorDate: Sun Jun 14 17:43:49 2026 -0700

    [SPARK-56395][SQL][FOLLOWUP] Fix RewriteNearestByJoin nullability for LEFT OUTER
    
    ### What changes were proposed in this pull request?
    
    Followup to https://github.com/apache/spark/pull/55682.
    
    In `RewriteNearestByJoin`, when the `NEAREST BY` join type is `LEFT OUTER`, the synthesized `Join` widens the right-side columns to nullable. However, the synthesized `Aggregate` (and the optional `__ranking__` `Project`) built on top of that join still referenced the right-side columns via `right.output` and `rankingExpression` with their original (non-nullable) nullability. As a result the rewritten plan can declare a right-side column as non-nullable while its child -- the join --  [...]
    
    This PR maps the right-side attributes to their widened (nullable) form for `LEFT OUTER` and rewrites both the `CreateStruct(right.*)` and the ranking expression to use that widened nullability, so the rewritten plan's schema is consistent with its child. For `INNER` joins the right side is not widened, so this is a no-op.
    
    ### Why are the changes needed?
    
    Without this fix the rewritten plan for a `LEFT OUTER NEAREST BY` declares right-side columns non-nullable while its join child produces them nullable -- an inconsistency that nullability/plan-integrity validation flags as a regression.
    
    ### Does this PR introduce any user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added a regression test to `RewriteNearestByJoinSuite` that, for both `INNER` and `LEFT OUTER`, asserts every right-side attribute the synthesized `Aggregate` references agrees on nullability with its join child. The test uses **non-nullable** right-side columns so that `LEFT OUTER`'s widening is observable -- it fails without this fix (`x#.. declared nullable=false but its child produces nullable=true`) and passes with it, while `INNER` stays a no-op. The suite's expected-plan helper [...]
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    No.
    
    Closes #56484 from cloud-fan/SPARK-56395-followup.
    
    Authored-by: Wenchen Fan <we...@databricks.com>
    Signed-off-by: Wenchen Fan <we...@databricks.com>
---
 .../catalyst/optimizer/RewriteNearestByJoin.scala  | 23 +++++--
 .../optimizer/RewriteNearestByJoinSuite.scala      | 75 +++++++++++++++++++++-
 2 files changed, 91 insertions(+), 7 deletions(-)

diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoin.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoin.scala
index ee8e61b457fa..974c5191ad99 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoin.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoin.scala
@@ -92,11 +92,26 @@ object RewriteNearestByJoin extends Rule[LogicalPlan] {
       //    cross-product today -- should not silently bypass that choice.
       val join = Join(taggedLeft, right, joinType, None, JoinHint.NONE)
 
-      val (aggInput, rankingForAgg) = if (!rankingExpression.deterministic) {
-        val rankingAlias = Alias(rankingExpression, "__ranking__")()
+      // A LEFT OUTER join widens the right-side columns to nullable. The synthesized Aggregate
+      // (and the optional `__ranking__` Project) below sit directly on top of this join, so every
+      // reference to a right-side column must carry that widened nullability. Otherwise the
+      // rewritten plan would declare a right column non-nullable while its child -- the join --
+      // produces it as nullable, which plan-integrity validation flags as a nullability
+      // regression. INNER joins do not widen the right side, so this is a no-op there.
+      val rightAttrs = joinType match {
+        case LeftOuter => right.output.map(_.withNullability(true))
+        case _ => right.output
+      }
+      val rightNullabilityMap = AttributeMap(right.output.zip(rightAttrs))
+      val rankingInJoin = rankingExpression.transform {
+        case a: Attribute => rightNullabilityMap.getOrElse(a, a)
+      }
+
+      val (aggInput, rankingForAgg) = if (!rankingInJoin.deterministic) {
+        val rankingAlias = Alias(rankingInJoin, "__ranking__")()
         Project(join.output :+ rankingAlias, join) -> rankingAlias.toAttribute
       } else {
-        join -> rankingExpression
+        join -> rankingInJoin
       }
 
       // 4. Aggregate grouped by `__qid`:
@@ -104,7 +119,7 @@ object RewriteNearestByJoin extends Rule[LogicalPlan] {
       //      - max_by/min_by(struct(right.*), ranking, k) as `_matches`.
       //    The ranking expression references left and right columns directly; no outer
       //    reference is needed because both sides are present in the joined input.
-      val rightStruct = CreateStruct(right.output)
+      val rightStruct = CreateStruct(rightAttrs)
       // reverse = true  -> MIN_BY (smallest ranking value first, for DISTANCE)
       // reverse = false -> MAX_BY (largest ranking value first, for SIMILARITY)
       val reverse = direction match {
diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoinSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoinSuite.scala
index 729b58394d4b..90d6ad806587 100644
--- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoinSuite.scala
+++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteNearestByJoinSuite.scala
@@ -19,7 +19,7 @@ package org.apache.spark.sql.catalyst.optimizer
 
 import org.apache.spark.sql.catalyst.dsl.expressions._
 import org.apache.spark.sql.catalyst.dsl.plans._
-import org.apache.spark.sql.catalyst.expressions.{Alias, AttributeReference, CreateStruct, Inline, Literal, Rand, Uuid}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeMap, AttributeReference, CreateStruct, Inline, Literal, Rand, Uuid}
 import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, First, MaxMinByK}
 import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, NearestByDistance, NearestBySimilarity, PlanTest}
 import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Generate, Join, JoinHint, LocalRelation, NearestByJoin, Project}
@@ -46,9 +46,19 @@ class RewriteNearestByJoinSuite extends PlanTest {
     val taggedLeft = Project(left.output :+ qidAlias, left)
     val join = Join(taggedLeft, right, joinType, None, JoinHint.NONE)
 
-    val rightStruct = CreateStruct(right.output)
+    // Mirror the rewrite: a LEFT OUTER join widens right-side columns to nullable, so the
+    // struct and ranking that sit on top of the join must reference them with that nullability.
+    val rightAttrs = joinType match {
+      case LeftOuter => right.output.map(_.withNullability(true))
+      case _ => right.output
+    }
+    val rightNullabilityMap = AttributeMap(right.output.zip(rightAttrs))
+    val rankingInJoin = ranking.transform {
+      case a: Attribute => rightNullabilityMap.getOrElse(a, a)
+    }
+    val rightStruct = CreateStruct(rightAttrs)
     val topKAgg = MaxMinByK(
-      rightStruct, ranking, Literal(numResults), reverse = reverse)
+      rightStruct, rankingInJoin, Literal(numResults), reverse = reverse)
       .toAggregateExpression()
     val matchesAlias = Alias(topKAgg, "__nearest_matches__")()
     val firstLeftAggs = left.output.map { attr =>
@@ -145,6 +155,65 @@ class RewriteNearestByJoinSuite extends PlanTest {
     comparePlans(normalizeUuidSeed(rewritten), expected, checkAnalysis = false)
   }
 
+  test("SPARK-56395: LEFT OUTER rewrite keeps right-side nullability consistent with its child") {
+    // A LEFT OUTER NEAREST BY widens the synthetic join's right-side columns to nullable. Every
+    // operator built on top of that join that references those columns (the `_matches` struct,
+    // the ranking) must carry the widened nullability -- otherwise the rewritten plan declares a
+    // column non-nullable while its child produces it as nullable, an internal inconsistency that
+    // no framework check catches (`LogicalPlanIntegrity` compares types `asNullable` and schemas
+    // `equalsIgnoreNullability`), so this assertion is the only guard. INNER does not widen the
+    // right side, so it stays a no-op.
+    //
+    // The right-side columns are declared non-nullable here: that is what makes LEFT OUTER's
+    // widening observable (with nullable columns the widening is a no-op and the bug is hidden).
+    // The ranking is exercised both deterministic (the reference lands directly in the Aggregate)
+    // and nondeterministic (the rule pre-materializes it into a `__ranking__` Project above the
+    // Join), so the widening is checked wherever the reference ends up.
+    val left = LocalRelation($"a".int, $"b".int)
+    val right = LocalRelation(
+      AttributeReference("x", IntegerType, nullable = false)(),
+      AttributeReference("y", IntegerType, nullable = false)())
+    val rankings = Seq(
+      "deterministic" -> (left.output(0) + right.output(0)),
+      "nondeterministic" -> (Rand(Literal(0L)) + right.output(0)))
+    for ((joinType, rightNullable) <- Seq(Inner -> false, LeftOuter -> true);
+         (label, ranking) <- rankings) {
+      val query = NearestByJoin(
+        left, right, joinType, approx = true, numResults = 1,
+        rankingExpression = ranking,
+        direction = NearestBySimilarity)
+
+      val rewritten = RewriteNearestByJoin(query.analyze)
+      val join = rewritten.collect { case j: Join => j }.head
+
+      // Sanity-check the fixture: the synthetic join widens its right-side output to nullable
+      // iff it is LEFT OUTER. (`join.right` is the right relation as it appears in the rewritten
+      // plan, so its ExprIds line up with the join's output.)
+      val rightExprIds = join.right.output.map(_.exprId).toSet
+      val joinRightOutput = join.output.filter(a => rightExprIds.contains(a.exprId))
+      assert(joinRightOutput.nonEmpty)
+      assert(joinRightOutput.forall(_.nullable == rightNullable))
+
+      // Whole-plan integrity: at every operator, an attribute reference whose ExprId is produced
+      // by one of that operator's children must agree with the child on nullability -- this is
+      // exactly what the fix corrects for LEFT OUTER. Walking the whole plan (rather than just
+      // the Aggregate) also covers the `__ranking__` Project that the nondeterministic path
+      // inserts above the Join, where the widened ranking reference lands.
+      rewritten.foreach { node =>
+        val childNullability =
+          node.children.flatMap(_.output).map(a => a.exprId -> a.nullable).toMap
+        node.expressions.foreach(_.foreach {
+          case ref: AttributeReference if childNullability.contains(ref.exprId) =>
+            assert(ref.nullable == childNullability(ref.exprId),
+              s"$joinType/$label: ${ref.name}#${ref.exprId.id} declared " +
+                s"nullable=${ref.nullable} but its child produces " +
+                s"nullable=${childNullability(ref.exprId)}")
+          case _ =>
+        })
+      }
+    }
+  }
+
   test("synthetic Join uses the user's joinType") {
     // Locks in that the rewrite's synthetic Join carries the user's `joinType`
     // (Inner or LeftOuter).


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