Skip to content

Commit d1c02ec

Browse files
committed
[SPARK-56660][SQL][FOLLOWUP] Add EqualNullSafe eval test and clarifying comments for the null-guard path
1 parent 1688ec0 commit d1c02ec

3 files changed

Lines changed: 413 additions & 35 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/expressions.scala

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -606,18 +606,15 @@ object DecomposeStructComparison extends Rule[LogicalPlan] with PredicateHelper
606606
// there NULL vs FALSE is observable -- so those go through the null-preserving
607607
// `decomposeCondition` path unchanged.
608608
val rewritten = splitConjunctivePredicates(condition).map {
609-
case EqualTo(l, r) if canDecompose(l, r) && filterEquivalentToConjunction(l, r) =>
609+
case Equality(l, r)
610+
if canDecompose(l, r) && filterEquivalentToConjunction(l, r) =>
610611
// Guard: AND IsNotNull(<nullable operand>) to preserve whole-null vs
611612
// all-null-fields semantics. Without it, a whole-null struct row would
612613
// pass the fieldConjunction when the literal has all-null fields.
613-
val nullGuards = (if (l.nullable) Seq(IsNotNull(l)) else Nil) ++
614-
(if (r.nullable) Seq(IsNotNull(r)) else Nil)
615-
val conjunction = fieldConjunction(l, r)
616-
if (nullGuards.isEmpty) conjunction
617-
else (nullGuards :+ conjunction).reduce(And)
618-
case EqualNullSafe(l, r) if canDecompose(l, r) =>
619-
// <=> on the pushable path (one foldable, one cheap). Guard nullable operands
620-
// with IsNotNull so that a whole-null struct is not conflated with struct(nulls).
614+
// Note: canDecompose requires one foldable operand (always non-nullable),
615+
// so both-nullable is unreachable and filterEquivalentToConjunction is
616+
// always true here -- the guard is a safety net that also makes the <=>
617+
// path fall through to decomposeEqualNullSafe when needed.
621618
val nullGuards = (if (l.nullable) Seq(IsNotNull(l)) else Nil) ++
622619
(if (r.nullable) Seq(IsNotNull(r)) else Nil)
623620
val conjunction = fieldConjunction(l, r)
@@ -640,6 +637,8 @@ object DecomposeStructComparison extends Rule[LogicalPlan] with PredicateHelper
640637
* shape has a non-nullable literal side, so it takes the pushable path.
641638
*/
642639
private def filterEquivalentToConjunction(left: Expression, right: Expression): Boolean =
640+
// Safety net: foldable structs (CreateNamedStruct) are non-nullable, so both-nullable is
641+
// currently unreachable given the foldable+cheap scope-narrowing invariant in canDecompose.
643642
!(left.nullable && right.nullable)
644643

645644
private def decomposeCondition(expr: Expression): Expression = expr.transformWithPruning(

sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/StructPredicateDecomposeSuite.scala

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)