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

voonhous 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 c59987a024cd fix(variant): project PushVariantIntoScan struct paths in 
the ... (#19783)
c59987a024cd is described below

commit c59987a024cd021cbb9aa34bdc74d341ea834b4d
Author: voonhous <[email protected]>
AuthorDate: Tue Sep 1 18:59:39 2026 +0800

    fix(variant): project PushVariantIntoScan struct paths in the ... (#19783)
    
    Spark 4.1+ rewrites a variant reached through a struct path into a
    projection struct nested in the scan schema, e.g. s: struct<inner:
    struct<"0": string>>. SparkFileFormatInternalRowReaderContext overlaid
    and projected that struct for top-level fields only, so on a MOR table
    with log files the merged row still held a raw VariantVal at s.inner
    while the plan read it as the projected struct: SIGBUS, InternalError,
    OOM or silent nulls, on any log format and record type. COW was fine.
    
    - SparkFileFormatInternalRowReaderContext: the projection overlay and
      its detection recurse into struct members, mirroring Spark's
      VariantInRelation.rewriteType; arrays and maps stay native.
    - BaseSpark4Adapter: buildVariantProjector hoisted from the identical
      4.1/4.2 copies and made recursive. A null variant in an avro log
      record projects to a NULL struct, not a struct of nulls, since
      PushVariantIntoScan rewrites IsNull/IsNotNull onto that struct.
    - SparkSchemaTransformUtils.addMissingFields: a projection struct
      requested over a file VariantType stays the reader type when a
      sibling member has an implicit type change; it used to be folded
      back to VariantType, and the type-change Cast then cast the variant
      value to the projected struct.
    - SparkAdapter.containsVariantProjection: one definition for the
      reader context and the adapter.
    - The deferral comments claiming a native VariantType request clips a
      shredded file to {metadata, value} now state the verified mechanism:
      the Spark 4.1+ parquet reader reconstructs a shredded variant at any
      depth. The full-variant rewrite stays top-level as a contract.
    
    Tests, over tables whose only variant is the nested one.
    TestVariantShreddingMixedLayouts covers MOR merge, read-optimized,
    compaction and clustering on both writer paths, CDC images, nested
    variant_get and cast on COW and on MOR over native parquet logs and
    avro blocks with pushVariantIntoScan on and off (each arm pins whether
    the scan carries the projection struct), a null variant through the
    log, implicit widening of a sibling, and an array<variant> element
    shredded through a declared write schema.
    TestBaseSpark4AdapterVariantMethods covers the projector and the
    implicit-change reconciliation as units. TestStreamingSource runs the
    legacy-RDD leg with and without a top-level variant beside the nested
    one.
    
    Closes #19775. Part of #18937.
---
 .../SparkFileFormatInternalRowReaderContext.scala  |  63 +--
 .../datasources/SparkSchemaTransformUtils.scala    |  13 +
 .../org/apache/spark/sql/hudi/SparkAdapter.scala   |  42 +-
 .../org/apache/hudi/HoodieMergeOnReadRDDV2.scala   |  19 +-
 .../org/apache/hudi/cdc/CDCFileGroupIterator.scala |  12 +-
 .../hudi/functional/TestStreamingSource.scala      | 115 ++++-
 .../TestBaseSpark4AdapterVariantMethods.scala      | 109 ++++-
 .../sql/hudi/dml/schema/TestVariantDataType.scala  |  31 +-
 .../schema/TestVariantShreddingMixedLayouts.scala  | 530 ++++++++++++++++++++-
 .../dml/schema/VariantShreddingTestSupport.scala   | 121 ++++-
 .../spark/sql/adapter/BaseSpark4Adapter.scala      |  94 +++-
 .../apache/spark/sql/adapter/Spark4_1Adapter.scala |  48 +-
 .../apache/spark/sql/adapter/Spark4_2Adapter.scala |  48 +-
 13 files changed, 1032 insertions(+), 213 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
index 1bd90bd86c61..8e42dd9831fc 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
@@ -93,13 +93,20 @@ class 
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
   private lazy val allFilters = filters ++ requiredFilters
 
   // For each field of `target`, replace its dataType with the matching 
field's projected
-  // variant struct from `source` (when present). Non-matching fields pass 
through. Why a parallel
-  // `sparkRequiredSchema` overlay exists at all is documented on that 
constructor parameter.
+  // variant struct from `source` (when present), recursing into struct 
members so a variant
+  // reached through a struct path is overlaid too. Fields are matched by name 
(findFieldByName);
+  // non-matching fields pass through. The recursion mirrors 
PushVariantIntoScan's
+  // VariantInRelation.rewriteType, which rewrites variants at the root of the 
relation output
+  // and below STRUCT paths only, so an array element or a map value is never 
overlaid here
+  // either (#19775). Why a parallel `sparkRequiredSchema` overlay exists at 
all is documented on
+  // that constructor parameter.
   private def overlayVariantProjections(target: StructType, source: 
StructType): StructType = {
     StructType(target.fields.map { f =>
-      SparkFileFormatInternalRowReaderContext.findFieldByName(source, 
f.name).map(_.dataType) match {
-        case Some(projStruct: StructType) if 
sparkAdapter.isVariantProjectionStruct(projStruct) =>
+      (f.dataType, 
SparkFileFormatInternalRowReaderContext.findFieldByName(source, 
f.name).map(_.dataType)) match {
+        case (_, Some(projStruct: StructType)) if 
sparkAdapter.isVariantProjectionStruct(projStruct) =>
           f.copy(dataType = projStruct)
+        case (targetStruct: StructType, Some(sourceStruct: StructType)) =>
+          f.copy(dataType = overlayVariantProjections(targetStruct, 
sourceStruct))
         case _ => f
       }
     })
@@ -111,10 +118,8 @@ class 
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
   // VariantType, so a row already rewritten into the projected struct shape 
would be mis-decoded.
   // Single source of truth for both reader paths (parquet native projection + 
avro rewrite).
   private def shouldProjectVariants(): Boolean = {
-    val hasVariantProjection = 
sparkRequiredSchema.exists(_.fields.exists(_.dataType match {
-      case st: StructType => sparkAdapter.isVariantProjectionStruct(st)
-      case _ => false
-    }))
+    val hasVariantProjection =
+      sparkRequiredSchema.exists(_.fields.exists(f => 
sparkAdapter.containsVariantProjection(f.dataType)))
     // getRecordMerger() is a Lombok getter over a field initialized to null 
(not Option.empty());
     // it stays null until HoodieReaderContext.initRecordMerger runs 
(HoodieFileGroupReader calls it
     // from its constructor), so the null guard is required.
@@ -187,22 +192,25 @@ class 
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
     }
 
     // Internal reads have no catalyst plan, so nothing rewrites VariantType 
fields the way
-    // PushVariantIntoScan does for user queries. Requesting native 
VariantType against a
-    // SHREDDED parquet base file clips the file group to {metadata, value} 
and reads
-    // value=null; write-side callers (compaction, clustering, merge) would 
then persist the
-    // nulls, silently losing the variant data (#19556). Query paths that 
build this context
-    // without sparkRequiredSchema hit the same null reads and take the same 
rewrite: CDC
-    // (CDCFileGroupIterator) under default configs, plus the legacy RDD paths 
(streaming
-    // source, MergeOnRead relations) only when 
hoodie.file.group.reader.enabled=false;
-    // batch incremental always scans through the file format with a catalyst 
schema.
-    // Request the full-variant projection shape
-    // instead and restore native VariantType after the scan. User-facing 
reads pass
-    // sparkRequiredSchema and are overlaid above. Only top-level variant 
fields are
-    // rewritten: no production write path shreds a nested variant today (a 
nested shredded
-    // write schema needs the test-only force config until shredding-schema 
inference
-    // lands), so the nested leg is deferred until such files can exist. Spark 
< 4.1 cannot
-    // reconstruct shredded values on read (SPARK-54410) and the adapter 
returns None;
-    // Spark 4.0 does write shredded files, so its pre-existing read-side gap 
stays as is.
+    // PushVariantIntoScan does for user queries. For parquet base files, 
request the same
+    // whole-variant shape that rewrite would (one child "0" at path "$") for 
each top-level
+    // variant column and restore native VariantType after the scan, so 
write-side callers
+    // (compaction, clustering, merge) and the query paths that build this 
context without
+    // sparkRequiredSchema (CDC under default configs; the streaming source 
only with
+    // hoodie.file.group.reader.enabled=false) read a SHREDDED file through 
the contract
+    // PushVariantIntoScan established (#19556, #19578). It is a contract, not 
a workaround for
+    // the reader: on Spark 4.1+ a native VariantType request is reconstructed 
from a shredded
+    // group at any depth (ParquetReadSupport.clipParquetType passes the leaf 
group through,
+    // ParquetToSparkSchemaConverter carries the catalyst target into struct 
members, list
+    // elements and map values, and ParquetRowConverter assembles it), which 
is why nested
+    // variants are read natively here and are not rewritten (#19775). What 
does read a
+    // shredded group as value=null is a request in the physical {metadata, 
value} shape,
+    // which only the schema-on-read internal-schema branch produces; the 
projection shape is
+    // what lets ParquetSchemaEvolutionUtils.validateNoShreddedVariants name 
this route there
+    // instead of failing inside the read. User-facing reads pass 
sparkRequiredSchema and are
+    // overlaid above. Spark < 4.1 cannot reconstruct shredded values on read 
(SPARK-54410)
+    // and the adapter returns None; Spark 4.0 does write shredded files, so 
its pre-existing
+    // read-side gap stays as is.
     val isParquetBaseFile = !isInlineLog && !FSUtils.isLogFile(filePath) &&
       HoodieFileFormat.fromFileExtension(filePath.getFileExtension) == 
HoodieFileFormat.PARQUET
     val (readStructTypeForScan, variantOrdinals) =
@@ -509,9 +517,10 @@ object SparkFileFormatInternalRowReaderContext {
   /**
    * Rewrites top-level VariantType fields of `structType` into the 
full-variant projection
    * shape (see SparkAdapter.buildFullVariantReadSchema) and returns it with 
the ordinals of
-   * the rewritten fields, or None when nothing rewrites (no variant fields, 
or no shredded
-   * read support on this Spark version). Shared by this context and direct 
base-file reads
-   * that bypass it (CDCFileGroupIterator's BASE_FILE_INSERT case).
+   * the rewritten fields, or None when nothing rewrites (no top-level variant 
field, or no
+   * shredded read support on this Spark version). Variants nested in structs, 
arrays and maps
+   * stay native and are reconstructed by the reader as they are. Shared by 
this context and
+   * direct base-file reads that bypass it (CDCFileGroupIterator's 
BASE_FILE_INSERT case).
    */
   private[hudi] def fullVariantReadSchemaWithOrdinals(structType: StructType): 
Option[(StructType, Set[Int])] = {
     
SparkAdapterSupport.sparkAdapter.buildFullVariantReadSchema(structType).map { 
rewritten =>
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
index c2676c710b9b..3150216c5e1e 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
@@ -426,6 +426,13 @@ object SparkSchemaTransformUtils {
     case (ArrayType(rt, _), ArrayType(ft, _)) => 
ArrayType(addMissingFields(rt, ft))
     case (MapType(requiredKey, requiredValue, _), MapType(fileKey, fileValue, 
_)) =>
       MapType(addMissingFields(requiredKey, fileKey), 
addMissingFields(requiredValue, fileValue))
+    // A Spark 4.1 variant projection struct requested over a file VariantType 
is the pair
+    // isDataTypeEqual declares equal through the adapter, and the reader has 
to be handed the
+    // projection struct, not the file's VariantType: parquet-mr decodes into 
the projected shape
+    // natively, while the type-change Cast applied afterwards would cast the 
variant VALUE to that
+    // struct. Without this arm a type change on a SIBLING member of the 
enclosing struct rewrote
+    // the projection back to VariantType (#19775).
+    case (requiredStruct: StructType, _) if 
isVariantProjectionOverVariant(requiredStruct, fileType) => requiredStruct
     case (StructType(requiredFields), StructType(fileFields)) =>
       val fileFieldMap = fileFields.map(f => f.name -> f).toMap
       StructType(requiredFields.map(f => {
@@ -436,4 +443,10 @@ object SparkSchemaTransformUtils {
       }))
     case _ => fileType
   }
+
+  // Same Try as in isDataTypeEqual: the adapter module may be absent from the 
classpath
+  // (hudi-spark-client tests), in which case there is no projection struct to 
recognise anyway.
+  private def isVariantProjectionOverVariant(requiredType: StructType, 
fileType: DataType): Boolean =
+    Try(HoodieSparkUtils.sparkAdapter.isVariantProjectionStruct(requiredType)
+      && 
HoodieSparkUtils.sparkAdapter.isVariantType(fileType)).getOrElse(false)
 }
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
index 03c266c96666..be3966634165 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
@@ -514,11 +514,27 @@ trait SparkAdapter extends Serializable {
   def isVariantProjectionStruct(structType: StructType): Boolean = false
 
   /**
-   * If `sparkRequiredSchema` contains any field that's a Spark 4.1 variant 
projection struct
-   * (i.e., the same-named field in `sparkDataSchema` is `VariantType`), 
returns a row
-   * transformer that takes an InternalRow in the data-schema shape (with full 
variants) and
-   * produces an InternalRow in the required-schema shape (with each variant 
column projected
-   * to its requested struct via VariantGet).
+   * True when `dataType` is a variant projection struct or holds one below a 
struct path: the two
+   * places PushVariantIntoScan puts them (the root of the relation output and 
STRUCT members), so
+   * an array element or a map value never matches. The reader context's 
schema overlay and the
+   * adapter's row projector both key off this, and they have to agree on it.
+   */
+  def containsVariantProjection(dataType: DataType): Boolean = dataType match {
+    case st: StructType =>
+      isVariantProjectionStruct(st) || st.fields.exists(f => 
containsVariantProjection(f.dataType))
+    case _ => false
+  }
+
+  /**
+   * If `sparkRequiredSchema` contains any Spark 4.1 variant projection struct 
(i.e., the
+   * same-named field in `sparkDataSchema` is `VariantType`), returns a row 
transformer that
+   * takes an InternalRow in the data-schema shape (with full variants) and 
produces an
+   * InternalRow in the required-schema shape (with each variant projected to 
its requested
+   * struct via VariantGet).
+   *
+   * Projection structs are looked for at the root and below any STRUCT path, 
the same places
+   * PushVariantIntoScan puts them; a variant that is an array element or a 
map value keeps its
+   * native VariantType on both sides and is passed through (#19775).
    *
    * Used on the MOR log-file path: log records carry the full variant on 
disk, but the merger
    * expects rows aligned to the post-PushVariantIntoScan required schema. 
Returns None when
@@ -530,14 +546,14 @@ trait SparkAdapter extends Serializable {
   /**
    * Rewrites each top-level VariantType field of `schema` into the 
full-variant projection
    * struct that PushVariantIntoScan would request for whole-variant access: a 
single child
-   * field "0" of VariantType carrying `VariantMetadata` for path "$". 
Requesting that shape
-   * makes the parquet reader reconstruct shredded variants by field name; 
requesting native
-   * VariantType instead clips a shredded file group down to {metadata, value} 
and reads
-   * value=null (#19556).
-   *
-   * Used by internal (non-catalyst) reads of parquet base files, which have no
-   * PushVariantIntoScan to do this for them. The caller restores the native 
VariantType
-   * shape by projecting child 0 of each rewritten field.
+   * field "0" of VariantType carrying `VariantMetadata` for path "$". 
Internal (non-catalyst)
+   * reads of parquet base files request that shape so they read a shredded 
file through the
+   * same contract as a rewritten user query (#19556); the caller restores 
native VariantType
+   * by projecting child 0 of each rewritten field. The shape is a contract, 
not a reader
+   * requirement: on Spark 4.1+ the parquet reader reconstructs a shredded 
variant for a native
+   * VariantType request too, at any depth, which is why only top-level fields 
are rewritten
+   * and variants nested in structs, arrays and maps are read natively 
(#19775). A request in
+   * the physical {metadata, value} struct shape is what reads a shredded 
group as value=null.
    *
    * Returns None when the schema has no top-level VariantType field or the 
Spark version has
    * no shredded-read support (Spark 3.x / 4.0).
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
index 267b4e4389a6..9f619e158ab4 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
@@ -149,13 +149,18 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext,
     }
   }
 
-  // The plain skip-merging reader cannot read a SHREDDED variant base file: 
it requests native
-  // VariantType, which clips the shredded group to {metadata, value} and 
reads value=null (the
-  // #19556 defect family). Such splits take the file-group reader below, 
whose reader context
-  // requests the full-variant projection shape instead (#19578). Keyed off 
the adapter building
-  // that shape rather than the mere presence of a variant column: it is None 
below Spark 4.1,
-  // where the file-group reader would read the same nulls, so re-routing 
there would cost the
-  // fast path for nothing.
+  // A split whose required schema has a top-level variant column takes the 
file-group reader
+  // below, whose reader context requests the full-variant projection shape 
for parquet base
+  // files (#19578), so a SHREDDED base file is read on this legacy path 
through the same
+  // contract as everywhere else. Without a top-level variant the base-only 
split stays on
+  // requiredSchemaReaderSkipMerging, whose native VariantType request the 
Spark 4.1+ parquet
+  // reader reconstructs at any depth - the vectorized one at stock settings, 
since the legacy
+  // file format inherits ParquetFileFormat.supportBatch and VariantType is 
atomic; the variant
+  // veto lives in HoodieFileGroupReaderBasedFileFormat only (pinned by 
TestStreamingSource's
+  // nested-only legacy leg), so this is about one contract, not a null read 
(#19775). Keyed off
+  // the adapter building that shape
+  // rather than the mere presence of a variant column: it is None below Spark 
4.1, where
+  // re-routing would cost the fast path for nothing.
   private val shouldRerouteVariantSplit: Boolean =
     
sparkAdapter.buildFullVariantReadSchema(requiredSchema.structTypeSchema).isDefined
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
index b845dbe38d35..a9e8091e9aa1 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
@@ -378,12 +378,12 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit,
 
           val pf = sparkPartitionedFileUtils.createPartitionedFile(
             InternalRow.empty, absCDCPath, 0, fileStatus.getLength)
-          // This read bypasses SparkFileFormatInternalRowReaderContext, so it 
needs the same
-          // full-variant treatment that context applies: requesting native 
VariantType against
-          // a SHREDDED base file clips the shredded group to {metadata, 
value} and reads
-          // value=null, which would surface as null variants in the insert 
after-images
-          // (#19556 family, #19578). The restore projection reuses one output 
buffer, hence
-          // the copy before buffering.
+          // This read bypasses SparkFileFormatInternalRowReaderContext, so it 
makes the same
+          // whole-variant request that context makes for top-level variant 
columns of a parquet
+          // base file and restores native VariantType afterwards (#19578); a 
variant nested in a
+          // struct, array or map stays native and the reader reconstructs it, 
shredded or not
+          // (#19775). The restore projection reuses one output buffer, hence 
the copy before
+          // buffering.
           val baseRows = SparkFileFormatInternalRowReaderContext
             
.fullVariantReadSchemaWithOrdinals(originTableSchema.structTypeSchema) match {
             case Some((rewritten, ordinals)) =>
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
index 3c943037f2e9..4fa1113b0261 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
@@ -21,6 +21,7 @@ import org.apache.hudi.{DataSourceReadOptions, 
HoodieSparkUtils}
 import org.apache.hudi.DataSourceReadOptions.{START_OFFSET, 
STREAMING_READ_TABLE_VERSION}
 import org.apache.hudi.DataSourceWriteOptions.{ORDERING_FIELDS, 
RECORDKEY_FIELD, TABLE_TYPE}
 import org.apache.hudi.common.config.HoodieReaderConfig
+import org.apache.hudi.common.fs.FSUtils
 import org.apache.hudi.common.model.HoodieTableType
 import org.apache.hudi.common.model.HoodieTableType.{COPY_ON_WRITE, 
MERGE_ON_READ}
 import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient, 
HoodieTableVersion}
@@ -30,6 +31,9 @@ import 
org.apache.hudi.config.HoodieWriteConfig.{DELETE_PARALLELISM_VALUE, INSER
 import org.apache.hudi.hadoop.fs.HadoopFSUtils
 import org.apache.hudi.util.JavaConversions
 
+import org.apache.hadoop.fs.Path
+import org.apache.parquet.hadoop.ParquetFileReader
+import org.apache.parquet.hadoop.util.HadoopInputFile
 import org.apache.spark.sql.{DataFrame, Row, SaveMode}
 import org.apache.spark.sql.streaming.StreamTest
 import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
@@ -381,16 +385,27 @@ class TestStreamingSource extends StreamTest {
     testLegacyIncrementalStreamSource(MERGE_ON_READ, HoodieTableVersion.EIGHT)
   }
 
-  test("test mor stream source reads shredded variant with legacy file group 
reader disabled") {
-    // #19578: with the file group reader disabled, MOR streaming batches 
materialize through
-    // HoodieMergeOnReadRDDV2, the only user-facing path reading shredded 
variant base files
-    // without a catalyst schema. The first stream covers the base-only split 
(first batch, the
-    // branch this fix re-routes) and the log-only split (second batch); the 
second stream covers
-    // the merged base + log split.
+  /**
+   * #19578: with the file group reader disabled, MOR streaming batches 
materialize through
+   * [[HoodieMergeOnReadRDDV2]], the only user-facing path reading shredded 
variant base files
+   * without a catalyst schema. The first stream covers the base-only split 
(first batch) and the
+   * log-only split (second batch); the second stream covers the merged base + 
log split.
+   *
+   * The table carries a variant one struct member down, force-shredded 
exactly like a top-level
+   * one. With a top-level `v` beside it the base-only split is re-routed to 
the file group reader
+   * (HoodieMergeOnReadRDDV2.shouldRerouteVariantSplit), while the nested-only 
leg keeps that split
+   * on requiredSchemaReaderSkipMerging - Spark's own parquet reader with a 
native VariantType
+   * request one struct member down, which the Spark 4.1+ parquet reader 
reconstructs out of the
+   * shredded group; the vectorized one at stock settings, as the legacy file 
format inherits
+   * ParquetFileFormat.supportBatch and VariantType is atomic (#19775). That 
second leg is the one
+   * HoodieMergeOnReadRDDV2's comment relies on.
+   */
+  private def testLegacyShreddedVariantStream(withTopLevelVariant: Boolean): 
Unit = {
     assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read 
requires Spark 4.1 or higher")
 
     withTempDir { inputDir =>
-      val tablePath = 
s"${inputDir.getCanonicalPath}/test_mor_variant_legacy_stream"
+      val suffix = if (withTopLevelVariant) "" else "_nested_only"
+      val tablePath = 
s"${inputDir.getCanonicalPath}/test_mor_variant_legacy_stream$suffix"
       HoodieTableMetaClient.newTableBuilder()
         .setTableType(MERGE_ON_READ)
         .setTableName(getTableName(tablePath))
@@ -398,6 +413,20 @@ class TestStreamingSource extends StreamTest {
         .setOrderingFields("ts")
         
.initTable(HadoopFSUtils.getStorageConf(spark.sessionState.newHadoopConf()), 
tablePath)
 
+      // Whether the top-level `v` column exists at all is decided here and 
nowhere else: on the
+      // nested-only leg the table is (id, s, ts) and every write, projection 
and expected row
+      // below drops it.
+      def rowSql(id: Int, topLevelKey: String, nestedKey: String, ts: Long): 
String = {
+        val topLevelCol = if (withTopLevelVariant) 
s"""parse_json('{"key":"$topLevelKey"}') as v, """ else ""
+        s"""select $id as id, ${topLevelCol}named_struct('inner', """ +
+          s"""parse_json('{"key":"$nestedKey"}')) as s, ${ts}L as ts"""
+      }
+
+      def expectedRow(id: Int, topLevelKey: String, nestedKey: String, ts: 
Long): Row = {
+        val topLevel = if (withTopLevelVariant) 
Seq(s"""{"key":"$topLevelKey"}""") else Seq.empty
+        Row((Seq[Any](id) ++ topLevel ++ Seq(s"""{"key":"$nestedKey"}""", 
ts)): _*)
+      }
+
       // INMEMORY index routes MOR inserts to log files, so the first base 
file is the
       // compaction's SHREDDED one; compact = true trips inline compaction on 
that write.
       def addVariantData(valuesSql: String, compact: Boolean): Unit = {
@@ -421,12 +450,15 @@ class TestStreamingSource extends StreamTest {
 
       // The read keeps the fully qualified name: it goes through 
StreamSourceProvider, which
       // never calls supportsDataType.
-      def variantStreamDf(): DataFrame = spark.readStream
-        .format("org.apache.hudi")
-        // force the legacy (non file-group-reader) incremental relation path
-        .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "false")
-        .load(tablePath)
-        .selectExpr("id", "cast(v as string) as v", "ts")
+      def variantStreamDf(): DataFrame = {
+        val topLevel = if (withTopLevelVariant) Seq("cast(v as string) as v") 
else Seq.empty
+        spark.readStream
+          .format("org.apache.hudi")
+          // force the legacy (non file-group-reader) incremental relation path
+          .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "false")
+          .load(tablePath)
+          .selectExpr((Seq("id") ++ topLevel ++ Seq("cast(s.inner as string) 
as inner", "ts")): _*)
+      }
 
       // The legacy branch of getBatch materializes the micro batch from an 
RDD via
       // internalCreateDataFrame, so the physical plan is a "Scan 
ExistingRDD"; this fails if the
@@ -441,16 +473,46 @@ class TestStreamingSource extends StreamTest {
         true
       }
 
-      addVariantData("""select 1 as id, parse_json('{"key":"v1"}') as v, 1000L 
as ts""", compact = false)
-      addVariantData("""select 2 as id, parse_json('{"key":"v2"}') as v, 1000L 
as ts""", compact = true)
+      addVariantData(rowSql(1, "v1", "n1", 1000L), compact = false)
+      addVariantData(rowSql(2, "v2", "n2", 1000L), compact = true)
+      // Pin that the compacted base file is shredded at every depth the leg 
carries: without it
+      // the streams below would pass just the same over an unshredded base 
and pin nothing about
+      // shredded reads. The listing goes through FSUtils.isBaseFile rather 
than a ".parquet"
+      // suffix test, which a native parquet log file 
(<fileId>_<token>_<instant>_<v>.log.parquet)
+      // also matches - so the assertion would hold even if inline compaction 
never ran.
+      val conf = spark.sessionState.newHadoopConf()
+      val baseFiles = new Path(tablePath).getFileSystem(conf).listStatus(new 
Path(tablePath))
+        .map(_.getPath).filter(p => FSUtils.isBaseFile(p.getName))
+      assertTrue(baseFiles.nonEmpty,
+        "expected a compacted BASE file under " + tablePath + " (log files 
excluded)")
+      baseFiles.foreach { file =>
+        val reader = ParquetFileReader.open(HadoopInputFile.fromPath(file, 
conf))
+        val footer = try reader.getFooter.getFileMetaData.getSchema finally 
reader.close()
+        // getFieldIndex + getType(int): the String overload of getType is 
ambiguous from Scala.
+        val s = footer.getType(footer.getFieldIndex("s")).asGroupType()
+        val inner = s.getType(s.getFieldIndex("inner")).asGroupType()
+        assertTrue(inner.containsField("typed_value"), "s.inner must be 
shredded in " + file + ":\n" + footer)
+        if (withTopLevelVariant) {
+          val v = footer.getType(footer.getFieldIndex("v")).asGroupType()
+          assertTrue(v.containsField("typed_value"), "v must be shredded in " 
+ file + ":\n" + footer)
+        } else {
+          // The absence of `v` is what keeps shouldRerouteVariantSplit false 
on this leg.
+          assertTrue(!footer.containsField("v"),
+            "the nested-only leg must not carry a top-level v in " + file + 
":\n" + footer)
+        }
+      }
 
       testStream(variantStreamDf())(
         // Base-only split: this batch spans both deltacommits and the 
compaction commit, whose
-        // affected files resolve to the compacted shredded base file with no 
log on top. This is
-        // the branch the fix re-routes to the file group reader.
+        // affected files resolve to the compacted shredded base file with no 
log on top. With a
+        // top-level `v` this is the split shouldRerouteVariantSplit sends to 
the file group
+        // reader; without one it stays on requiredSchemaReaderSkipMerging, 
the leg that pins the
+        // nested-shredded base read by Spark's own parquet reader.
         AssertOnQuery { q => q.processAllAvailable(); true },
         assertLegacyRddPlan,
-        CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1\"}", 1000L), Row(2, 
"{\"key\":\"v2\"}", 1000L)),
+        CheckAnswerRows(Seq(
+          expectedRow(1, "v1", "n1", 1000L),
+          expectedRow(2, "v2", "n2", 1000L)),
           lastOnly = true, isSorted = false),
         StopStream,
 
@@ -458,12 +520,13 @@ class TestStreamingSource extends StreamTest {
         // span's affected files alone, and this span covers only the update 
deltacommit, so the
         // slice is the appended log file with no base file.
         AssertOnQuery { _ =>
-          addVariantData("""select 1 as id, parse_json('{"key":"v1-updated"}') 
as v, 1001L as ts""", compact = false)
+          addVariantData(rowSql(1, "v1-updated", "n1-updated", 1001L), compact 
= false)
           true
         },
         StartStream(),
         AssertOnQuery { q => q.processAllAvailable(); true },
-        CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1-updated\"}", 1001L)), 
lastOnly = true, isSorted = false)
+        CheckAnswerRows(Seq(expectedRow(1, "v1-updated", "n1-updated", 1001L)),
+          lastOnly = true, isSorted = false)
       )
 
       // Merged split: a fresh testStream over a fresh streaming DataFrame 
gets its own
@@ -474,12 +537,22 @@ class TestStreamingSource extends StreamTest {
       testStream(variantStreamDf())(
         AssertOnQuery { q => q.processAllAvailable(); true },
         assertLegacyRddPlan,
-        CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1-updated\"}", 1001L), Row(2, 
"{\"key\":\"v2\"}", 1000L)),
+        CheckAnswerRows(Seq(
+          expectedRow(1, "v1-updated", "n1-updated", 1001L),
+          expectedRow(2, "v2", "n2", 1000L)),
           lastOnly = true, isSorted = false)
       )
     }
   }
 
+  test("test mor stream source reads shredded variant with legacy file group 
reader disabled") {
+    testLegacyShreddedVariantStream(withTopLevelVariant = true)
+  }
+
+  test("test mor stream source reads a nested-only shredded variant with 
legacy file group reader disabled") {
+    testLegacyShreddedVariantStream(withTopLevelVariant = false)
+  }
+
   private def testCheckpointTranslation(tableName: String,
                                         tableType: HoodieTableType,
                                         writeTableVersion: HoodieTableVersion,
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
index 8f14dd1ceed8..b9a7dbe07500 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
@@ -23,7 +23,10 @@ import org.apache.hudi.common.schema.{HoodieSchema, 
HoodieSchemaType}
 
 import org.apache.parquet.schema.PrimitiveType
 import org.apache.parquet.schema.Type.Repetition
-import org.apache.spark.sql.types.{BinaryType, IntegerType, StringType, 
StructField, StructType}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.execution.datasources.SparkSchemaTransformUtils
+import org.apache.spark.sql.types.{BinaryType, IntegerType, LongType, 
Metadata, MetadataBuilder, StringType, StructField, StructType}
+import org.apache.spark.unsafe.types.UTF8String
 import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, 
assertThrows, assertTrue}
 import org.junit.jupiter.api.Assumptions.assumeTrue
 import org.junit.jupiter.api.Test
@@ -259,4 +262,108 @@ class TestBaseSpark4AdapterVariantMethods extends 
SparkAdapterSupport {
     val result = sparkAdapter.isDataTypeEqualForPhysicalSchema(invalidStruct, 
variantType)
     assertTrue(result.isEmpty, "Should return None when reversed struct 
doesn't match variant physical schema")
   }
+
+  /**
+   * The per-child metadata PushVariantIntoScan attaches to a projection 
struct. Built key by key
+   * rather than through Spark 4.1's `VariantMetadata`, which does not exist 
on the Spark 3.x
+   * profiles this suite also compiles for; the test pins the shape against 
the adapter before it
+   * relies on it.
+   */
+  private def variantProjectionMetadata(path: String): Metadata =
+    new MetadataBuilder().putMetadata("__VARIANT_METADATA_KEY",
+      new MetadataBuilder()
+        .putString("path", path)
+        .putBoolean("failOnError", true)
+        .putString("timeZoneId", "UTC")
+        .build()).build()
+
+  /** A `VariantVal` holding `json`, reached by reflection for the same 
cross-version reason. */
+  private def parseJson(json: String): Any = {
+    val cls = 
Class.forName("org.apache.spark.sql.catalyst.expressions.variant.VariantExpressionEvalUtils$")
+    val module = cls.getField("MODULE$").get(null)
+    // parseJson(UTF8String, allowDuplicateKeys, ...): the trailing flags 
gained an entry across
+    // 4.x, so every one of them is passed as its default false.
+    val method = cls.getMethods.find(m => m.getName == "parseJson"
+      && m.getParameterTypes.headOption.contains(classOf[UTF8String])).get
+    val args: Seq[AnyRef] = UTF8String.fromString(json) +:
+      Seq.fill(method.getParameterCount - 1)(Boolean.box(false))
+    method.invoke(module, args: _*)
+  }
+
+  @Test
+  def testBuildVariantProjectorRecursesIntoStructPaths(): Unit = {
+    assumeTrue(HoodieSparkUtils.gteqSpark4_1, "Variant projection structs only 
exist on Spark 4.1+")
+    val variantType = sparkAdapter.getVariantDataType.get
+    val projectionStruct = StructType(Seq(
+      StructField("0", StringType, metadata = 
variantProjectionMetadata("$.k"))))
+    assertTrue(sparkAdapter.isVariantProjectionStruct(projectionStruct),
+      "the hand-built metadata must be what Spark recognizes as a projection 
struct")
+
+    // `cast(s.inner as string)` on `s struct<inner: variant, other: int>`: 
PushVariantIntoScan
+    // rewrites the variant one struct member down, leaving the struct itself 
in place.
+    val dataSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", variantType),
+        StructField("other", IntegerType))))))
+    val requiredSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", projectionStruct),
+        StructField("other", IntegerType))))))
+
+    val projector = sparkAdapter.buildVariantProjector(dataSchema, 
requiredSchema)
+    assertTrue(projector.isDefined, "a projection below a struct path must 
build a projector")
+    val project = projector.get
+
+    val row = project(InternalRow(1, InternalRow(parseJson("""{"k":"n1"}"""), 
7)))
+    assertEquals(1, row.getInt(0))
+    val struct = row.getStruct(1, 2)
+    assertEquals("n1", struct.getStruct(0, 1).getUTF8String(0).toString,
+      "the nested variant must be projected to its requested extraction")
+    assertEquals(7, struct.getInt(1), "the sibling of the projected variant 
must survive")
+
+    // CreateNamedStruct is never null on its own, so a null struct has to be 
preserved explicitly.
+    assertTrue(project(InternalRow(2, null)).isNullAt(1), "a null struct must 
stay null")
+
+    // A null variant inside a live struct: its projection struct must be 
NULL, not a struct of
+    // nulls, because PushVariantIntoScan rewrites `s.inner is null` onto that 
struct.
+    val nullVariant = project(InternalRow(3, InternalRow(null, 9)))
+    val liveStruct = nullVariant.getStruct(1, 2)
+    assertTrue(liveStruct.isNullAt(0), "a null variant must project to a null 
struct")
+    assertEquals(9, liveStruct.getInt(1), "the sibling of a null variant must 
survive")
+
+    assertTrue(sparkAdapter.buildVariantProjector(dataSchema, 
dataSchema).isEmpty,
+      "a required schema with no projection struct anywhere must not build a 
projector")
+  }
+
+  @Test
+  def testImplicitSchemaChangeKeepsNestedVariantProjection(): Unit = {
+    assumeTrue(HoodieSparkUtils.gteqSpark4_1, "Variant projection structs only 
exist on Spark 4.1+")
+    val variantType = sparkAdapter.getVariantDataType.get
+    val projectionStruct = StructType(Seq(
+      StructField("0", StringType, metadata = 
variantProjectionMetadata("$.k"))))
+
+    // The file wrote s.n as int and the table has since widened it to long, 
so `s` is an implicit
+    // type change on this file and every member of `s` is reconciled by 
addMissingFields --
+    // including the projected variant next to the widened column.
+    val fileStruct = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", variantType),
+        StructField("n", IntegerType))))))
+    val requiredSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", projectionStruct),
+        StructField("n", LongType))))))
+
+    val (typeChanges, readerSchema) = 
SparkSchemaTransformUtils.buildImplicitSchemaChangeInfo(fileStruct, 
requiredSchema)
+    val readerS = readerSchema("s").dataType.asInstanceOf[StructType]
+    assertEquals(projectionStruct, readerS("inner").dataType,
+      "the reader must be handed the projection struct, not the file's 
VariantType")
+    assertEquals(IntegerType, readerS("n").dataType, "the widened sibling is 
still read at its file type")
+    assertTrue(typeChanges.containsKey(1), "s is an implicit type change")
+    assertEquals(readerS, typeChanges.get(1).getRight, "the recorded reader 
type is the reconciled struct")
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
index 7bd872880a52..7644c232d61b 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
@@ -22,13 +22,11 @@ package org.apache.spark.sql.hudi.dml.schema
 import org.apache.hudi.{DataSourceReadOptions, HoodieSparkUtils}
 import org.apache.hudi.common.avro.VariantShreddingRuntime
 import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.HoodieLogFile
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
 import org.apache.hudi.common.model.WriteOperationType
 import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.schema.internal.HoodieSchemaException
 import org.apache.hudi.common.table.TableSchemaResolver
-import org.apache.hudi.common.table.log.HoodieLogFormat
 import org.apache.hudi.common.table.log.block.HoodieLogBlock.HoodieLogBlockType
 import org.apache.hudi.common.testutils.HoodieTestUtils
 import org.apache.hudi.common.util.StringUtils
@@ -42,11 +40,9 @@ import org.apache.spark.sql.catalyst.TableIdentifier
 import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, 
CatalogTable, CatalogTableType}
 import org.apache.spark.sql.hudi.command.CreateHoodieTableCommand
 import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
-import 
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.{getLastCommitMetadata, 
getMetaClientAndFileSystemView}
+import 
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.getLastCommitMetadata
 import org.apache.spark.sql.types.{ArrayType, BinaryType, DataType, LongType, 
MapType, MetadataBuilder, StringType, StructField, StructType}
 
-import scala.collection.JavaConverters._
-
 class TestVariantDataType extends HoodieSparkSqlTestBase with 
VariantShreddingTestSupport {
 
   test(s"Test Table with Variant Data Type") {
@@ -1436,29 +1432,4 @@ class TestVariantDataType extends HoodieSparkSqlTestBase 
with VariantShreddingTe
     }
   }
 
-  /**
-   * Block types of every log block in the table, read from the log files 
themselves. Tests that pin
-   * a log format assert on this rather than on file names: native logs carry 
a .log.parquet suffix,
-   * but an inline log file is named the same whether its data blocks are avro 
or parquet.
-   */
-  private def listLogBlockTypes(tablePath: String): Seq[HoodieLogBlockType] = {
-    val (metaClient, fsView) = getMetaClientAndFileSystemView(tablePath)
-    val schema = new TableSchemaResolver(metaClient).getTableSchema
-    val logFiles = fsView.getAllFileSlices("").iterator().asScala
-      .flatMap(slice => 
HoodieTestUtils.getLogFileListFromFileSlice(slice).asScala).toSeq
-    assert(logFiles.nonEmpty, "expected at least one log file")
-    logFiles.flatMap { path =>
-      val reader = HoodieLogFormat.newReader(metaClient, new 
HoodieLogFile(path), schema)
-      try {
-        val types = scala.collection.mutable.ArrayBuffer[HoodieLogBlockType]()
-        while (reader.hasNext) {
-          types += reader.next().getBlockType
-        }
-        types.toSeq
-      } finally {
-        reader.close()
-      }
-    }
-  }
-
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
index 4535a04e6261..345e82d44242 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
@@ -20,16 +20,23 @@
 package org.apache.spark.sql.hudi.dml.schema
 
 import org.apache.hudi.{HoodieSchemaConversionUtils, HoodieSparkUtils, 
HoodieTableSchema, SparkAdapterSupport}
+import org.apache.hudi.common.fs.FSUtils
 import org.apache.hudi.common.model.HoodieFileFormat
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
+import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaField, 
HoodieSchemaType}
+import org.apache.hudi.common.table.log.block.HoodieLogBlock.HoodieLogBlockType
 import org.apache.hudi.core.io.storage.VariantShreddingInferenceFileWriter
 import org.apache.hudi.testutils.DataSourceTestUtils
 
+import org.apache.hadoop.fs.{Path => HadoopPath}
 import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
+import org.apache.spark.sql.SaveMode
 import 
org.apache.spark.sql.execution.datasources.parquet.HoodieFileGroupReaderBasedFileFormat
 import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
 import 
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.getLastCommitMetadata
-import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.apache.spark.sql.types.{IntegerType, LongType, StructField, 
StructType}
+
+import scala.collection.JavaConverters._
 
 /**
  * Mixed-layout variant shredding matrix: files with DIFFERENT typed_value 
layouts in one table,
@@ -84,6 +91,52 @@ class TestVariantShreddingMixedLayouts extends 
HoodieSparkSqlTestBase with Varia
       s"options => 'hoodie.datasource.write.row.writer.enable=$rowWriter')")
   }
 
+  /**
+   * Pins the layout of a NESTED variant column (a dotted path) in the base 
files written by one
+   * instant. [[variantFileLayouts]] and its derivatives resolve their column 
as a TOP-LEVEL footer
+   * field, so the nested path is walked here with [[variantGroupOf]] instead.
+   */
+  private def assertNestedBaseLayout(tablePath: String, instant: String, 
shredded: Boolean,
+                                     leg: String, column: String = "s.inner"): 
Unit = {
+    val files = listDataParquetFiles(tablePath).filter { file =>
+      val name = new HadoopPath(file).getName
+      FSUtils.isBaseFile(name) && FSUtils.getCommitTime(name) == instant
+    }
+    assert(files.nonEmpty, s"[$leg] expected at least one base file written by 
instant $instant")
+    files.foreach { file =>
+      val group = variantGroupOf(file, column)
+      val expected = if (shredded) "shredded" else "unshredded"
+      assert(group.containsField("typed_value") == shredded,
+        s"[$leg] base file of instant $instant should be $expected at $column: 
$file\n$group")
+    }
+  }
+
+  /**
+   * The declared write schema of the array-element leg: the `(id int, arr 
array<variant>, ts long)`
+   * table schema with the ELEMENT of `arr` replaced by a variant whose 
shredded object declares
+   * `k string`. No DDL and no forced-schema conf can express that shape, so 
it is built through the
+   * HoodieSchema API and handed to the write as `hoodie.write.schema`.
+   */
+  private def arrayElementWriteSchema(): String = {
+    // fromDDL parses `variant` on Spark 4.x only, which is all this suite 
runs on.
+    val base = HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(
+      StructType.fromDDL("id int, arr array<variant>, ts long"), 
"hoodie.test.record")
+    val shreddedFields = new java.util.LinkedHashMap[String, HoodieSchema]()
+    shreddedFields.put("k", HoodieSchema.create(HoodieSchemaType.STRING))
+    val shreddedElement = HoodieSchema.createVariantShreddedObject(null, null, 
null, shreddedFields)
+    val fields = base.getFields.asScala.map { field =>
+      if (field.name() == "arr") {
+        HoodieSchemaField.of("arr",
+          
HoodieSchema.createNullable(HoodieSchema.createArray(HoodieSchema.createNullable(shreddedElement))),
+          null, HoodieSchema.NULL_VALUE)
+      } else {
+        HoodieSchemaField.of(field.name(), field.schema(), 
field.doc().orElse(null),
+          field.defaultVal().orElse(null))
+      }
+    }.asJava
+    HoodieSchema.createRecord(base.getName, "hoodie.test", null, 
fields).toString
+  }
+
   // 
-----------------------------------------------------------------------------------------------
   // A. Mixed records inside one file
   // 
-----------------------------------------------------------------------------------------------
@@ -818,6 +871,481 @@ class TestVariantShreddingMixedLayouts extends 
HoodieSparkSqlTestBase with Varia
     }
   }
 
+  test("MOR merge, compaction and clustering carry a nested-shredded base 
through the internal reader") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // The nested variant is the ONLY variant in the table, so no top-level 
rewrite carries it (see
+    // withNestedOnlyVariantTable): every read below has to resolve s.inner on 
its own - through the
+    // nested projection struct PushVariantIntoScan pushes into the scan for 
the user queries, and
+    // natively for the internal reads that have no catalyst schema 
(compaction, clustering, the
+    // legacy RDD) - which is what makes these paths say anything about nested 
shredding at all.
+    // The projected arm crashed the JVM before #19775 (the projection was 
applied at the top level
+    // only, so the merged row still held a raw variant where the plan read a 
struct).
+    val mergedRows = Seq(
+      Seq(1, """{"k":"n1"}"""), Seq(2, """{"k":"n2b"}"""), Seq(3, 
"""{"k":"n3"}"""))
+    val finalRows = Seq(
+      Seq(1, """{"k":"n1"}"""), Seq(2, """{"k":"n2b"}"""), Seq(3, 
"""{"k":"n3c"}"""))
+
+    Seq(true, false).foreach { rowWriter =>
+      // No INMEMORY index: the first insert creates a base file, the updates 
go to log files.
+      withNestedOnlyVariantTable(s"mor nested rowWriter=$rowWriter", "mor",
+        props = Seq("hoodie.compact.inline = 'false'"),
+        recordTypes = clusteringRecordTypes(rowWriter)) { (tableName, 
tablePath, leg) =>
+        val snapshotQuery = s"select id, cast(s.inner as string) from 
$tableName order by id"
+        val readOptimizedQuery = s"select id, cast(s.inner as string) from " +
+          s"hudi_query('$tableName', 'read_optimized') order by id"
+
+        withWriteLayout(Forced("k string")) {
+          spark.sql(s"insert into $tableName values " +
+            """(1, named_struct('inner', parse_json('{"k":"n1"}')), 1000), """ 
+
+            """(2, named_struct('inner', parse_json('{"k":"n2"}')), 1000), """ 
+
+            """(3, named_struct('inner', parse_json('{"k":"n3"}')), 1000)""")
+        }
+        val baseFiles = listDataParquetFiles(tablePath)
+        assert(baseFiles.size == 1, s"[$leg] expected one base file, got 
$baseFiles")
+        assertVariantLayout(tablePath, shredded = true, leg, column = 
"s.inner")
+        val innerGroup = variantGroupOf(baseFiles.head, "s.inner")
+        assert(getFieldAsGroup(innerGroup, "typed_value").containsField("k"),
+          s"[$leg] nested typed_value should carry k:\n$innerGroup")
+
+        // A nested-shredded native log on top of the nested-shredded base.
+        withWriteLayout(Forced("k string")) {
+          spark.sql(s"update $tableName set " +
+            """s = named_struct('inner', parse_json('{"k":"n2b"}')), ts = 1001 
where id = 2""")
+        }
+        
assert(listDataParquetFiles(tablePath).exists(_.endsWith(".log.parquet")),
+          s"[$leg] the update should have written a native parquet log file")
+
+        // The conf only says what the reader MAY do - supportBatch vetoes 
vectorization for a
+        // variant at any depth (pinned in F2) - so the sweep is the control 
if that guard is ever
+        // narrowed back to top-level columns.
+        Seq("true", "false").foreach { vectorizedReader =>
+          withSQLConf("spark.sql.parquet.enableVectorizedReader" -> 
vectorizedReader) {
+            checkAnswer(snapshotQuery)(mergedRows: _*)
+          }
+        }
+
+        // Not swept here: hoodie.file.group.reader.enabled=false, which no 
longer routes a batch
+        // read anywhere (only the streaming sources consult it). The legacy 
RDD path over a
+        // nested-shredded base - HoodieMergeOnReadRDDV2, whose 
shouldRerouteVariantSplit stays
+        // false without a top-level variant - is pinned by 
TestStreamingSource's nested-only
+        // legacy leg (the leg with a top-level variant beside it is re-routed 
to the file-group
+        // reader instead).
+
+        // Read-optimized serves the base file alone: id 2 is still the 
pre-update value.
+        checkAnswer(readOptimizedQuery)(
+          Seq(1, """{"k":"n1"}"""), Seq(2, """{"k":"n2"}"""), Seq(3, 
"""{"k":"n3"}"""))
+
+        // Compaction merges the nested-shredded log onto the nested-shredded 
base and re-derives
+        // the layout from the forced DDL. On the AVRO record type that write 
goes through
+        // HoodieAvroWriteSupport, whose nested forced hook is #19689's parity 
fix.
+        withWriteLayout(Forced("k string")) {
+          runCompaction(tableName)
+        }
+        assertCompactionCount(tablePath, 1, leg)
+        assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = true, leg)
+        checkAnswer(snapshotQuery)(mergedRows: _*)
+
+        // Unshredded round: the update and the compaction both run with 
shredding off, so
+        // typed_value has to be stripped at depth on the way out.
+        withWriteLayout(Unshredded) {
+          spark.sql(s"update $tableName set " +
+            """s = named_struct('inner', parse_json('{"k":"n3c"}')), ts = 1002 
where id = 3""")
+          runCompaction(tableName)
+        }
+        assertCompactionCount(tablePath, 2, leg)
+        assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = false, leg)
+        checkAnswer(snapshotQuery)(finalRows: _*)
+
+        // Clustering re-derives the nested layout from the forced DDL over 
that unshredded input:
+        // the row-writer path when rowWriter is true, the record writers 
otherwise.
+        withWriteLayout(Forced("k string")) {
+          runClustering(tableName, rowWriter)
+        }
+        val clusteringInstant = completedClusteringInstant(tablePath, leg)
+        assertNestedBaseLayout(tablePath, clusteringInstant, shredded = true, 
leg)
+        checkAnswer(snapshotQuery)(finalRows: _*)
+        checkAnswer(readOptimizedQuery)(finalRows: _*)
+      }
+    }
+  }
+
+  test("CDC images carry a nested-shredded variant") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // The nested twin of TestVariantDataType's CDC test. OP_KEY_ONLY 
reconstructs both images by
+    // reading the file slices; DATA_BEFORE_AFTER (the default) reads the 
update images from the cdc
+    // log instead. The insert leg takes BASE_FILE_INSERT in both modes, which 
reads the new base
+    // file directly rather than through the reader context.
+    Seq("OP_KEY_ONLY", "DATA_BEFORE_AFTER").foreach { loggingMode =>
+      // SPARK pinned: a cdc-enabled table always writes through 
FileGroupReaderBasedMergeHandle and
+      // the merger's record type picks that handle's reader context; AVRO 
would route the update's
+      // base-file read through HoodieAvroParquetReader, the separate defect 
tracked as #19567.
+      withNestedOnlyVariantTable(s"cdc nested $loggingMode", "cow", props = 
Seq(
+        "'hoodie.table.cdc.enabled' = 'true'",
+        s"'hoodie.table.cdc.supplemental.logging.mode' = '$loggingMode'",
+        "hoodie.index.type = 'INMEMORY'"),
+        recordTypes = Seq(HoodieRecordType.SPARK)) { (tableName, tablePath, 
leg) =>
+        withWriteLayout(Forced("k string")) {
+          spark.sql(s"insert into $tableName values " +
+            """(1, named_struct('inner', parse_json('{"k":"c1"}')), 1000)""")
+        }
+        assertVariantLayout(tablePath, shredded = true, leg, column = 
"s.inner")
+
+        withWriteLayout(Forced("k string")) {
+          spark.sql(s"update $tableName set " +
+            """s = named_struct('inner', parse_json('{"k":"c2"}')), ts = 1001 
where id = 1""")
+        }
+        // Layout flip: the second update rewrites the file unshredded, so the 
images below span
+        // both physical slots.
+        withWriteLayout(Unshredded) {
+          spark.sql(s"update $tableName set " +
+            """s = named_struct('inner', parse_json('{"k":"c3"}')), ts = 1002 
where id = 1""")
+        }
+        assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = false, leg)
+
+        val cdc = spark.sql(s"select op, get_json_object(before, 
'$$.s.inner.k') as before_k, " +
+          s"get_json_object(after, '$$.s.inner.k') as after_k " +
+          s"from hudi_table_changes('$tableName', 'cdc', 'earliest')")
+        val insertRows = cdc.where("op = 'i'").collect()
+        assert(insertRows.length == 1, s"[$leg] expected exactly one insert 
cdc row")
+        assert(insertRows(0).getString(2) == "c1",
+          s"[$leg] insert after-image lost the nested variant payload: 
${insertRows(0)}")
+
+        val updateRows = cdc.where("op = 'u'").orderBy("after_k").collect()
+        assert(updateRows.length == 2, s"[$leg] expected two update cdc rows")
+        assert(updateRows(0).getString(1) == "c1" && 
updateRows(0).getString(2) == "c2",
+          s"[$leg] first update images lost the nested variant payload: 
${updateRows(0)}")
+        assert(updateRows(1).getString(1) == "c2" && 
updateRows(1).getString(2) == "c3",
+          s"[$leg] second (layout-flipped) update images lost the nested 
variant payload: ${updateRows(1)}")
+      }
+    }
+  }
+
+  test("variant_get projections and filters resolve a nested-shredded 
variant") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // The nested twin of the mixed-layout variant_get test above; SPARK 
pinned for the same reason.
+    // pushVariantIntoScan is swept because the two arms reach the file 
differently: on, Spark
+    // rewrites the s.inner struct path into its own projection struct and 
pushes it into the scan;
+    // off, the whole variant is read and variant_get evaluates on top of it.
+    def nestedRowsSql(lo: Int, hi: Int): String =
+      s"""select cast(id as int) as id,
+         | named_struct('inner', parse_json(concat('{"k":"x', id, '"}'))) as s,
+         | 1000L as ts from range($lo, $hi, 1, 1)""".stripMargin
+
+    Seq("true", "false").foreach { pushIntoScan =>
+      withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) {
+        withNestedOnlyVariantTable(s"nested 
pushVariantIntoScan=$pushIntoScan", "cow",
+          props = Seq(NEW_FILE_GROUP_PER_COMMIT), recordTypes = 
Seq(HoodieRecordType.SPARK)) {
+          (tableName, tablePath, leg) =>
+          withWriteLayout(Forced("k string")) {
+            spark.sql(s"insert into $tableName ${nestedRowsSql(0, 5)}")
+          }
+          val shreddedInstant = latestCompletedInstant(tablePath)
+          withWriteLayout(Unshredded) {
+            spark.sql(s"insert into $tableName ${nestedRowsSql(5, 10)}")
+          }
+          val unshreddedInstant = latestCompletedInstant(tablePath)
+          // One file per commit (small.file.limit = 0) and one layout each, 
so $.k is typed in the
+          // first file and residual in the second: both slots answer the 
queries below.
+          assertNestedBaseLayout(tablePath, shreddedInstant, shredded = true, 
leg)
+          assertNestedBaseLayout(tablePath, unshreddedInstant, shredded = 
false, leg)
+
+          checkAnswer(s"select id, variant_get(s.inner, '$$.k', 'string') from 
$tableName order by id")(
+            (0 until 10).map(id => Seq(id, s"x$id")): _*)
+          // A filter served out of the residual file, and one out of the 
typed file.
+          checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'x7'")(
+            Seq(7))
+          checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'x2'")(
+            Seq(2))
+          checkAnswer(s"select count(*) from $tableName " +
+            s"where try_variant_get(s.inner, '$$.missing', 'string') is 
null")(Seq(10))
+          checkAnswer(s"select count(*) from $tableName where s.inner is 
null")(Seq(0))
+          checkAnswer(s"select id, cast(s.inner as string) from $tableName 
order by id")(
+            (0 until 10).map(id => Seq(id, s"""{"k":"x$id"}""")): _*)
+          // Both arms expect the very same rows, so this is what tells them 
apart: whether the
+          // rule actually rewrote s.inner into a projection struct inside the 
scan.
+          val pushed = pushIntoScan.toBoolean
+          val verdict = if (pushed) "should have" else "must not have"
+          assert(variantProjectionPushedIntoScan(
+            s"select id, variant_get(s.inner, '$$.k', 'string') from 
$tableName") == pushed,
+            s"[$leg] PushVariantIntoScan $verdict rewritten s.inner into a 
projection struct")
+        }
+      }
+    }
+
+    // MOR: a nested-shredded base under an unshredded log update, so the 
merged read has to serve
+    // ids 0-2 from the base file and ids 3-4 from the log. Before #19775 the 
pushIntoScan=true arms
+    // here crashed the JVM (SIGSEGV/SIGBUS inside an unsafe copy stub, or a 
java.lang.InternalError
+    // about a fault in a recent unsafe memory access): the internal reader 
applied the
+    // PushVariantIntoScan projection to TOP-LEVEL fields only, so the merged 
row still held a raw
+    // VariantVal at s.inner while the plan read that memory as the projected 
struct s.inner.0.
+    def seedNestedMor(tableName: String, tablePath: String, leg: String): Unit 
= {
+      withWriteLayout(Forced("k string")) {
+        spark.sql(s"insert into $tableName ${nestedRowsSql(0, 5)}")
+      }
+      assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = true, leg)
+      // id 4's variant is nulled out on the way through the log: before 
#19775 the avro-path
+      // projector emitted a non-null struct of nulls for it rather than a 
NULL struct, which is
+      // what the `s.inner is null` assertion below catches.
+      withWriteLayout(Unshredded) {
+        spark.sql(s"update $tableName set " +
+          """s = named_struct('inner', parse_json(case when id = 4 then 
cast(null as string) """ +
+          """else concat('{"k":"y', id, '"}') end)), ts = 1001 """ +
+          "where id >= 3")
+      }
+    }
+    def assertNestedMorReads(tableName: String, leg: String, pushed: Boolean): 
Unit = {
+      // ids 0-2 come off the base file, id 3 off the log, and id 4's variant 
is the log's NULL.
+      def merged(id: Int): String = if (id < 3) s"x$id" else if (id == 3) "y3" 
else null
+      def mergedJson(id: Int): String = Option(merged(id)).map(k => 
s"""{"k":"$k"}""").orNull
+      checkAnswer(s"select id, variant_get(s.inner, '$$.k', 'string') from 
$tableName order by id")(
+        (0 until 5).map(id => Seq(id, merged(id))): _*)
+      // A filter served out of the log row, then one out of the base row.
+      checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'y3'")(
+        Seq(3))
+      checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'x1'")(
+        Seq(1))
+      checkAnswer(s"select id, cast(s.inner as string) from $tableName order 
by id")(
+        (0 until 5).map(id => Seq(id, mergedJson(id))): _*)
+      // PushVariantIntoScan rewrites `s.inner is null` onto the projection 
struct itself, so a
+      // null variant projected as a struct OF nulls rather than a NULL struct 
made this row
+      // disappear: the avro-block leg with the rule on is the one that failed 
before the fix.
+      checkAnswer(s"select id from $tableName where s.inner is null")(Seq(4))
+      checkAnswer(s"select count(*) from $tableName where s.inner is not 
null")(Seq(4))
+      // The whole struct: no extraction, so nothing is rewritten even with 
pushVariantIntoScan on,
+      // and the merge is read through a plain native VariantType at depth. 
`s` itself is never
+      // null - only its `inner` member is, and only for id 4 - and the 
payload has to be the
+      // merged one, not a stale or nulled-out variant (VariantVal.toString is 
its JSON).
+      val wholeStruct = spark.sql(s"select id, s from $tableName order by 
id").collect()
+      assert(wholeStruct.length == 5, s"[$leg] whole-struct read should return 
5 rows")
+      wholeStruct.foreach { row =>
+        val id = row.getInt(0)
+        assert(!row.isNullAt(1), s"[$leg] whole-struct read nulled out s for 
id $id")
+        val inner = row.getStruct(1).getAs[Any]("inner")
+        assert(Option(inner).map(_.toString).orNull == mergedJson(id),
+          s"[$leg] whole-struct read of s.inner for id $id should be 
${mergedJson(id)}, got $inner")
+      }
+      // Both arms expect the very same rows; only the plan tells them apart.
+      val verdict = if (pushed) "should have" else "must not have"
+      assert(variantProjectionPushedIntoScan(
+        s"select id, variant_get(s.inner, '$$.k', 'string') from $tableName") 
== pushed,
+        s"[$leg] PushVariantIntoScan $verdict rewritten s.inner into a 
projection struct")
+    }
+
+    Seq("true", "false").foreach { pushIntoScan =>
+      withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) {
+        // Parquet log files: the log block is read by 
HoodieSparkParquetReader.getUnsafeRowIterator
+        // with the projected struct threaded into the requested schema.
+        withNestedOnlyVariantTable(s"nested mor 
pushVariantIntoScan=$pushIntoScan", "mor",
+          props = Seq("hoodie.compact.inline = 'false'"), recordTypes = 
Seq(HoodieRecordType.SPARK)) {
+          (tableName, tablePath, leg) =>
+          seedNestedMor(tableName, tablePath, leg)
+          
assert(listDataParquetFiles(tablePath).exists(_.endsWith(".log.parquet")),
+            s"[$leg] the update should have written a native parquet log file")
+          assertNestedMorReads(tableName, leg, pushIntoScan.toBoolean)
+        }
+
+        // Avro data blocks: the other projection site, 
HoodieReaderContext.projectLogBlockRecords.
+        // On the current table version the append handle writes native 
parquet log files whatever
+        // the block format says, so this leg only exists on a pre-native 
table version.
+        withNestedOnlyVariantTable(s"nested mor avro blocks 
pushVariantIntoScan=$pushIntoScan", "mor",
+          props = Seq("hoodie.compact.inline = 'false'",
+            "hoodie.write.table.version = '9'",
+            "hoodie.logfile.data.block.format = 'avro'"),
+          recordTypes = Seq(HoodieRecordType.AVRO)) { (tableName, tablePath, 
leg) =>
+          seedNestedMor(tableName, tablePath, leg)
+          val blockTypes = listLogBlockTypes(tablePath)
+          assert(blockTypes.contains(HoodieLogBlockType.AVRO_DATA_BLOCK),
+            s"[$leg] expected an avro data block in the log files, found: 
$blockTypes")
+          assert(!blockTypes.contains(HoodieLogBlockType.PARQUET_DATA_BLOCK),
+            s"[$leg] this leg must not write parquet data blocks, found: 
$blockTypes")
+          assertNestedMorReads(tableName, leg, pushIntoScan.toBoolean)
+        }
+      }
+    }
+  }
+
+  test("Implicit widening of a sibling keeps the nested variant projection") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // A file written with s.n as int under a table that has since widened s.n 
to long makes `s`
+    // an implicit type change on that file, so every member of `s` is 
reconciled by
+    // SparkSchemaTransformUtils.addMissingFields. Before #19775 that walk 
handed the reader the
+    // file's VariantType at s.inner instead of the PushVariantIntoScan 
projection struct, and
+    // the widening Cast then cast the variant value to the projected struct: 
nulls.
+    withSQLConf("spark.sql.variant.pushVariantIntoScan" -> "true") {
+      withNestedOnlyVariantTable("implicit widening beside a nested variant", 
"cow",
+        props = Seq(NEW_FILE_GROUP_PER_COMMIT), recordTypes = 
Seq(HoodieRecordType.SPARK),
+        structMembers = "inner: variant, n: int") { (tableName, tablePath, 
leg) =>
+        def rowsSql(lo: Int, hi: Int, nType: String): String =
+          s"""select cast(id as int) as id,
+             | named_struct('inner', parse_json(concat('{"k":"x', id, '"}')),
+             |   'n', cast(id as $nType)) as s,
+             | 1000L as ts from range($lo, $hi, 1, 1)""".stripMargin
+        withWriteLayout(Forced("k string")) {
+          spark.sql(s"insert into $tableName ${rowsSql(0, 3, "int")}")
+        }
+        val intInstant = latestCompletedInstant(tablePath)
+        assertNestedBaseLayout(tablePath, intInstant, shredded = true, leg)
+
+        // The widening goes through the DataFrame API: a SQL insert coerces 
to the table schema,
+        // while a DataFrame whose s.n is bigint evolves the table schema in 
place. Every knob the
+        // write needs is an explicit option here - the write path collects 
spark.hoodie.* only and
+        // drops bare hoodie.* session confs, so neither withWriteLayout nor 
the small-file
+        // tblproperty of NEW_FILE_GROUP_PER_COMMIT reaches a df.write (see 
layoutConfs).
+        spark.sql(rowsSql(3, 6, "bigint")).write.format("hudi")
+          .options(layoutConfs(Forced("k string")).toMap)
+          .option("hoodie.table.name", tableName)
+          .option("hoodie.datasource.write.recordkey.field", "id")
+          .option("hoodie.datasource.write.precombine.field", "ts")
+          .option("hoodie.datasource.write.operation", "insert")
+          .option("hoodie.parquet.small.file.limit", "0")
+          .mode(SaveMode.Append)
+          .save(tablePath)
+        // The widening lands in the commit schema (a path-based relation 
reads s.n back as bigint)
+        // while the SQL catalog's view of the table keeps reporting it as 
int, so the reads go
+        // through a path-based view: that is the reader that sees the widened 
table schema over
+        // the int file, which is what makes `s` an implicit type change there.
+        val widenedView = s"${tableName}_widened"
+        
spark.read.format("hudi").load(tablePath).createOrReplaceTempView(widenedView)
+        val widenedN = 
spark.table(widenedView).schema("s").dataType.asInstanceOf[StructType]("n").dataType
+        assert(widenedN == LongType,
+          s"[$leg] the DataFrame write should have widened s.n to bigint, got 
$widenedN")
+
+        checkAnswer(s"select id, variant_get(s.inner, '$$.k', 'string'), s.n 
from $widenedView order by id")(
+          (0 until 6).map(id => Seq(id, s"x$id", id.toLong)): _*)
+        // One filter served out of the int file and one out of the bigint 
file.
+        checkAnswer(s"select id from $widenedView where variant_get(s.inner, 
'$$.k', 'string') = 'x1'")(Seq(1))
+        checkAnswer(s"select id from $widenedView where variant_get(s.inner, 
'$$.k', 'string') = 'x4'")(Seq(4))
+        assert(variantProjectionPushedIntoScan(
+          s"select id, variant_get(s.inner, '$$.k', 'string'), s.n from 
$widenedView"),
+          s"[$leg] the projection must still be pushed into the scan")
+      }
+    }
+  }
+
+  test("An array element shredded through a declared write schema reads 
natively and survives compaction") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // No DDL forces a variant that is directly an ARRAY ELEMENT - the forced 
hook reaches record
+    // members only, pinned by the record-writer test above - and Spark's 
PushVariantIntoScan
+    // rewrites struct paths only, so array<variant> is the shape every reader 
has to handle
+    // NATIVELY, shredded or not. The only write that produces a shredded 
element is one whose write
+    // schema already declares typed_value there, which the row write support 
honours through
+    // hoodie.write.schema (HoodieWriteConfig.WRITE_SCHEMA_OVERRIDE). SPARK 
record type only: the
+    // AVRO insert cannot take a write-schema override of a different shape 
(its records are
+    // serialized under hoodie.avro.schema and deserialized under the writer 
schema, which throws
+    // ArrayIndexOutOfBounds inside SerializableIndexedRecord).
+    withRecordType(Seq(HoodieRecordType.SPARK))(withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = tmp.getCanonicalPath
+      val leg = s"array element shredding, $tableName"
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  arr array<variant>,
+           |  ts long
+           |) using hudi
+           | location '$tablePath'
+           | tblproperties (
+           |  primaryKey = 'id',
+           |  preCombineField = 'ts',
+           |  type = 'mor',
+           |  hoodie.compact.inline = 'false'
+           | )
+       """.stripMargin)
+
+      val declaredLayout = Seq(
+        "hoodie.write.schema" -> arrayElementWriteSchema(),
+        WRITE_SHREDDING_KEY -> "true",
+        FORCE_SCHEMA_KEY -> "",
+        INFERENCE_KEY -> "false")
+      // get(arr, 1) rather than arr[1]: under ANSI mode the subscript on the 
one-element array of
+      // id 2 throws instead of returning null.
+      val elementsQuery = s"select id, cast(arr[0] as string), cast(get(arr, 
1) as string), size(arr) " +
+        s"from $tableName order by id"
+      // pushVariantIntoScan is swept alongside the vectorized reader although 
an array element is
+      // never rewritten: both arms must read the element the same way.
+      def withReadSweep(f: => Unit): Unit = {
+        Seq("true", "false").foreach { pushIntoScan =>
+          Seq("true", "false").foreach { vectorizedReader =>
+            withSQLConf("spark.sql.variant.pushVariantIntoScan" -> 
pushIntoScan,
+              "spark.sql.parquet.enableVectorizedReader" -> 
vectorizedReader)(f)
+          }
+        }
+      }
+
+      withSQLConf(declaredLayout: _*) {
+        spark.sql(s"""insert into $tableName values """ +
+          """(1, array(parse_json('{"k":"a1"}'), parse_json('{"k":"a2"}')), 
1000), """ +
+          """(2, array(parse_json('{"k":"b1"}')), 1000)""")
+      }
+      assertVariantLayout(tablePath, shredded = true, leg, column = "arr")
+      listDataParquetFiles(tablePath).foreach { file =>
+        val elementGroup = variantGroupOf(file, "arr")
+        assert(getFieldAsGroup(elementGroup, "typed_value").containsField("k"),
+          s"[$leg] typed_value of the array element should carry 
k:\n$elementGroup")
+      }
+
+      withReadSweep {
+        checkAnswer(elementsQuery)(
+          Seq(1, """{"k":"a1"}""", """{"k":"a2"}""", 2),
+          Seq(2, """{"k":"b1"}""", null, 1)
+        )
+        checkAnswer(s"select id, variant_get(arr[0], '$$.k', 'string') from 
$tableName order by id")(
+          Seq(1, "a1"),
+          Seq(2, "b1")
+        )
+        checkAnswer(s"select id from $tableName where variant_get(arr[0], 
'$$.k', 'string') = 'b1'")(
+          Seq(2))
+      }
+
+      // A shredded native log over the shredded base: the merged read serves 
id 2 out of the log.
+      withSQLConf(declaredLayout: _*) {
+        spark.sql(s"""update $tableName set arr = 
array(parse_json('{"k":"b1x"}')), ts = 1001 where id = 2""")
+      }
+      
assert(listDataParquetFiles(tablePath).exists(_.endsWith(".log.parquet")),
+        s"[$leg] the update should have written a native parquet log file")
+      val updatedRows = Seq(
+        Seq(1, """{"k":"a1"}""", """{"k":"a2"}""", 2),
+        Seq(2, """{"k":"b1x"}""", null, 1))
+      withReadSweep {
+        checkAnswer(elementsQuery)(updatedRows: _*)
+      }
+      checkAnswer(s"select id, cast(arr[0] as string) from " +
+        s"hudi_query('$tableName', 'read_optimized') order by id")(
+        Seq(1, """{"k":"a1"}"""),
+        Seq(2, """{"k":"b1"}""")
+      )
+
+      // Compaction under the same declared schema: the row write support 
honours it at depth again.
+      withSQLConf(declaredLayout: _*) {
+        runCompaction(tableName)
+      }
+      assertCompactionCount(tablePath, 1, leg)
+      assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = true, leg,
+        column = "arr")
+      checkAnswer(elementsQuery)(updatedRows: _*)
+
+      // And with no declared schema the element goes back to unshredded, 
values intact.
+      withWriteLayout(Unshredded) {
+        spark.sql(s"""update $tableName set """ +
+          """arr = array(parse_json('{"k":"a1y"}'), 
parse_json('{"k":"a2y"}')), ts = 1002 where id = 1""")
+        runCompaction(tableName)
+      }
+      assertCompactionCount(tablePath, 2, leg)
+      assertNestedBaseLayout(tablePath, latestCompletedInstant(tablePath), 
shredded = false, leg,
+        column = "arr")
+      checkAnswer(elementsQuery)(
+        Seq(1, """{"k":"a1y"}""", """{"k":"a2y"}""", 2),
+        Seq(2, """{"k":"b1x"}""", null, 1)
+      )
+    })
+  }
+
   // 
-----------------------------------------------------------------------------------------------
   // F2. Vectorized read decision
   // 
-----------------------------------------------------------------------------------------------
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/VariantShreddingTestSupport.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/VariantShreddingTestSupport.scala
index 64d9b49fa003..3ed37a776080 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/VariantShreddingTestSupport.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/VariantShreddingTestSupport.scala
@@ -19,10 +19,15 @@
 
 package org.apache.spark.sql.hudi.dml.schema
 
-import org.apache.hudi.DataSourceReadOptions
+import org.apache.hudi.{DataSourceReadOptions, SparkAdapterSupport}
 import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.model.HoodieLogFile
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
 import org.apache.hudi.common.model.WriteOperationType
+import org.apache.hudi.common.table.TableSchemaResolver
+import org.apache.hudi.common.table.log.HoodieLogFormat
+import org.apache.hudi.common.table.log.block.HoodieLogBlock.HoodieLogBlockType
+import org.apache.hudi.common.testutils.HoodieTestUtils
 import org.apache.hudi.storage.StoragePath
 import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
 
@@ -35,9 +40,10 @@ import org.apache.parquet.hadoop.example.GroupReadSupport
 import org.apache.parquet.hadoop.util.HadoopInputFile
 import org.apache.parquet.schema.{GroupType, LogicalTypeAnnotation, 
MessageType, Type}
 import org.apache.spark.sql.Row
+import org.apache.spark.sql.execution.FileSourceScanExec
 import 
org.apache.spark.sql.execution.datasources.parquet.VariantParquetTestFixtures.{listElement,
 mapValue}
 import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
-import 
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.getLastCommitMetadata
+import 
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.{getLastCommitMetadata, 
getMetaClientAndFileSystemView}
 
 import scala.collection.JavaConverters._
 import scala.collection.mutable
@@ -108,29 +114,45 @@ trait VariantShreddingTestSupport { self: 
HoodieSparkSqlTestBase =>
   }
 
   /**
-   * Creates `(id int, s struct<inner: variant>, ts long)` and bulk-inserts 
row 1 through the row
-   * writer under a forced `k string` nested schema. Both forced hooks recurse 
into record members
-   * (the row writer's always did, the Avro write support's since the #19689 
fix), so a nested
-   * variant shreds on either write path; inference alone stays top-level, and 
a variant that is
-   * directly an array element or a map value shreds only where the write 
schema already declares
-   * it. This row-writer seed is what the nested-shredding legs open on.
+   * The `(id int, s struct<inner: variant>, ts long)` table of the nested 
legs: the variant lives
+   * one struct member down and the table has NO top-level variant column. 
Same tblproperties
+   * rendering as [[createVariantTable]]. `structMembers` is the member list 
of `s`, so a leg that
+   * needs a SIBLING beside the variant (an implicit type change on one, say) 
declares it here
+   * rather than building its own DDL.
    */
-  private def createNestedVariantRowWriterTable(tableName: String, tablePath: 
String): Unit = {
+  protected def createNestedVariantTable(tableName: String,
+                                         tablePath: String,
+                                         tableType: String,
+                                         props: Seq[String] = Seq.empty,
+                                         structMembers: String = "inner: 
variant"): Unit = {
+    val extraProps = if (props.isEmpty) "" else props.mkString(",\n  ", ",\n  
", "")
     spark.sql(
       s"""
          |create table $tableName (
          |  id int,
-         |  s struct<inner: variant>,
+         |  s struct<$structMembers>,
          |  ts long
          |) using hudi
          | location '$tablePath'
          | tblproperties (
          |  primaryKey = 'id',
          |  preCombineField = 'ts',
-         |  type = 'cow',
-         |  hoodie.datasource.write.row.writer.enable = 'true'
+         |  type = '$tableType'$extraProps
          | )
        """.stripMargin)
+  }
+
+  /**
+   * Creates the nested table as a row-writer COW one and bulk-inserts row 1 
through the row writer
+   * under a forced `k string` nested schema. Both forced hooks recurse into 
record members (the row
+   * writer's always did, the Avro write support's since the #19689 fix), so a 
nested variant shreds
+   * on either write path; inference alone stays top-level, and a variant that 
is directly an array
+   * element or a map value shreds only where the write schema already 
declares it. This row-writer
+   * seed is what the nested-shredding legs open on.
+   */
+  private def createNestedVariantRowWriterTable(tableName: String, tablePath: 
String): Unit = {
+    createNestedVariantTable(tableName, tablePath, "cow",
+      props = Seq("hoodie.datasource.write.row.writer.enable = 'true'"))
     withSQLConf("hoodie.spark.sql.insert.into.operation" -> "bulk_insert") {
       withWriteLayout(Forced("k string")) {
         spark.sql(s"""insert into $tableName values (1, named_struct('inner', 
parse_json('{"k":"x1"}')), 1000)""")
@@ -149,6 +171,33 @@ trait VariantShreddingTestSupport { self: 
HoodieSparkSqlTestBase =>
     withTableScaffold(label, recordTypes)(createNestedVariantRowWriterTable)(f)
   }
 
+  /**
+   * The nested-only twin of [[withVariantTable]]: 
[[createNestedVariantTable]] with the table type
+   * and tblproperties of the leg, and NO seed row, so the body owns every 
commit.
+   *
+   * The absence of a top-level variant is the point. Hudi's Spark-native 
internal read paths only
+   * rewrite TOP-LEVEL variant columns into the full-variant projection shape
+   * (SparkAdapter.buildFullVariantReadSchema, used by 
SparkFileFormatInternalRowReaderContext;
+   * Spark's own PushVariantIntoScan and the sparkRequiredSchema overlay that 
follows it do go on
+   * down struct paths, #19775), so on a table that also carried a `v` column 
those top-level
+   * switches would be on for the whole read and the nested path would never 
be pinned on its own.
+   * With no top-level variant the reader takes the plain native path - on the 
MOR RDD,
+   * HoodieMergeOnReadRDDV2.shouldRerouteVariantSplit is false as well - and 
these legs really do
+   * exercise the nested read.
+   */
+  protected def withNestedOnlyVariantTable[T](label: String,
+                                              tableType: String,
+                                              props: Seq[String] = Seq.empty,
+                                              recordTypes: 
Seq[HoodieRecordType] =
+                                                Seq(HoodieRecordType.AVRO, 
HoodieRecordType.SPARK),
+                                              structMembers: String = "inner: 
variant")
+                                             (f: (String, String, String) => 
T): Unit = {
+    withTableScaffold(label, recordTypes) { (tableName, tablePath) =>
+      createNestedVariantTable(tableName, tablePath, tableType, props = props,
+        structMembers = structMembers)
+    }(f)
+  }
+
   // 
---------------------------------------------------------------------------------------------
   // Parquet footer helpers
   // 
---------------------------------------------------------------------------------------------
@@ -589,12 +638,31 @@ trait VariantShreddingTestSupport { self: 
HoodieSparkSqlTestBase =>
       .collect()
   }
 
+  /**
+   * Whether the physical plan of `sql` reads a variant through a 
PushVariantIntoScan projection
+   * struct: the file scan's required schema carries the projected struct at 
some struct path.
+   * Pins that the rule actually fired for a true arm, so it cannot silently 
become a copy of the
+   * arm with the rule off. `sparkPlan` rather than `executedPlan`: under AQE 
the latter is a
+   * placeholder whose children only exist once the query runs.
+   */
+  protected def variantProjectionPushedIntoScan(sql: String): Boolean = {
+    val scans = spark.sql(sql).queryExecution.sparkPlan.collect { case scan: 
FileSourceScanExec => scan }
+    assert(scans.nonEmpty, s"expected a file scan in the plan of: $sql")
+    scans.exists(_.requiredSchema.fields.exists(f =>
+      SparkAdapterSupport.sparkAdapter.containsVariantProjection(f.dataType)))
+  }
+
   // 
---------------------------------------------------------------------------------------------
   // Write-layout toggle
   // 
---------------------------------------------------------------------------------------------
 
-  /** The three write-side variant layout configs for a [[WriteLayout]]. */
-  private def layoutConfs(layout: WriteLayout): Seq[(String, String)] = layout 
match {
+  /**
+   * The three write-side variant layout configs for a [[WriteLayout]]. 
Protected because a
+   * DataFrame write needs them as explicit `.option`s: 
DefaultSource.createRelation for writes
+   * collects `spark.hoodie.*` only and deliberately drops bare `hoodie.*` 
session confs, so
+   * [[withWriteLayout]] around a `df.write` would silently write the default 
layout.
+   */
+  protected def layoutConfs(layout: WriteLayout): Seq[(String, String)] = 
layout match {
     case Unshredded => Seq(
       WRITE_SHREDDING_KEY -> "false",
       FORCE_SCHEMA_KEY -> "",
@@ -670,6 +738,31 @@ trait VariantShreddingTestSupport { self: 
HoodieSparkSqlTestBase =>
       s"[$leg] expected $expected completed compaction commits, got $commits")
   }
 
+  /**
+   * Block types of every log block in the table, read from the log files 
themselves. Tests that pin
+   * a log format assert on this rather than on file names: native logs carry 
a .log.parquet suffix,
+   * but an inline log file is named the same whether its data blocks are avro 
or parquet.
+   */
+  protected def listLogBlockTypes(tablePath: String): Seq[HoodieLogBlockType] 
= {
+    val (metaClient, fsView) = getMetaClientAndFileSystemView(tablePath)
+    val schema = new TableSchemaResolver(metaClient).getTableSchema
+    val logFiles = fsView.getAllFileSlices("").iterator().asScala
+      .flatMap(slice => 
HoodieTestUtils.getLogFileListFromFileSlice(slice).asScala).toSeq
+    assert(logFiles.nonEmpty, "expected at least one log file")
+    logFiles.flatMap { path =>
+      val reader = HoodieLogFormat.newReader(metaClient, new 
HoodieLogFile(path), schema)
+      try {
+        val types = mutable.ArrayBuffer[HoodieLogBlockType]()
+        while (reader.hasNext) {
+          types += reader.next().getBlockType
+        }
+        types.toSeq
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
   /** The requested time of the latest completed commit-like instant, for time 
travel. */
   protected def latestCompletedInstant(tablePath: String): String = {
     val metaClient = createMetaClient(spark, tablePath)
diff --git 
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
 
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
index fab2f0480dc7..75dc1e3b8e9f 100644
--- 
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
+++ 
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
@@ -36,10 +36,12 @@ import 
org.apache.spark.sql.FileFormatUtilsForFileGroupReader.applyFiltersToPlan
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases
 import org.apache.spark.sql.catalyst.catalog.CatalogTable
-import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
Expression, InterpretedPredicate, Predicate, SpecializedGetters}
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
BoundReference, CreateNamedStruct, Expression, GetStructField, If, 
InterpretedPredicate, IsNull, Literal, Predicate, SpecializedGetters, 
UnsafeProjection}
+import org.apache.spark.sql.catalyst.expressions.variant.VariantGet
 import org.apache.spark.sql.catalyst.parser.ParseException
 import org.apache.spark.sql.catalyst.planning.PhysicalOperation
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.types.DataTypeUtils
 import org.apache.spark.sql.catalyst.util.DateFormatter
 import org.apache.spark.sql.classic.ColumnConversions
 import org.apache.spark.sql.execution.{PartitionedFileUtil, QueryExecution, 
SQLExecution}
@@ -49,7 +51,7 @@ import 
org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, Pa
 import org.apache.spark.sql.hudi.SparkAdapter
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.sources.{BaseRelation, Filter}
-import org.apache.spark.sql.types.{BinaryType, DataType, StructField, 
StructType, VariantType}
+import org.apache.spark.sql.types.{BinaryType, DataType, StringType, 
StructField, StructType, VariantType}
 import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
 import org.apache.spark.storage.StorageLevel
 import org.apache.spark.types.variant.Variant
@@ -300,9 +302,11 @@ abstract class BaseSpark4Adapter extends SparkAdapter with 
Logging {
   /**
    * Shared implementation behind [[SparkAdapter#buildFullVariantReadSchema]] 
for the 4.x
    * adapters whose parquet reader can reconstruct shredded variants (4.1+, 
SPARK-54410).
-   * Spark 4.0 keeps the default None: the write-side methods above have no 
version gate, so
-   * it does write shredded files, but its reader cannot rebuild them and the 
projection
-   * shape would not help.
+   * Top-level fields only, matching the whole-variant request 
PushVariantIntoScan makes for a
+   * root attribute; a variant below the top level is left native, and that 
reader rebuilds it
+   * at any depth from a native VariantType request (#19775). Spark 4.0 keeps 
the default None:
+   * the write-side methods above have no version gate, so it does write 
shredded files, but
+   * its reader cannot rebuild them and the projection shape would not help.
    */
   protected final def rewriteTopLevelVariantsForFullRead(schema: StructType): 
Option[StructType] = {
     var rewritten = false
@@ -320,6 +324,86 @@ abstract class BaseSpark4Adapter extends SparkAdapter with 
Logging {
     if (rewritten) Some(StructType(fields)) else None
   }
 
+  /**
+   * Shared implementation behind [[SparkAdapter#buildVariantProjector]] for 
the 4.x adapters
+   * whose planner rewrites variants into projection structs (4.1+).
+   *
+   * Recurses into struct members, mirroring PushVariantIntoScan's 
`VariantInRelation.rewriteType`:
+   * a variant is rewritten at the root of the relation output or below a 
STRUCT path, while
+   * arrays and maps keep their native VariantType, so nothing under a 
collection is projected
+   * here either. Before #19775 this walked top-level fields only, and a 
projection struct sitting
+   * one struct member down was left holding a raw variant that the plan then 
read as its
+   * projected children.
+   */
+  protected final def buildVariantProjectorForStructPaths(
+      sparkDataSchema: StructType,
+      sparkRequiredSchema: StructType): Option[InternalRow => InternalRow] = {
+    // Quick check: does any required field carry a variant projection struct, 
at any depth?
+    if (!sparkRequiredSchema.fields.exists(f => 
containsVariantProjection(f.dataType))) {
+      None
+    } else {
+      // Surface mismatched schemas with both field lists rather than Spark's 
bare
+      // IllegalArgumentException from fieldIndex. `path` is the dotted field 
path of `name`.
+      def lookupDataField(dataStruct: StructType, requiredStruct: StructType,
+                          name: String, path: String): (Int, StructField) = {
+        val idx = dataStruct.getFieldIndex(name).getOrElse(
+          throw new IllegalStateException(
+            s"Required field '$path' is absent from sparkDataSchema; " +
+              s"required=${requiredStruct.fieldNames.mkString("[", ",", "]")}, 
" +
+              s"data=${dataStruct.fieldNames.mkString("[", ",", "]")}"))
+        (idx, dataStruct.fields(idx))
+      }
+
+      // `ref` reads the data-schema value of type `dataType`; the result has 
type `requiredType`.
+      def projectionExpr(ref: Expression, dataType: DataType, requiredType: 
DataType,
+                         path: String): Expression = requiredType match {
+        case projectedStruct: StructType if 
VariantMetadata.isVariantStruct(projectedStruct) =>
+          require(isVariantType(dataType),
+            s"Expected VariantType for field '$path' in data schema, got 
$dataType")
+          val childExprs: Seq[Expression] = 
projectedStruct.fields.toSeq.flatMap { child =>
+            val vm = VariantMetadata.fromMetadata(child.metadata)
+            val pathLit = Literal(UTF8String.fromString(vm.path), StringType)
+            val variantGet: Expression =
+              VariantGet(ref, pathLit, child.dataType, vm.failOnError, 
Option(vm.timeZoneId))
+            Seq(Literal(UTF8String.fromString(child.name), StringType), 
variantGet)
+          }
+          val projected = CreateNamedStruct(childExprs)
+          // A null variant has to come out as a NULL struct, not a struct of 
nulls: CreateNamedStruct
+          // is never null, the parquet paths leave the field null, and 
PushVariantIntoScan rewrites
+          // IsNull(v) / IsNotNull(v) onto this struct directly.
+          If(IsNull(ref), Literal(null, projected.dataType), projected)
+        case requiredStruct: StructType =>
+          dataType match {
+            // Rebuild the struct member by member only when something below 
it is projected;
+            // otherwise the reference is already in the required shape and is 
cheaper untouched.
+            case dataStruct: StructType if 
containsVariantProjection(requiredStruct) =>
+              val childExprs: Seq[Expression] = 
requiredStruct.fields.toSeq.flatMap { rf =>
+                val childPath = s"$path.${rf.name}"
+                val (childIdx, childField) = lookupDataField(dataStruct, 
requiredStruct, rf.name, childPath)
+                val childRef = GetStructField(ref, childIdx, Some(rf.name))
+                Seq(Literal(UTF8String.fromString(rf.name), StringType),
+                  projectionExpr(childRef, childField.dataType, rf.dataType, 
childPath))
+              }
+              val rebuilt = CreateNamedStruct(childExprs)
+              // CreateNamedStruct is never null, so a null struct would come 
back as a struct of
+              // nulls without this guard.
+              If(IsNull(ref), Literal(null, rebuilt.dataType), rebuilt)
+            case _ => ref
+          }
+        case _ => ref
+      }
+
+      val exprs: Array[Expression] = sparkRequiredSchema.fields.map { rf =>
+        val (dataIdx, dataField) = lookupDataField(sparkDataSchema, 
sparkRequiredSchema, rf.name, rf.name)
+        val ref: Expression = BoundReference(dataIdx, dataField.dataType, 
dataField.nullable)
+        projectionExpr(ref, dataField.dataType, rf.dataType, rf.name)
+      }
+
+      val projection = UnsafeProjection.create(exprs.toIndexedSeq, 
DataTypeUtils.toAttributes(sparkDataSchema))
+      Some(row => projection(row))
+    }
+  }
+
   override def createShreddedVariantWriter(
     shreddedStructType: StructType,
     writeStruct: Consumer[InternalRow]
diff --git 
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
 
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
index c445d89f1e69..ddaf0f581476 100644
--- 
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
+++ 
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
@@ -32,13 +32,11 @@ import org.apache.spark.sql.avro._
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, 
ResolvedTable}
 import org.apache.spark.sql.catalyst.catalog.CatalogTable
-import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
CreateNamedStruct, Expression, Literal, UnsafeProjection}
-import org.apache.spark.sql.catalyst.expressions.variant.VariantGet
+import org.apache.spark.sql.catalyst.expressions.Expression
 import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
 import org.apache.spark.sql.catalyst.planning.PhysicalOperation
 import org.apache.spark.sql.catalyst.plans.logical._
 import org.apache.spark.sql.catalyst.trees.Origin
-import org.apache.spark.sql.catalyst.types.DataTypeUtils
 import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, 
RebaseDateTime}
 import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback}
 import org.apache.spark.sql.execution.datasources._
@@ -52,7 +50,7 @@ import org.apache.spark.sql.hudi.analysis.TableValuedFunctions
 import org.apache.spark.sql.hudi.blob.{BatchedBlobReaderStrategy, 
ScalarFunctions}
 import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
 import org.apache.spark.sql.parser.{HoodieExtendedParserInterface, 
HoodieSpark4_1ExtendedSqlParser}
-import org.apache.spark.sql.types.{DataType, DataTypes, Metadata, 
MetadataBuilder, StructField, StructType}
+import org.apache.spark.sql.types.{DataType, DataTypes, Metadata, 
MetadataBuilder, StructType}
 import org.apache.spark.sql.vectorized.ColumnarBatchRow
 import org.apache.spark.storage.StorageLevel
 import org.apache.spark.storage.StorageLevel._
@@ -242,46 +240,8 @@ class Spark4_1Adapter extends BaseSpark4Adapter {
     rewriteTopLevelVariantsForFullRead(schema)
 
   override def buildVariantProjector(sparkDataSchema: StructType,
-                                     sparkRequiredSchema: StructType): 
Option[InternalRow => InternalRow] = {
-    // Quick check: any required field a variant projection struct?
-    if (!sparkRequiredSchema.fields.exists(f => 
VariantMetadata.isVariantStruct(f.dataType))) {
-      None
-    } else {
-      // Surface mismatched schemas with both field lists rather than Spark's 
bare
-      // IllegalArgumentException from fieldIndex.
-      def lookupDataField(name: String): (Int, StructField) = {
-        val idx = sparkDataSchema.getFieldIndex(name).getOrElse(
-          throw new IllegalStateException(
-            s"Required field '$name' is absent from sparkDataSchema; " +
-              s"required=${sparkRequiredSchema.fieldNames.mkString("[", ",", 
"]")}, " +
-              s"data=${sparkDataSchema.fieldNames.mkString("[", ",", "]")}"))
-        (idx, sparkDataSchema.fields(idx))
-      }
-      val exprs: Array[Expression] = sparkRequiredSchema.fields.map { rf =>
-        rf.dataType match {
-          case projectedStruct: StructType if 
VariantMetadata.isVariantStruct(projectedStruct) =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            require(isVariantType(dataField.dataType),
-              s"Expected VariantType for field '${rf.name}' in data schema, 
got ${dataField.dataType}")
-            val variantRef: Expression = BoundReference(dataIdx, 
dataField.dataType, dataField.nullable)
-            val childExprs: Seq[Expression] = 
projectedStruct.fields.toSeq.flatMap { child =>
-              val vm = VariantMetadata.fromMetadata(child.metadata)
-              val pathLit = Literal(UTF8String.fromString(vm.path), 
DataTypes.StringType)
-              val tz: Option[String] = Option(vm.timeZoneId)
-              val variantGet: Expression = VariantGet(variantRef, pathLit, 
child.dataType, vm.failOnError, tz)
-              Seq(Literal(UTF8String.fromString(child.name), 
DataTypes.StringType), variantGet)
-            }
-            CreateNamedStruct(childExprs)
-          case _ =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            BoundReference(dataIdx, dataField.dataType, dataField.nullable)
-        }
-      }
-
-      val projection = UnsafeProjection.create(exprs.toIndexedSeq, 
DataTypeUtils.toAttributes(sparkDataSchema))
-      Some(row => projection(row))
-    }
-  }
+                                     sparkRequiredSchema: StructType): 
Option[InternalRow => InternalRow] =
+    buildVariantProjectorForStructPaths(sparkDataSchema, sparkRequiredSchema)
 
   // Apply LogicalTypeAnnotation.variantType((byte) 1) to the variant group, 
matching parquet 1.16+'s
   // SparkToParquetSchemaConverter convention.
diff --git 
a/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
 
b/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
index 388582c9e963..b6425b03e5f6 100644
--- 
a/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
+++ 
b/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
@@ -32,13 +32,11 @@ import org.apache.spark.sql.avro._
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, 
ResolvedTable}
 import org.apache.spark.sql.catalyst.catalog.CatalogTable
-import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
CreateNamedStruct, Expression, Literal, UnsafeProjection}
-import org.apache.spark.sql.catalyst.expressions.variant.VariantGet
+import org.apache.spark.sql.catalyst.expressions.Expression
 import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
 import org.apache.spark.sql.catalyst.planning.PhysicalOperation
 import org.apache.spark.sql.catalyst.plans.logical._
 import org.apache.spark.sql.catalyst.trees.Origin
-import org.apache.spark.sql.catalyst.types.DataTypeUtils
 import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, 
RebaseDateTime}
 import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback}
 import org.apache.spark.sql.execution.datasources._
@@ -52,7 +50,7 @@ import org.apache.spark.sql.hudi.analysis.TableValuedFunctions
 import org.apache.spark.sql.hudi.blob.{BatchedBlobReaderStrategy, 
ScalarFunctions}
 import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
 import org.apache.spark.sql.parser.{HoodieExtendedParserInterface, 
HoodieSpark4_2ExtendedSqlParser}
-import org.apache.spark.sql.types.{DataType, DataTypes, Metadata, 
MetadataBuilder, StructField, StructType}
+import org.apache.spark.sql.types.{DataType, DataTypes, Metadata, 
MetadataBuilder, StructType}
 import org.apache.spark.sql.vectorized.ColumnarBatchRow
 import org.apache.spark.storage.StorageLevel
 import org.apache.spark.storage.StorageLevel._
@@ -242,46 +240,8 @@ class Spark4_2Adapter extends BaseSpark4Adapter {
     rewriteTopLevelVariantsForFullRead(schema)
 
   override def buildVariantProjector(sparkDataSchema: StructType,
-                                     sparkRequiredSchema: StructType): 
Option[InternalRow => InternalRow] = {
-    // Quick check: any required field a variant projection struct?
-    if (!sparkRequiredSchema.fields.exists(f => 
VariantMetadata.isVariantStruct(f.dataType))) {
-      None
-    } else {
-      // Surface mismatched schemas with both field lists rather than Spark's 
bare
-      // IllegalArgumentException from fieldIndex.
-      def lookupDataField(name: String): (Int, StructField) = {
-        val idx = sparkDataSchema.getFieldIndex(name).getOrElse(
-          throw new IllegalStateException(
-            s"Required field '$name' is absent from sparkDataSchema; " +
-              s"required=${sparkRequiredSchema.fieldNames.mkString("[", ",", 
"]")}, " +
-              s"data=${sparkDataSchema.fieldNames.mkString("[", ",", "]")}"))
-        (idx, sparkDataSchema.fields(idx))
-      }
-      val exprs: Array[Expression] = sparkRequiredSchema.fields.map { rf =>
-        rf.dataType match {
-          case projectedStruct: StructType if 
VariantMetadata.isVariantStruct(projectedStruct) =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            require(isVariantType(dataField.dataType),
-              s"Expected VariantType for field '${rf.name}' in data schema, 
got ${dataField.dataType}")
-            val variantRef: Expression = BoundReference(dataIdx, 
dataField.dataType, dataField.nullable)
-            val childExprs: Seq[Expression] = 
projectedStruct.fields.toSeq.flatMap { child =>
-              val vm = VariantMetadata.fromMetadata(child.metadata)
-              val pathLit = Literal(UTF8String.fromString(vm.path), 
DataTypes.StringType)
-              val tz: Option[String] = Option(vm.timeZoneId)
-              val variantGet: Expression = VariantGet(variantRef, pathLit, 
child.dataType, vm.failOnError, tz)
-              Seq(Literal(UTF8String.fromString(child.name), 
DataTypes.StringType), variantGet)
-            }
-            CreateNamedStruct(childExprs)
-          case _ =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            BoundReference(dataIdx, dataField.dataType, dataField.nullable)
-        }
-      }
-
-      val projection = UnsafeProjection.create(exprs.toIndexedSeq, 
DataTypeUtils.toAttributes(sparkDataSchema))
-      Some(row => projection(row))
-    }
-  }
+                                     sparkRequiredSchema: StructType): 
Option[InternalRow => InternalRow] =
+    buildVariantProjectorForStructPaths(sparkDataSchema, sparkRequiredSchema)
 
   // Apply LogicalTypeAnnotation.variantType((byte) 1) to the variant group, 
matching parquet 1.16+'s
   // SparkToParquetSchemaConverter convention.

Reply via email to