@@ -432,6 +432,39 @@ class StructPredicateDecomposeSuite extends PlanTest {
432432 s " The IsNotNull guard should prevent the row from being kept. " )
433433 }
434434
435+ test(" SPARK-56660 FIXED: pushable EqualNullSafe with all-null-fields literal correctly " +
436+ " excludes whole-null struct row (IsNotNull guard)" ) {
437+ // Analogous to the EqualTo eval test above, but for the <=> path.
438+ // s <=> named_struct('a', CAST(NULL AS INT)) on a whole-null struct row:
439+ // original <=> yields FALSE (null is not null-struct), rewritten must agree.
440+ val structType = StructType (Seq (StructField (" a" , IntegerType , nullable = true )))
441+ val nullableRelation = LocalRelation (AttributeReference (" s" , structType, nullable = true )())
442+ val sAttr = nullableRelation.output.head
443+ val literal = CreateNamedStruct (Seq (Literal (" a" ), Literal (null , IntegerType )))
444+ val original = EqualNullSafe (sAttr, literal)
445+
446+ val plan = nullableRelation.where(original).analyze
447+ val rewritten = Optimize .execute(plan).collect { case f : Filter => f.condition }.head
448+
449+ // Evaluate on a whole-null-struct row
450+ val row = InternalRow (null )
451+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
452+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
453+
454+ val origResult = boundOriginal.eval(row)
455+ val rewrittenResult = boundRewritten.eval(row)
456+
457+ // Original <=> returns FALSE (null != struct(null)). Rewritten must also yield FALSE.
458+ def keptByFilter (v : Any ): Boolean = v == true
459+ assert(! keptByFilter(origResult),
460+ s " Original <=> should not keep whole-null row, got: $origResult" )
461+ assert(keptByFilter(origResult) === keptByFilter(rewrittenResult),
462+ s " Filter outcome mismatch on whole-null row with all-null-fields literal (<=> path). \n " +
463+ s " original: $original -> $origResult\n " +
464+ s " rewritten: $rewritten -> $rewrittenResult\n " +
465+ s " The IsNotNull guard should prevent the row from being kept. " )
466+ }
467+
435468 test(" SPARK-56660 FIXED: pushable EqualNullSafe with two nullable struct columns no longer " +
436469 " decomposes (scope narrowing prevents the bug)" ) {
437470 // After scope narrowing, col <=> col doesn't pass canDecompose (neither is foldable),
@@ -455,6 +488,230 @@ class StructPredicateDecomposeSuite extends PlanTest {
455488 s " col <=> col should NOT be decomposed (no foldable operand). Plan condition: $rewritten" )
456489 }
457490
491+ // -----------------------------------------------------------------------------------
492+ // Null-preserving (non-pushable) path tests: these exercise `decomposeCondition`,
493+ // which fires for comparisons NOT in top-level filter position (e.g. under Not/Or).
494+ // The null-preserving form wraps the conjunction in If(...) to preserve NULL vs FALSE
495+ // distinctions that are observable under negation.
496+ // -----------------------------------------------------------------------------------
497+
498+ /**
499+ * Helper: wraps `original` in a Filter, runs the optimizer, and returns the rewritten
500+ * condition. Uses a relation with the given attributes so references resolve.
501+ */
502+ private def optimizeWithRelation (
503+ original : Expression ,
504+ relation : LocalRelation ): Expression = {
505+ val plan = relation.where(original).analyze
506+ Optimize .execute(plan).collect { case f : Filter => f.condition }.head
507+ }
508+
509+ test(" SPARK-56660 oracle: Not(EqualTo(nullableAttr, literal)) reaches decomposeEqualTo " +
510+ " nullable If branch" ) {
511+ // This exercises decomposeEqualTo's `If(anyNull, NULL, conjunction)` branch
512+ // (expressions.scala ~line 726) via the decomposeCondition path.
513+ val structType = StructType (Seq (
514+ StructField (" a" , IntegerType , nullable = false ),
515+ StructField (" b" , IntegerType , nullable = false )))
516+ val nullableRelation = LocalRelation (
517+ AttributeReference (" s" , structType, nullable = true )())
518+ val sAttr = nullableRelation.output.head
519+ val literal = litStruct((" a" , 1 , IntegerType ), (" b" , 2 , IntegerType ))
520+ val original = Not (EqualTo (sAttr, literal))
521+
522+ // Verify the rule fires and produces the null-preserving If form
523+ val rewritten = optimizeWithRelation(original, nullableRelation)
524+
525+ // The rewritten form must contain an If (the null-preserving wrapper)
526+ val hasIfNull = rewritten.collect {
527+ case If (IsNull (_), Literal (null , _), _) => true
528+ }
529+ assert(hasIfNull.nonEmpty,
530+ s " decomposeEqualTo nullable branch should produce If(IsNull, NULL, conjunction). " +
531+ s " Got: $rewritten" )
532+
533+ // Semantic check: evaluate on three representative rows
534+ val nonNullRow = InternalRow (InternalRow (1 , 2 )) // s = struct(1, 2): = is TRUE, Not = FALSE
535+ val nullRow = InternalRow (null ) // s = NULL: = is NULL, Not(NULL) = NULL
536+ val mismatchRow = InternalRow (InternalRow (1 , 99 )) // s = struct(1,99): = is FALSE, Not = TRUE
537+
538+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
539+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
540+
541+ // Non-null matching row: Not(TRUE) = FALSE
542+ assert(boundOriginal.eval(nonNullRow) === boundRewritten.eval(nonNullRow),
543+ s " Mismatch on non-null matching row " )
544+ // Whole-null row: Not(NULL) = NULL (not FALSE!)
545+ assert(boundOriginal.eval(nullRow) === boundRewritten.eval(nullRow),
546+ s " Mismatch on whole-null row: original= ${boundOriginal.eval(nullRow)}, " +
547+ s " rewritten= ${boundRewritten.eval(nullRow)}" )
548+ assert(boundOriginal.eval(nullRow) == null ,
549+ " Not(NULL struct = literal) should be NULL, not FALSE" )
550+ // Mismatching row: Not(FALSE) = TRUE
551+ assert(boundOriginal.eval(mismatchRow) === boundRewritten.eval(mismatchRow),
552+ s " Mismatch on non-matching row " )
553+ }
554+
555+ test(" SPARK-56660 oracle: Not(EqualNullSafe(nullableAttr, literal)) reaches " +
556+ " decomposeEqualNullSafe (true, false) branch" ) {
557+ // This exercises decomposeEqualNullSafe's case (true, false) branch
558+ // (expressions.scala ~line 756): If(IsNull(left), FalseLiteral, conjunction)
559+ val structType = StructType (Seq (
560+ StructField (" a" , IntegerType , nullable = false ),
561+ StructField (" b" , IntegerType , nullable = false )))
562+ val nullableRelation = LocalRelation (
563+ AttributeReference (" s" , structType, nullable = true )())
564+ val sAttr = nullableRelation.output.head
565+ val literal = litStruct((" a" , 1 , IntegerType ), (" b" , 2 , IntegerType ))
566+ val original = Not (EqualNullSafe (sAttr, literal))
567+
568+ val rewritten = optimizeWithRelation(original, nullableRelation)
569+
570+ // The rewritten form must contain If(IsNull(...), FALSE, conjunction)
571+ val hasIfFalse = rewritten.collect {
572+ case If (IsNull (_), Literal (false , _), _) => true
573+ }
574+ assert(hasIfFalse.nonEmpty,
575+ s " decomposeEqualNullSafe (true, false) branch should produce " +
576+ s " If(IsNull, FALSE, conjunction). Got: $rewritten" )
577+
578+ // Semantic check on three row kinds
579+ val nonNullRow = InternalRow (InternalRow (1 , 2 )) // s <=> struct(1,2) = TRUE, Not = FALSE
580+ val nullRow = InternalRow (null ) // s <=> struct(1,2) = FALSE, Not = TRUE
581+ val mismatchRow = InternalRow (InternalRow (1 , 99 )) // s <=> struct(1,99) = FALSE, Not = TRUE
582+
583+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
584+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
585+
586+ assert(boundOriginal.eval(nonNullRow) === boundRewritten.eval(nonNullRow))
587+ assert(boundOriginal.eval(nullRow) === boundRewritten.eval(nullRow))
588+ assert(boundOriginal.eval(nullRow) == true ,
589+ " Not(NULL <=> literal) should be TRUE (since NULL <=> non-null = FALSE)" )
590+ assert(boundOriginal.eval(mismatchRow) === boundRewritten.eval(mismatchRow))
591+ }
592+
593+ test(" SPARK-56660 oracle: Not(EqualNullSafe(literal, nullableAttr)) reaches " +
594+ " decomposeEqualNullSafe (false, true) branch" ) {
595+ // This exercises decomposeEqualNullSafe's case (false, true) branch
596+ // (expressions.scala ~line 759): If(IsNull(right), FalseLiteral, conjunction)
597+ val structType = StructType (Seq (
598+ StructField (" a" , IntegerType , nullable = false ),
599+ StructField (" b" , IntegerType , nullable = false )))
600+ val nullableRelation = LocalRelation (
601+ AttributeReference (" s" , structType, nullable = true )())
602+ val sAttr = nullableRelation.output.head
603+ val literal = litStruct((" a" , 1 , IntegerType ), (" b" , 2 , IntegerType ))
604+ // Note: literal on LEFT, attribute on RIGHT
605+ val original = Not (EqualNullSafe (literal, sAttr))
606+
607+ val rewritten = optimizeWithRelation(original, nullableRelation)
608+
609+ // The rewritten form must contain If(IsNull(...), FALSE, conjunction)
610+ val hasIfFalse = rewritten.collect {
611+ case If (IsNull (_), Literal (false , _), _) => true
612+ }
613+ assert(hasIfFalse.nonEmpty,
614+ s " decomposeEqualNullSafe (false, true) branch should produce " +
615+ s " If(IsNull, FALSE, conjunction). Got: $rewritten" )
616+
617+ val nonNullRow = InternalRow (InternalRow (1 , 2 ))
618+ val nullRow = InternalRow (null )
619+ val mismatchRow = InternalRow (InternalRow (1 , 99 ))
620+
621+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
622+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
623+
624+ assert(boundOriginal.eval(nonNullRow) === boundRewritten.eval(nonNullRow))
625+ assert(boundOriginal.eval(nullRow) === boundRewritten.eval(nullRow))
626+ assert(boundOriginal.eval(mismatchRow) === boundRewritten.eval(mismatchRow))
627+ }
628+
629+ test(" SPARK-56660 oracle: Or(EqualTo(nullableAttr, lit), other) reaches null-preserving " +
630+ " decomposeEqualTo path" ) {
631+ // Under Or, NULL vs FALSE is observable: Or(NULL, FALSE) = NULL (row dropped),
632+ // Or(FALSE, FALSE) = FALSE (row dropped too, but conceptually different).
633+ // The null-preserving If wrapper in decomposeEqualTo ensures correctness here.
634+ val structType = StructType (Seq (StructField (" a" , IntegerType , nullable = false )))
635+ val nullableRelation = LocalRelation (
636+ AttributeReference (" s" , structType, nullable = true )(),
637+ AttributeReference (" flag" , IntegerType , nullable = false )())
638+ val sAttr = nullableRelation.output(0 )
639+ val flagAttr = nullableRelation.output(1 )
640+ val literal = litStruct((" a" , 42 , IntegerType ))
641+ val original = Or (EqualTo (sAttr, literal), EqualTo (flagAttr, Literal (1 )))
642+
643+ val rewritten = optimizeWithRelation(original, nullableRelation)
644+
645+ // When s is NULL and flag != 1: Or(NULL, FALSE) = NULL (row dropped by filter)
646+ // The rewrite must NOT turn this into Or(FALSE, FALSE) = FALSE unless it correctly
647+ // preserves the If wrapper.
648+ val nullRow = InternalRow (null , 0 )
649+ val boundOriginal = BindReferences .bindReference(original, nullableRelation.output)
650+ val boundRewritten = BindReferences .bindReference(rewritten, nullableRelation.output)
651+
652+ // For this specific case, NULL and FALSE have the same filter outcome (both drop),
653+ // but the value must match for correctness under further composition.
654+ assert(boundOriginal.eval(nullRow) === boundRewritten.eval(nullRow),
655+ s " Or with null struct: original= ${boundOriginal.eval(nullRow)}, " +
656+ s " rewritten= ${boundRewritten.eval(nullRow)}" )
657+
658+ // When s is NULL and flag == 1: Or(NULL, TRUE) = TRUE (row kept)
659+ val nullKeepRow = InternalRow (null , 1 )
660+ assert(boundOriginal.eval(nullKeepRow) === boundRewritten.eval(nullKeepRow))
661+ assert(boundOriginal.eval(nullKeepRow) == true )
662+ }
663+
664+ test(" SPARK-56660 oracle: decomposeEqualNullSafe (false, false) branch with non-nullable " +
665+ " struct attribute" ) {
666+ // When both operands are non-nullable, decomposeEqualNullSafe returns bare conjunction.
667+ val structType = StructType (Seq (
668+ StructField (" a" , IntegerType , nullable = false ),
669+ StructField (" b" , IntegerType , nullable = false )))
670+ val nonNullableRelation = LocalRelation (
671+ AttributeReference (" s" , structType, nullable = false )())
672+ val sAttr = nonNullableRelation.output.head
673+ val literal = litStruct((" a" , 1 , IntegerType ), (" b" , 2 , IntegerType ))
674+ val original = Not (EqualNullSafe (sAttr, literal))
675+
676+ val rewritten = optimizeWithRelation(original, nonNullableRelation)
677+
678+ // No If wrapper needed when both are non-nullable
679+ val hasIf = rewritten.collect { case _ : If => true }
680+ assert(hasIf.isEmpty,
681+ s " Non-nullable <=> should decompose to bare conjunction (no If). Got: $rewritten" )
682+
683+ val matchRow = InternalRow (InternalRow (1 , 2 ))
684+ val noMatchRow = InternalRow (InternalRow (3 , 4 ))
685+ val boundOriginal = BindReferences .bindReference(original, Seq (sAttr))
686+ val boundRewritten = BindReferences .bindReference(rewritten, Seq (sAttr))
687+
688+ assert(boundOriginal.eval(matchRow) === boundRewritten.eval(matchRow))
689+ assert(boundOriginal.eval(noMatchRow) === boundRewritten.eval(noMatchRow))
690+ }
691+
692+ test(" SPARK-56660: decomposeEqualNullSafe (true, true) is unreachable with current " +
693+ " canDecompose invariant" ) {
694+ // Document that (true, true) in decomposeEqualNullSafe is unreachable because
695+ // canDecompose requires one foldable operand, and foldable CreateNamedStruct is
696+ // always non-nullable. Two nullable attributes fail canDecompose (neither is foldable).
697+ val structType = StructType (Seq (StructField (" a" , IntegerType , nullable = true )))
698+ val rel = LocalRelation (
699+ AttributeReference (" s1" , structType, nullable = true )(),
700+ AttributeReference (" s2" , structType, nullable = true )())
701+ val s1 = rel.output(0 )
702+ val s2 = rel.output(1 )
703+ val original = Not (EqualNullSafe (s1, s2))
704+
705+ val rewritten = optimizeWithRelation(original, rel)
706+
707+ // The rule should NOT fire (neither is foldable) -- condition stays as Not(s1 <=> s2)
708+ val hasStructEns = rewritten.collect {
709+ case EqualNullSafe (l, _) if l.dataType.isInstanceOf [StructType ] => true
710+ }
711+ assert(hasStructEns.nonEmpty,
712+ s " Both-nullable col <=> col should NOT be decomposed. Got: $rewritten" )
713+ }
714+
458715 // Note on schema mismatch: the analyzer rejects struct comparisons with different
459716 // field types at type-check time (see DATATYPE_MISMATCH.BINARY_OP_DIFF_TYPES) before
460717 // the optimizer ever runs. The `sameType` check inside `canDecompose` is therefore a
0 commit comments