cloud-fan commented on code in PR #56244:
URL: https://github.com/apache/spark/pull/56244#discussion_r3502688751


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -141,6 +141,7 @@ abstract class Optimizer(catalogManager: CatalogManager)
         BooleanSimplification,
         SimplifyConditionals,
         PushFoldableIntoBranches,
+        DecomposeStructComparison,

Review Comment:
   **Design question: should this decomposition live at the 
pushdown/translation layer instead of as a blanket logical rule?**
   
   `DecomposeStructComparison` matches `Filter` and rewrites its condition 
(`expressions.scala:600`) without inspecting the child relation, and runs 
unconditionally in the optimizer batch. So by the time 
`PushDownUtils.pushFilters` consults the concrete source 
(`PushDownUtils.scala:56`), the whole-struct `EqualTo` no longer exists -- the 
source only ever sees the field predicates. Two consequences:
   
   1. **Silent pushdown regression for struct-capable sources.** A DSv2 
connector that natively supports a whole-struct equality but not nested-leaf 
predicates (common -- many connectors only push top-level columns) now pushes 
*nothing* for `s = struct(...)`, where it previously pushed the whole struct. 
Since the rule is relation-blind, such a source can't opt out. This isn't a 
wrong-results bug (the rewrite is filter-equivalent) but a capability 
regression, and no test covers it.
   2. **The exact-NULL-equivalence complexity is a consequence of this layer 
choice.** Because the rule mutates the real logical predicate, it must be 
exactly filter-equivalent -- which is the only reason the `IsNotNull` guard / 
`filterEquivalentToConjunction` / both-nullable handling are needed. Field 
decomposition already exists at the translation layer (`PushableColumn` 
extracts `GetStructField` chains, `DataSourceStrategy.scala` ~L915), and pushed 
filters are inexact over-approximations with the original `Filter` retained 
post-scan (`FileSourceStrategy.scala` ~L212) -- so a decomposition done there 
only needs to be a sound superset, not NULL-exact.
   
   Would it make sense to perform the decomposition at the filter-translation / 
pushdown layer (`PushDownUtils`), scoping it to sources that lack whole-struct 
support? That preserves whole-struct pushdown for capable sources and drops the 
NULL-equivalence surface. (Gating the *logical* rule on child-relation type 
would instead be a layering smell -- the point is to move the work down, not 
teach the optimizer about physical sources.) There are tradeoffs worth your 
call: a logical rewrite also exposes field predicates to other optimizer rules, 
and any DSv2 source reporting exact pushdown would need the decomposed form 
kept inexact.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala:
##########
@@ -564,6 +564,196 @@ object BooleanSimplification extends Rule[LogicalPlan] 
with PredicateHelper {
 }
 
 
+/**
+ * Decomposes struct-level equality comparisons appearing in Filter conditions 
into a
+ * conjunction of field-level equalities. This enables per-field filter 
pushdown to data
+ * sources (Parquet row group skipping, Iceberg/Delta column statistics, 
partition pruning).
+ *
+ * For a non-nullable struct comparison `struct_col = struct(1, 'a')`, the 
rewrite produces
+ * `struct_col.field1 = 1 AND struct_col.field2 = 'a'`. When either operand is 
nullable,
+ * the conjunction is wrapped in a null-check expression that mirrors the 
original
+ * struct-level comparison's NULL semantics (see comments on 
`decomposeEqualTo` and
+ * `decomposeEqualNullSafe`).
+ *
+ * Scope: the rule only rewrites comparisons appearing inside `Filter` 
conditions. It
+ * deliberately does NOT rewrite:
+ *   - Join conditions: an equi-join key on a struct is matched by the planner 
as a single
+ *     equality and routes to BroadcastHashJoin / SortMergeJoin. Decomposing 
it would
+ *     break key matching for those join strategies.
+ *   - Aggregate grouping expressions: structurally cannot be reached because 
the
+ *     transformation is scoped to `case Filter`.
+ *   - Project expressions: outside the pushdown path; rewriting them does not 
enable
+ *     pushdown and would expand the projection unnecessarily.
+ *
+ * The rewrite is gated on 
`spark.sql.optimizer.decomposeStructComparison.enabled`
+ * (default off) so users can opt in once the behavior has soaked in their 
workloads.
+ * The total number of decomposed predicates per top-level comparison is 
bounded by
+ * `spark.sql.optimizer.decomposeStructComparison.maxFields` to prevent 
runaway expansion
+ * on deeply nested or wide structs.
+ */
+object DecomposeStructComparison extends Rule[LogicalPlan] with 
PredicateHelper {
+  def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!conf.decomposeStructComparisonEnabled) {
+      return plan
+    }
+    plan.transformWithPruning(_.containsPattern(FILTER), ruleId) {
+      case f @ Filter(condition, _) =>
+        // A Filter drops rows whose condition is not TRUE, so at a top-level 
conjunct
+        // NULL and FALSE are indistinguishable. That lets us emit the bare, 
*pushable*
+        // field conjunction for a struct comparison sitting directly in the 
WHERE,
+        // instead of the null-preserving `If(...)` form (which is opaque to 
filter
+        // pushdown). Comparisons nested under Not/Or/etc. are NOT in filter 
position --
+        // there NULL vs FALSE is observable -- so those go through the 
null-preserving
+        // `decomposeCondition` path unchanged.
+        val rewritten = splitConjunctivePredicates(condition).map {
+          case Equality(l, r)
+              if canDecompose(l, r) && filterEquivalentToConjunction(l, r) =>
+            // Guard: AND IsNotNull(<nullable operand>) to preserve whole-null 
vs
+            // all-null-fields semantics. Without it, a whole-null struct row 
would
+            // pass the fieldConjunction when the literal has all-null fields.
+            // Note: canDecompose requires one foldable operand (always 
non-nullable),
+            // so both-nullable is unreachable and 
filterEquivalentToConjunction is
+            // always true here -- the guard is a safety net that also makes 
the <=>
+            // path fall through to decomposeEqualNullSafe when needed.
+            val nullGuards = (if (l.nullable) Seq(IsNotNull(l)) else Nil) ++
+              (if (r.nullable) Seq(IsNotNull(r)) else Nil)
+            val conjunction = fieldConjunction(l, r)
+            if (nullGuards.isEmpty) conjunction

Review Comment:
   Nit: verbless sentence fragment -- "`<=>` on the pushable path (one 
foldable, one cheap)." "Guard nullable operands" already flows into line 622, 
so just give the fragment a verb. (If @peter-toth's suggestion on line 618 to 
merge this arm with the `EqualTo` arm lands, this comment is removed entirely, 
so feel free to skip.)
   ```suggestion
               // This is the <=> pushable path (one operand foldable, the 
other cheap). Guard nullable operands
   ```



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/StructPredicateDecomposeSuite.scala:
##########
@@ -0,0 +1,719 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.optimizer
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.dsl.plans._
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.plans.{Inner, PlanTest}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
LocalRelation, LogicalPlan}
+import org.apache.spark.sql.catalyst.rules.RuleExecutor
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+
+/**
+ * Tests for [[DecomposeStructComparison]]. Most semantic-correctness tests 
are oracle
+ * tests that compare results of evaluating the original expression against 
the rewritten
+ * expression: the rule must never change the value of an existing predicate, 
only its
+ * shape. End-to-end Filter behavior is exercised in 
`StructPredicateDecomposeE2ESuite`.
+ */
+class StructPredicateDecomposeSuite extends PlanTest {
+
+  // The rule is gated by `decomposeStructComparison.enabled`, default off. 
Tests in
+  // this suite enable it for the rule to fire.
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    SQLConf.get.setConf(SQLConf.DECOMPOSE_STRUCT_COMPARISON_ENABLED, true)
+  }
+
+  override def afterAll(): Unit = {
+    SQLConf.get.unsetConf(SQLConf.DECOMPOSE_STRUCT_COMPARISON_ENABLED)
+    super.afterAll()
+  }
+
+  object Optimize extends RuleExecutor[LogicalPlan] {
+    val batches =
+      Batch("Optimizer", FixedPoint(10),
+        DecomposeStructComparison,
+        ConstantFolding,
+        BooleanSimplification,
+        SimplifyBinaryComparison) :: Nil
+  }
+
+  private val structRelation = LocalRelation(
+    $"nested".struct($"col1".string, $"col2".string),
+    $"id".int
+  )
+
+  // 
-----------------------------------------------------------------------------------
+  // Structural tests: verify the rewrite happens (or does not happen) for the 
right
+  // input shapes. Semantic preservation is covered by the oracle tests 
further below.
+  // 
-----------------------------------------------------------------------------------
+
+  test("SPARK-56660: struct equality is decomposed into field-level 
predicates") {
+    val structLiteral = CreateNamedStruct(Seq(
+      Literal("col1"), Literal("foo"),
+      Literal("col2"), Literal("bar")
+    ))
+    val plan = structRelation
+      .where(EqualTo($"nested", structLiteral))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    // After decomposition + ConstantFolding + BooleanSimplification, no 
struct-level
+    // EqualTo should remain in the filter condition.
+    val structComparisons = filter.condition.collect {
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(structComparisons.isEmpty,
+      s"Should not have struct-level comparison. 
Plan:\n${optimized.treeString}")
+  }
+
+  test("SPARK-56660: struct EqualNullSafe is decomposed into field-level 
predicates") {
+    val structLiteral = CreateNamedStruct(Seq(
+      Literal("col1"), Literal("foo"),
+      Literal("col2"), Literal("bar")
+    ))
+    val plan = structRelation
+      .where(EqualNullSafe($"nested", structLiteral))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val structComparisons = filter.condition.collect {
+      case EqualNullSafe(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(structComparisons.isEmpty,
+      s"Should not have struct-level EqualNullSafe. 
Plan:\n${optimized.treeString}")
+  }
+
+  test("SPARK-56660: nested struct equality is recursively decomposed") {
+    val nestedStructRelation = LocalRelation(
+      $"outer".struct($"inner".struct($"a".string, $"b".string), $"c".int),
+      $"id".int
+    )
+    val structLiteral = CreateNamedStruct(Seq(
+      Literal("inner"), CreateNamedStruct(Seq(
+        Literal("a"), Literal("x"),
+        Literal("b"), Literal("y")
+      )),
+      Literal("c"), Literal(42)
+    ))
+    val plan = nestedStructRelation
+      .where(EqualTo($"outer", structLiteral))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val hasStructComparison = filter.condition.collect {
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+      case EqualNullSafe(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(hasStructComparison.isEmpty,
+      s"Nested struct should be fully decomposed. 
Plan:\n${optimized.treeString}")
+  }
+
+  // After scope narrowing, CreateStruct over attributes is NOT cheap (it's 
not an Attribute
+  // or ExtractValue), so this no longer decomposes even though the right side 
is foldable.
+  test("SPARK-56660: tuple comparison (CreateStruct = CreateStruct) is NOT 
decomposed " +
+      "(scope narrowing)") {
+    val relation = LocalRelation($"col1".string, $"col2".string)
+    val leftStruct = CreateStruct(Seq($"col1", $"col2"))
+    val rightStruct = CreateStruct(Seq(Literal("a"), Literal("b")))
+    val plan = relation
+      .where(EqualTo(leftStruct, rightStruct))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val structComparisons = filter.condition.collect {
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(structComparisons.nonEmpty,
+      s"Tuple comparison should NOT be decomposed (left is not cheap). " +
+        s"Plan:\n${optimized.treeString}")
+  }
+
+  // After scope narrowing (SPARK-56660 FOLLOWUP), col=col no longer 
decomposes because
+  // neither operand is foldable. This is intentional: col=col yields no 
pushdown benefit
+  // and just expands the plan.
+  test("SPARK-56660: struct col=col is NOT decomposed (scope narrowing)") {
+    val relation = LocalRelation(
+      $"s1".struct($"a".string, $"b".int),
+      $"s2".struct($"a".string, $"b".int)
+    )
+    val plan = relation
+      .where(EqualTo($"s1", $"s2"))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val structComparisons = filter.condition.collect {
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(structComparisons.nonEmpty,
+      s"Struct-to-struct column comparison should NOT be decomposed (no 
foldable operand). " +
+        s"Plan:\n${optimized.treeString}")
+  }
+
+  // 
-----------------------------------------------------------------------------------
+  // Negative tests: verify the rule does NOT fire for shapes outside its 
scope.
+  // 
-----------------------------------------------------------------------------------
+
+  // GreaterThan/LessThan on structs use lexicographic ordering across fields. 
The rule
+  // intentionally only matches EqualTo / EqualNullSafe; one negative test for 
the
+  // comparator family is sufficient since all non-equality cases share a code 
path.
+  test("SPARK-56660: non-equality struct comparisons are NOT decomposed") {
+    val plan = structRelation
+      .where(GreaterThan($"nested",
+        CreateNamedStruct(Seq(Literal("col1"), Literal("a"), Literal("col2"), 
Literal("b")))))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val hasStructGreaterThan = filter.condition.collect {
+      case GreaterThan(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(hasStructGreaterThan.nonEmpty,
+      "GreaterThan on structs should NOT be decomposed")
+  }
+
+  // Non-deterministic expressions cannot be decomposed: each field-level 
comparison
+  // would re-evaluate the expression independently with a different value.
+  test("SPARK-56660: non-deterministic expressions are not decomposed") {
+    val doubleStructRelation = LocalRelation(
+      $"s".struct($"a".double, $"b".double),
+      $"id".int
+    )
+    val nonDetStruct = CreateNamedStruct(Seq(
+      Literal("a"), Rand(0),
+      Literal("b"), Rand(1)
+    ))
+    val plan = doubleStructRelation
+      .where(EqualTo($"s", nonDetStruct))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+    val filter = optimized.collect { case f: Filter => f }.head
+
+    val hasStructComparison = filter.condition.collect {
+      case EqualTo(_, _: CreateNamedStruct) => true
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(hasStructComparison.nonEmpty,
+      "Non-deterministic struct comparison should NOT be decomposed")
+  }
+
+  // Critical: join conditions must NOT be decomposed. The rule is 
Filter-scoped.
+  // Decomposing join keys would break equi-join detection for SMJ/BHJ 
planning.
+  test("SPARK-56660: join condition struct equality is NOT decomposed") {
+    val left = LocalRelation(
+      $"s1".struct($"a".string, $"b".int),
+      $"id".int
+    )
+    val right = LocalRelation(
+      $"s2".struct($"a".string, $"b".int),
+      $"id".int
+    )
+    val plan = left.join(right, Inner,
+      Some(EqualTo($"s1", $"s2"))).analyze
+
+    val optimized = Optimize.execute(plan)
+
+    val joins = optimized.collect { case j: Join => j }
+    assert(joins.nonEmpty, "Plan should contain a Join")
+    val joinCondition = joins.head.condition.get
+    val structComparisons = joinCondition.collect {
+      case EqualTo(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(structComparisons.nonEmpty,
+      s"Join condition should NOT be decomposed. 
Plan:\n${optimized.treeString}")
+  }
+
+  // The rule is Filter-scoped via the outer `case Filter` pattern, so an 
Aggregate
+  // grouping key on a struct is structurally unreachable. This test guards 
against
+  // accidentally broadening the rule's scope in a future change.
+  test("SPARK-56660: aggregate grouping struct key is NOT decomposed (scope 
guard)") {
+    val relation = LocalRelation(
+      $"s".struct($"a".string, $"b".int),
+      $"value".int
+    )
+    val plan = relation
+      .groupBy($"s")(count($"value").as("cnt"))
+      .analyze
+
+    val optimized = Optimize.execute(plan)
+
+    val aggregates = optimized.collect { case a: Aggregate => a }
+    assert(aggregates.nonEmpty, "Plan should contain an Aggregate")
+    val groupingExprs = aggregates.head.groupingExpressions
+    val hasStructGroupKey = 
groupingExprs.exists(_.dataType.isInstanceOf[StructType])
+    assert(hasStructGroupKey,
+      s"Aggregate grouping key should NOT be decomposed. 
Plan:\n${optimized.treeString}")
+  }
+
+  // 
-----------------------------------------------------------------------------------
+  // Oracle tests: build an `EqualTo` / `EqualNullSafe` directly, fold the 
rewrite
+  // through Optimize, and assert the rewritten expression evaluates to the 
same value
+  // as the original on representative inputs. These are the tests that catch
+  // correctness regressions in NULL handling.
+  // 
-----------------------------------------------------------------------------------
+
+  /**
+   * Returns the optimized condition produced by the rule for `original`, by 
wrapping it
+   * in a `Filter` over a single-column relation. The relation type does not 
matter; the
+   * rule rewrites the condition expression in place.
+   */
+  private def optimizeAsFilter(original: Expression): Expression = {
+    val dummy = LocalRelation($"id".int)
+    val plan = dummy.where(original).analyze
+    Optimize.execute(plan).collect { case f: Filter => f.condition }.head
+  }
+
+  private def assertSameSemantics(original: Expression): Unit = {
+    val rewritten = optimizeAsFilter(original)
+    val expected = original.eval(InternalRow.empty)
+    val actual = rewritten.eval(InternalRow.empty)
+    assert(expected === actual,
+      s"Rewritten expression changed semantics.\n" +
+        s"  original: $original -> $expected\n" +
+        s"  rewritten: $rewritten -> $actual")
+  }
+
+  private def litStruct(values: (String, Any, 
org.apache.spark.sql.types.DataType)*)
+      : CreateNamedStruct = {
+    CreateNamedStruct(values.flatMap {
+      case (n, v, t) => Seq(Literal(n), Literal.create(v, t))
+    })
+  }
+
+  test("SPARK-56660 oracle: EqualTo(struct(1, null), struct(1, null)) 
preserves TRUE") {
+    val left = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    assertSameSemantics(EqualTo(left, right))
+  }
+
+  test("SPARK-56660 oracle: EqualTo(struct(1, null), struct(1, 2)) preserves 
FALSE") {
+    val left = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", 2, IntegerType))
+    assertSameSemantics(EqualTo(left, right))
+  }
+
+  test("SPARK-56660 oracle: EqualTo(struct(1, 2), struct(1, 2)) preserves 
TRUE") {
+    val left = litStruct(("a", 1, IntegerType), ("b", 2, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", 2, IntegerType))
+    assertSameSemantics(EqualTo(left, right))
+  }
+
+  test("SPARK-56660 oracle: EqualTo with nullable left struct (null = 
struct(...)) " +
+      "is filter-equivalent") {
+    // For a struct EqualTo sitting directly in a Filter, the rule emits the 
bare, pushable
+    // field conjunction. On a whole-struct-null row the original yields NULL 
while the
+    // rewrite yields FALSE -- these are NOT value-equal, but they ARE 
filter-equivalent
+    // (a Filter drops the row in both cases, since neither is TRUE). Assert 
that filter
+    // outcome, which is the contract the rule actually guarantees in filter 
position.
+    val structType = StructType(Seq(StructField("a", IntegerType, nullable = 
false)))
+    val nullableRelation = LocalRelation(AttributeReference("s", structType, 
nullable = true)())
+    val sAttr = nullableRelation.output.head
+    val literal = litStruct(("a", 1, IntegerType))
+    val original = EqualTo(sAttr, literal)
+
+    val rewritten = 
Optimize.execute(nullableRelation.where(original).analyze).collect {
+      case f: Filter => f.condition
+    }.head
+    val row = InternalRow(null)
+    val boundOriginal = BindReferences.bindReference(original, Seq(sAttr))
+    val boundRewritten = BindReferences.bindReference(rewritten, Seq(sAttr))
+
+    // A Filter keeps a row iff the predicate evaluates to TRUE; both NULL and 
FALSE drop it.
+    def keptByFilter(v: Any): Boolean = v == true
+    assert(keptByFilter(boundOriginal.eval(row)) === 
keptByFilter(boundRewritten.eval(row)),
+      s"Filter outcome mismatch on whole-struct-null row.\n" +
+        s"  original: $original -> ${boundOriginal.eval(row)}\n" +
+        s"  rewritten: $rewritten -> ${boundRewritten.eval(row)}")
+    // Document the value-level divergence that makes the rewrite pushable.
+    assert(boundOriginal.eval(row) == null && boundRewritten.eval(row) == 
false,
+      "Expected original NULL and rewritten FALSE on the whole-struct-null 
row")
+  }
+
+  test("SPARK-56660 oracle: EqualNullSafe(struct(1, null), struct(1, null)) 
preserves TRUE") {
+    val left = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    assertSameSemantics(EqualNullSafe(left, right))
+  }
+
+  test("SPARK-56660 oracle: EqualNullSafe(struct(1, null), struct(1, 2)) 
preserves FALSE") {
+    val left = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", 2, IntegerType))
+    assertSameSemantics(EqualNullSafe(left, right))
+  }
+
+  test("SPARK-56660 oracle: EqualNullSafe with one nullable struct null 
preserves FALSE") {
+    val structType = StructType(Seq(StructField("a", IntegerType, nullable = 
false)))
+    val nullableRelation = LocalRelation(AttributeReference("s", structType, 
nullable = true)())
+    val sAttr = nullableRelation.output.head
+    val literal = litStruct(("a", 1, IntegerType))
+    val original = EqualNullSafe(sAttr, literal)
+
+    val plan = nullableRelation.where(original).analyze
+    val rewritten = Optimize.execute(plan).collect {
+      case f: Filter => f.condition
+    }.head
+    val row = InternalRow(null)
+    val boundOriginal = BindReferences.bindReference(original, Seq(sAttr))
+    val boundRewritten = BindReferences.bindReference(rewritten, Seq(sAttr))
+    assert(boundOriginal.eval(row) === boundRewritten.eval(row),
+      s"EqualNullSafe one-side-null mismatch.\n  original: $original\n  
rewritten: $rewritten")
+  }
+
+  test("SPARK-56660 oracle: Not(EqualTo) preserves semantics with null 
fields") {
+    // Negation is the canonical place where field-level decomposition can 
quietly diverge
+    // from struct-level semantics if NULL handling is wrong: Not(NULL) is 
NULL, not TRUE.
+    val left = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    val right = litStruct(("a", 1, IntegerType), ("b", null, IntegerType))
+    assertSameSemantics(Not(EqualTo(left, right)))
+  }
+
+  test("SPARK-56660 FIXED: pushable EqualTo with all-null-fields literal 
correctly excludes " +
+      "whole-null struct row (IsNotNull guard)") {
+    // Previously the pushable path emitted `s.a <=> null` without a null 
guard.
+    // On a whole-null-struct row, GetStructField(null, 0) returns null, so
+    // `null <=> null` -> TRUE (row wrongly kept). The IsNotNull(s) guard now
+    // ensures the row is correctly excluded.
+    val structType = StructType(Seq(StructField("a", IntegerType, nullable = 
true)))
+    val nullableRelation = LocalRelation(AttributeReference("s", structType, 
nullable = true)())
+    val sAttr = nullableRelation.output.head
+    // named_struct('a', CAST(NULL AS INT)) -- non-nullable wrapper, all-null 
fields
+    val literal = CreateNamedStruct(Seq(Literal("a"), Literal(null, 
IntegerType)))
+    val original = EqualTo(sAttr, literal)
+
+    val plan = nullableRelation.where(original).analyze
+    val rewritten = Optimize.execute(plan).collect { case f: Filter => 
f.condition }.head
+
+    // Evaluate on a whole-null-struct row
+    val row = InternalRow(null)
+    val boundOriginal = BindReferences.bindReference(original, Seq(sAttr))
+    val boundRewritten = BindReferences.bindReference(rewritten, Seq(sAttr))
+
+    val origResult = boundOriginal.eval(row)
+    val rewrittenResult = boundRewritten.eval(row)
+
+    // A Filter keeps a row iff predicate is TRUE. Original yields NULL (drop).
+    // Rewritten must also NOT keep the row.
+    def keptByFilter(v: Any): Boolean = v == true
+    assert(!keptByFilter(origResult), s"Original should not keep whole-null 
row, got: $origResult")
+    assert(keptByFilter(origResult) === keptByFilter(rewrittenResult),
+      s"Filter outcome mismatch on whole-null row with all-null-fields 
literal.\n" +
+        s"  original: $original -> $origResult\n" +
+        s"  rewritten: $rewritten -> $rewrittenResult\n" +
+        s"  The IsNotNull guard should prevent the row from being kept.")
+  }
+
+  test("SPARK-56660 FIXED: pushable EqualNullSafe with all-null-fields literal 
correctly " +
+      "excludes whole-null struct row (IsNotNull guard)") {
+    // Analogous to the EqualTo eval test above, but for the <=> path.
+    // s <=> named_struct('a', CAST(NULL AS INT)) on a whole-null struct row:
+    // original <=> yields FALSE (null is not null-struct), rewritten must 
agree.
+    val structType = StructType(Seq(StructField("a", IntegerType, nullable = 
true)))
+    val nullableRelation = LocalRelation(AttributeReference("s", structType, 
nullable = true)())
+    val sAttr = nullableRelation.output.head
+    val literal = CreateNamedStruct(Seq(Literal("a"), Literal(null, 
IntegerType)))
+    val original = EqualNullSafe(sAttr, literal)
+
+    val plan = nullableRelation.where(original).analyze
+    val rewritten = Optimize.execute(plan).collect { case f: Filter => 
f.condition }.head
+
+    // Evaluate on a whole-null-struct row
+    val row = InternalRow(null)
+    val boundOriginal = BindReferences.bindReference(original, Seq(sAttr))
+    val boundRewritten = BindReferences.bindReference(rewritten, Seq(sAttr))
+
+    val origResult = boundOriginal.eval(row)
+    val rewrittenResult = boundRewritten.eval(row)
+
+    // Original <=> returns FALSE (null != struct(null)). Rewritten must also 
yield FALSE.
+    def keptByFilter(v: Any): Boolean = v == true
+    assert(!keptByFilter(origResult),
+      s"Original <=> should not keep whole-null row, got: $origResult")
+    assert(keptByFilter(origResult) === keptByFilter(rewrittenResult),
+      s"Filter outcome mismatch on whole-null row with all-null-fields literal 
(<=> path).\n" +
+        s"  original: $original -> $origResult\n" +
+        s"  rewritten: $rewritten -> $rewrittenResult\n" +
+        s"  The IsNotNull guard should prevent the row from being kept.")
+  }
+
+  test("SPARK-56660 FIXED: pushable EqualNullSafe with two nullable struct 
columns no longer " +
+      "decomposes (scope narrowing prevents the bug)") {
+    // After scope narrowing, col <=> col doesn't pass canDecompose (neither 
is foldable),
+    // so the buggy fieldConjunction is never emitted. The plan is left intact.
+    val structType = StructType(Seq(StructField("a", IntegerType, nullable = 
true)))
+    val rel = LocalRelation(
+      AttributeReference("s1", structType, nullable = true)(),
+      AttributeReference("s2", structType, nullable = true)())
+    val s1 = rel.output(0)
+    val s2 = rel.output(1)
+    val original = EqualNullSafe(s1, s2)
+
+    val plan = rel.where(original).analyze
+    val rewritten = Optimize.execute(plan).collect { case f: Filter => 
f.condition }.head
+
+    // The rule should NOT have fired; condition should still contain 
struct-level <=>
+    val hasStructEns = rewritten.collect {
+      case EqualNullSafe(l, _) if l.dataType.isInstanceOf[StructType] => true
+    }
+    assert(hasStructEns.nonEmpty,
+      s"col <=> col should NOT be decomposed (no foldable operand). Plan 
condition: $rewritten")
+  }
+
+  // 
-----------------------------------------------------------------------------------
+  // Null-preserving (non-pushable) path tests: these exercise 
`decomposeCondition`,
+  // which fires for comparisons NOT in top-level filter position (e.g. under 
Not/Or).
+  // The null-preserving form wraps the conjunction in If(...) to preserve 
NULL vs FALSE

Review Comment:
   `canDecompose` has no `sameType` check (the only `sameType` token in this 
file is here in the comment) -- type compatibility is enforced upstream by the 
analyzer (`DATATYPE_MISMATCH.BINARY_OP_DIFF_TYPES`, as the first sentence 
notes) plus the `StructType` match and `l.length == r.length` arity check 
inside `canDecompose`. Naming a check that doesn't exist will mislead a future 
reader.
   ```suggestion
     // the optimizer ever runs. The `StructType` match and `l.length == 
r.length` arity check
     // inside `canDecompose` are therefore defense-in-depth guards, not 
directly testable at
     // the rule level.
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to