Skip to content

Commit 8941106

Browse files
committed
[SPARK-56660][SQL][FOLLOWUP] Preserve NULL semantics; gate behind SQLConf; rework tests
Addresses review feedback on PR #56244: 1. Correctness fix for NULL handling. The original decomposition rewrote EqualTo(struct, struct) into a plain conjunction of per-field EqualTo comparisons, which silently changed semantics for non-null structs that contained NULL fields: - Before this PR: struct(1, null) = struct(1, null) returned TRUE (Spark's whole-struct EqualTo evaluates ordering.equiv on the row, which treats per-field NULL == NULL as equal). - With original PR #56244: returned NULL. The fix wraps the conjunction with a null-check that mirrors the original outer null behavior: - EqualTo(L, R) over nullable structs: IF (L IS NULL OR R IS NULL) THEN NULL ELSE And(EqualNullSafe(L.fi, R.fi)). - EqualNullSafe(L, R): IF (L IS NULL AND R IS NULL) THEN TRUE ELSE IF (L IS NULL OR R IS NULL) THEN FALSE ELSE And(EqualNullSafe(L.fi, R.fi)). The wrappers fold out cleanly when either operand is non-nullable, leaving the simple conjunction in the common `CreateNamedStruct = column` pushdown case. 2. SQLConf gate. Add `spark.sql.optimizer.decomposeStructComparison.enabled` (default false) so users opt in once the behavior has soaked. Add `spark.sql.optimizer.decomposeStructComparison.maxFields` (default 1000) that bounds total decomposed predicates including recursively nested struct fields, replacing the unprincipled per-level field cap of 100. 3. Scaladoc explaining Filter scope. Document why join conditions and aggregate grouping keys are deliberately not rewritten. 4. Tests reworked as oracle tests. The original suite asserted post-rewrite NULL behavior directly, which codified the regression as expected. The rewritten suite uses two patterns: - Catalyst-level: build expressions and assert eval result of original expression equals eval result of rewritten expression on representative inputs (struct(1, null), whole-struct null, Not wrapper, etc.). - End-to-end: run each query with the rule enabled and with the conf disabled; assert row sets are identical. Added tests for: Not(struct = struct) with NULL fields, whole-struct null on one side, conf gating. Removed: 3 wrong-oracle NULL tests, structural- only "nullable fields decomposes" test, duplicate LessThan, duplicate 3-level nested, single-field, duplicate join test in catalyst suite.
1 parent 707a859 commit 8941106

4 files changed

Lines changed: 451 additions & 264 deletions

File tree

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

Lines changed: 123 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -565,43 +565,149 @@ object BooleanSimplification extends Rule[LogicalPlan] with PredicateHelper {
565565

566566

567567
/**
568-
* Decomposes struct-level equality comparisons into conjunctions of field-level equalities.
569-
* This enables filter pushdown for individual struct fields.
570-
* For example, `struct_col = struct(1, 'a')` becomes
571-
* `struct_col.field1 = 1 AND struct_col.field2 = 'a'`.
568+
* Decomposes struct-level equality comparisons appearing in Filter conditions into a
569+
* conjunction of field-level equalities. This enables per-field filter pushdown to data
570+
* sources (Parquet row group skipping, Iceberg/Delta column statistics, partition pruning).
571+
*
572+
* For a non-nullable struct comparison `struct_col = struct(1, 'a')`, the rewrite produces
573+
* `struct_col.field1 = 1 AND struct_col.field2 = 'a'`. When either operand is nullable,
574+
* the conjunction is wrapped in a null-check expression that mirrors the original
575+
* struct-level comparison's NULL semantics (see comments on `decomposeEqualTo` and
576+
* `decomposeEqualNullSafe`).
577+
*
578+
* Scope: the rule only rewrites comparisons appearing inside `Filter` conditions. It
579+
* deliberately does NOT rewrite:
580+
* - Join conditions: an equi-join key on a struct is matched by the planner as a single
581+
* equality and routes to BroadcastHashJoin / SortMergeJoin. Decomposing it would
582+
* break key matching for those join strategies.
583+
* - Aggregate grouping expressions: structurally cannot be reached because the
584+
* transformation is scoped to `case Filter`.
585+
* - Project expressions: outside the pushdown path; rewriting them does not enable
586+
* pushdown and would expand the projection unnecessarily.
587+
*
588+
* The rewrite is gated on `spark.sql.optimizer.decomposeStructComparison.enabled`
589+
* (default off) so users can opt in once the behavior has soaked in their workloads.
590+
* The total number of decomposed predicates per top-level comparison is bounded by
591+
* `spark.sql.optimizer.decomposeStructComparison.maxFields` to prevent runaway expansion
592+
* on deeply nested or wide structs.
572593
*/
573594
object DecomposeStructComparison extends Rule[LogicalPlan] {
574-
def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
575-
_.containsPattern(FILTER), ruleId) {
576-
case f @ Filter(condition, _) =>
577-
f.copy(condition = decomposeCondition(condition))
595+
def apply(plan: LogicalPlan): LogicalPlan = {
596+
if (!conf.decomposeStructComparisonEnabled) {
597+
return plan
598+
}
599+
plan.transformWithPruning(_.containsPattern(FILTER), ruleId) {
600+
case f @ Filter(condition, _) =>
601+
f.copy(condition = decomposeCondition(condition))
602+
}
578603
}
579604

580605
private def decomposeCondition(expr: Expression): Expression = expr.transformWithPruning(
581606
_.containsPattern(BINARY_COMPARISON)) {
582607
case EqualTo(left, right) if canDecompose(left, right) =>
583-
decompose(left, right, EqualTo)
608+
decomposeEqualTo(left, right)
584609
case EqualNullSafe(left, right) if canDecompose(left, right) =>
585-
decompose(left, right, EqualNullSafe)
610+
decomposeEqualNullSafe(left, right)
586611
}
587612

588613
private def canDecompose(left: Expression, right: Expression): Boolean = {
589614
(left.dataType, right.dataType) match {
590615
case (l: StructType, r: StructType) =>
591-
l.length > 0 && l.length <= 100 && l.length == r.length &&
616+
// The analyzer has already validated the operand types match structurally
617+
// (BinaryComparison uses equalsStructurally, which checks types positionally
618+
// and ignores field names). We bound expansion via maxFields and require
619+
// determinism so the rewrite does not duplicate side effects.
620+
l.length > 0 && l.length == r.length &&
621+
totalLeafFields(l) <= conf.decomposeStructComparisonMaxFields &&
592622
left.deterministic && right.deterministic
593623
case _ => false
594624
}
595625
}
596626

597-
private def decompose(
598-
left: Expression,
599-
right: Expression,
600-
cmp: (Expression, Expression) => Expression): Expression = {
627+
/**
628+
* Counts the total number of leaf (non-struct) fields in a struct type, recursing into
629+
* nested structs. Used to bound rewrite expansion on deeply nested structs: a 3-level
630+
* nested struct of 100 fields per level expands to 1M field-level predicates, which
631+
* `maxFields` is intended to prevent.
632+
*/
633+
private def totalLeafFields(t: StructType): Int = {
634+
t.fields.iterator.map { f =>
635+
f.dataType match {
636+
case s: StructType => totalLeafFields(s)
637+
case _ => 1
638+
}
639+
}.sum
640+
}
641+
642+
/**
643+
* Builds the field-by-field conjunction `(L.f0 <=> R.f0) AND ... AND (L.fn <=> R.fn)`.
644+
* Per-field comparisons use `EqualNullSafe` so that NULL fields at the same position
645+
* compare as equal -- this matches Spark's existing struct equality semantics, where
646+
* `InterpretedOrdering.compare` treats two NULLs at the same field position as equal
647+
* and continues comparing remaining fields.
648+
*/
649+
private def fieldConjunction(left: Expression, right: Expression): Expression = {
601650
val fields = left.dataType.asInstanceOf[StructType].fields
602651
fields.indices.map { i =>
603-
cmp(GetStructField(left, i), GetStructField(right, i))
604-
}.reduceLeft(And)
652+
EqualNullSafe(GetStructField(left, i), GetStructField(right, i)).asInstanceOf[Expression]
653+
}.reduce(And)
654+
}
655+
656+
/**
657+
* Decomposes `EqualTo(L, R)` over struct types preserving original semantics:
658+
* - If either L or R is NULL (the whole struct), the result is NULL.
659+
* - Otherwise, two struct values are equal iff each pair of fields at the same
660+
* position is equal, with NULL at the same field position counting as equal
661+
* (per `InterpretedOrdering`).
662+
*
663+
* Lowering: `IF (L IS NULL OR R IS NULL) THEN NULL ELSE And(EqualNullSafe(L.fi, R.fi))`.
664+
* The outer null-check is omitted when both operands are non-nullable.
665+
*/
666+
private def decomposeEqualTo(left: Expression, right: Expression): Expression = {
667+
val conjunction = fieldConjunction(left, right)
668+
val nullableSides = (if (left.nullable) Seq(IsNull(left)) else Nil) ++
669+
(if (right.nullable) Seq(IsNull(right)) else Nil)
670+
if (nullableSides.isEmpty) {
671+
conjunction
672+
} else {
673+
val anyNull = nullableSides.reduce(Or)
674+
If(anyNull, Literal(null, BooleanType), conjunction)
675+
}
676+
}
677+
678+
/**
679+
* Decomposes `EqualNullSafe(L, R)` (`<=>`) over struct types preserving original semantics:
680+
* - `null <=> null` -> TRUE
681+
* - exactly one side null -> FALSE
682+
* - both sides non-null -> equal iff each field pair is equal under `<=>`
683+
*
684+
* Lowering:
685+
* IF (L IS NULL AND R IS NULL) THEN TRUE
686+
* ELSE IF (L IS NULL OR R IS NULL) THEN FALSE
687+
* ELSE And(EqualNullSafe(L.fi, R.fi))
688+
*
689+
* Branches whose preconditions are statically false (because the corresponding operand is
690+
* non-nullable) are omitted.
691+
*/
692+
private def decomposeEqualNullSafe(left: Expression, right: Expression): Expression = {
693+
val conjunction = fieldConjunction(left, right)
694+
(left.nullable, right.nullable) match {
695+
case (false, false) =>
696+
conjunction
697+
case (true, false) =>
698+
// Right cannot be null. If left is null, result is FALSE; else conjunction.
699+
If(IsNull(left), Literal.FalseLiteral, conjunction)
700+
case (false, true) =>
701+
// Left cannot be null. If right is null, result is FALSE; else conjunction.
702+
If(IsNull(right), Literal.FalseLiteral, conjunction)
703+
case (true, true) =>
704+
// Both nullable. Need to distinguish both-null (TRUE) from exactly-one-null (FALSE).
705+
If(And(IsNull(left), IsNull(right)),
706+
Literal.TrueLiteral,
707+
If(Or(IsNull(left), IsNull(right)),
708+
Literal.FalseLiteral,
709+
conjunction))
710+
}
605711
}
606712
}
607713

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,30 @@ object SQLConf {
522522
.booleanConf
523523
.createWithDefault(true)
524524

525+
val DECOMPOSE_STRUCT_COMPARISON_ENABLED =
526+
buildConf("spark.sql.optimizer.decomposeStructComparison.enabled")
527+
.doc("When true, the optimizer rewrites struct equality (= and <=>) appearing in Filter " +
528+
"conditions into a conjunction of field-level equalities, enabling per-field filter " +
529+
"pushdown to data sources. The rewrite preserves NULL semantics by wrapping the " +
530+
"conjunction in a null-check that mirrors the original struct-level comparison.")
531+
.version("4.2.0")
532+
.withBindingPolicy(ConfigBindingPolicy.SESSION)
533+
.booleanConf
534+
.createWithDefault(false)
535+
536+
val DECOMPOSE_STRUCT_COMPARISON_MAX_FIELDS =
537+
buildConf("spark.sql.optimizer.decomposeStructComparison.maxFields")
538+
.internal()
539+
.doc("The maximum total number of field-level predicates a single struct comparison may " +
540+
"be decomposed into, including fields from recursively nested structs. Comparisons " +
541+
"exceeding this limit are left as struct-level predicates. Used to bound rewrite cost " +
542+
"and memory for deeply nested or wide structs.")
543+
.version("4.2.0")
544+
.withBindingPolicy(ConfigBindingPolicy.SESSION)
545+
.intConf
546+
.checkValue(_ > 0, "The threshold must be positive.")
547+
.createWithDefault(1000)
548+
525549
val OPTIMIZER_EXCLUDED_RULES = buildConf("spark.sql.optimizer.excludedRules")
526550
.doc("Configures a list of rules to be disabled in the optimizer, in which the rules are " +
527551
"specified by their rule names and separated by comma. It is not guaranteed that all the " +
@@ -8581,6 +8605,10 @@ class SQLConf extends Serializable with Logging with SqlApiConf {
85818605

85828606
def avoidDoubleFilterEval: Boolean = getConf(AVOID_DOUBLE_FILTER_EVAL)
85838607

8608+
def decomposeStructComparisonEnabled: Boolean = getConf(DECOMPOSE_STRUCT_COMPARISON_ENABLED)
8609+
8610+
def decomposeStructComparisonMaxFields: Int = getConf(DECOMPOSE_STRUCT_COMPARISON_MAX_FIELDS)
8611+
85848612
def readSideCharPadding: Boolean = getConf(SQLConf.READ_SIDE_CHAR_PADDING)
85858613

85868614
def cliPrintHeader: Boolean = getConf(SQLConf.CLI_PRINT_HEADER)

0 commit comments

Comments
 (0)