cloud-fan commented on code in PR #58077:
URL: https://github.com/apache/spark/pull/58077#discussion_r3914041627
##########
sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala:
##########
@@ -2678,4 +2681,277 @@ class SubquerySuite extends SharedSparkSession
assert(exposedAttribute.exprId == outerReferenceAttribute.exprId)
}
+
+ test("SPARK-58481: InSubqueryExec nullable correctly accounts for subquery
output nullability") {
Review Comment:
**Non-blocking (P2):** This test does not currently exercise
`InSubqueryExec.nullable`: the default
`spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled=true`
rewrites this join-condition IN before `PlanSubqueries`. Removing the
RHS-derived nullability would still leave the six-row assertion green. Please
disable that rewrite here and assert that the executed condition contains
`InSubqueryExec` before checking the result.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala:
##########
@@ -165,14 +197,187 @@ case class InSubqueryExec(
}
}
+ // Invariant schema/ordering data for the multi-column evaluator, computed
once after the result
+ // is available. @transient so that serialization (result=null) does not
trigger evaluation.
+ @transient private lazy val multiColFieldTypes: Array[DataType] =
+ plan.output.map(_.dataType).toArray
+ @transient private lazy val multiColFieldOrderings: Array[Ordering[Any]] =
+ multiColFieldTypes.map(TypeUtils.getInterpretedOrdering)
+ // Struct-level ordering used to index fully non-null result rows in a
TreeSet.
+ @transient private lazy val multiColRowOrdering: Ordering[InternalRow] =
+
TypeUtils.getInterpretedOrdering(child.dataType).asInstanceOf[Ordering[InternalRow]]
+
+ // Split collected rows into two sorted sets, both using the struct-level
Catalyst ordering
+ // so that duplicate rows are deduplicated. Fully non-null rows go into
multiColNonNullSet
+ // for O(log n) membership tests; rows with at least one null field go into
multiColNullRows
+ // (also a TreeSet, not an Array) so each distinct null-containing row is
scanned at most
+ // once per outer row regardless of RHS duplicate multiplicity. See
SPARK-58481.
+ @transient private lazy val (multiColNonNullSet, multiColNullRows) = {
+ val withNull = TreeSet.newBuilder[InternalRow](multiColRowOrdering)
+ val nonNull = TreeSet.newBuilder[InternalRow](multiColRowOrdering)
+ result.foreach { r =>
+ val row = r.asInstanceOf[InternalRow]
+ if (row.anyNull) withNull += row else nonNull += row
+ }
+ (nonNull.result(), withNull.result().toArray)
+ }
+
+ // Three-valued IN semantics for multi-column subqueries.
+ // Result rows are InternalRow objects; InSet's TreeSet uses Catalyst
ordering, but membership
+ // cannot distinguish a definitively-false candidate from an indeterminate
one.
+ //
+ // When the LHS struct has no null fields:
+ // Fast path: O(log n) TreeSet lookup against fully non-null result rows
for TRUE.
+ // Slow path: linear scan over null-containing result rows only for
potential UNKNOWN.
+ //
+ // When the LHS struct has at least one null field, the fast path cannot be
used (a null LHS
+ // field produces UNKNOWN against any non-null RHS row whose non-null fields
all match). Both
+ // sets of result rows are scanned linearly, stopping once UNKNOWN is
established.
+ //
+ // Per-candidate three-valued logic: TRUE if every field matches; UNKNOWN if
no field is
+ // definitively unequal but at least one comparison involves null; FALSE
otherwise.
+ private def evalMultiColumn(inputRow: InternalRow): Any = {
+ // Current behavior (ANSI on, or legacyNullInEmptyBehavior=false): IN
(empty set) is always
+ // FALSE without evaluating the LHS. Legacy behavior (ANSI off by default)
returns NULL when
+ // the LHS is null and FALSE otherwise, requiring the LHS to be evaluated.
Mirror InSet.eval's
+ // guard exactly: skip child.eval only when legacyNullInEmptyBehavior is
false (SPARK-44550).
+ if (result.isEmpty && !legacyNullInEmptyBehavior) return false
+ val value = child.eval(inputRow)
+ if (value == null) return null
+ val inputStruct = value.asInstanceOf[InternalRow]
+ val fieldTypes = multiColFieldTypes
+ val orderings = multiColFieldOrderings
+ val numFields = fieldTypes.length
+ // Cache lazy accessors in locals so the loop bodies do not re-enter them
on every iteration.
+ val nullRows = multiColNullRows
+ val nonNullSet = multiColNonNullSet
+
+ if (!inputStruct.anyNull) {
+ // Fast path: indexed lookup among fully non-null candidates.
+ if (nonNullSet.contains(inputStruct)) return true
+ // No null-containing candidates: no path to UNKNOWN, result is FALSE.
+ if (nullRows.isEmpty) return false
+ // Materialize LHS fields once before the candidate scans to avoid
repeated get() calls
+ // inside the per-candidate loop.
+ val inputFields = Array.tabulate(numFields)(i => inputStruct.get(i,
fieldTypes(i)))
+ // Slow path: scan null-containing candidates for potential UNKNOWN.
+ // Stop early once hasUnknown is set: the indexed lookup already ruled
out TRUE,
+ // and every row here contains NULL, so no later candidate can improve
UNKNOWN to TRUE.
+ var hasUnknown = false
+ var i = 0
+ while (i < nullRows.length && !hasUnknown) {
+ val candidate = nullRows(i)
+ var fieldIdx = 0
+ var candidateIsUnknown = false
+ var candidateIsFalse = false
+ while (fieldIdx < numFields && !candidateIsFalse) {
+ val candidateField = candidate.get(fieldIdx, fieldTypes(fieldIdx))
+ if (candidateField == null) {
+ candidateIsUnknown = true
+ } else if (orderings(fieldIdx).compare(inputFields(fieldIdx),
candidateField) != 0) {
+ candidateIsFalse = true
+ }
+ fieldIdx += 1
+ }
+ if (!candidateIsFalse && candidateIsUnknown) hasUnknown = true
+ i += 1
+ }
+ if (hasUnknown) null else false
+ } else {
+ // LHS has at least one null field: must scan both result sets, stopping
once UNKNOWN
+ // is established (a null LHS field can produce UNKNOWN against any
non-null RHS row
+ // whose other fields all match).
+ // No candidates at all: result is FALSE (no match possible).
+ if (nullRows.isEmpty && nonNullSet.isEmpty) return false
+ // Materialize LHS fields once before the scans to avoid repeated get()
calls.
+ val inputFields = Array.tabulate(numFields)(i => inputStruct.get(i,
fieldTypes(i)))
+ var hasUnknown = false
+ // Scan null-containing result rows first.
+ var i = 0
+ while (i < nullRows.length && !hasUnknown) {
Review Comment:
**Non-blocking (P2):** No added test reaches this loop with null-containing
rows on both sides. Please add a forced physical multi-column case that
distinguishes `(NULL, 1) IN ((NULL, 1))` (UNKNOWN) from `(NULL, 1) IN ((NULL,
2))` (FALSE). An implementation that returns UNKNOWN at the first null would
otherwise pass every current case while missing the later definitive mismatch.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala:
##########
@@ -165,14 +197,187 @@ case class InSubqueryExec(
}
}
+ // Invariant schema/ordering data for the multi-column evaluator, computed
once after the result
+ // is available. @transient so that serialization (result=null) does not
trigger evaluation.
+ @transient private lazy val multiColFieldTypes: Array[DataType] =
+ plan.output.map(_.dataType).toArray
+ @transient private lazy val multiColFieldOrderings: Array[Ordering[Any]] =
+ multiColFieldTypes.map(TypeUtils.getInterpretedOrdering)
+ // Struct-level ordering used to index fully non-null result rows in a
TreeSet.
+ @transient private lazy val multiColRowOrdering: Ordering[InternalRow] =
+
TypeUtils.getInterpretedOrdering(child.dataType).asInstanceOf[Ordering[InternalRow]]
+
+ // Split collected rows into two sorted sets, both using the struct-level
Catalyst ordering
+ // so that duplicate rows are deduplicated. Fully non-null rows go into
multiColNonNullSet
+ // for O(log n) membership tests; rows with at least one null field go into
multiColNullRows
+ // (also a TreeSet, not an Array) so each distinct null-containing row is
scanned at most
+ // once per outer row regardless of RHS duplicate multiplicity. See
SPARK-58481.
+ @transient private lazy val (multiColNonNullSet, multiColNullRows) = {
+ val withNull = TreeSet.newBuilder[InternalRow](multiColRowOrdering)
+ val nonNull = TreeSet.newBuilder[InternalRow](multiColRowOrdering)
+ result.foreach { r =>
+ val row = r.asInstanceOf[InternalRow]
+ if (row.anyNull) withNull += row else nonNull += row
+ }
+ (nonNull.result(), withNull.result().toArray)
+ }
+
+ // Three-valued IN semantics for multi-column subqueries.
+ // Result rows are InternalRow objects; InSet's TreeSet uses Catalyst
ordering, but membership
+ // cannot distinguish a definitively-false candidate from an indeterminate
one.
+ //
+ // When the LHS struct has no null fields:
+ // Fast path: O(log n) TreeSet lookup against fully non-null result rows
for TRUE.
+ // Slow path: linear scan over null-containing result rows only for
potential UNKNOWN.
+ //
+ // When the LHS struct has at least one null field, the fast path cannot be
used (a null LHS
+ // field produces UNKNOWN against any non-null RHS row whose non-null fields
all match). Both
+ // sets of result rows are scanned linearly, stopping once UNKNOWN is
established.
+ //
+ // Per-candidate three-valued logic: TRUE if every field matches; UNKNOWN if
no field is
+ // definitively unequal but at least one comparison involves null; FALSE
otherwise.
+ private def evalMultiColumn(inputRow: InternalRow): Any = {
+ // Current behavior (ANSI on, or legacyNullInEmptyBehavior=false): IN
(empty set) is always
+ // FALSE without evaluating the LHS. Legacy behavior (ANSI off by default)
returns NULL when
+ // the LHS is null and FALSE otherwise, requiring the LHS to be evaluated.
Mirror InSet.eval's
+ // guard exactly: skip child.eval only when legacyNullInEmptyBehavior is
false (SPARK-44550).
+ if (result.isEmpty && !legacyNullInEmptyBehavior) return false
Review Comment:
**Non-blocking (P2):** The new empty-RHS regression covers only the
non-legacy branch that skips `child.eval`. Please add the complementary
forced-`InSubqueryExec` case with ANSI enabled and
`spark.sql.legacy.nullInEmptyListBehavior=true`, and verify that a
division-by-zero LHS raises `DIVIDE_BY_ZERO`. Otherwise an unconditional early
FALSE return would pass the current test while breaking the compatibility
branch.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala:
##########
@@ -165,14 +197,187 @@ case class InSubqueryExec(
}
}
+ // Invariant schema/ordering data for the multi-column evaluator, computed
once after the result
+ // is available. @transient so that serialization (result=null) does not
trigger evaluation.
+ @transient private lazy val multiColFieldTypes: Array[DataType] =
+ plan.output.map(_.dataType).toArray
+ @transient private lazy val multiColFieldOrderings: Array[Ordering[Any]] =
+ multiColFieldTypes.map(TypeUtils.getInterpretedOrdering)
+ // Struct-level ordering used to index fully non-null result rows in a
TreeSet.
+ @transient private lazy val multiColRowOrdering: Ordering[InternalRow] =
+
TypeUtils.getInterpretedOrdering(child.dataType).asInstanceOf[Ordering[InternalRow]]
+
+ // Split collected rows into two sorted sets, both using the struct-level
Catalyst ordering
+ // so that duplicate rows are deduplicated. Fully non-null rows go into
multiColNonNullSet
+ // for O(log n) membership tests; rows with at least one null field go into
multiColNullRows
+ // (also a TreeSet, not an Array) so each distinct null-containing row is
scanned at most
Review Comment:
**Nit (P3):** `multiColNullRows` is an `Array[InternalRow]`, not a
`TreeSet`: the initializer ends with `withNull.result().toArray`, and the
evaluator uses array indexing. Please update this comment so it describes the
deduplication step without claiming the stored representation remains a sorted
set.
##########
sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala:
##########
@@ -2678,4 +2681,277 @@ class SubquerySuite extends SharedSparkSession
assert(exposedAttribute.exprId == outerReferenceAttribute.exprId)
}
+
+ test("SPARK-58481: InSubqueryExec nullable correctly accounts for subquery
output nullability") {
+ // 5 NOT IN (99, NULL) is UNKNOWN, not TRUE or FALSE. A join condition
that is not TRUE
+ // matches no rows, so a FULL OUTER JOIN must emit null-padded rows for
every row in each
+ // side -- 3 + 3 = 6 null-padded rows -- not the full cross product (9
rows).
+ withTable("t0", "t1", "t3") {
+ sql("CREATE TABLE t0(c0 INT) USING PARQUET")
+ sql("INSERT INTO t0 VALUES (1), (2), (3)")
+ sql("CREATE TABLE t1(c0 INT) USING PARQUET")
+ sql("INSERT INTO t1 VALUES (10), (20), (30)")
+ sql("CREATE TABLE t3(c0 INT) USING PARQUET")
+ sql("INSERT INTO t3 VALUES (99), (CAST(NULL AS INT))")
+
+ // Unmatched t1 rows null-pad t0; unmatched t0 rows null-pad t1.
+ val expected = Seq(
+ Row(null, 10), Row(null, 20), Row(null, 30), // unmatched t1, t0
column null-padded
+ Row(1, null), Row(2, null), Row(3, null)) // unmatched t0, t1
column null-padded
+ checkAnswer(
+ sql("SELECT t0.c0, t1.c0 FROM t1 FULL OUTER JOIN t0 ON (5 NOT IN
(SELECT t3.c0 FROM t3))"),
+ expected)
+ }
+ }
+
+ test("SPARK-58481: multi-column IN subquery with nullable non-head output is
nullable") {
+ // Disable the optimizer's join-condition IN rewrite so the query
exercises InSubqueryExec.
+ // Use VALUES-derived temp views: their nullability is inferred from the
literals (no NULL
+ // literal => non-nullable), rather than declared and then widened.
Parquet file-source
+ // analysis applies dataSchema.asNullable regardless of DDL NOT NULL,
which would defeat
+ // the nullability control this test relies on.
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ // Case A: NULL after a definitive match (null in non-head position
after matching head).
+ // RHS: (99,99) and (1,NULL).
+ // (1,1) vs (99,99): first field 1!=99 => FALSE.
+ // (1,1) vs (1,NULL): first fields equal, second null => UNKNOWN.
+ // Overall for (1,1): UNKNOWN => NOT IN = null-padded.
+ // (2,2) vs both: all FALSE => NOT IN = TRUE => joins with both rhs
rows.
+ withTempView("lhs", "rhs") {
+ sql("CREATE TEMPORARY VIEW lhs AS SELECT * FROM VALUES (1, 1), (2, 2)
AS t(a, b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs AS
+ |SELECT * FROM VALUES (99, 99), (1, CAST(NULL AS INT)) AS t(a,
b)""".stripMargin)
+ checkAnswer(
+ sql(
+ """SELECT lhs.a, rhs.a FROM lhs FULL OUTER JOIN rhs
+ |ON ((lhs.a, lhs.b) NOT IN (SELECT a, b FROM
rhs))""".stripMargin),
+ Seq(Row(1, null), Row(2, 99), Row(2, 1)))
+ }
+
+ // Case B: NULL in head position followed by a definitive mismatch in a
later field.
+ // RHS: (NULL, 99).
+ // (1,1) vs (NULL,99): first field null => UNKNOWN so far; second
field 1!=99 => FALSE.
+ // A later definitive mismatch must override the earlier UNKNOWN:
result is FALSE,
+ // NOT IN = TRUE. A field-order regression would leave (1,1) as
UNKNOWN instead.
+ withTempView("lhs2", "rhs2") {
+ sql("CREATE TEMPORARY VIEW lhs2 AS SELECT * FROM VALUES (1, 1) AS t(a,
b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs2 AS
+ |SELECT * FROM VALUES (CAST(NULL AS INT), 99) AS t(a,
b)""".stripMargin)
+ // (1,1) NOT IN ((NULL,99)): second field 1!=99 makes the candidate
FALSE =>
+ // NOT IN = TRUE => inner join returns the single matching row.
+ checkAnswer(
+ sql(
+ """SELECT lhs2.a FROM lhs2 JOIN (SELECT 1 AS a)
+ |ON ((lhs2.a, lhs2.b) NOT IN (SELECT a, b FROM
rhs2))""".stripMargin),
+ Seq(Row(1)))
+ }
+
+ // Case C: UNKNOWN candidate followed by an exact-match candidate => IN
= TRUE.
+ // RHS: (1,NULL) and (1,1).
+ // (1,1) vs (1,NULL): first fields equal, second null => UNKNOWN.
+ // (1,1) vs (1,1): exact match => TRUE.
+ // The exact match must dominate the UNKNOWN: IN = TRUE, NOT IN =
FALSE.
+ // A candidate-order regression would short-circuit on UNKNOWN and
miss the TRUE.
+ withTempView("lhs3", "rhs3") {
+ sql("CREATE TEMPORARY VIEW lhs3 AS SELECT * FROM VALUES (1, 1) AS t(a,
b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs3 AS
+ |SELECT * FROM VALUES (1, CAST(NULL AS INT)), (1, 1) AS t(a,
b)""".stripMargin)
+ // (1,1) IN ((1,NULL),(1,1)): exact match exists => IN = TRUE => inner
join returns row.
+ checkAnswer(
+ sql(
+ """SELECT lhs3.a FROM lhs3 JOIN (SELECT 1 AS a)
+ |ON ((lhs3.a, lhs3.b) IN (SELECT a, b FROM
rhs3))""".stripMargin),
+ Seq(Row(1)))
+ }
+ }
+ }
+
+ test("SPARK-58481: multi-column IN subquery uses Catalyst ordering for
BinaryType fields") {
+ // Object.equals on Array[Byte] compares by identity, not value; Catalyst
ordering compares
+ // by content. A multi-column IN where one field is BinaryType would
incorrectly return FALSE
+ // (no match) with JVM equality even when the bytes are equal. Use an
inner join to keep the
+ // assertion simple: the join condition is TRUE iff the IN match succeeds.
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("lbin", "rbin") {
+ sql("CREATE TABLE lbin(id INT NOT NULL, b BINARY NOT NULL) USING
PARQUET")
+ sql("INSERT INTO lbin VALUES (1, X'01')")
+ sql("CREATE TABLE rbin(id INT NOT NULL, b BINARY NOT NULL) USING
PARQUET")
+ sql("INSERT INTO rbin VALUES (1, X'01')")
+ // (1, 0x01) IN ((1, 0x01)) must be TRUE; the join should return one
row.
+ checkAnswer(
+ sql(
+ """SELECT lbin.id FROM lbin JOIN rbin
+ |ON ((lbin.id, lbin.b) IN (SELECT id, b FROM
rbin))""".stripMargin),
+ Seq(Row(1)))
+ }
+ }
+ }
+
+ test("SPARK-58481: multi-column NOT IN with nullable LHS and non-nullable
RHS is nullable") {
+ // CreateNamedStruct.nullable is always false, so child.nullable would
return false for a
+ // multi-column LHS even when individual fields are nullable. The
generated NOT IN code
+ // would then suppress null handling and turn UNKNOWN into TRUE, producing
wrong results.
+ // Fixture: lhs.a is nullable; rhs columns are NOT NULL (VALUES-derived to
avoid Parquet
+ // dataSchema.asNullable widening that would defeat the RHS
non-nullability control).
+ // (NULL, 2) vs (99, 2): second fields equal (2=2), first field is null =>
UNKNOWN.
+ // (1, 1) vs (99, 2): first field 1!=99 => FALSE => NOT IN = TRUE =>
matches all rhs rows.
+ // FULL OUTER JOIN: (NULL,2) gets null-padded (UNKNOWN condition); (1,1)
joins with (99,2);
+ // since (1,1) matched rhs(99,2), rhs(99,2) is not null-padded.
+ // Pre-fix: (NULL,2) NOT IN is wrongly TRUE (null suppressed) => emits
(null,99); no
+ // null-padded rows. Post-fix: UNKNOWN propagated => emits (null,null)
for (NULL,2).
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("lhs") {
+ withTempView("rhs") {
+ sql("CREATE TABLE lhs(a INT, b INT NOT NULL) USING PARQUET")
+ sql("INSERT INTO lhs VALUES (1, 1), (NULL, 2)")
+ // rhs as VALUES view: both columns inferred non-nullable from
all-literal rows.
+ sql("CREATE TEMPORARY VIEW rhs AS SELECT * FROM VALUES (99, 2) AS
t(a, b)")
+ // (NULL, 2) NOT IN ((99,2)): second fields match, first is null =>
UNKNOWN
+ // => join condition not TRUE => (NULL,2) is null-padded:
Row(null, null).
+ // (1, 1) NOT IN ((99,2)): first field 1!=99 => FALSE => NOT IN =
TRUE
+ // => (1,1) joins with rhs(99,2): Row(1, 99). rhs(99,2) is
matched; no null-padded rhs.
+ checkAnswer(
+ sql(
+ """SELECT lhs.a, rhs.a FROM lhs FULL OUTER JOIN rhs
+ |ON ((lhs.a, lhs.b) NOT IN (SELECT a, b FROM
rhs))""".stripMargin),
+ Seq(Row(1, 99), Row(null, null)))
+ }
+ }
+ }
+ }
+
+ test("SPARK-58481: LEGACY_IN_SUBQUERY_NULLABILITY suppresses RHS-only
nullability") {
+ // Legacy mode suppresses only RHS-derived nullability (plan.output
nullable).
+ // A non-nullable scalar LHS (Literal 5) has lhsNullable=false; with RHS
suppressed,
+ // nullable=false. The generated code omits null handling and NOT IN on a
subquery that
+ // returns NULL evaluates to TRUE -- the pre-fix single-column behaviour
the flag preserves.
+ // Note: intentionally codegen-specific. The interpreted path correctly
returns UNKNOWN
+ // regardless of nullable (6 rows); the assertion of 9 verifies codegen
ran.
+ withSQLConf(
+ SQLConf.LEGACY_IN_SUBQUERY_NULLABILITY.key -> "true",
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("t0", "t1", "t3") {
+ sql("CREATE TABLE t0(c0 INT) USING PARQUET")
+ sql("INSERT INTO t0 VALUES (1), (2), (3)")
+ sql("CREATE TABLE t1(c0 INT) USING PARQUET")
+ sql("INSERT INTO t1 VALUES (10), (20), (30)")
+ sql("CREATE TABLE t3(c0 INT) USING PARQUET")
+ sql("INSERT INTO t3 VALUES (99), (CAST(NULL AS INT))")
+
+ // Legacy: lhsNullable=false (Literal 5), rhsNullable suppressed =>
nullable=false.
+ // Generated code suppresses null; 5 NOT IN (99, NULL) evaluates to
TRUE.
+ // FULL OUTER JOIN condition is TRUE => full cross product of 3 x 3 =
9 rows.
+ assert(sql(
+ "SELECT t0.c0, t1.c0 FROM t1 FULL OUTER JOIN t0 ON (5 NOT IN (SELECT
t3.c0 FROM t3))")
+ .count() === 9)
+ }
+ }
+ }
+
+ test("SPARK-58481: LEGACY_IN_SUBQUERY_NULLABILITY preserves nullable LHS
fields multi-column") {
Review Comment:
**Nit (P3):** This test name is missing a connector before `multi-column`.
For example, `LEGACY_IN_SUBQUERY_NULLABILITY preserves nullable LHS fields for
multi-column IN subqueries` states the scenario clearly.
##########
sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala:
##########
@@ -2678,4 +2681,277 @@ class SubquerySuite extends SharedSparkSession
assert(exposedAttribute.exprId == outerReferenceAttribute.exprId)
}
+
+ test("SPARK-58481: InSubqueryExec nullable correctly accounts for subquery
output nullability") {
+ // 5 NOT IN (99, NULL) is UNKNOWN, not TRUE or FALSE. A join condition
that is not TRUE
+ // matches no rows, so a FULL OUTER JOIN must emit null-padded rows for
every row in each
+ // side -- 3 + 3 = 6 null-padded rows -- not the full cross product (9
rows).
+ withTable("t0", "t1", "t3") {
+ sql("CREATE TABLE t0(c0 INT) USING PARQUET")
+ sql("INSERT INTO t0 VALUES (1), (2), (3)")
+ sql("CREATE TABLE t1(c0 INT) USING PARQUET")
+ sql("INSERT INTO t1 VALUES (10), (20), (30)")
+ sql("CREATE TABLE t3(c0 INT) USING PARQUET")
+ sql("INSERT INTO t3 VALUES (99), (CAST(NULL AS INT))")
+
+ // Unmatched t1 rows null-pad t0; unmatched t0 rows null-pad t1.
+ val expected = Seq(
+ Row(null, 10), Row(null, 20), Row(null, 30), // unmatched t1, t0
column null-padded
+ Row(1, null), Row(2, null), Row(3, null)) // unmatched t0, t1
column null-padded
+ checkAnswer(
+ sql("SELECT t0.c0, t1.c0 FROM t1 FULL OUTER JOIN t0 ON (5 NOT IN
(SELECT t3.c0 FROM t3))"),
+ expected)
+ }
+ }
+
+ test("SPARK-58481: multi-column IN subquery with nullable non-head output is
nullable") {
+ // Disable the optimizer's join-condition IN rewrite so the query
exercises InSubqueryExec.
+ // Use VALUES-derived temp views: their nullability is inferred from the
literals (no NULL
+ // literal => non-nullable), rather than declared and then widened.
Parquet file-source
+ // analysis applies dataSchema.asNullable regardless of DDL NOT NULL,
which would defeat
+ // the nullability control this test relies on.
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ // Case A: NULL after a definitive match (null in non-head position
after matching head).
+ // RHS: (99,99) and (1,NULL).
+ // (1,1) vs (99,99): first field 1!=99 => FALSE.
+ // (1,1) vs (1,NULL): first fields equal, second null => UNKNOWN.
+ // Overall for (1,1): UNKNOWN => NOT IN = null-padded.
+ // (2,2) vs both: all FALSE => NOT IN = TRUE => joins with both rhs
rows.
+ withTempView("lhs", "rhs") {
+ sql("CREATE TEMPORARY VIEW lhs AS SELECT * FROM VALUES (1, 1), (2, 2)
AS t(a, b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs AS
+ |SELECT * FROM VALUES (99, 99), (1, CAST(NULL AS INT)) AS t(a,
b)""".stripMargin)
+ checkAnswer(
+ sql(
+ """SELECT lhs.a, rhs.a FROM lhs FULL OUTER JOIN rhs
+ |ON ((lhs.a, lhs.b) NOT IN (SELECT a, b FROM
rhs))""".stripMargin),
+ Seq(Row(1, null), Row(2, 99), Row(2, 1)))
+ }
+
+ // Case B: NULL in head position followed by a definitive mismatch in a
later field.
+ // RHS: (NULL, 99).
+ // (1,1) vs (NULL,99): first field null => UNKNOWN so far; second
field 1!=99 => FALSE.
+ // A later definitive mismatch must override the earlier UNKNOWN:
result is FALSE,
+ // NOT IN = TRUE. A field-order regression would leave (1,1) as
UNKNOWN instead.
+ withTempView("lhs2", "rhs2") {
+ sql("CREATE TEMPORARY VIEW lhs2 AS SELECT * FROM VALUES (1, 1) AS t(a,
b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs2 AS
+ |SELECT * FROM VALUES (CAST(NULL AS INT), 99) AS t(a,
b)""".stripMargin)
+ // (1,1) NOT IN ((NULL,99)): second field 1!=99 makes the candidate
FALSE =>
+ // NOT IN = TRUE => inner join returns the single matching row.
+ checkAnswer(
+ sql(
+ """SELECT lhs2.a FROM lhs2 JOIN (SELECT 1 AS a)
+ |ON ((lhs2.a, lhs2.b) NOT IN (SELECT a, b FROM
rhs2))""".stripMargin),
+ Seq(Row(1)))
+ }
+
+ // Case C: UNKNOWN candidate followed by an exact-match candidate => IN
= TRUE.
+ // RHS: (1,NULL) and (1,1).
+ // (1,1) vs (1,NULL): first fields equal, second null => UNKNOWN.
+ // (1,1) vs (1,1): exact match => TRUE.
+ // The exact match must dominate the UNKNOWN: IN = TRUE, NOT IN =
FALSE.
+ // A candidate-order regression would short-circuit on UNKNOWN and
miss the TRUE.
+ withTempView("lhs3", "rhs3") {
+ sql("CREATE TEMPORARY VIEW lhs3 AS SELECT * FROM VALUES (1, 1) AS t(a,
b)")
+ sql(
+ """CREATE TEMPORARY VIEW rhs3 AS
+ |SELECT * FROM VALUES (1, CAST(NULL AS INT)), (1, 1) AS t(a,
b)""".stripMargin)
+ // (1,1) IN ((1,NULL),(1,1)): exact match exists => IN = TRUE => inner
join returns row.
+ checkAnswer(
+ sql(
+ """SELECT lhs3.a FROM lhs3 JOIN (SELECT 1 AS a)
+ |ON ((lhs3.a, lhs3.b) IN (SELECT a, b FROM
rhs3))""".stripMargin),
+ Seq(Row(1)))
+ }
+ }
+ }
+
+ test("SPARK-58481: multi-column IN subquery uses Catalyst ordering for
BinaryType fields") {
+ // Object.equals on Array[Byte] compares by identity, not value; Catalyst
ordering compares
+ // by content. A multi-column IN where one field is BinaryType would
incorrectly return FALSE
+ // (no match) with JVM equality even when the bytes are equal. Use an
inner join to keep the
+ // assertion simple: the join condition is TRUE iff the IN match succeeds.
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("lbin", "rbin") {
+ sql("CREATE TABLE lbin(id INT NOT NULL, b BINARY NOT NULL) USING
PARQUET")
+ sql("INSERT INTO lbin VALUES (1, X'01')")
+ sql("CREATE TABLE rbin(id INT NOT NULL, b BINARY NOT NULL) USING
PARQUET")
+ sql("INSERT INTO rbin VALUES (1, X'01')")
+ // (1, 0x01) IN ((1, 0x01)) must be TRUE; the join should return one
row.
+ checkAnswer(
+ sql(
+ """SELECT lbin.id FROM lbin JOIN rbin
+ |ON ((lbin.id, lbin.b) IN (SELECT id, b FROM
rbin))""".stripMargin),
+ Seq(Row(1)))
+ }
+ }
+ }
+
+ test("SPARK-58481: multi-column NOT IN with nullable LHS and non-nullable
RHS is nullable") {
+ // CreateNamedStruct.nullable is always false, so child.nullable would
return false for a
+ // multi-column LHS even when individual fields are nullable. The
generated NOT IN code
+ // would then suppress null handling and turn UNKNOWN into TRUE, producing
wrong results.
+ // Fixture: lhs.a is nullable; rhs columns are NOT NULL (VALUES-derived to
avoid Parquet
+ // dataSchema.asNullable widening that would defeat the RHS
non-nullability control).
+ // (NULL, 2) vs (99, 2): second fields equal (2=2), first field is null =>
UNKNOWN.
+ // (1, 1) vs (99, 2): first field 1!=99 => FALSE => NOT IN = TRUE =>
matches all rhs rows.
+ // FULL OUTER JOIN: (NULL,2) gets null-padded (UNKNOWN condition); (1,1)
joins with (99,2);
+ // since (1,1) matched rhs(99,2), rhs(99,2) is not null-padded.
+ // Pre-fix: (NULL,2) NOT IN is wrongly TRUE (null suppressed) => emits
(null,99); no
+ // null-padded rows. Post-fix: UNKNOWN propagated => emits (null,null)
for (NULL,2).
+ withSQLConf(
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("lhs") {
+ withTempView("rhs") {
+ sql("CREATE TABLE lhs(a INT, b INT NOT NULL) USING PARQUET")
+ sql("INSERT INTO lhs VALUES (1, 1), (NULL, 2)")
+ // rhs as VALUES view: both columns inferred non-nullable from
all-literal rows.
+ sql("CREATE TEMPORARY VIEW rhs AS SELECT * FROM VALUES (99, 2) AS
t(a, b)")
+ // (NULL, 2) NOT IN ((99,2)): second fields match, first is null =>
UNKNOWN
+ // => join condition not TRUE => (NULL,2) is null-padded:
Row(null, null).
+ // (1, 1) NOT IN ((99,2)): first field 1!=99 => FALSE => NOT IN =
TRUE
+ // => (1,1) joins with rhs(99,2): Row(1, 99). rhs(99,2) is
matched; no null-padded rhs.
+ checkAnswer(
+ sql(
+ """SELECT lhs.a, rhs.a FROM lhs FULL OUTER JOIN rhs
+ |ON ((lhs.a, lhs.b) NOT IN (SELECT a, b FROM
rhs))""".stripMargin),
+ Seq(Row(1, 99), Row(null, null)))
+ }
+ }
+ }
+ }
+
+ test("SPARK-58481: LEGACY_IN_SUBQUERY_NULLABILITY suppresses RHS-only
nullability") {
+ // Legacy mode suppresses only RHS-derived nullability (plan.output
nullable).
+ // A non-nullable scalar LHS (Literal 5) has lhsNullable=false; with RHS
suppressed,
+ // nullable=false. The generated code omits null handling and NOT IN on a
subquery that
+ // returns NULL evaluates to TRUE -- the pre-fix single-column behaviour
the flag preserves.
+ // Note: intentionally codegen-specific. The interpreted path correctly
returns UNKNOWN
+ // regardless of nullable (6 rows); the assertion of 9 verifies codegen
ran.
+ withSQLConf(
+ SQLConf.LEGACY_IN_SUBQUERY_NULLABILITY.key -> "true",
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("t0", "t1", "t3") {
+ sql("CREATE TABLE t0(c0 INT) USING PARQUET")
+ sql("INSERT INTO t0 VALUES (1), (2), (3)")
+ sql("CREATE TABLE t1(c0 INT) USING PARQUET")
+ sql("INSERT INTO t1 VALUES (10), (20), (30)")
+ sql("CREATE TABLE t3(c0 INT) USING PARQUET")
+ sql("INSERT INTO t3 VALUES (99), (CAST(NULL AS INT))")
+
+ // Legacy: lhsNullable=false (Literal 5), rhsNullable suppressed =>
nullable=false.
+ // Generated code suppresses null; 5 NOT IN (99, NULL) evaluates to
TRUE.
+ // FULL OUTER JOIN condition is TRUE => full cross product of 3 x 3 =
9 rows.
+ assert(sql(
+ "SELECT t0.c0, t1.c0 FROM t1 FULL OUTER JOIN t0 ON (5 NOT IN (SELECT
t3.c0 FROM t3))")
+ .count() === 9)
+ }
+ }
+ }
+
+ test("SPARK-58481: LEGACY_IN_SUBQUERY_NULLABILITY preserves nullable LHS
fields multi-column") {
+ // Legacy mode suppresses RHS nullability but preserves LHS field
nullability.
+ // With a nullable LHS field, lhsNullable=true even in legacy mode, so
nullable=true.
+ // Generated NOT IN code propagates UNKNOWN correctly; result is identical
to non-legacy.
+ withSQLConf(
+ SQLConf.LEGACY_IN_SUBQUERY_NULLABILITY.key -> "true",
+
"spark.sql.optimizer.optimizeUncorrelatedInSubqueriesInJoinCondition.enabled"
-> "false"
+ ) {
+ withTable("lhs", "rhs") {
+ sql("CREATE TABLE lhs(a INT, b INT NOT NULL) USING PARQUET")
+ sql("INSERT INTO lhs VALUES (1, 1), (NULL, 2)")
+ sql("CREATE TABLE rhs(a INT NOT NULL, b INT NOT NULL) USING PARQUET")
+ sql("INSERT INTO rhs VALUES (99, 2)")
+ // (NULL, 2) NOT IN ((99,2)): first field null => UNKNOWN =>
null-padded: Row(null, null).
+ // (1, 1) NOT IN ((99,2)): 1!=99 => FALSE => NOT IN=TRUE => joins:
Row(1, 99).
+ checkAnswer(
+ sql(
+ """SELECT lhs.a, rhs.a FROM lhs FULL OUTER JOIN rhs
+ |ON ((lhs.a, lhs.b) NOT IN (SELECT a, b FROM
rhs))""".stripMargin),
+ Seq(Row(1, 99), Row(null, null)))
+ }
+ }
+ }
+
+ test("SPARK-58481: InSubqueryExec.doGenCode registers Nondeterministic
children " +
+ "for partition-level initialization") {
+ // A nondeterministic expression in the LHS of a multi-column IN subquery
is unreachable
+ // via SQL: filter IN subqueries are rewritten to LeftSemi by
RewritePredicateSubquery
+ // before PlanSubqueries runs, and join-condition IN subqueries with
nondeterministic
+ // operands are rejected by CheckAnalysis. Construct InSubqueryExec
directly to verify
+ // that doGenCode correctly registers each Nondeterministic descendant of
the LHS child
+ // for partition-level initialization. Without that registration, a Rand
node's eval()
+ // would throw IllegalArgumentException (via require(initialized, ...))
because
+ // initialize() was never called.
+ //
+ // Two output columns forces plan.output.length > 1, taking the
multi-column fallback
Review Comment:
**Nit (P3):** Minor grammar fix: `Two output columns` is plural, so this
should read `Two output columns force plan.output.length > 1`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]