This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new c3f6abd07ef6 [SPARK-56660][SQL] Decompose struct equality into field
predicates at the data source pushdown layer
c3f6abd07ef6 is described below
commit c3f6abd07ef61b3028ecb2c77e13387e6dc54762
Author: Anupam Yadav <[email protected]>
AuthorDate: Thu Jul 2 13:42:51 2026 +0800
[SPARK-56660][SQL] Decompose struct equality into field predicates at the
data source pushdown layer
### What changes were proposed in this pull request?
At the data source pushdown/translation layer, decompose a struct-level
equality (`s = struct_literal` or `s <=> struct_literal`) in a `Filter` into
field-level equality predicates (`s.f1 = v1 AND s.f2 = v2 ...`) offered to the
source as *additional* pushdown candidates. The original struct predicate is
left in the plan and retained as a post-scan filter, so the pushed field
predicates only need to be a sound over-approximation.
Implemented as `DataSourceStrategy.expandStructPredicatesForPushdown`,
wired into both pushdown paths:
- **V1**: `FileSourceStrategy`.
- **V2**: `FileScanBuilder.pushFilters` (the
`SupportsPushDownCatalystFilters` interface that all V2 file sources -
Parquet/ORC/CSV/JSON/Avro - use). Only the translate-and-push loop sees the
expanded filters; the returned post-scan filters remain the original struct
predicates.
Gated by `spark.sql.sources.structPredicateDecompose.enabled` (default
true), bounded by `spark.sql.sources.structPredicateDecompose.maxFields`
(default 100, counting leaf fields).
Soundness: since the field predicates are only pushdown hints (the original
struct filter still runs post-scan), they must over-approximate. For a struct
literal with a NULL-valued field we do not push `s.f = null` (which would
incorrectly drop rows); only non-null fields are pushed. Both `=` and `<=>` are
handled (field predicates use `=`).
### Why are the changes needed?
Struct-literal equality is opaque to data source filter pushdown (which
only understands scalar predicates), so `struct_col = <literal>` cannot drive
file pruning (Parquet row-group skipping, partition pruning, etc.) even though
the equivalent per-field predicates could. Decomposing at the pushdown layer
produces those pushable field predicates while - unlike a logical rewrite -
preserving the whole-struct predicate for sources that can push it natively,
and without requiring exact NU [...]
### Does this PR introduce _any_ user-facing change?
No. It only adds pushdown candidates; results are unchanged (the original
predicate is always retained post-scan).
### How was this patch tested?
`StructPredicatePushdownSuite` (sql/core): decomposed field predicates
reach the scan's pushed filters; the original struct filter is retained
post-scan; NULL-valued literal fields are not pushed (soundness); nested
structs decompose to the exact leaf-field set; `maxFields` bounds the
expansion; the conf toggles the behavior; and results are identical rule-on vs
rule-off across whole-null / all-null-fields / non-null rows for both `=` and
`<=>`. Both the V1 (`FileSourceScanExec`) and [...]
### Was this patch authored or co-authored using generative AI tooling?
Authored with assistance by Claude Opus 4.8.
Closes #56244 from yadavay-amzn/fix/SPARK-56660-struct-predicate-decompose.
Authored-by: Anupam Yadav <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 28 ++
.../execution/datasources/DataSourceStrategy.scala | 100 +++++
.../execution/datasources/FileSourceStrategy.scala | 8 +-
.../execution/datasources/v2/FileScanBuilder.scala | 8 +-
.../datasources/StructPredicatePushdownSuite.scala | 433 +++++++++++++++++++++
5 files changed, 574 insertions(+), 3 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 91635d346605..8be23cdaef00 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5499,6 +5499,30 @@ object SQLConf {
.stringConf
.createWithDefault("parquet,orc")
+ val STRUCT_PREDICATE_DECOMPOSE_ENABLED =
+ buildConf("spark.sql.sources.structPredicateDecompose.enabled")
+ .doc("When true, struct equality predicates (= and <=>) are decomposed
into " +
+ "field-level equality predicates for filter pushdown to data sources.
The " +
+ "decomposed predicates are pushed as additional hints for data
skipping (e.g. " +
+ "Parquet row-group filtering). The original struct predicate is always
retained " +
+ "as a post-scan filter for correctness.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
+ val STRUCT_PREDICATE_DECOMPOSE_MAX_FIELDS =
+ buildConf("spark.sql.sources.structPredicateDecompose.maxFields")
+ .internal()
+ .doc("The maximum number of leaf fields a struct type may have for its
equality " +
+ "predicates to be decomposed into field-level predicates for pushdown.
Structs " +
+ "exceeding this limit are not decomposed.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .intConf
+ .checkValue(_ > 0, "The threshold must be positive.")
+ .createWithDefault(100)
+
val SERIALIZER_NESTED_SCHEMA_PRUNING_ENABLED =
buildConf("spark.sql.optimizer.serializer.nestedSchemaPruning.enabled")
.internal()
@@ -8746,6 +8770,10 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def avoidDoubleFilterEval: Boolean = getConf(AVOID_DOUBLE_FILTER_EVAL)
+ def structPredicateDecomposeEnabled: Boolean =
getConf(STRUCT_PREDICATE_DECOMPOSE_ENABLED)
+
+ def structPredicateDecomposeMaxFields: Int =
getConf(STRUCT_PREDICATE_DECOMPOSE_MAX_FIELDS)
+
def readSideCharPadding: Boolean = getConf(SQLConf.READ_SIDE_CHAR_PADDING)
def cliPrintHeader: Boolean = getConf(SQLConf.CLI_PRINT_HEADER)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceStrategy.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceStrategy.scala
index 7aff4ed1e3de..05bc14dfb700 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceStrategy.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceStrategy.scala
@@ -868,6 +868,106 @@ object DataSourceStrategy
sortOrders.flatMap(translateSortOrder)
}
+ /**
+ * Expands struct equality predicates into additional field-level equality
predicates
+ * suitable for data source pushdown. The original predicates are preserved
(not replaced)
+ * so they serve as post-scan correctness filters.
+ *
+ * For `struct_col = struct_literal` or `struct_col <=> struct_literal`,
generates:
+ * GetStructField(struct_col, i) = literal_field_i
+ * for each non-null leaf field in the struct literal. Fields whose literal
value is null
+ * are NOT decomposed because pushing `field = null` would incorrectly
filter out rows
+ * (SQL `= null` is always false/null). Skipping null-valued fields is
sound: the pushed
+ * predicates form a weaker (superset) filter, and the original struct
predicate retained
+ * post-scan guarantees exact correctness.
+ *
+ * @param filters The original filter expressions.
+ * @param conf The active SQLConf.
+ * @return original filters ++ decomposed field-level equality filters.
+ */
+ protected[sql] def expandStructPredicatesForPushdown(
+ filters: Seq[Expression],
+ conf: SQLConf): Seq[Expression] = {
+ if (!conf.structPredicateDecomposeEnabled) {
+ return filters
+ }
+ val maxFields = conf.structPredicateDecomposeMaxFields
+ val additional = mutable.ArrayBuffer.empty[Expression]
+ filters.foreach {
+ case expressions.EqualTo(left, right) =>
+ decomposeStructEquality(left, right, maxFields).foreach(additional ++=
_)
+ case expressions.EqualNullSafe(left, right) =>
+ decomposeStructEquality(left, right, maxFields).foreach(additional ++=
_)
+ case _ =>
+ }
+ if (additional.isEmpty) filters else filters ++ additional
+ }
+
+ /**
+ * For a struct equality (either `=` or `<=>`), decomposes into field-level
`EqualTo`
+ * predicates for non-null literal fields. Returns None if the predicate is
not
+ * decomposable (not a struct equality against a foldable literal, or
exceeds maxFields).
+ */
+ private def decomposeStructEquality(
+ left: Expression,
+ right: Expression,
+ maxFields: Int): Option[Seq[Expression]] = {
+ val (col, lit) = (left, right) match {
+ case (l, r) if r.foldable && r.dataType.isInstanceOf[StructType] &&
+ l.dataType.isInstanceOf[StructType] => (l, r)
+ case (l, r) if l.foldable && l.dataType.isInstanceOf[StructType] &&
+ r.dataType.isInstanceOf[StructType] => (r, l)
+ case _ => return None
+ }
+ val st = lit.dataType.asInstanceOf[StructType]
+ if (totalLeafFields(st) > maxFields) return None
+ val litValue = lit.eval(EmptyRow)
+ if (litValue == null) return None // whole struct is null, nothing to push
+ val litRow = litValue.asInstanceOf[InternalRow]
+ val fieldPreds = extractFieldPredicates(col, litRow, st)
+ if (fieldPreds.isEmpty) None else Some(fieldPreds)
+ }
+
+ /**
+ * Recursively extracts field-level EqualTo predicates for all leaf
(non-struct) fields
+ * whose literal value is non-null.
+ */
+ private def extractFieldPredicates(
+ col: Expression,
+ litRow: InternalRow,
+ st: StructType): Seq[Expression] = {
+ val buf = mutable.ArrayBuffer.empty[Expression]
+ st.fields.indices.foreach { i =>
+ val field = st.fields(i)
+ val fieldExpr = GetStructField(col, i)
+ field.dataType match {
+ case nested: StructType =>
+ if (!litRow.isNullAt(i)) {
+ val nestedRow = litRow.getStruct(i, nested.length)
+ buf ++= extractFieldPredicates(fieldExpr, nestedRow, nested)
+ }
+ // if litRow.isNullAt(i), skip entire nested struct (sound
over-approximation)
+ case dt =>
+ if (!litRow.isNullAt(i)) {
+ val litVal = litRow.get(i, dt)
+ buf += expressions.EqualTo(fieldExpr, Literal(litVal, dt))
+ }
+ // null-valued field: do NOT push `field = null` (unsound)
+ }
+ }
+ buf.toSeq
+ }
+
+ /** Counts total leaf (non-struct) fields recursively. */
+ private def totalLeafFields(st: StructType): Int = {
+ st.fields.iterator.map { f =>
+ f.dataType match {
+ case s: StructType => totalLeafFields(s)
+ case _ => 1
+ }
+ }.sum
+ }
+
/**
* Convert RDD of Row into RDD of InternalRow with objects in catalyst types
*/
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
index 396375890c24..b7a1736bc2e9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
@@ -204,7 +204,11 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
val supportNestedPredicatePushdown =
DataSourceUtils.supportNestedPredicatePushdown(fsRelation)
- val pushedFilters = dataFilters
+ // Expand struct equality predicates into field-level predicates for
pushdown.
+ // The original struct predicates remain in afterScanFilters for
correctness.
+ val expandedDataFilters =
DataSourceStrategy.expandStructPredicatesForPushdown(
+ dataFilters, fsRelation.sparkSession.sessionState.conf)
+ val pushedFilters = expandedDataFilters
.flatMap(DataSourceStrategy.translateFilter(_,
supportNestedPredicatePushdown))
logInfo(log"Pushed Filters: ${MDC(PUSHED_FILTERS,
pushedFilters.mkString(","))}")
@@ -332,7 +336,7 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
partitionKeyFilters.toSeq,
bucketSet,
None,
- rebindFileSourceMetadataAttributesInFilters(dataFilters),
+ rebindFileSourceMetadataAttributesInFilters(expandedDataFilters),
table.map(_.identifier))
// extra Project node: wrap flat metadata columns to a metadata struct
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileScanBuilder.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileScanBuilder.scala
index 7e0bc25a9a1e..46f6702003ff 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileScanBuilder.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileScanBuilder.scala
@@ -78,8 +78,14 @@ abstract class FileScanBuilder(
!SubqueryExpression.hasSubquery(f) &&
!f.exists(_.isInstanceOf[PythonUDF])
}
this.dataFilters = dataFilters
+ // Expand struct equality predicates into field-level predicates for
pushdown only.
+ // The original struct predicates stay in `dataFilters` (returned below as
post-scan
+ // filters), so the expanded field predicates only need to be a sound
over-approximation.
+ val expandedDataFilters =
+ DataSourceStrategy.expandStructPredicatesForPushdown(
+ dataFilters, sparkSession.sessionState.conf)
val translatedFilters = mutable.ArrayBuffer.empty[sources.Filter]
- for (filterExpr <- dataFilters) {
+ for (filterExpr <- expandedDataFilters) {
val translated = DataSourceStrategy.translateFilter(filterExpr, true)
if (translated.nonEmpty) {
translatedFilters += translated.get
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/StructPredicatePushdownSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/StructPredicatePushdownSuite.scala
new file mode 100644
index 000000000000..ac463d602d5a
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/StructPredicatePushdownSuite.scala
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.catalyst.expressions.{EqualTo, Expression,
GetStructField, Literal}
+import org.apache.spark.sql.execution.{FileSourceScanExec, FilterExec}
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.execution.datasources.v2.parquet.ParquetScan
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.sources.{EqualTo => SourcesEqualTo, Filter =>
SourcesFilter}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+
+/**
+ * Tests for struct equality predicate decomposition at the pushdown layer.
+ *
+ * Validates that:
+ * - Decomposed field-level predicates reach the scan
(PushedFilters/DataFilters)
+ * - The original struct predicate is retained as a post-scan filter
(correctness)
+ * - Null-valued literal fields are NOT pushed as `= null` (soundness)
+ * - maxFields bound is respected
+ * - Conf on/off behavior works correctly
+ * - Results are identical with rule on vs off (parity)
+ */
+class StructPredicatePushdownSuite extends QueryTest with SharedSparkSession {
+
+ private def getFileSourceScanDataFilters(df: DataFrame): Seq[Expression] = {
+ val plan = df.queryExecution.executedPlan
+ plan.collect { case scan: FileSourceScanExec => scan.dataFilters }.flatten
+ }
+
+ private def hasPostScanFilter(df: DataFrame): Boolean = {
+ val plan = df.queryExecution.executedPlan
+ plan.collect { case f: FilterExec => f }.nonEmpty
+ }
+
+ // Pushed filters on the DSv2 parquet scan (BatchScanExec -> ParquetScan).
+ private def getV2ParquetPushedFilters(df: DataFrame): Seq[SourcesFilter] = {
+ val plan = df.queryExecution.executedPlan
+ plan.collect {
+ case scan: BatchScanExec =>
+ scan.scan match {
+ case p: ParquetScan => p.pushedFilters.toSeq
+ case _ => Seq.empty[SourcesFilter]
+ }
+ }.flatten
+ }
+
+ test("field-level predicates are pushed for struct equality") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as string)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 5, 'b', '6')")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ // Should contain field-level EqualTo predicates for s.a and s.b
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), Literal(_, _)) => eq
+ case eq @ EqualTo(Literal(_, _), GetStructField(_, _, _)) => eq
+ }
+ assert(fieldPredicates.nonEmpty,
+ s"Expected field-level predicates in DataFilters but got:
$dataFilters")
+
+ // Verify results are correct
+ checkAnswer(df, spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as string)) as s"
+ ).where("s.a = 5 AND s.b = '6'"))
+ }
+ }
+
+ test("original struct filter is retained post-scan") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 3, 'b', 4)")
+
+ // The FilterExec above the scan should still contain the original
struct predicate
+ assert(hasPostScanFilter(df),
+ "Expected post-scan FilterExec containing the original struct
predicate")
+
+ checkAnswer(df, Row(Row(3, 4)))
+ }
+ }
+
+ test("null-valued literal fields are NOT pushed as field = null
(soundness)") {
+ withTempPath { path =>
+ // Create data with some null struct fields
+ val data = Seq(
+ Row(Row(1, null)),
+ Row(Row(2, "hello")),
+ Row(null),
+ Row(Row(1, "world"))
+ )
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(
+ StructField("a", IntegerType, nullable = false),
+ StructField("b", StringType, nullable = true)
+ )), nullable = true)
+ ))
+ spark.createDataFrame(spark.sparkContext.parallelize(data), schema)
+ .write.parquet(path.getAbsolutePath)
+
+ // Filter where the literal has a null field: s = struct(1, null)
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 1, 'b', cast(null as string))")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ // Should push s.a = 1 but NOT s.b = null
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, ordinal, _), lit: Literal) =>
(ordinal, lit)
+ case eq @ EqualTo(lit: Literal, GetStructField(_, ordinal, _)) =>
(ordinal, lit)
+ }
+ // s.a = 1 should be pushed
+ assert(fieldPredicates.exists { case (_, lit) => lit.value != null },
+ s"Expected non-null field predicates but got: $fieldPredicates")
+ // s.b = null should NOT be pushed
+ assert(!fieldPredicates.exists { case (_, lit) => lit.value == null },
+ s"Should not push null field predicate but found one in:
$fieldPredicates")
+
+ // Result correctness: s = struct(1, null) matches the row [1, null]
because
+ // Spark's struct equality treats null=null field-by-field as equal.
+ checkAnswer(df, Row(Row(1, null)))
+ }
+ }
+
+ test("only non-null literal fields are pushed for struct with all-null
fields") {
+ withTempPath { path =>
+ val data = Seq(Row(Row(null, null)), Row(Row(1, 2)), Row(null))
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(
+ StructField("a", IntegerType, nullable = true),
+ StructField("b", IntegerType, nullable = true)
+ )), nullable = true)
+ ))
+ spark.createDataFrame(spark.sparkContext.parallelize(data), schema)
+ .write.parquet(path.getAbsolutePath)
+
+ // All fields in literal are null - no field predicates should be
generated
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', cast(null as int), 'b', cast(null as
int))")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ // No field predicates should be pushed since all literal fields are null
+ assert(fieldPredicates.isEmpty,
+ s"Expected no field predicates for all-null literal but got:
$fieldPredicates")
+ }
+ }
+
+ test("EqualNullSafe (<=>) also triggers struct decomposition") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id * 2 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s <=> named_struct('a', 3, 'b', 6)")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ assert(fieldPredicates.nonEmpty,
+ s"Expected field-level predicates for <=> but got: $dataFilters")
+
+ checkAnswer(df, Row(Row(3, 6)))
+ }
+ }
+
+ test("maxFields bound is respected - wide struct is not decomposed") {
+ withTempPath { path =>
+ // Create a struct with 5 fields
+ spark.range(5).selectExpr(
+ "named_struct('f1', cast(id as int), 'f2', cast(id as int), " +
+ "'f3', cast(id as int), 'f4', cast(id as int), 'f5', cast(id as
int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ // Set maxFields to 3 so a 5-field struct is not decomposed
+ withSQLConf(SQLConf.STRUCT_PREDICATE_DECOMPOSE_MAX_FIELDS.key -> "3") {
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('f1', 2, 'f2', 2, 'f3', 2, 'f4', 2, 'f5',
2)")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ // Should NOT have field-level predicates since struct exceeds
maxFields
+ assert(fieldPredicates.isEmpty,
+ s"Expected no field predicates (maxFields=3) but got:
$fieldPredicates")
+ }
+
+ // With default maxFields (100), the 5-field struct IS decomposed
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('f1', 2, 'f2', 2, 'f3', 2, 'f4', 2, 'f5', 2)")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ assert(fieldPredicates.length == 5,
+ s"Expected 5 field predicates but got: $fieldPredicates")
+
+ checkAnswer(df, Row(Row(2, 2, 2, 2, 2)))
+ }
+ }
+
+ test("conf disabled - no decomposition") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ withSQLConf(SQLConf.STRUCT_PREDICATE_DECOMPOSE_ENABLED.key -> "false") {
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 5, 'b', 6)")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ // No field-level predicates when conf is disabled
+ assert(fieldPredicates.isEmpty,
+ s"Expected no field predicates when conf disabled but got:
$fieldPredicates")
+
+ // Results should still be correct (just without the pushdown
optimization)
+ checkAnswer(df, Row(Row(5, 6)))
+ }
+ }
+ }
+
+ test("nested struct decomposition") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('inner', named_struct('x', cast(id as int), " +
+ "'y', cast(id + 1 as int)), 'z', cast(id * 10 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('inner', named_struct('x', 3, 'y', 4), 'z',
30)")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(g: GetStructField, _: Literal) => eq
+ case eq @ EqualTo(_: Literal, g: GetStructField) => eq
+ }
+ // Should have predicates for s.inner.x, s.inner.y, and s.z (3 leaf
fields)
+ assert(fieldPredicates.length == 3,
+ s"Expected 3 field predicates but got ${fieldPredicates.length}:
$dataFilters")
+
+ checkAnswer(df, Row(Row(Row(3, 4), 30)))
+ }
+ }
+
+ test("parity: results identical with decomposition on vs off") {
+ withTempPath { path =>
+ // Include rows with: normal values, null struct, struct with null fields
+ val data = Seq(
+ Row(Row(1, "a")),
+ Row(Row(2, "b")),
+ Row(Row(1, null)),
+ Row(null),
+ Row(Row(3, "a"))
+ )
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(
+ StructField("a", IntegerType, nullable = false),
+ StructField("b", StringType, nullable = true)
+ )), nullable = true)
+ ))
+ spark.createDataFrame(spark.sparkContext.parallelize(data), schema)
+ .write.parquet(path.getAbsolutePath)
+
+ val queries = Seq(
+ "s = named_struct('a', 1, 'b', 'a')",
+ "s = named_struct('a', 1, 'b', cast(null as string))",
+ "s <=> named_struct('a', 1, 'b', 'a')",
+ "s <=> named_struct('a', 1, 'b', cast(null as string))"
+ )
+
+ for (query <- queries) {
+ val resultOn = withSQLConf(
+ SQLConf.STRUCT_PREDICATE_DECOMPOSE_ENABLED.key -> "true") {
+ spark.read.parquet(path.getAbsolutePath).where(query).collect()
+ }
+ val resultOff = withSQLConf(
+ SQLConf.STRUCT_PREDICATE_DECOMPOSE_ENABLED.key -> "false") {
+ spark.read.parquet(path.getAbsolutePath).where(query).collect()
+ }
+ assert(resultOn.toSeq.sortBy(_.toString) ==
resultOff.toSeq.sortBy(_.toString),
+ s"Results differ for query '$query': on=$resultOn, off=$resultOff")
+ }
+ }
+ }
+
+ test("whole-null struct rows are correctly filtered out") {
+ withTempPath { path =>
+ val data = Seq(
+ Row(Row(1, 2)),
+ Row(null),
+ Row(Row(3, 4))
+ )
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(
+ StructField("a", IntegerType),
+ StructField("b", IntegerType)
+ )), nullable = true)
+ ))
+ spark.createDataFrame(spark.sparkContext.parallelize(data), schema)
+ .write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 1, 'b', 2)")
+
+ // Post-scan filter ensures null struct rows are filtered out
+ checkAnswer(df, Row(Row(1, 2)))
+ }
+ }
+
+ test("literal on left side of equality") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ // Literal on the left side
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("named_struct('a', 5, 'b', 6) = s")
+
+ val dataFilters = getFileSourceScanDataFilters(df)
+ val fieldPredicates = dataFilters.collect {
+ case eq @ EqualTo(GetStructField(_, _, _), _) => eq
+ case eq @ EqualTo(_, GetStructField(_, _, _)) => eq
+ }
+ assert(fieldPredicates.nonEmpty,
+ s"Expected field predicates for literal-on-left but got: $dataFilters")
+
+ checkAnswer(df, Row(Row(5, 6)))
+ }
+ }
+
+ test("field-level predicates are pushed on the DSv2 file-source path") {
+ // Force parquet to resolve to the DSv2 reader (FileScanBuilder /
+ // SupportsPushDownCatalystFilters) rather than the V1 FileSourceStrategy.
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ withTempPath { path =>
+ spark.range(10).selectExpr(
+ "named_struct('a', cast(id as int), 'b', cast(id + 1 as int)) as s"
+ ).write.parquet(path.getAbsolutePath)
+
+ val df = spark.read.parquet(path.getAbsolutePath)
+ .where("s = named_struct('a', 5, 'b', 6)")
+
+ // Sanity: this must actually be the V2 path.
+ assert(df.queryExecution.executedPlan.collect {
+ case b: BatchScanExec => b
+ }.nonEmpty, "Expected a DSv2 BatchScanExec in the plan")
+
+ // The decomposed field predicates (s.a = 5, s.b = 6) must reach the
V2 scan.
+ val pushed = getV2ParquetPushedFilters(df)
+ val fieldPredicates = pushed.collect {
+ case eq @ SourcesEqualTo(attr, _) if attr.contains(".") => eq
+ }
+ assert(fieldPredicates.nonEmpty,
+ s"Expected decomposed field predicates in V2 pushed filters but got:
$pushed")
+
+ checkAnswer(df, Row(Row(5, 6)))
+ }
+ }
+ }
+
+ test("DSv2 parity: results identical with decomposition on vs off") {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ withTempPath { path =>
+ val data = Seq(
+ Row(Row(1, "a")), Row(Row(2, "b")), Row(Row(1, null)), Row(null),
Row(Row(3, "a")))
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(
+ StructField("a", IntegerType, nullable = false),
+ StructField("b", StringType, nullable = true)
+ )), nullable = true)))
+ spark.createDataFrame(spark.sparkContext.parallelize(data), schema)
+ .write.parquet(path.getAbsolutePath)
+
+ val queries = Seq(
+ "s = named_struct('a', 1, 'b', 'a')",
+ "s = named_struct('a', 1, 'b', cast(null as string))",
+ "s <=> named_struct('a', 1, 'b', 'a')")
+
+ for (query <- queries) {
+ val resultOn = withSQLConf(
+ SQLConf.STRUCT_PREDICATE_DECOMPOSE_ENABLED.key -> "true") {
+ spark.read.parquet(path.getAbsolutePath).where(query).collect()
+ }
+ val resultOff = withSQLConf(
+ SQLConf.STRUCT_PREDICATE_DECOMPOSE_ENABLED.key -> "false") {
+ spark.read.parquet(path.getAbsolutePath).where(query).collect()
+ }
+ assert(resultOn.toSeq.sortBy(_.toString) ==
resultOff.toSeq.sortBy(_.toString),
+ s"DSv2 results differ for query '$query': on=$resultOn,
off=$resultOff")
+ }
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]