sunchao commented on code in PR #59706:
URL: https://github.com/apache/spark/pull/59706#discussion_r4417133805
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1015,8 +1016,58 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
Project(rewrittenProjectList, withFilter)
}
- def pruneColumns(plan: LogicalPlan): LogicalPlan = plan.transform {
+ def pruneColumns(plan: LogicalPlan, isSubquery: Boolean): LogicalPlan =
plan.transform {
+ case op @ SchemaPruning.GeneratorsOverScan(segments, generators, sHolder:
ScanBuilderHolder)
+ if !hasBlockingPushdown(sHolder) =>
+ // Fields read from the generators' elements above them narrow the scan
below them. This
+ // prunes and builds the holder's builder before the plan above is
re-resolved. If that is
+ // then rejected, the builder must not be pruned again with a wider
schema (a builder may
+ // only narrow, e.g. Iceberg's), so the plain scan is built from a fresh
builder instead, as
+ // scan merging does, and the lower generators of the chain do not try
again.
+ var builderUsed = false
+ SchemaPruning.pruneThroughGenerators(op, sHolder) { (scan,
otherProjects, inputRootFields) =>
+ builderUsed = true
+ Some(pruneScan(scan.projects, scan.filtersStayUp,
scan.filtersPushDown, sHolder,
+ Some(otherProjects), inputRootFields))
+ }.getOrElse {
+ if (builderUsed) {
+ val bottom = generators.last.child
+ val rebuilt = rebuildWithoutGenerators(segments.last, sHolder,
isSubquery)
+ op.transformDown { case p if p eq bottom => rebuilt }
+ } else {
+ op
+ }
+ }
+
case ScanOperation(project, filtersStayUp, filtersPushDown, sHolder:
ScanBuilderHolder) =>
+ pruneScan(project, filtersStayUp, filtersPushDown, sHolder, None, Nil)
+ }
+
+ /**
+ * Plans `scan`, the projects and filters over `sHolder` below a chain of
generators, from a
+ * fresh `ScanBuilder` of the same relation, like [[rebuildScan]]: the
filters pushed to
+ * `sHolder` and the pushable ones of `scan` are offered to the new builder
together, directly
+ * over the relation. Only valid when `sHolder` has no pushdown such a
rebuild cannot reproduce.
+ */
+ private def rebuildWithoutGenerators(
+ scan: SchemaPruning.Segment,
+ sHolder: ScanBuilderHolder,
+ isSubquery: Boolean): LogicalPlan = {
+ val relation = sHolder.relation
+ val pushable = (sHolder.pushedFilterExpressions ++
scan.filtersPushDown).reduceOption(And)
+ .map(Filter(_, relation)).getOrElse(relation)
+ val filtered = scan.filtersStayUp.reduceOption(And)
Review Comment:
**Preserve separate filters when replaying `filtersStayUp`.**
`ScanOperation` keeps these filters separate when they cannot safely be
combined. A filter discards a row when its condition is NULL. With `NULL AND
rhs`, Spark evaluates `rhs`, so this rewrite can evaluate a cast the original
plan skipped.
I reproduced this on V2 Parquet with ANSI enabled. For `range(10)`, set
`(k,s)` to `(true,'1')` on `id=7` and `(NULL,'bad')` otherwise. Apply
`.filter("rand(3)>0").filter("k OR rand(7)<0.000001").filter("cast(s as
int)>0")` below an outer `explode`. Feature-off and accepted-rewrite plans
return the row for `id=7`. With `throughGenerate.rejectForTesting=true`, the
fallback throws `CAST_INVALID_INPUT` on `'bad'`. The previous head returned the
row in all three cases. I verified that folding `scan.filtersStayUp` over
`pushable` in order, building one `Filter` per condition, restores the expected
result. This reproducer uses forced rejection.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/SchemaPruning.scala:
##########
@@ -133,6 +134,63 @@ object SchemaPruning extends SQLConfHelper {
} ++ rootFields
}
+ /**
+ * Gets the root field that the array `input` of an `explode` or
`posexplode` needs, when the
+ * generator's element output `element` is only read by `exprs` through
nested field accesses.
+ * For example, for `explode(items) AS item` read as `item.a` and `item.b`,
with
+ * `items: array<struct<a, b, c>>`, it returns `items: array<struct<a, b>>`.
+ *
+ * `elementFields` are fields of the element read by other means, e.g. as
the input of a
+ * generator above this one, named like the element.
+ *
+ * If nothing of the element is read, only the array sizes are needed, so a
single smallest
+ * field is kept, as in
[[org.apache.spark.sql.catalyst.optimizer.GenerateOptimization]].
+ *
+ * If the element is used whole, `input` is needed whole, e.g. `items.parts`
for
+ * `explode(items.parts)`, which still narrows `items` to `parts`.
+ *
+ * `input` must be expressed over the scan's output, or over the element of
a generator below
+ * it. Returns None if `input` is not an access path [[SelectedField]] can
narrow.
+ */
+ def getGeneratorInputRootField(
+ input: Expression,
+ element: Attribute,
+ exprs: Seq[Expression],
+ elementFields: Seq[StructField] = Nil): Option[RootField] =
input.dataType match {
+ case ArrayType(elementType, containsNull) =>
+ // Rename the element, and every other attribute too, so that the
element's fields can be
+ // told apart by name from other columns and generator outputs.
+ val name = s"${element.name}#${element.exprId.id}"
+ val renamed = exprs.map(_.transform {
+ case a: Attribute if a.exprId == element.exprId => a.withName(name)
+ case a: Attribute => a.withName(s"${a.name}#${a.exprId.id}")
+ })
+ val fields = identifyRootFields(Nil, renamed,
+ elementFields.map(f => RootField(f.copy(name = name), derivedFromAtt =
false)))
+ .filter(_.field.name == name)
+ val prunedElementType = (fields, elementType) match {
+ // not read: only the array sizes are needed
+ case (Seq(), StructType(structFields)) if structFields.length > 1 =>
+ StructType(Array(structFields.minBy(_.dataType.defaultSize)))
+ case (Seq(), _) => elementType
+ // used whole: the input is needed whole, which still narrows what it
is extracted from
+ case _ if fields.exists(_.derivedFromAtt) => elementType
+ case _ =>
+ pruneSchema(StructType(Array(StructField(name, elementType))),
fields).head.dataType
+ }
+ SelectedField.withDataType(input, ArrayType(prunedElementType,
containsNull))
Review Comment:
I verified that the original `posexplode(map_keys(m))` now returns both
positions. There is still a row-loss case when `map_keys` is evaluated above
the generator. With the ORC row reader, put a map with struct keys `(id,b,z) =
(1,23,17)` and `(2,23,17)` inside the single element of `items`, alongside `idx
= 4`. The query is
```sql
SELECT e.idx, map_keys(e.m).b
FROM t LATERAL VIEW OUTER explode(items) g AS e
```
The base and the head with generator pruning off return `(4,[23,23])`. On
`d9120db1` with pruning on, both V1 and V2 return `(4,[23])`. Filtering on
`array_size(filter(map_keys(e.m).b, b -> b = 23)) > 1` also changes one
matching row into zero. Here the generator input is just `items`, so
`input.exists(MapKeys)` never sees the map-key access in the projection or
filter. Please preserve the full key type when propagating these requirements
to the scan, including when they come from a higher generator. An assertion on
this filter’s row count would cover it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SchemaPruning.scala:
##########
@@ -40,6 +49,19 @@ object SchemaPruning extends Rule[LogicalPlan] {
override def apply(plan: LogicalPlan): LogicalPlan =
plan transformDown {
+ case op @ GeneratorsOverScan(_, _,
+ l @ LogicalRelationWithTable(hadoopFsRelation: HadoopFsRelation, _))
=>
+ // Fields read from the generators' elements above them narrow the
scan below them.
+ pruneThroughGenerators(op, l) { (scan, otherProjects, inputRootFields)
=>
+ val allFilters = scan.filtersPushDown.reduceOption(And).toSeq ++
scan.filtersStayUp
+ prunePhysicalColumns(l, scan.projects, allFilters, hadoopFsRelation,
+ (prunedDataSchema, prunedMetadataSchema) => {
+ val prunedHadoopRelation = hadoopFsRelation.copy(
+ dataSchema = prunedDataSchema)(hadoopFsRelation.sparkSession)
Review Comment:
I confirmed your master diagnosis. On base `4b6955eb` ordinary `SELECT
items.b,items.c` already hits the ordinal mismatch when nested pruning is
enabled. The two-field `explode` query on that same base succeeds in both
reader modes. On `d9120db1` it succeeds with generator pruning disabled and
throws `ORC_TYPE_CAST` with it enabled. I’d favor landing the ORC reader fix
first, as you offered. Guarding the new path for affected V1 scans until that
fix lands would also preserve the existing query behavior.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/SchemaPruningThroughGenerateV2BuilderSuite.scala:
##########
@@ -0,0 +1,157 @@
+/*
+ * 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.v2
+
+import java.util
+import java.util.concurrent.atomic.AtomicInteger
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.spark.sql.{QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.{SupportsRead, Table,
TableCapability}
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownRequiredColumns}
+import
org.apache.spark.sql.execution.datasources.v2.parquet.{ParquetDataSourceV2,
ParquetScan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{ArrayType, DataType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Pruning nested fields through generators on a DataSource V2 scan whose
builder only narrows:
+ * a later `pruneColumns` call cannot widen what an earlier one kept, as in
Iceberg.
+ */
+class SchemaPruningThroughGenerateV2BuilderSuite extends QueryTest with
SharedSparkSession {
+
+ test("SPARK-47230: a rejected rewrite does not prune a narrow-only builder
again") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ sql(
+ """select id, array(
+ | named_struct('a', id, 'b', id * 2, 'c', id * 3, 's', cast(id * 4
as int)),
+ | named_struct('a', id * 10, 'b', id * 20, 'c', id * 30, 's',
cast(id * 40 as int))
+ |) as items from range(1, 3)""".stripMargin).write.parquet(path)
+ val query = "select item.a, item.c from t lateral view explode(items) e
as item"
+ // V1 Parquet as the baseline
+ var expected: Seq[Row] = Nil
+ withTempView("t") {
+ spark.read.parquet(path).createOrReplaceTempView("t")
+ expected = sql(query).collect().toSeq
+ }
+ assert(expected.size == 4)
+
+ Seq(false, true).foreach { reject =>
+ withSQLConf(
+
SQLConf.NESTED_SCHEMA_PRUNING_THROUGH_GENERATE_REJECT_FOR_TESTING.key ->
+ reject.toString) {
+ NarrowOnlyParquetSource.pruneCalls.clear()
+ withTempView("t") {
+
spark.read.format(classOf[NarrowOnlyParquetSource].getName).load(path)
+ .createOrReplaceTempView("t")
+ checkAnswer(sql(query), expected)
+ }
+ val calls = NarrowOnlyParquetSource.pruneCalls.map(_.get).toSeq
+ assert(calls.nonEmpty)
+ assert(calls.forall(_ <= 1), s"pruneColumns calls per builder:
$calls")
+ // a rejected rewrite plans the scan from a second, fresh builder
+ if (reject) assert(calls.count(_ == 1) >= 2, s"pruneColumns calls
per builder: $calls")
+ }
+ }
+ }
+ }
+
+ test("SPARK-47230: a rejected rewrite offers the data filters to the fresh
builder") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ sql("select id as n, array(named_struct('a', id, 'b', id * 2, 'c', id *
3)) as items " +
+ "from range(10)").write.parquet(path)
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ Seq(false, true).foreach { reject =>
+ withSQLConf(
+
SQLConf.NESTED_SCHEMA_PRUNING_THROUGH_GENERATE_REJECT_FOR_TESTING.key ->
+ reject.toString) {
+ withTempView("t") {
+ spark.read.parquet(path).createOrReplaceTempView("t")
+ val query = sql("select item.a, item.c from t " +
+ "lateral view outer explode(items) e as item where n = 7")
+ val pushed = query.queryExecution.optimizedPlan.collect {
+ case r: DataSourceV2ScanRelation => r.scan
+ }.collect { case s: ParquetScan => s.pushedFilters.toSeq
}.flatten
+ assert(pushed.exists(_.references.contains("n")),
s"reject=$reject: $pushed")
Review Comment:
**Exercise the predicate combination that broke replay.** I ran this
unpartitioned fixture against `dec83e8fe4`, before the fix. Even with forced
rejection it already pushed `EqualTo(n,7)` and returned `[7,21]`, so the new
assertion would pass if the production fix were reverted. The original failure
needs both a fully handled partition predicate and a partially pushed data
predicate. Please partition the table by `p`, filter on `p = 1 AND n = 7`, and
check for the actual `EqualTo(n,7)` predicate. The current assertion also
accepts `IsNotNull(n)` alone. On the partitioned fixture I confirmed the
previous head drops the equality on rejection and the current head retains it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1015,8 +1016,56 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
Project(rewrittenProjectList, withFilter)
}
- def pruneColumns(plan: LogicalPlan): LogicalPlan = plan.transform {
+ def pruneColumns(plan: LogicalPlan, isSubquery: Boolean): LogicalPlan =
plan.transform {
+ case op @ SchemaPruning.GeneratorsOverScan(_, generators, sHolder:
ScanBuilderHolder)
+ if !hasBlockingPushdown(sHolder) =>
+ // Fields read from the generators' elements above them narrow the scan
below them. This
+ // prunes and builds the holder's builder before the plan above is
re-resolved. If that is
+ // then rejected, the builder must not be pruned again with a wider
schema (a builder may
+ // only narrow, e.g. Iceberg's), so the plain scan is built from a fresh
builder instead, as
+ // scan merging does, and the lower generators of the chain do not try
again.
+ var builderUsed = false
+ SchemaPruning.pruneThroughGenerators(op, sHolder) { (scan,
otherProjects, inputRootFields) =>
+ builderUsed = true
+ Some(pruneScan(scan.projects, scan.filtersStayUp,
scan.filtersPushDown, sHolder,
Review Comment:
Agreed that the positional reader problem predates the PR. The two-field
`explode` still returns `(22,33)` on base `4b6955eb` and `(11,22)` on
`d9120db1` with generator pruning enabled. I also reproduced this on V2 without
setting `orc.force.positional.evolution`. Write `SELECT
array(named_struct('a',11,'b',22,'c',33)) AS _col0` to ORC, read it with
`spark.read.orc(path)`, and run `SELECT e.b,e.c FROM t LATERAL VIEW OUTER
explode(_col0) g AS e`. Both reader modes silently read `(a,b)`. ORC selects
positional evolution for files with these names. Please land the reader fix
first or guard the new pruning path for both explicit and inferred positional
reads. Checking only the user option would miss this file.
--
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]