This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 379f566530d0 fix(Spark): Fix nested struct predicate pushdown for 
HoodieFileGroupReaderBasedFileFormat (#20029)
379f566530d0 is described below

commit 379f566530d0a73e865864a2a2878c2b5b94da42
Author: Prashant Wason <[email protected]>
AuthorDate: Tue Oct 6 16:51:39 2026 -0700

    fix(Spark): Fix nested struct predicate pushdown for 
HoodieFileGroupReaderBasedFileFormat (#20029)
---
 .../HoodieFileGroupReaderBasedFileFormat.scala     |  17 ++-
 .../parquet/ParquetSchemaEvolutionUtils.scala      |  55 +++++--
 .../parquet/TestParquetSchemaEvolutionUtils.scala  |  77 +++++++++-
 .../TestNestedSchemaPruningOptimization.scala      | 158 ++++++++++++++++++++-
 .../apache/spark/sql/hudi/ddl/TestSpark3DDL.scala  |   6 +
 5 files changed, 296 insertions(+), 17 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
index f1285f95ca0e..16f9a7554756 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
@@ -52,6 +52,8 @@ import org.apache.spark.sql.sources.Filter
 import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType}
 import org.apache.spark.util.SerializableConfiguration
 
+import java.util.Locale
+
 import scala.collection.JavaConverters.mapAsJavaMapConverter
 
 trait HoodieFormatTrait {
@@ -90,7 +92,20 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String,
 
   private lazy val hasTimestampMillisFieldInTableSchema = 
HoodieSchemaRepair.hasTimestampMillisField(schema)
   private lazy val supportBatchWithTableSchema = HoodieSparkUtils.gteqSpark3_5 
|| !hasTimestampMillisFieldInTableSchema
-  override def shortName(): String = "HudiFileGroup"
+  /**
+   * Returns the base file format's short name so that Spark's nested 
predicate pushdown allowlist
+   * (spark.sql.optimizer.nestedPredicatePushdown.supportedFileSources, 
default "parquet,orc")
+   * matches on every read path. Tables with multiple base file formats report 
"parquet", since
+   * both the Parquet and ORC filter translation resolve nested column names. 
Other formats (Lance,
+   * Vortex) report their own name, which keeps them off the default allowlist.
+   */
+  override def shortName(): String = {
+    if (isMultipleBaseFileFormatsEnabled || hoodieFileFormat == null) {
+      "parquet"
+    } else {
+      hoodieFileFormat.name().toLowerCase(Locale.ROOT)
+    }
+  }
 
   override def toString: String = "HoodieFileGroupReaderBasedFileFormat"
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
index b9470141aac1..b99ba83850e7 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
@@ -44,6 +44,8 @@ import org.apache.parquet.schema.{GroupType, MessageType, 
Type => ParquetType}
 import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
 import org.apache.spark.sql.HoodieSchemaUtils
 import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
UnsafeProjection}
+import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
+import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
 import org.apache.spark.sql.execution.datasources.SparkSchemaTransformUtils
 import 
org.apache.spark.sql.execution.datasources.parquet.ParquetSchemaEvolutionUtils.pruneInternalSchema
 import org.apache.spark.sql.sources._
@@ -52,6 +54,7 @@ import org.apache.spark.sql.types.{ArrayType, AtomicType, 
BinaryType, DataType,
 import java.time.ZoneId
 
 import scala.collection.convert.ImplicitConversions.`collection 
AsScalaIterable`
+import scala.util.Try
 
 class ParquetSchemaEvolutionUtils(readConf: Configuration,
                                   filePath: Path,
@@ -192,31 +195,31 @@ object ParquetSchemaEvolutionUtils {
     } else {
       oldFilter match {
         case eq: EqualTo =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(eq.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(eq.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else eq.copy(attribute = 
newAttribute)
         case eqs: EqualNullSafe =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(eqs.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(eqs.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else eqs.copy(attribute = 
newAttribute)
         case gt: GreaterThan =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(gt.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(gt.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else gt.copy(attribute = 
newAttribute)
         case gtr: GreaterThanOrEqual =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(gtr.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(gtr.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else gtr.copy(attribute = 
newAttribute)
         case lt: LessThan =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(lt.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(lt.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else lt.copy(attribute = 
newAttribute)
         case lte: LessThanOrEqual =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(lte.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(lte.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else lte.copy(attribute = 
newAttribute)
         case i: In =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(i.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(i.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else i.copy(attribute = 
newAttribute)
         case isn: IsNull =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(isn.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(isn.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else isn.copy(attribute = 
newAttribute)
         case isnn: IsNotNull =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(isnn.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(isnn.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else isnn.copy(attribute = 
newAttribute)
         case And(left, right) =>
           And(rebuildFilterFromParquet(left, fileSchema, querySchema), 
rebuildFilterFromParquet(right, fileSchema, querySchema))
@@ -225,13 +228,13 @@ object ParquetSchemaEvolutionUtils {
         case Not(child) =>
           Not(rebuildFilterFromParquet(child, fileSchema, querySchema))
         case ssw: StringStartsWith =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(ssw.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(ssw.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else ssw.copy(attribute = 
newAttribute)
         case ses: StringEndsWith =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(ses.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(ses.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else ses.copy(attribute = 
newAttribute)
         case sc: StringContains =>
-          val newAttribute = 
InternalSchemaUtils.reBuildFilterName(sc.attribute, fileSchema, querySchema)
+          val newAttribute = rebuildFilterName(sc.attribute, fileSchema, 
querySchema)
           if (newAttribute.isEmpty) AlwaysTrue else sc.copy(attribute = 
newAttribute)
         case AlwaysTrue =>
           AlwaysTrue
@@ -243,6 +246,34 @@ object ParquetSchemaEvolutionUtils {
     }
   }
 
+  /**
+   * Re-spells one filter column name onto the file schema. With nested 
predicate pushdown, Spark
+   * names a column by its multipart path with every part that is not a plain 
identifier quoted
+   * (`名字`, item.`my-name`, `a.b`), while the internal schema keys fields by 
their dot-joined
+   * parts. The name is parsed into parts for the lookup, and the file's name 
is quoted back the
+   * same way, which is how ParquetFilters keys its columns. A name that does 
not parse is one
+   * Spark passed through raw (nested pushdown off) and is looked up as it is.
+   */
+  private def rebuildFilterName(name: String, fileSchema: InternalSchema, 
querySchema: InternalSchema): String = {
+    Try(CatalystSqlParser.parseMultipartIdentifier(name)).toOption match {
+      case Some(parts) =>
+        val fullName = parts.mkString(".")
+        val fileFullName = InternalSchemaUtils.reBuildFilterName(fullName, 
fileSchema, querySchema)
+        if (fileFullName.isEmpty) {
+          ""
+        } else if (fileFullName == fullName) {
+          name
+        } else {
+          // Any level of the path may have been renamed, so resolve each 
prefix by field id.
+          parts.indices.map { i =>
+            fileSchema.findField(querySchema.findIdByName(parts.take(i + 
1).mkString("."))).name()
+          }.quoted
+        }
+      case None =>
+        InternalSchemaUtils.reBuildFilterName(name, fileSchema, querySchema)
+    }
+  }
+
   /**
    * Fails fast when schema-on-read meets a shredded variant file. The 
internal schema models a
    * variant as a two-field {metadata, value} record (with sentinel negative 
field ids, see
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
index 463c1993b888..686288fb8a23 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
@@ -20,8 +20,10 @@
 package org.apache.spark.sql.execution.datasources.parquet
 
 import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaField, 
HoodieSchemaType}
-import org.apache.hudi.common.schema.internal.InternalSchema
+import org.apache.hudi.common.schema.internal.{InternalSchema, Type => 
InternalType, Types => InternalTypes}
+import org.apache.hudi.common.schema.internal.action.TableChanges
 import org.apache.hudi.common.schema.internal.convert.InternalSchemaConverter
+import org.apache.hudi.common.schema.internal.utils.SchemaChangeUtils
 import org.apache.hudi.common.util
 import org.apache.hudi.exception.HoodieException
 
@@ -394,6 +396,79 @@ class TestParquetSchemaEvolutionUtils {
       ParquetSchemaEvolutionUtils.rebuildFilterFromParquet(untouched, null, 
null))
   }
 
+  /**
+   * A nested field renamed under schema-on-read is re-spelled onto the file's 
name, as a dotted
+   * path, rather than collapsing to AlwaysTrue (which would read correctly 
but skip nothing).
+   */
+  @Test
+  def testRebuildFilterFromParquetRespellsNestedFilters(): Unit = {
+    val intType = HoodieSchema.create(HoodieSchemaType.INT)
+    val stringType = HoodieSchema.create(HoodieSchemaType.STRING)
+    val fileSchema = internalSchemaOf(("id", intType),
+      ("item", recordOf("item", ("name", stringType), ("price", intType))))
+    val querySchema = evolve(fileSchema, renames = Seq("item.name" -> 
"title"), adds = Seq(("item", "added")))
+
+    def rebuild(filter: Filter): Filter =
+      ParquetSchemaEvolutionUtils.rebuildFilterFromParquet(filter, fileSchema, 
querySchema)
+
+    Assertions.assertEquals(EqualTo("item.name", "a1"), 
rebuild(EqualTo("item.title", "a1")))
+    Assertions.assertEquals(GreaterThan("item.price", 5), 
rebuild(GreaterThan("item.price", 5)))
+    Assertions.assertEquals(AlwaysTrue, rebuild(IsNotNull("item.added")))
+  }
+
+  /**
+   * With nested predicate pushdown, Spark quotes every name part that is not 
a plain identifier
+   * (non-ASCII, hyphens, dots), so the filter carries e.g. `名字` or 
item.`my-title`. The rebuild
+   * must resolve those against the internal schema and hand ParquetFilters 
the file's name quoted
+   * the same way, since that is how ParquetFilters keys its columns.
+   */
+  @Test
+  def testRebuildFilterFromParquetHandlesQuotedNames(): Unit = {
+    // Built from internal types directly, since these names are not valid 
Avro names.
+    def field(id: Int, name: String, fieldType: InternalType): 
InternalTypes.Field =
+      InternalTypes.Field.get(id, true, name, fieldType)
+    val fileSchema = new InternalSchema(InternalTypes.RecordType.get(
+      field(0, "名字", InternalTypes.StringType.get()),
+      field(1, "old col", InternalTypes.IntType.get()),
+      field(2, "a.b", InternalTypes.IntType.get()),
+      field(3, "item", InternalTypes.RecordType.get(
+        field(4, "my-name", InternalTypes.StringType.get()),
+        field(5, "price", InternalTypes.IntType.get())))))
+    val querySchema = evolve(fileSchema,
+      renames = Seq("old col" -> "new-col", "item.my-name" -> "my-title"), 
adds = Seq(("", "新增")))
+
+    def rebuild(filter: Filter): Filter =
+      ParquetSchemaEvolutionUtils.rebuildFilterFromParquet(filter, fileSchema, 
querySchema)
+
+    // Untouched columns keep their quoted spelling.
+    Assertions.assertEquals(EqualTo("`名字`", "x"), rebuild(EqualTo("`名字`", 
"x")))
+    Assertions.assertEquals(EqualTo("`a.b`", 1), rebuild(EqualTo("`a.b`", 1)))
+    // Renamed columns are re-spelled onto the file's name, quoted where it 
needs to be.
+    Assertions.assertEquals(GreaterThan("`old col`", 1), 
rebuild(GreaterThan("`new-col`", 1)))
+    Assertions.assertEquals(EqualTo("item.`my-name`", "x"), 
rebuild(EqualTo("item.`my-title`", "x")))
+    // A quoted column the file does not hold collapses to AlwaysTrue.
+    Assertions.assertEquals(AlwaysTrue, rebuild(IsNotNull("`新增`")))
+    // Without nested pushdown Spark passes top-level names through raw, which 
must keep working.
+    Assertions.assertEquals(EqualTo("名字", "x"), rebuild(EqualTo("名字", "x")))
+    Assertions.assertEquals(GreaterThan("old col", 1), 
rebuild(GreaterThan("new-col", 1)))
+  }
+
+  /** Applies schema-on-read renames, then adds int columns under the given 
parent ("" for top level). */
+  private def evolve(schema: InternalSchema,
+                     renames: Seq[(String, String)],
+                     adds: Seq[(String, String)]): InternalSchema = {
+    val updates = TableChanges.ColumnUpdateChange.get(schema)
+    renames.foreach { case (name, newName) => updates.renameColumn(name, 
newName) }
+    val renamed = SchemaChangeUtils.applyTableChanges2Schema(schema, updates)
+    val addChange = TableChanges.ColumnAddChange.get(renamed)
+    adds.foreach { case (parent, name) => addChange.addColumns(parent, name, 
InternalTypes.IntType.get(), null) }
+    SchemaChangeUtils.applyTableChanges2Schema(renamed, addChange)
+  }
+
+  private def recordOf(name: String, fields: (String, HoodieSchema)*): 
HoodieSchema =
+    HoodieSchema.createRecord(name, "org.apache.hudi.test", null,
+      Arrays.asList(fields.map { case (n, schema) => HoodieSchemaField.of(n, 
schema) }: _*))
+
   /**
    * The read configuration may be shared by other readers: a file that needs 
its own requested schema or a
    * pushed filter gets a copy, and any other file reads with the read 
configuration itself.
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
index 3ca08f7eec80..f4b539de252d 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
@@ -17,9 +17,10 @@
 
 package org.apache.spark.sql.hudi.common
 
+import org.apache.hudi.{HoodieSchemaConversionUtils, HoodieTableSchema}
 import org.apache.hudi.client.SparkRDDWriteClient
 import org.apache.hudi.client.common.HoodieSparkEngineContext
-import org.apache.hudi.common.model.{HoodieAvroPayload, HoodieAvroRecord, 
HoodieKey, HoodieRecord, HoodieTableType}
+import org.apache.hudi.common.model.{HoodieAvroPayload, HoodieAvroRecord, 
HoodieFileFormat, HoodieKey, HoodieRecord, HoodieTableType}
 import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaType}
 import org.apache.hudi.common.table.{HoodieTableMetaClient, 
TableSchemaResolver}
 import org.apache.hudi.common.table.read.CustomPayloadForTesting
@@ -31,10 +32,11 @@ import 
org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
 import org.apache.avro.generic.{GenericData, GenericRecord}
 import org.apache.spark.api.java.JavaSparkContext
 import org.apache.spark.sql.{DataFrame, Row}
-import org.apache.spark.sql.execution.{FileSourceScanExec, ProjectExec}
+import org.apache.spark.sql.execution.{FileSourceScanExec, ProjectExec, 
SparkPlan}
+import 
org.apache.spark.sql.execution.datasources.parquet.HoodieFileGroupReaderBasedFileFormat
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{ArrayType, IntegerType, MapType, 
StringType, StructField, StructType}
-import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
 
 import scala.collection.JavaConverters._
 
@@ -299,6 +301,156 @@ class TestNestedSchemaPruningOptimization extends 
HoodieSparkSqlTestBase {
     }
   }
 
+  test("Nested struct predicate pushdown") {
+    withTempDir { tmp =>
+      Seq("cow", "mor").foreach { tableType =>
+        val tableName = generateTableName
+        val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+        // NOTE: Meta fields stay enabled since the SQL UPDATE on MOR needs 
them to locate the file group
+        createTableWithNestedStructSchema(tableType, tableName, tablePath, 
populateMetaFields = true)
+        // Move id 1 off the filtered value and add id 2 on it; for MOR the 
update lands in a log
+        // file, so the read has to merge a base record that matches with a 
log record that does not.
+        spark.sql(s"INSERT INTO $tableName SELECT 2 AS id, 
named_struct('name', 'a1', 'price', 20) AS item, 123457 AS ts")
+        spark.sql(s"UPDATE $tableName SET item = named_struct('name', 'zz', 
'price', 10) WHERE id = 1")
+        if (tableType == "mor") {
+          assertHasLogFiles(tablePath)
+        }
+
+        val query = s"SELECT id FROM $tableName WHERE item.name = 'a1' AND 
item.price > 5"
+        // NOTE: Spark infers a top-level IsNotNull(item) from the nested 
predicates and pushes it
+        //       regardless of the allowlist, so assert on the nested 
predicates themselves.
+        val nestedFilters = Seq("EqualTo(item.name,a1)", 
"GreaterThan(item.price,5)")
+
+        val pushedFilters = pushedFiltersOf(spark.sql(query))
+        nestedFilters.foreach { f =>
+          assertTrue(pushedFilters.contains(f), s"[$tableType] $f should be 
pushed down, got $pushedFilters")
+        }
+        checkAnswer(query)(Seq(2))
+        checkAnswer(s"SELECT id FROM $tableName WHERE item.name = 
'zz'")(Seq(1))
+
+        // Negative leg: with an empty allowlist the nested predicates must 
not be pushed.
+        withSQLConf(SQLConf.NESTED_PREDICATE_PUSHDOWN_FILE_SOURCE_LIST.key -> 
"") {
+          val prunedFilters = pushedFiltersOf(spark.sql(query))
+          nestedFilters.foreach { f =>
+            assertFalse(prunedFilters.contains(f), s"[$tableType] $f should 
not be pushed down, got $prunedFilters")
+          }
+          checkAnswer(query)(Seq(2))
+        }
+      }
+    }
+  }
+
+  test("Nested struct predicate pushdown with schema on read and a renamed 
nested column") {
+    withTempDir { tmp =>
+      Seq("cow", "mor").foreach { tableType =>
+        withSQLConf("hoodie.schema.on.read.enable" -> "true") {
+          val tableName = generateTableName
+          val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+          createTableWithNestedStructSchema(tableType, tableName, tablePath, 
populateMetaFields = true)
+          spark.sql(s"ALTER TABLE $tableName RENAME COLUMN item.name TO title")
+          // For MOR, also merge in a log file written under the new name. COW 
is left alone so its
+          // base file keeps the old name (an update would rewrite it under 
the new one).
+          if (tableType == "mor") {
+            spark.sql(s"UPDATE $tableName SET ts = ts + 1 WHERE id = 1")
+            assertHasLogFiles(tablePath)
+          }
+
+          // The base file still stores item.name, so the pushed filter must 
be rewritten to the
+          // file-side name rather than dropped or failing the read.
+          val query = s"SELECT id FROM $tableName WHERE item.title = 'a1'"
+          val pushedFilters = pushedFiltersOf(spark.sql(query))
+          assertTrue(pushedFilters.contains("EqualTo(item.title,a1)"),
+            s"[$tableType] EqualTo(item.title,a1) should be pushed down, got 
$pushedFilters")
+          checkAnswer(query)(Seq(1))
+          checkAnswer(s"SELECT id FROM $tableName WHERE item.title = 'a2'")()
+        }
+      }
+    }
+  }
+
+  test("Nested struct predicate pushdown with schema on read and quoted column 
names") {
+    withTempDir { tmp =>
+      Seq("cow", "mor").foreach { tableType =>
+        // NOTE: A top-level rename fails the incoming-schema check unless 
column drops are allowed
+        withSQLConf("hoodie.schema.on.read.enable" -> "true",
+          "hoodie.datasource.write.schema.allow.auto.evolution.column.drop" -> 
"true") {
+          val tableName = generateTableName
+          val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+          // Names that are not plain identifiers reach the reader 
backtick-quoted (`名字`,
+          // item.`名称`) once nested predicate pushdown is enabled for the 
source. Only names
+          // Avro accepts can be written, so hyphens and dots are covered by 
the unit tests.
+          spark.sql(
+            s"""
+               |CREATE TABLE $tableName USING HUDI
+               |TBLPROPERTIES (type = '$tableType', primaryKey = 'id', 
orderingFields = 'ts')
+               |LOCATION '$tablePath'
+               |AS SELECT
+               |  1 AS id,
+               |  '李明' AS `名字`,
+               |  10 AS `旧分数`,
+               |  named_struct('名称', 'a1', 'price', 10) AS item,
+               |  123456 AS ts
+               """.stripMargin)
+          spark.sql(s"ALTER TABLE $tableName RENAME COLUMN `旧分数` TO `新分数`")
+          spark.sql(s"ALTER TABLE $tableName RENAME COLUMN item.`名称` TO `标题`")
+          if (tableType == "mor") {
+            spark.sql(s"UPDATE $tableName SET ts = ts + 1 WHERE id = 1")
+            assertHasLogFiles(tablePath)
+          }
+
+          val nestedQuery = s"SELECT id FROM $tableName WHERE item.`标题` = 'a1'"
+          val pushedFilters = pushedFiltersOf(spark.sql(nestedQuery))
+          assertTrue(pushedFilters.contains("EqualTo(item.`标题`,a1)"),
+            s"[$tableType] EqualTo(item.`标题`,a1) should be pushed down, got 
$pushedFilters")
+          checkAnswer(nestedQuery)(Seq(1))
+          checkAnswer(s"SELECT id FROM $tableName WHERE item.`标题` = 'a2'")()
+          checkAnswer(s"SELECT id FROM $tableName WHERE `名字` = '李明'")(Seq(1))
+          checkAnswer(s"SELECT id FROM $tableName WHERE `新分数` > 5")(Seq(1))
+          checkAnswer(s"SELECT id FROM $tableName WHERE `新分数` > 50")()
+        }
+      }
+    }
+  }
+
+  test("shortName matches the base file format") {
+    val unusedSchema = StructType.fromDDL("id int, ts long")
+    def formatFor(fileFormat: HoodieFileFormat, multipleBaseFileFormats: 
Boolean = false) =
+      new HoodieFileGroupReaderBasedFileFormat(
+        "unused", HoodieTableSchema(unusedSchema,
+          
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(unusedSchema, 
"unused", "hoodie.test")),
+        "unused", "000", Seq.empty, isMOR = false, isBootstrap = false, 
isIncremental = false,
+        "", shouldUseRecordPosition = false, Seq.empty, 
multipleBaseFileFormats, fileFormat)
+
+    assertEquals("parquet", formatFor(HoodieFileFormat.PARQUET).shortName())
+    assertEquals("orc", formatFor(HoodieFileFormat.ORC).shortName())
+    assertEquals("lance", formatFor(HoodieFileFormat.LANCE).shortName())
+    assertEquals("vortex", formatFor(HoodieFileFormat.VORTEX).shortName())
+    assertEquals("parquet", formatFor(HoodieFileFormat.ORC, 
multipleBaseFileFormats = true).shortName())
+  }
+
+  private def pushedFiltersOf(selectDF: DataFrame): String =
+    
findFileScan(selectDF.queryExecution.executedPlan).metadata.getOrElse("PushedFilters",
 "")
+
+  private def assertHasLogFiles(tablePath: String): Unit = {
+    val stream = java.nio.file.Files.walk(java.nio.file.Paths.get(tablePath))
+    try {
+      val hasLogFile = stream.iterator().asScala.exists { p =>
+        !p.toString.contains("/.hoodie/") && 
p.getFileName.toString.contains(".log.")
+      }
+      assertTrue(hasLogFile, s"Expected log files under $tablePath")
+    } finally {
+      stream.close()
+    }
+  }
+
+  private def findFileScan(plan: SparkPlan): FileSourceScanExec = {
+    val scans = plan.collect { case f: FileSourceScanExec => f }
+    assert(scans.nonEmpty, s"No FileSourceScanExec found in plan:\n$plan")
+    scans.head
+  }
+
   private def assertPrunedReadSchema(selectDF: DataFrame,
                                      tableName: String,
                                      expectedSchema: StructType): Unit = {
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSpark3DDL.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSpark3DDL.scala
index 2219fefc87c3..d8c1d00849c6 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSpark3DDL.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSpark3DDL.scala
@@ -563,6 +563,12 @@ class TestSpark3DDL extends HoodieSparkSqlTestBase {
                | (1,3,'李明', '读书', 100,180.0001,99.0001,DATE'2021-12-26', 
DATE'2021-12-26')
                |""".stripMargin)
 
+          // Filters on non-ASCII columns, including one renamed after the 
base file was written,
+          // are pushed down with backtick-quoted names and must resolve 
against the file schema.
+          checkAnswer(spark.sql(s"select id from $tableName where `名字` = 
'李明'").collect())(Seq(1))
+          checkAnswer(spark.sql(s"select id from $tableName where `爱好_Best` = 
'读书'").collect())(Seq(1))
+          checkAnswer(spark.sql(s"select id from $tableName where `爱好_Best` = 
'跑步'").collect())()
+
           // alter date to string
           spark.sql(s"alter table $tableName alter column `上次更新时间` type string 
")
           checkAnswer(spark.sql(s"select `上次更新时间` from $tableName").collect())(

Reply via email to