@@ -133,7 +133,10 @@ class StructPredicateDecomposeSuite extends PlanTest {
133133 s " Nested struct should be fully decomposed. Plan: \n ${optimized.treeString}" )
134134 }
135135
136- test(" SPARK-56660: tuple comparison (CreateStruct = CreateStruct) is decomposed" ) {
136+ // After scope narrowing, CreateStruct over attributes is NOT cheap (it's not an Attribute
137+ // or ExtractValue), so this no longer decomposes even though the right side is foldable.
138+ test(" SPARK-56660: tuple comparison (CreateStruct = CreateStruct) is NOT decomposed " +
139+ " (scope narrowing)" ) {
137140 val relation = LocalRelation ($" col1" .string, $" col2" .string)
138141 val leftStruct = CreateStruct (Seq ($" col1" , $" col2" ))
139142 val rightStruct = CreateStruct (Seq (Literal (" a" ), Literal (" b" )))
@@ -147,11 +150,15 @@ class StructPredicateDecomposeSuite extends PlanTest {
147150 val structComparisons = filter.condition.collect {
148151 case EqualTo (l, _) if l.dataType.isInstanceOf [StructType ] => true
149152 }
150- assert(structComparisons.isEmpty,
151- s " Tuple comparison should be decomposed. Plan: \n ${optimized.treeString}" )
153+ assert(structComparisons.nonEmpty,
154+ s " Tuple comparison should NOT be decomposed (left is not cheap). " +
155+ s " Plan: \n ${optimized.treeString}" )
152156 }
153157
154- test(" SPARK-56660: struct compared to another struct column is decomposed" ) {
158+ // After scope narrowing (SPARK-56660 FOLLOWUP), col=col no longer decomposes because
159+ // neither operand is foldable. This is intentional: col=col yields no pushdown benefit
160+ // and just expands the plan.
161+ test(" SPARK-56660: struct col=col is NOT decomposed (scope narrowing)" ) {
155162 val relation = LocalRelation (
156163 $" s1" .struct($" a" .string, $" b" .int),
157164 $" s2" .struct($" a" .string, $" b" .int)
@@ -166,8 +173,9 @@ class StructPredicateDecomposeSuite extends PlanTest {
166173 val structComparisons = filter.condition.collect {
167174 case EqualTo (l, _) if l.dataType.isInstanceOf [StructType ] => true
168175 }
169- assert(structComparisons.isEmpty,
170- s " Struct-to-struct column comparison should be decomposed. Plan: \n ${optimized.treeString}" )
176+ assert(structComparisons.nonEmpty,
177+ s " Struct-to-struct column comparison should NOT be decomposed (no foldable operand). " +
178+ s " Plan: \n ${optimized.treeString}" )
171179 }
172180
173181 // -----------------------------------------------------------------------------------
@@ -389,6 +397,64 @@ class StructPredicateDecomposeSuite extends PlanTest {
389397 assertSameSemantics(Not (EqualTo (left, right)))
390398 }
391399
400+ test(" SPARK-56660 FIXED: pushable EqualTo with all-null-fields literal correctly excludes " +
401+ " whole-null struct row (IsNotNull guard)" ) {
402+ // Previously the pushable path emitted `s.a <=> null` without a null guard.
403+ // On a whole-null-struct row, GetStructField(null, 0) returns null, so
404+ // `null <=> null` -> TRUE (row wrongly kept). The IsNotNull(s) guard now
405+ // ensures the row is correctly excluded.
406+ val structType = StructType (Seq (StructField (" a" , IntegerType , nullable = true )))
407+ val nullableRelation = LocalRelation (AttributeReference (" s" , structType, nullable = true )())
408+ val sAttr = nullableRelation.output.head
409+ // named_struct('a', CAST(NULL AS INT)) -- non-nullable wrapper, all-null fields
410+ val literal = CreateNamedStruct (Seq (Literal (" a" ), Literal (null , IntegerType )))
411+ val original = EqualTo (sAttr, literal)
412+
413+ val plan = nullableRelation.where(original).analyze
414+ val rewritten = Optimize .execute(plan).collect { case f : Filter => f.condition }.head
415+
416+ // Evaluate on a whole-null-struct row
417+ val row = InternalRow (null )
418+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
419+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
420+
421+ val origResult = boundOriginal.eval(row)
422+ val rewrittenResult = boundRewritten.eval(row)
423+
424+ // A Filter keeps a row iff predicate is TRUE. Original yields NULL (drop).
425+ // Rewritten must also NOT keep the row.
426+ def keptByFilter (v : Any ): Boolean = v == true
427+ assert(! keptByFilter(origResult), s " Original should not keep whole-null row, got: $origResult" )
428+ assert(keptByFilter(origResult) === keptByFilter(rewrittenResult),
429+ s " Filter outcome mismatch on whole-null row with all-null-fields literal. \n " +
430+ s " original: $original -> $origResult\n " +
431+ s " rewritten: $rewritten -> $rewrittenResult\n " +
432+ s " The IsNotNull guard should prevent the row from being kept. " )
433+ }
434+
435+ test(" SPARK-56660 FIXED: pushable EqualNullSafe with two nullable struct columns no longer " +
436+ " decomposes (scope narrowing prevents the bug)" ) {
437+ // After scope narrowing, col <=> col doesn't pass canDecompose (neither is foldable),
438+ // so the buggy fieldConjunction is never emitted. The plan is left intact.
439+ val structType = StructType (Seq (StructField (" a" , IntegerType , nullable = true )))
440+ val rel = LocalRelation (
441+ AttributeReference (" s1" , structType, nullable = true )(),
442+ AttributeReference (" s2" , structType, nullable = true )())
443+ val s1 = rel.output(0 )
444+ val s2 = rel.output(1 )
445+ val original = EqualNullSafe (s1, s2)
446+
447+ val plan = rel.where(original).analyze
448+ val rewritten = Optimize .execute(plan).collect { case f : Filter => f.condition }.head
449+
450+ // The rule should NOT have fired; condition should still contain struct-level <=>
451+ val hasStructEns = rewritten.collect {
452+ case EqualNullSafe (l, _) if l.dataType.isInstanceOf [StructType ] => true
453+ }
454+ assert(hasStructEns.nonEmpty,
455+ s " col <=> col should NOT be decomposed (no foldable operand). Plan condition: $rewritten" )
456+ }
457+
392458 // Note on schema mismatch: the analyzer rejects struct comparisons with different
393459 // field types at type-check time (see DATATYPE_MISMATCH.BINARY_OP_DIFF_TYPES) before
394460 // the optimizer ever runs. The `sameType` check inside `canDecompose` is therefore a
0 commit comments