Skip to content

Commit 1688ec0

Browse files
committed
[SPARK-56660][SQL][FOLLOWUP] Narrow struct decomposition to foldable+cheap operands and guard NULL semantics on the pushable path
1 parent 80c61f9 commit 1688ec0

4 files changed

Lines changed: 153 additions & 20 deletions

File tree

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -607,12 +607,22 @@ object DecomposeStructComparison extends Rule[LogicalPlan] with PredicateHelper
607607
// `decomposeCondition` path unchanged.
608608
val rewritten = splitConjunctivePredicates(condition).map {
609609
case EqualTo(l, r) if canDecompose(l, r) && filterEquivalentToConjunction(l, r) =>
610-
fieldConjunction(l, r)
610+
// Guard: AND IsNotNull(<nullable operand>) to preserve whole-null vs
611+
// all-null-fields semantics. Without it, a whole-null struct row would
612+
// 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)
611618
case EqualNullSafe(l, r) if canDecompose(l, r) =>
612-
// <=> is filter-equivalent to the bare conjunction for every nullability
613-
// combination (both-null -> TRUE under <=>, and the conjunction's
614-
// `null <=> null` per field also yields TRUE).
615-
fieldConjunction(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).
621+
val nullGuards = (if (l.nullable) Seq(IsNotNull(l)) else Nil) ++
622+
(if (r.nullable) Seq(IsNotNull(r)) else Nil)
623+
val conjunction = fieldConjunction(l, r)
624+
if (nullGuards.isEmpty) conjunction
625+
else (nullGuards :+ conjunction).reduce(And)
616626
case other =>
617627
decomposeCondition(other)
618628
}.reduce(And)
@@ -643,11 +653,15 @@ object DecomposeStructComparison extends Rule[LogicalPlan] with PredicateHelper
643653
private def canDecompose(left: Expression, right: Expression): Boolean = {
644654
(left.dataType, right.dataType) match {
645655
case (l: StructType, r: StructType) =>
646-
// The analyzer has already validated the operand types match structurally
647-
// (BinaryComparison uses equalsStructurally, which checks types positionally
648-
// and ignores field names). We bound expansion via maxFields and require
649-
// determinism so the rewrite does not duplicate side effects.
656+
// Gate: one operand must be foldable (literal/constant) and the other must be cheap
657+
// (attribute/extractvalue -- see CollapseProject.isCheap). This avoids:
658+
// - col=col shapes that yield no pushdown benefit (field conjunction is just expansion)
659+
// - non-cheap operands (e.g. UDFs) that would be duplicated N times
660+
val hasFoldableAndCheap =
661+
(left.foldable && CollapseProject.isCheap(right)) ||
662+
(right.foldable && CollapseProject.isCheap(left))
650663
l.length > 0 && l.length == r.length &&
664+
hasFoldableAndCheap &&
651665
totalLeafFields(l) <= conf.decomposeStructComparisonMaxFields &&
652666
left.deterministic && right.deterministic
653667
case _ => false

sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ object SQLConf {
528528
"conditions into a conjunction of field-level equalities, enabling per-field filter " +
529529
"pushdown to data sources. The rewrite preserves NULL semantics by wrapping the " +
530530
"conjunction in a null-check that mirrors the original struct-level comparison.")
531-
.version("4.2.0")
531+
.version("4.3.0")
532532
.withBindingPolicy(ConfigBindingPolicy.SESSION)
533533
.booleanConf
534534
.createWithDefault(false)
@@ -540,7 +540,7 @@ object SQLConf {
540540
"be decomposed into, including fields from recursively nested structs. Comparisons " +
541541
"exceeding this limit are left as struct-level predicates. Used to bound rewrite cost " +
542542
"and memory for deeply nested or wide structs.")
543-
.version("4.2.0")
543+
.version("4.3.0")
544544
.withBindingPolicy(ConfigBindingPolicy.SESSION)
545545
.intConf
546546
.checkValue(_ > 0, "The threshold must be positive.")

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

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

sql/core/src/test/scala/org/apache/spark/sql/StructPredicateDecomposeE2ESuite.scala

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ class StructPredicateDecomposeE2ESuite extends QueryTest with SharedSparkSession
6666

6767
assertSameUnderRule(df.filter($"s" === struct(lit("a"), lit("x"))))
6868

69-
// Plan inspection: with the rule on, no struct-level EqualTo should remain.
69+
// After scope narrowing, the rule requires one foldable + one cheap operand.
70+
// When CollapseProject inlines the struct projection, the filter sees
71+
// CreateNamedStruct(f1, f2) = CreateNamedStruct(lit_a, lit_x) -- the left side
72+
// is not cheap (not an attribute). So decomposition does NOT fire here.
73+
// The rule fires for base attributes from scans (e.g. Parquet), tested separately.
7074
withSQLConf(SQLConf.DECOMPOSE_STRUCT_COMPARISON_ENABLED.key -> "true") {
7175
val optimizedPlan = df.filter($"s" === struct(lit("a"), lit("x")))
7276
.queryExecution.optimizedPlan
@@ -76,8 +80,8 @@ class StructPredicateDecomposeE2ESuite extends QueryTest with SharedSparkSession
7680
case e: org.apache.spark.sql.catalyst.expressions.EqualTo
7781
if e.left.dataType.isInstanceOf[org.apache.spark.sql.types.StructType] => true
7882
}.nonEmpty)
79-
assert(!hasStructComparison,
80-
"Struct equality should be decomposed (no struct-level EqualTo in optimized plan)")
83+
assert(hasStructComparison,
84+
"Inline struct projection should NOT be decomposed (left operand is not cheap)")
8185
}
8286
}
8387

@@ -217,6 +221,55 @@ class StructPredicateDecomposeE2ESuite extends QueryTest with SharedSparkSession
217221
}
218222
}
219223

224+
test("SPARK-56660: all-null-fields literal vs whole-null struct (pushable EqualTo path)") {
225+
// Bug: when the literal has ALL fields null, the pushable fieldConjunction emits
226+
// `s.a <=> null` which evaluates to TRUE on a whole-null-struct row (GetStructField
227+
// on null struct returns null). The original `s = named_struct('a', null)` returns
228+
// NULL on a whole-null row (dropped by WHERE). The rewrite incorrectly keeps it.
229+
withTempPath { path =>
230+
val dir = path.getCanonicalPath
231+
// Create data with a genuine whole-null struct row using when/otherwise
232+
Seq((1, 1), (2, -1), (3, 2))
233+
.toDF("id", "a")
234+
.withColumn("s", when($"a" =!= -1, struct($"a"))) // id=2 gets NULL struct
235+
.select("id", "s")
236+
.write.parquet(dir)
237+
238+
val df = spark.read.parquet(dir)
239+
assert(df.schema("s").nullable, "precondition: struct column should be nullable")
240+
241+
// The correct result: `s = named_struct('a', CAST(NULL AS INT))` on a whole-null
242+
// struct row returns NULL (row dropped). No row has s=struct(null) with s non-null,
243+
// so the correct result is EMPTY.
244+
assertSameUnderRule(
245+
df.filter($"s" === struct(lit(null).cast("int"))))
246+
}
247+
}
248+
249+
test("SPARK-56660: all-null-fields EqualNullSafe with two nullable struct columns") {
250+
// Bug: the <=> branch in the pushable path (expressions.scala:611) is unguarded.
251+
// Two nullable struct columns both null: original `null <=> null` -> TRUE (keep),
252+
// but if one is null and the other is struct(null), original is FALSE (drop).
253+
// The fieldConjunction can't distinguish these cases.
254+
withTempPath { path =>
255+
val dir = path.getCanonicalPath
256+
// Row 1: s1=struct(null), s2=struct(null) -> s1 <=> s2 is TRUE (keep)
257+
// Row 2: s1=NULL (whole), s2=struct(null) -> s1 <=> s2 is FALSE (drop)
258+
// Row 3: s1=struct(1), s2=struct(1) -> s1 <=> s2 is TRUE (keep)
259+
Seq((1, 1), (2, -1), (3, 2))
260+
.toDF("id", "v")
261+
.withColumn("s1", when($"v" =!= -1, struct(lit(null).cast("int").as("a")))
262+
.otherwise(lit(null).cast("struct<a:int>")))
263+
.withColumn("s2", struct(lit(null).cast("int").as("a"))) // always struct(null)
264+
.select("id", "s1", "s2")
265+
.write.parquet(dir)
266+
267+
val df = spark.read.parquet(dir)
268+
// Correct: rows 1 and 3 match. Row 2 must NOT match.
269+
assertSameUnderRule(df.filter($"s1" <=> $"s2"))
270+
}
271+
}
272+
220273
test("SPARK-56660: rule is gated by spark.sql.optimizer.decomposeStructComparison.enabled") {
221274
val df = Seq(
222275
(1, "a", "x"),

0 commit comments

Comments
 (0)