ohad-definity commented on code in PR #59706:
URL: https://github.com/apache/spark/pull/59706#discussion_r4425793439


##########
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:
   Thanks, fixed in 00f72e9a: map key types are now never narrowed by this 
path, wherever the requirement comes from (reads above the generator, a higher 
generator, or an input that is not a field path). Your query is a new test 
checking `(4, [23, 23])` and the filter's row count.
   
   A correction to my earlier reply: master does narrow map keys in ordinary 
nested pruning. On the 2026-10-29 nightly, `SELECT map_keys(m).key FROM t` (no 
generator) reads `m: map<struct<key:int>, string>` and returns `[23]` instead 
of `[23, 23]` with the ORC row reader on V1 and V2 (the vectorized reader 
returns both). My first check also selected `size(map_keys(m))`, which read the 
map whole. That one is a separate master issue.



##########
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:
   Agreed. In 00f72e9a this PR no longer prunes ORC (V1 or V2) through 
generators, so the two-field `explode` reads as on the base until the ORC 
reader fix lands. New test "ORC queries through generators read as before" 
covers your timestamp file in both reader modes; the ORC suites also check that 
every generator query scans the same as with this path disabled. I'll file the 
reader fix (match nested fields by name before the compatibility check) 
separately.



##########
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:
   Thanks for the inferred case. Since the positional choice is made per file 
at read time, the guard in 00f72e9a covers all ORC scans (V1 and V2) rather 
than the option alone, so both the explicit and the inferred positional reads 
keep the base behavior. The new ORC test includes your `_col0` file in both 
reader modes and expects `(22, 33)`. Enabling ORC can follow once the 
positional reader handles narrowed nested schemas.



##########
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:
   Fixed in 00f72e9a as you verified: the filters that stay up are folded over 
the pushable ones in order, one `Filter` per condition.



##########
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:
   Done in 00f72e9a: the fixture is partitioned by `p`, the query filters `p = 
1 AND n = 7`, and the assertion requires `EqualTo(n, 7)` itself in the pushed 
filters, with and without forced rejection.



-- 
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]

Reply via email to