sunchao commented on code in PR #59972:
URL: https://github.com/apache/spark/pull/59972#discussion_r4432330733
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -690,7 +693,13 @@ object PushDownUtils extends Logging {
scanBuilder match {
case r: SupportsPushDownRequiredColumns if
SQLConf.get.nestedSchemaPruningEnabled =>
- val rootFields = SchemaPruning.identifyRootFields(projects, filters)
+ val pruneDynamicSelectors = scanBuilder match {
Review Comment:
Moved `supportsDynamicSelectorPruning` to `FileScanBuilder` in 6ac1708fe40.
ORC and Parquet now declare their own behavior. `PushDownUtils` no longer
imports either format.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/SelectedField.scala:
##########
@@ -81,16 +81,22 @@ object SelectedField {
* Convert an expression into the parts of the schema (the field) it
accesses.
*/
@scala.annotation.tailrec
- private def selectField(expr: Expression, dataTypeOpt: Option[DataType]):
Option[StructField] = {
+ private def selectField(
+ expr: Expression,
+ dataTypeOpt: Option[DataType],
+ hasDynamicLookup: Boolean = false): Option[StructField] = {
Review Comment:
Removed the flag from the recursive calls. Both entry points now check
stored defaults once, using the same dynamic-lookup matcher as scan planning.
The check follows the collection input: for `filter(items, x -> array(true,
false)[idx])[0].b`, the index inside the lambda should still let Spark read
only `items.b`. The new scan-schema assertion fails on both V1 and V2 if the
check walks the whole expression.
##########
sql/core/src/test/scala/org/apache/spark/sql/DatasetOptimizationSuite.scala:
##########
@@ -132,6 +132,29 @@ class DatasetOptimizationSuite extends SharedSparkSession {
}
}
+ test("dynamic map lookup uses the fields emitted by the serializer") {
+ val ds = spark.range(1).map { _ =>
+ ((Map("k" -> ("map-a", "map-b")), ("other-a", "other-b")), "k")
+ }
+ checkAnswer(ds.selectExpr("_1._1[_2]._2", "_1._2._1"), Row("map-b",
"other-a"))
Review Comment:
Added a `testSerializer` assertion for `_1._1[_2]._2` using the same
Dataset. I checked that upstream returns the same row but fails the new
assertion because the map value still contains both fields. The original
two-output query can legitimately keep the full serializer when it also
requests the sibling, so I kept its value check.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaPruningSuite.scala:
##########
@@ -35,6 +41,207 @@ abstract class ParquetSchemaPruningSuite extends
SchemaPruningSuite with Adaptiv
override protected val vectorizedReaderNestedEnabledKey: String =
SQLConf.PARQUET_VECTORIZED_READER_NESTED_COLUMN_ENABLED.key
+ test("dynamic array selection preserves two-level Parquet LIST elements
after pruning") {
+ withTempPath { file =>
+ // After clipping legacy to just wanted, both LISTs have identical
repeated groups.
+ val fileSchema = MessageTypeParser.parseMessageType(
+ """message m {
+ | optional group legacy (LIST) {
+ | repeated group entries { optional int32 sibling = 18; optional
int32 wanted = 17; }
+ | }
+ | optional group standard (LIST) {
+ | repeated group entries { optional int32 wanted = 17; }
+ | }
+ | optional int32 idx;
+ |}""".stripMargin)
+ val writer = ExampleParquetWriter.builder(new Path(file.toURI))
+
.withConf(spark.sessionState.newHadoopConf()).withType(fileSchema).build()
+ try {
+ val row = new SimpleGroupFactory(fileSchema).newGroup().append("idx",
1)
+ val legacy = row.addGroup("legacy")
+ legacy.addGroup("entries").append("sibling", 10).append("wanted", 11)
+ legacy.addGroup("entries").append("sibling", 20).append("wanted", 22)
+ val standard = row.addGroup("standard")
+ standard.addGroup("entries").append("wanted", 30)
+ standard.addGroup("entries").append("wanted", 33)
+ writer.write(row)
+ } finally {
+ writer.close()
+ }
+ val fieldId = new
MetadataBuilder().putLong(ParquetUtils.FIELD_ID_METADATA_KEY, 17).build()
+ val missingId = new
MetadataBuilder().putLong(ParquetUtils.FIELD_ID_METADATA_KEY, 19).build()
+ val element = new StructType()
+ .add("sibling", IntegerType, nullable = true, metadata = missingId)
+ .add("renamed", IntegerType, nullable = true, metadata = fieldId)
+ val readSchema = new StructType().add("legacy", ArrayType(element))
+ .add("standard", ArrayType(IntegerType)).add("idx", IntegerType)
+ Seq("true", "false").foreach { vectorized =>
+ withSQLConf(
+ vectorizedReaderEnabledKey -> vectorized,
+ SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true",
+ SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key -> "true") {
+ val data =
spark.read.schema(readSchema).parquet(file.getCanonicalPath)
+ val query = data.selectExpr("legacy[idx].renamed", "standard[idx]")
+ checkScanSchemata(query,
+
"struct<legacy:array<struct<renamed:int>>,standard:array<int>,idx:int>")
+ checkAnswer(query, Row(22, 33))
+
+ // Only the missing ID is requested. The first physical field is
read for levels
+ // and shares its name, but has a different ID.
+ checkAnswer(data.selectExpr("legacy[idx].sibling"), Row(null))
+ }
+ }
+ }
+ }
+
+ test("a two-level Parquet LIST preserves enclosing struct nullness after
pruning") {
+ withTempPath { file =>
+ val fileSchema = MessageTypeParser.parseMessageType(
+ """message m {
+ | optional group record {
+ | optional group items (LIST) {
+ | repeated group entries { optional int32 x; optional int32 y; }
+ | }
+ | }
+ |}""".stripMargin)
+ val writer = ExampleParquetWriter.builder(new Path(file.toURI))
+
.withConf(spark.sessionState.newHadoopConf()).withType(fileSchema).build()
+ val factory = new SimpleGroupFactory(fileSchema)
+ try {
+ val row = factory.newGroup()
+
row.addGroup("record").addGroup("items").addGroup("entries").append("x",
1).append("y", 2)
+ writer.write(row)
+ writer.write(factory.newGroup())
+ } finally {
+ writer.close()
+ }
+ Seq("true", "false").foreach { vectorized =>
+ withSQLConf(vectorizedReaderEnabledKey -> vectorized) {
+ // The only physical child available for the record's levels is the
clipped LIST.
+ val data = spark.read.schema("record STRUCT<missing:
INT>").parquet(file.getCanonicalPath)
+ checkAnswer(data.selectExpr("record.missing", "record IS NULL"),
+ Seq(Row(null, false), Row(null, true)))
+ }
+ }
+ }
+ }
+
+ test("dynamic array selection preserves root and nested Parquet field IDs") {
+ withSQLConf(
+ SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key -> "true",
+ SQLConf.PARQUET_FIELD_ID_WRITE_ENABLED.key -> "true",
+ SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true") {
+ withTempPath { dir =>
+ val rootId = new MetadataBuilder()
+ .putLong(ParquetUtils.FIELD_ID_METADATA_KEY, 1).build()
+ val leafId = new MetadataBuilder()
+ .putLong(ParquetUtils.FIELD_ID_METADATA_KEY, 17).build()
+ val leafType = new StructType()
+ .add("old_id", IntegerType, nullable = true, metadata = leafId)
+ .add("payload", StringType)
+ def schema(rootName: String, leaf: StructType): StructType = new
StructType()
+ .add(rootName, ArrayType(new StructType().add("children",
ArrayType(leaf))),
+ nullable = true, metadata = rootId)
+ .add("idx", IntegerType)
+ val writeSchema = schema("old_items", leafType)
+ val readSchema = schema("new_items", StructType(Seq(
+ leafType("old_id").copy(name = "new_id"), leafType("payload"))))
+ val rows = Seq(Row(Seq(Row(Seq(Row(42, "unused")))), 0))
+ val path = dir.getCanonicalPath
+ spark.createDataFrame(spark.sparkContext.parallelize(rows),
writeSchema)
+ .write.parquet(path)
+
+ Seq("true", "false").foreach { vectorized =>
+ withSQLConf(vectorizedReaderEnabledKey -> vectorized) {
+ val query = spark.read.schema(readSchema).parquet(path)
+ .selectExpr("new_items[idx].children.new_id")
+ checkScanSchemata(query,
+
"struct<new_items:array<struct<children:array<struct<new_id:int>>>>,idx:int>")
+ checkAnswer(query, Row(Seq(42)))
+ }
+ }
+ }
+ }
+ }
+
+ test("dynamic lookups preserve complex column defaults and constant lookups
still prune") {
+ withTempPath { dir =>
+ withTable("t") {
+ val path = dir.getCanonicalPath
+ sql(
+ s"""CREATE TABLE t (idx INT, k STRING,
+ | items ARRAY<STRUCT<a: INT, b: INT>> DEFAULT array(),
+ | byKey MAP<STRING, STRUCT<a: INT, b: INT>> DEFAULT map()
+ |) USING parquet LOCATION '$path'""".stripMargin)
+ sql("INSERT INTO t SELECT 0, 'k', array(named_struct('a', 5, 'b', 6)),
" +
+ "map('k', named_struct('a', 7, 'b', 8))")
+ sql(
+ """ALTER TABLE t ADD COLUMNS (
+ | arr ARRAY<STRUCT<a: INT, b: INT>> DEFAULT
array(named_struct('a', 11, 'b', 22)),
+ | m MAP<STRING, STRUCT<a: INT, b: INT>>
+ | DEFAULT map('k', named_struct('a', 33, 'b', 44))
+ |)""".stripMargin)
+
+ val data = spark.read.schema(spark.table("t").schema).parquet(path)
+ Seq("true", "false").foreach { vectorized =>
+ withSQLConf(vectorizedReaderEnabledKey -> vectorized) {
+ val query = data.selectExpr("arr[idx].b", "m[k].b", "items[0].b",
"byKey['k'].b")
+ checkAnswer(query, Row(22, 44, 6, 8))
+ checkScanSchemata(query,
"struct<idx:int,k:string,items:array<struct<b:int>>," +
+
"byKey:map<string,struct<b:int>>,arr:array<struct<a:int,b:int>>," +
+ "m:map<string,struct<a:int,b:int>>>")
+ }
+ }
+ }
+ }
+ }
+
+ test("dynamic lookups preserve defaults on nested fields") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ sql(
+ """SELECT 0 AS i,
+ | named_struct('arr', array(named_struct('a', 1, 'b', 22))) AS s,
+ | map(0, named_struct('anchor', 7, 'n', named_struct('a', 3, 'b',
44))) AS m
+ |""".stripMargin).write.parquet(path)
+
+ val pair = StructType.fromDDL("a INT, b INT")
+ val default = "named_struct('a', 5, 'b', 6)"
+ val arr = StructField("arr",
ArrayType(pair)).withExistenceDefaultValue(s"array($default)")
+ val n = StructField("n", pair).withExistenceDefaultValue(default)
+ val schema = new StructType()
+ .add("i", IntegerType)
+ .add("s", new StructType().add(arr))
+ .add("m", MapType(IntegerType, new StructType().add("anchor",
IntegerType).add(n)))
+
+ withSQLConf(vectorizedReaderEnabledKey -> "false") {
Review Comment:
The nested-default regression now runs with both row and vectorized readers
in both the Parquet V1 and V2 suites. This includes the case where a constant
and a dynamic lookup share a map.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala:
##########
@@ -106,20 +106,61 @@ class ParquetReadSupport(
new ParquetRecordMaterializer(
parquetRequestedSchema,
ParquetReadSupport.expandUDT(catalystRequestedSchema),
- new ParquetToSparkSchemaConverter(conf),
+ new ParquetReadSchemaConverter(conf, parquetRequestedSchema, fileSchema),
convertTz,
datetimeRebaseSpec,
int96RebaseSpec)
}
}
+/**
+ * Interpret LISTs using the file schema. Clipping a repeated group to one
field can make a
+ * two-level LIST indistinguishable from a three-level LIST in the requested
schema.
+ */
+private[parquet] class ParquetReadSchemaConverter(
+ conf: Configuration,
+ requestedSchema: MessageType,
+ fileSchema: MessageType) extends ParquetToSparkSchemaConverter(conf) {
+
+ private lazy val fileRepeatedTypes = {
+ // Equal requested types at different paths can have different layouts in
the file.
+ val types = new util.IdentityHashMap[Type, Type]()
+ def visit(requested: GroupType, file: GroupType): Unit = {
+ if
(file.getLogicalTypeAnnotation.isInstanceOf[ListLogicalTypeAnnotation] &&
+ requested.getFieldCount == 1 && file.getFieldCount == 1) {
+ types.put(requested.getType(0), file.getType(0))
+ }
+ requested.getFields.asScala.foreach {
+ case group: GroupType if file.containsField(group.getName) =>
+ file.getType(group.getName) match {
+ case fileGroup: GroupType => visit(group, fileGroup)
+ case _ =>
+ }
+ case _ =>
+ }
+ }
+ visit(requestedSchema, fileSchema)
+ types
+ }
+
+ override private[parquet] def isElementType(repeatedType: Type, parentName:
String): Boolean = {
+ super.isElementType(fileRepeatedTypes.getOrDefault(repeatedType,
repeatedType), parentName)
+ }
+}
+
object ParquetReadSupport extends Logging {
val SPARK_ROW_REQUESTED_SCHEMA =
"org.apache.spark.sql.parquet.row.requested_schema"
val SPARK_METADATA_KEY = "org.apache.spark.sql.parquet.row.metadata"
def generateFakeColumnName: String = s"_fake_name_${UUID.randomUUID()}"
+ // In legacy mode, pruning all physical children can also lose the enclosing
array length.
+ def returnNullStructIfAllFieldsMissing(conf: Configuration): Boolean = {
Review Comment:
Updated the vectorized reader to call
`ParquetReadSupport.returnNullStructIfAllFieldsMissing`. It now uses the same
helper as the row reader and planner.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SchemaPruning.scala:
##########
@@ -67,7 +67,17 @@ object SchemaPruning extends Rule[LogicalPlan] {
val normalizedProjects = normalizeAttributeRefNames(attrNameMap, projects)
.asInstanceOf[Seq[NamedExpression]]
val normalizedFilters = normalizeAttributeRefNames(attrNameMap, filters)
- val requestedRootFields = identifyRootFields(normalizedProjects,
normalizedFilters)
+ val pruneDynamicSelectors = hadoopFsRelation.fileFormat match {
+ case _: OrcFileFormat => false
+ case _: ParquetFileFormat
+ if (projects.iterator ++ filters.iterator).exists(hasDynamicLookup)
=>
+ val hadoopConf = hadoopFsRelation.sparkSession.sessionState
Review Comment:
Kept the effective Hadoop configuration and added the suggested comment. I
reproduced a difference with the legacy flag set through `spark.hadoop.*` and
the SQL setting unset. Reading only relation options and `SQLConf` made an
out-of-bounds access return NULL on both V1 readers. Using the same
configuration as the reader preserved `INVALID_ARRAY_INDEX`.
--
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]