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

szehon-ho pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new d6a02836bca1 [SPARK-59068][SQL] Restore support for nested runtime 
filter attributes
d6a02836bca1 is described below

commit d6a02836bca1ad460c900328ce41c4a22b0c45e0
Author: Szehon Ho <[email protected]>
AuthorDate: Sat Aug 29 08:27:27 2026 -0700

    [SPARK-59068][SQL] Restore support for nested runtime filter attributes
    
    ### What changes were proposed in this pull request?
    
    This PR restores support for nested `filterAttributes()` references in:
    
    - `SupportsRuntimeFiltering`
    - `SupportsRuntimeV2Filtering`
    - `SupportsRuntimeCatalystFiltering`
    
    For ordinary runtime filtering, it removes the top-level-only validation 
added by #58296. It keeps
    `fullyPushedFilterAttributes()` restricted to top-level attributes because 
Spark removes the
    post-scan predicate for those attributes.
    
    The PR also enables nested runtime group filtering for row-level 
operations. This is new capability,
    not behavior restored from before #58296.
    
    Invalid runtime filter references now produce structured
    `DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE` or
    `DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.NOT_TOP_LEVEL` errors with 
the offending attribute,
    connector method, scan class, and scan relation output. Resolution failures 
preserve the original
    analysis error as their cause.
    
    ### Why are the changes needed?
    
    The validation in #58296 was added following this review comment:
    
    > Accepting `s.tz` here widens it to `s` and makes filters over every field 
eligible.
    
    https://github.com/apache/spark/pull/58296#discussion_r3859456719
    
    The concern is valid because Spark currently uses an `AttributeSet` for 
eligibility. A nested
    reference such as `derives.toStr` contributes its root attribute, 
`derives`, so Spark cannot
    distinguish it from `derives.other`.
    
    However, rejecting nested references breaks an existing Iceberg 
runtime-filtering flow.
    
    For example, consider an Iceberg table partitioned by:
    
    ```
    truncate(2, derives.toStr)
    ```
    
    The end-to-end flow before #58296 was:
    
    - Iceberg found the partition field's source column ID.
    - It converted that source column's full path into a `NamedReference`.
    - `filterAttributes()` therefore returned `derives.toStr`.
    - Spark resolved that nested reference. Its current eligibility 
representation reduced it to the
      root attribute `derives`.
    - A DPP predicate on `derives.toStr` passed eligibility.
    - Importantly, the actual predicate sent to Iceberg still contained the 
nested `derives.toStr`
      access.
    - Iceberg projected that predicate through each partition spec and used the 
resulting evaluator
      to prune scan tasks.
    
    Iceberg implementation:
    
    - 
[`filterAttributes()`](https://github.com/apache/iceberg/blob/ef8a69dc3dab5858d612770062bbf5a6043170f0/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java#L105-L123)
    - [Runtime-filter projection and task 
pruning](https://github.com/apache/iceberg/blob/ef8a69dc3dab5858d612770062bbf5a6043170f0/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java#L127-L146)
    
    Therefore, this flow worked end to end even though Spark's eligibility 
check was too coarse.
    
    After #58296, Spark rejects `derives.toStr` while constructing the scan 
relation, breaking the
    existing Iceberg integration.
    
    This PR restores the previous scan behavior as a compatibility fix. 
Separately, it makes row-level
    runtime group filtering work with nested attributes. 
[SPARK-59095](https://issues.apache.org/jira/browse/SPARK-59095) tracks making 
runtime-filter
    eligibility path-aware so Spark can distinguish `derives.toStr` from 
sibling fields such as
    `derives.other`. Until then, connectors cannot soundly declare a 
fully-pushed nested path: declaring
    the root struct would also make sibling-field predicates appear fully 
pushed, and Spark would
    remove their post-scan evaluation.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Data sources can once again advertise nested runtime filter attributes.
    
    This restores the behavior before #58296: connectors such as Iceberg no 
longer fail during
    planning and can receive nested runtime predicates for partition pruning. 
Row-level operations can
    also use nested runtime filter attributes for runtime group pruning.
    
    ### How was this patch tested?
    
    Added end-to-end DPP regression tests for:
    
    - `SupportsRuntimeV2Filtering`
    - `SupportsRuntimeCatalystFiltering`
    - nested row-level DELETE and MERGE group filtering
    - malformed and missing ordinary and fully-pushed runtime filter attributes
    
    The tests verify that a scan advertising `derives.toStr`:
    
    - is eligible for a runtime filter on `derives.toStr`;
    - receives a predicate retaining the nested access; and
    - prunes the expected partition.
    
    The following validation passed:
    
    - `DataSourceV2CatalystRuntimeFilterSuite`,
      `GroupBasedRowLevelOperationCatalystRuntimeFilterSuite`, and
      `DeltaBasedRowLevelOperationCatalystRuntimeFilterSuite`: 30/30 tests
    - focused V1/V2 nested partition source tests: 2/2 tests
    - `SparkThrowableSuite`: 37/37 tests
    - Catalyst and SQL scalastyle checks
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex with GPT-5
    
    Closes #58370 from szehon-ho/codex/runtime-filter-nested-compat.
    
    Authored-by: Szehon Ho <[email protected]>
    Signed-off-by: Szehon Ho <[email protected]>
    (cherry picked from commit 221f8de0d2e793646dcbbda4095ef9dcca8b9022)
    Signed-off-by: Szehon Ho <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  18 ++
 .../connector/read/SupportsRuntimeFiltering.java   |  12 +-
 .../connector/read/SupportsRuntimeV2Filtering.java |  10 +-
 .../catalyst/expressions/V2ExpressionUtils.scala   |   2 +-
 .../spark/sql/errors/QueryCompilationErrors.scala  |  40 ++++
 .../datasources/v2/DataSourceV2Relation.scala      |  80 ++++---
 .../SupportsRuntimeCatalystFiltering.scala         |  16 +-
 .../sql/connector/catalog/InMemoryBaseTable.scala  |   8 +-
 .../InMemoryCatalystRuntimeFilterTable.scala       |  27 +--
 .../catalog/InMemoryRowLevelOperationTable.scala   |   5 +-
 .../catalog/InMemoryTableWithV2Filter.scala        |   5 +-
 .../RowLevelOperationRuntimeGroupFiltering.scala   |  21 +-
 .../DataSourceV2CatalystRuntimeFilterSuite.scala   | 235 ++++++++++++++-------
 .../spark/sql/connector/DataSourceV2SQLSuite.scala |  44 +++-
 ...wLevelOperationCatalystRuntimeFilterSuite.scala |  31 +++
 ...elOperationCatalystRuntimeFilterSuiteBase.scala |  69 +++++-
 .../sql/connector/RowLevelOperationSuiteBase.scala |   9 +
 17 files changed, 478 insertions(+), 154 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 31ec0418d1b4..2b73c1759733 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -2265,6 +2265,24 @@
     ],
     "sqlState" : "KD010"
   },
+  "DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE" : {
+    "message" : [
+      "The runtime filter attribute <attribute> reported by `<method>` in data 
source scan <scanClass> is invalid for the scan relation output 
<relationOutput>."
+    ],
+    "subClass" : {
+      "CANNOT_RESOLVE" : {
+        "message" : [
+          "The attribute cannot be resolved."
+        ]
+      },
+      "NOT_TOP_LEVEL" : {
+        "message" : [
+          "The attribute must be top-level, but it is a nested reference."
+        ]
+      }
+    },
+    "sqlState" : "KD000"
+  },
   "DATA_SOURCE_METADATA_SCHEMA_NOT_IMPLEMENTED" : {
     "message" : [
       "<class> does not implement metadataSchema."
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeFiltering.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeFiltering.java
index 067202a36270..c752c8c95d54 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeFiltering.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeFiltering.java
@@ -39,9 +39,8 @@ public interface SupportsRuntimeFiltering extends 
SupportsRuntimeV2Filtering {
    * Spark will call {@link #filter(Filter[])} if it can derive a runtime
    * predicate for any of the filter attributes.
    * <p>
-   * Each reference must be a top-level attribute present in {@link 
Scan#readSchema()}.
-   * Nested references and attributes pruned out of the read schema fail to 
resolve when
-   * Spark builds the scan relation.
+   * Each reference must resolve against the scan relation output when Spark 
builds it. Attributes
+   * pruned out of {@link Scan#readSchema()} fail to resolve.
    */
   NamedReference[] filterAttributes();
 
@@ -51,6 +50,13 @@ public interface SupportsRuntimeFiltering extends 
SupportsRuntimeV2Filtering {
    * The provided expressions must be interpreted as a set of filters that are 
ANDed together.
    * Implementations may use the filters to prune initially planned {@link 
InputPartition}s.
    * <p>
+   * Spark tracks runtime-filter eligibility by root attribute. If {@link 
#filterAttributes()}
+   * returns a nested reference, this method may receive a filter on another 
nested field under
+   * the same root. Implementations must inspect each filter and use only 
filters they can apply.
+   * Nested paths are encoded in a V1 {@link Filter} as unquoted dot-separated 
names such as
+   * {@code parent.child}. A top-level column whose name contains a dot 
remains quoted, such as
+   * {@code `parent.child`}.
+   * <p>
    * If the scan also implements {@link SupportsReportPartitioning}, it must 
preserve
    * the originally reported partitioning during runtime filtering. While 
applying runtime filters,
    * the scan may detect that some {@link InputPartition}s have no matching 
data, in which case
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeV2Filtering.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeV2Filtering.java
index 6b286f041b01..671a822096bb 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeV2Filtering.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeV2Filtering.java
@@ -52,9 +52,8 @@ public interface SupportsRuntimeV2Filtering extends Scan {
    * Spark will call {@link #filter(Predicate[])} if it can derive a runtime
    * predicate for any of the filter attributes.
    * <p>
-   * Each reference must be a top-level attribute present in {@link 
Scan#readSchema()}.
-   * Nested references and attributes pruned out of the read schema fail to 
resolve when
-   * Spark builds the scan relation.
+   * Each reference must resolve against the scan relation output when Spark 
builds it. Attributes
+   * pruned out of {@link Scan#readSchema()} fail to resolve.
    */
   NamedReference[] filterAttributes();
 
@@ -64,6 +63,11 @@ public interface SupportsRuntimeV2Filtering extends Scan {
    * The provided expressions must be interpreted as a set of predicates that 
are ANDed together.
    * Implementations may use the predicates to prune initially planned {@link 
InputPartition}s.
    * <p>
+   * Spark tracks runtime-filter eligibility by root attribute. If {@link 
#filterAttributes()}
+   * returns a nested reference, this method may receive a predicate on 
another nested field under
+   * the same root. Implementations must inspect each predicate and use only 
predicates they can
+   * apply.
+   * <p>
    * If the scan also implements {@link SupportsReportPartitioning}, it must 
preserve
    * the originally reported partitioning during runtime filtering. While 
applying runtime
    * predicates, the scan may detect that some {@link InputPartition}s have no 
matching data, in
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
index 702255e07574..349a59a1aaf8 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
@@ -67,7 +67,7 @@ object V2ExpressionUtils extends SQLConfHelper with Logging {
       refs: Array[NamedReference],
       output: Seq[Attribute]): AttributeSet = {
     val plan = LocalRelation(output)
-    AttributeSet(resolveRefs[Attribute](refs.toImmutableArraySeq, plan))
+    AttributeSet(resolveRefs[NamedExpression](refs.toImmutableArraySeq, plan))
   }
 
   /**
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index ada71af43d3b..a662eee4ea41 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -4654,6 +4654,46 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
     )
   }
 
+  def cannotResolveDataSourceRuntimeFilterAttributeError(
+      attribute: Array[String],
+      method: String,
+      scanClass: String,
+      relationOutput: StructType,
+      cause: AnalysisException): AnalysisException = {
+    invalidDataSourceRuntimeFilterAttributeError(
+      attribute, method, scanClass, relationOutput, "CANNOT_RESOLVE", 
Some(cause))
+  }
+
+  def nestedDataSourceFullyPushedRuntimeFilterAttributeError(
+      attribute: Array[String],
+      scanClass: String,
+      relationOutput: StructType): AnalysisException = {
+    invalidDataSourceRuntimeFilterAttributeError(
+      attribute,
+      "fullyPushedFilterAttributes()",
+      scanClass,
+      relationOutput,
+      "NOT_TOP_LEVEL",
+      None)
+  }
+
+  private def invalidDataSourceRuntimeFilterAttributeError(
+      attribute: Array[String],
+      method: String,
+      scanClass: String,
+      relationOutput: StructType,
+      errorSubClass: String,
+      cause: Option[AnalysisException]): AnalysisException = {
+    new AnalysisException(
+      errorClass = 
s"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.$errorSubClass",
+      messageParameters = Map(
+        "attribute" -> toSQLId(attribute.toImmutableArraySeq),
+        "method" -> method,
+        "scanClass" -> scanClass,
+        "relationOutput" -> toSQLType(relationOutput)),
+      cause = cause)
+  }
+
   def foundMultipleXMLDataSourceError(provider: String,
       sourceNames: Seq[String],
       externalSource: String): Throwable = {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala
index 7b2cfacb652f..c13b44ab5fc6 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala
@@ -20,15 +20,16 @@ package org.apache.spark.sql.execution.datasources.v2
 import java.util.{Collections, Optional, OptionalLong}
 
 import org.apache.spark.SparkException
+import org.apache.spark.sql.AnalysisException
 import org.apache.spark.sql.catalyst.analysis.{MultiInstanceRelation, 
NamedRelation, TimeTravelSpec}
 import org.apache.spark.sql.catalyst.catalog.{CatalogColumnStat, 
CatalogStatistics}
-import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeReference, AttributeSet, Expression, SortOrder, V2ExpressionUtils}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeReference, AttributeSet, Expression, NamedExpression, SortOrder, 
V2ExpressionUtils}
 import org.apache.spark.sql.catalyst.plans.QueryPlan
 import org.apache.spark.sql.catalyst.plans.logical.{ColumnStat, 
ExposesMetadataColumns, Histogram, HistogramBin, LeafNode, LogicalPlan, 
Statistics}
 import 
org.apache.spark.sql.catalyst.plans.logical.statsEstimation.EstimationUtils
 import 
org.apache.spark.sql.catalyst.streaming.{StreamingSourceIdentifyingName, 
Unassigned}
 import 
org.apache.spark.sql.catalyst.trees.TreePattern.{DATA_SOURCE_V2_RELATION, 
DATA_SOURCE_V2_SCAN_RELATION, TreePattern}
-import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
+import org.apache.spark.sql.catalyst.types.DataTypeUtils.{fromAttributes, 
toAttributes}
 import org.apache.spark.sql.catalyst.util.{removeInternalMetadata, 
truncatedString, CharVarcharUtils}
 import org.apache.spark.sql.connector.catalog.{CatalogPlugin, FunctionCatalog, 
Identifier, SupportsMetadataColumns, Table, TableCapability, TableCatalog, 
V2TableUtil}
 import org.apache.spark.sql.connector.catalog.CatalogV2Implicits.CatalogHelper
@@ -36,10 +37,10 @@ import 
org.apache.spark.sql.connector.expressions.{FieldReference, NamedReferenc
 import org.apache.spark.sql.connector.read.{Scan, Statistics => V2Statistics, 
SupportsReportStatistics, SupportsRuntimeV2Filtering}
 import org.apache.spark.sql.connector.read.colstats.{ColumnStatistics, 
Histogram => V2Histogram, HistogramBin => V2HistogramBin}
 import org.apache.spark.sql.connector.read.streaming.{Offset, SparkDataStream}
+import org.apache.spark.sql.errors.QueryCompilationErrors
 import 
org.apache.spark.sql.internal.connector.{SupportsRuntimeCatalystFiltering, 
V2StatisticsUtils}
 import org.apache.spark.sql.types.{DataType, StructType}
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
-import org.apache.spark.util.ArrayImplicits._
 import org.apache.spark.util.Utils
 
 /**
@@ -201,16 +202,23 @@ case class DataSourceV2ScanRelation(
    * Resolved attributes that the scan declares for runtime filtering via
    * [[SupportsRuntimeV2Filtering.filterAttributes]] or
    * [[SupportsRuntimeCatalystFiltering.filterAttributes]]. Empty when the scan
-   * implements neither interface or exposes no attributes.
+   * implements neither interface or exposes no attributes. Accessing this 
value also validates
+   * attributes returned by 
[[SupportsRuntimeCatalystFiltering.fullyPushedFilterAttributes]].
    */
   lazy val runtimeFilterAttrs: AttributeSet = {
     checkRuntimeFilteringInterfaces()
+    checkFullyPushedFilterAttrs()
     val filterAttrs = scan match {
       case s: SupportsRuntimeV2Filtering => s.filterAttributes
       case s: SupportsRuntimeCatalystFiltering => s.filterAttributes()
       case _ => Array.empty[NamedReference]
     }
-    resolveTopLevelFilterAttrs(filterAttrs)
+    resolveFilterAttrs(filterAttrs, "filterAttributes()")
+  }
+
+  private lazy val declaredFullyPushedRuntimeFilterAttrs: 
Array[NamedReference] = scan match {
+    case s: SupportsRuntimeCatalystFiltering => s.fullyPushedFilterAttributes()
+    case _ => Array.empty
   }
 
   /**
@@ -219,27 +227,34 @@ case class DataSourceV2ScanRelation(
    */
   lazy val fullyPushedRuntimeFilterAttrs: AttributeSet = {
     checkRuntimeFilteringInterfaces()
-    val filterAttrs = scan match {
-      case s: SupportsRuntimeCatalystFiltering => 
s.fullyPushedFilterAttributes()
-      case _ => Array.empty[NamedReference]
-    }
-    resolveTopLevelFilterAttrs(filterAttrs)
+    checkFullyPushedFilterAttrs()
+    resolveFilterAttrs(
+      declaredFullyPushedRuntimeFilterAttrs, "fullyPushedFilterAttributes()")
   }
 
   /**
-   * Resolves the given runtime-filter references against this relation's 
output. Both runtime
-   * filtering interfaces require each reference to be a top-level attribute 
of the read schema,
-   * so a nested reference is rejected.
+   * Resolves runtime-filter references against this relation's output.
+   *
+   * [[AttributeSet]] reduces nested references to their root attributes. This 
is sufficient for
+   * ordinary runtime-filter eligibility because Spark retains the post-scan 
predicate.
    */
-  private def resolveTopLevelFilterAttrs(filterAttrs: Array[NamedReference]): 
AttributeSet = {
-    filterAttrs.find(_.fieldNames.length > 1).foreach { ref =>
-      throw SparkException.internalError(
-        s"Runtime filter attribute '${ref.fieldNames.mkString(".")}' declared 
by " +
-        s"${scan.getClass.getName} must be a top-level attribute of the scan 
read schema, " +
-        "but it is a nested reference.")
+  private def resolveFilterAttrs(
+      filterAttrs: Array[NamedReference],
+      method: String): AttributeSet = {
+    val resolvedAttrs = filterAttrs.map { ref =>
+      try {
+        V2ExpressionUtils.resolveRef[NamedExpression](ref, this)
+      } catch {
+        case e: AnalysisException =>
+          throw 
QueryCompilationErrors.cannotResolveDataSourceRuntimeFilterAttributeError(
+            attribute = ref.fieldNames,
+            method = method,
+            scanClass = scan.getClass.getName,
+            relationOutput = fromAttributes(output),
+            cause = e)
+      }
     }
-    AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
-      filterAttrs.toImmutableArraySeq, this))
+    AttributeSet(resolvedAttrs)
   }
 
   override val nodePatterns: Seq[TreePattern] = 
Seq(DATA_SOURCE_V2_SCAN_RELATION)
@@ -290,12 +305,23 @@ case class DataSourceV2ScanRelation(
     Statistics(sizeInBytes = conf.defaultSizeInBytes)
   }
 
-  private def checkRuntimeFilteringInterfaces(): Unit = scan match {
-    case _: SupportsRuntimeV2Filtering with SupportsRuntimeCatalystFiltering =>
-      throw SparkException.internalError(
-        "A scan must not implement both SupportsRuntimeV2Filtering and " +
-        s"SupportsRuntimeCatalystFiltering, but ${scan.getClass.getName} 
implements both.")
-    case _ =>
+  private def checkRuntimeFilteringInterfaces(): Unit = {
+    scan match {
+      case _: SupportsRuntimeV2Filtering with SupportsRuntimeCatalystFiltering 
=>
+        throw SparkException.internalError(
+          "A scan must not implement both SupportsRuntimeV2Filtering and " +
+          s"SupportsRuntimeCatalystFiltering, but ${scan.getClass.getName} 
implements both.")
+      case _ =>
+    }
+  }
+
+  private def checkFullyPushedFilterAttrs(): Unit = {
+    declaredFullyPushedRuntimeFilterAttrs.find(_.fieldNames.length > 
1).foreach { ref =>
+      throw 
QueryCompilationErrors.nestedDataSourceFullyPushedRuntimeFilterAttributeError(
+        attribute = ref.fieldNames,
+        scanClass = scan.getClass.getName,
+        relationOutput = fromAttributes(output))
+    }
   }
 
   override def doCanonicalize(): DataSourceV2ScanRelation = {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala
index eaa4857f1abe..ec34efed2625 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala
@@ -41,9 +41,8 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
    * Returns attributes this scan can be filtered by at runtime.
    *
    * Spark will call [[filter]] if it can derive a runtime filter for any of 
these attributes.
-   * Each reference must be a top-level attribute present in 
[[Scan.readSchema]]. Nested
-   * references are rejected, and attributes pruned out of the read schema 
fail to resolve, when
-   * Spark builds the scan relation.
+   * Each reference must resolve against the scan relation output when Spark 
builds it. Attributes
+   * pruned out of [[Scan.readSchema]] fail to resolve.
    */
   def filterAttributes(): Array[NamedReference]
 
@@ -65,7 +64,10 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
    *
    * Each reference must be a top-level attribute present in 
[[Scan.readSchema]]. Nested
    * references are rejected, and attributes pruned out of the read schema 
fail to resolve, when
-   * Spark builds the scan relation.
+   * Spark builds the scan relation. Spark cannot currently represent an 
individual fully pushed
+   * nested path. A scan must not return the root struct as a substitute 
unless it can fully
+   * evaluate predicates over every nested field, since Spark would remove 
their post-scan
+   * evaluation as well.
    */
   def fullyPushedFilterAttributes(): Array[NamedReference] = Array.empty
 
@@ -76,9 +78,9 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
    * Implementations may use the expressions to prune initially planned
    * [[org.apache.spark.sql.connector.read.InputPartition]]s.
    *
-   * An expression may access nested fields of an attribute returned by 
[[filterAttributes]], as
-   * that attribute is required to be top-level. The scan is responsible for 
matching such
-   * accesses against its own partition layout.
+   * Spark tracks runtime-filter eligibility by root attribute. If 
[[filterAttributes]] returns a
+   * nested reference, an expression may access another nested field under the 
same root. The scan
+   * must match each access against its own partition layout and use only 
expressions it can apply.
    *
    * Spark may call this method more than once for the same scan instance: a 
plan can hold several
    * scan nodes sharing one scan (e.g. the two branches of a group-based 
UPDATE), and each pushes
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
index 06e4158edbe5..ed7df753bc0a 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
@@ -780,7 +780,9 @@ abstract class InMemoryBaseTable(
     private def partitionAttributes: Seq[(Seq[String], AttributeReference)] = {
       partitioning.flatMap(_.references()).flatMap { ref =>
         val path = ref.fieldNames.toImmutableArraySeq
-        
readSchema.findNestedField(path).orElse(tableSchema.findNestedField(path)).map {
+        val resolver = SQLConf.get.resolver
+        readSchema.findNestedField(path, resolver = resolver)
+          .orElse(tableSchema.findNestedField(path, resolver = resolver)).map {
           case (_, f) =>
             path -> AttributeReference(ref.fieldNames.mkString("."), 
f.dataType, f.nullable)()
         }
@@ -837,9 +839,9 @@ abstract class InMemoryBaseTable(
     var pushedFilters: Array[Filter] = Array.empty
 
     override def filterAttributes(): Array[NamedReference] = {
-      val scanFields = readSchema.fields.map(_.name).toSet
       partitioning.flatMap(_.references)
-        .filter(ref => scanFields.contains(ref.fieldNames.mkString(".")))
+        .filter(ref => readSchema.findNestedField(
+          ref.fieldNames.toImmutableArraySeq, resolver = 
SQLConf.get.resolver).isDefined)
     }
 
     override def filter(filters: Array[Filter]): Unit = {
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryCatalystRuntimeFilterTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryCatalystRuntimeFilterTable.scala
index 369e8123f1d7..58f07307dcbe 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryCatalystRuntimeFilterTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryCatalystRuntimeFilterTable.scala
@@ -23,8 +23,9 @@ import InMemoryCatalystRuntimeFilterTable._
 
 import org.apache.spark.sql.connector.catalog.constraints.Constraint
 import org.apache.spark.sql.connector.distributions.{Distribution, 
Distributions}
-import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference, SortOrder, Transform}
+import org.apache.spark.sql.connector.expressions.{NamedReference, SortOrder, 
Transform}
 import org.apache.spark.sql.connector.read.{InputPartition, Scan, ScanBuilder}
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.StructType
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.util.ArrayImplicits._
@@ -90,29 +91,23 @@ class InMemoryCatalystRuntimeFilterTable(
       .map(_.split(",").map(_.trim).toSet)
       .getOrElse(Set.empty)
 
-    /**
-     * The partition columns, each named by the top level read schema column 
it lives under, the
-     * form both interface methods require. Columns pruned out of the read 
schema are dropped,
-     * since neither method may name one. Examples:
-     *   - `PARTITIONED BY (part)` -> `"part"`
-     *   - `PARTITIONED BY (s.nested)` -> `"s"`, the struct column holding the 
partition field
-     */
-    private def partitionAttrNames: Array[String] = {
-      val scanFields = readSchema.fields.map(_.name).toSet
-      partitioning.flatMap(_.references()).map(_.fieldNames.head).distinct
-        .filter(scanFields.contains)
+    /** Partition source columns that are present in the scan read schema. */
+    private def partitionAttrs: Array[NamedReference] = {
+      partitioning.flatMap(_.references()).distinct
+        .filter(ref => readSchema.findNestedField(
+          ref.fieldNames.toImmutableArraySeq, resolver = 
SQLConf.get.resolver).isDefined)
     }
 
     override def filterAttributes(): Array[NamedReference] = {
-      partitionAttrNames
-        .filter(name => restrictedFilterAttrs.forall(_.contains(name)))
-        .map(FieldReference.column)
+      partitionAttrs.filter { ref =>
+        restrictedFilterAttrs.forall(_.contains(ref.fieldNames.mkString(".")))
+      }
     }
 
     // Not intersected with `filterAttributes()`, so a table can declare a 
fully pushed attribute
     // that is not a filter attribute, a combination the interface forbids.
     override def fullyPushedFilterAttributes(): Array[NamedReference] = {
-      
partitionAttrNames.filter(fullyPushedFilterAttrs.contains).map(FieldReference.column)
+      partitionAttrs.filter(ref => 
fullyPushedFilterAttrs.contains(ref.fieldNames.mkString(".")))
     }
   }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
index 6cb23d505f78..428a9215dcce 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
@@ -28,6 +28,7 @@ import 
org.apache.spark.sql.connector.expressions.filter.Predicate
 import org.apache.spark.sql.connector.read.{InputPartition, Scan, ScanBuilder}
 import org.apache.spark.sql.connector.write.{BatchWrite, DeltaBatchWrite, 
DeltaWrite, DeltaWriteBuilder, DeltaWriter, DeltaWriterFactory, 
LogicalWriteInfo, PhysicalWriteInfo, RequiresDistributionAndOrdering, 
RowLevelOperation, RowLevelOperationBuilder, RowLevelOperationInfo, 
SupportsDelta, Write, WriteBuilder, WriterCommitMessage}
 import org.apache.spark.sql.connector.write.RowLevelOperation.Command
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.StructType
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.unsafe.types.UTF8String
@@ -310,9 +311,9 @@ class InMemoryRowLevelOperationTable private (
     with CatalystRuntimeFilteringScan {
 
     override def filterAttributes(): Array[NamedReference] = {
-      val scanFields = readSchema.fields.map(_.name).toSet
       partitioning.flatMap(_.references())
-        .filter(ref => scanFields.contains(ref.fieldNames.mkString(".")))
+        .filter(ref => readSchema.findNestedField(
+          ref.fieldNames.toImmutableArraySeq, resolver = 
SQLConf.get.resolver).isDefined)
     }
   }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2Filter.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2Filter.scala
index b59a81cc2909..fa50da6732bf 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2Filter.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2Filter.scala
@@ -27,6 +27,7 @@ import 
org.apache.spark.sql.connector.expressions.{FieldReference, LiteralValue,
 import org.apache.spark.sql.connector.expressions.filter.{And, Predicate}
 import org.apache.spark.sql.connector.read.{InputPartition, Scan, ScanBuilder, 
SupportsRuntimeV2Filtering}
 import org.apache.spark.sql.connector.write.{LogicalWriteInfo, 
SupportsOverwriteV2, WriteBuilder, WriterCommitMessage}
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.StructType
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.util.ArrayImplicits._
@@ -77,9 +78,9 @@ class InMemoryTableWithV2Filter(
     extends BatchScanBaseClass(_data, readSchema, tableSchema) with 
SupportsRuntimeV2Filtering {
 
     override def filterAttributes(): Array[NamedReference] = {
-      val scanFields = readSchema.fields.map(_.name).toSet
       partitioning.flatMap(_.references)
-        .filter(ref => scanFields.contains(ref.fieldNames.mkString(".")))
+        .filter(ref => readSchema.findNestedField(
+          ref.fieldNames.toImmutableArraySeq, resolver = 
SQLConf.get.resolver).isDefined)
     }
 
     override def filter(filters: Array[Predicate]): Unit = {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala
index ae7b432d72f5..b17e224c4a5a 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.execution.dynamicpruning
 
 import org.apache.spark.sql.AnalysisException
-import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeReference, DynamicPruningExpression, Expression, InSubquery, 
ListQuery, PredicateHelper, V2ExpressionUtils}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
AttributeMap, AttributeReference, DynamicPruningExpression, Expression, 
InSubquery, ListQuery, NamedExpression, PredicateHelper, V2ExpressionUtils}
 import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
 import org.apache.spark.sql.catalyst.optimizer.RewritePredicateSubquery
 import org.apache.spark.sql.catalyst.planning.{DeltaBasedRowLevelOperation, 
GroupBasedRowLevelOperation}
@@ -97,8 +97,9 @@ class 
RowLevelOperationRuntimeGroupFiltering(optimizeSubqueries: Rule[LogicalPla
         val relation = r.relation.copy(table = originalTable)
         val matchingRowsPlan = buildMatchingRowsPlan(write, relation, cond)
         val filterAttrsSeq = filterAttrs.toImmutableArraySeq
-        val buildKeys = 
V2ExpressionUtils.resolveRefs[Attribute](filterAttrsSeq, matchingRowsPlan)
-        val pruningKeys = 
V2ExpressionUtils.resolveRefs[Attribute](filterAttrsSeq, r)
+        val buildKeys =
+          V2ExpressionUtils.resolveRefs[NamedExpression](filterAttrsSeq, 
matchingRowsPlan)
+        val pruningKeys = 
V2ExpressionUtils.resolveRefs[NamedExpression](filterAttrsSeq, r)
         Filter(buildDynamicPruningCond(matchingRowsPlan, buildKeys, 
pruningKeys), r)
     }
     // optimize subqueries to rewrite them as joins and trigger job planning
@@ -143,13 +144,19 @@ class 
RowLevelOperationRuntimeGroupFiltering(optimizeSubqueries: Rule[LogicalPla
 
   private def buildDynamicPruningCond(
       matchingRowsPlan: LogicalPlan,
-      buildKeys: Seq[Attribute],
-      pruningKeys: Seq[Attribute]): Expression = {
+      buildKeys: Seq[NamedExpression],
+      pruningKeys: Seq[NamedExpression]): Expression = {
     assert(buildKeys.nonEmpty && pruningKeys.nonEmpty)
 
-    val buildQuery = Aggregate(buildKeys, buildKeys, matchingRowsPlan)
+    def unalias(expr: NamedExpression): Expression = expr match {
+      case alias: Alias => alias.child
+      case other => other
+    }
+
+    val buildQuery = Aggregate(buildKeys.map(unalias), buildKeys, 
matchingRowsPlan)
     DynamicPruningExpression(
-      InSubquery(pruningKeys, ListQuery(buildQuery, numCols = 
buildQuery.output.length)))
+      InSubquery(pruningKeys.map(unalias),
+        ListQuery(buildQuery, numCols = buildQuery.output.length)))
   }
 
   private def buildTableToScanAttrMap(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
index c0a1faa03263..11e7461bc7f1 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
@@ -116,6 +116,117 @@ class DataSourceV2CatalystRuntimeFilterSuite extends 
SharedSparkSession {
     }
   }
 
+  test("nested fully pushed filter attribute -> rejected without a runtime 
filter") {
+    val tbl = s"$catalogName.tbl_nested_fully_pushed"
+    withTable(tbl) {
+      sql(s"CREATE TABLE $tbl (id INT, s STRUCT<part: INT, other: INT>) USING 
$v2Source " +
+        "PARTITIONED BY (s.part) " +
+        "TBLPROPERTIES('fully-pushed-filter-attributes' = 's.part')")
+
+      val df = sql(s"SELECT * FROM $tbl")
+      val scanClass = df.queryExecution.optimizedPlan.collectFirst {
+        case r: DataSourceV2ScanRelation => r.scan.getClass.getName
+      }.getOrElse(fail("Expected a DataSourceV2ScanRelation"))
+      val e = intercept[AnalysisException] {
+        df.queryExecution.executedPlan
+      }
+      checkError(
+        exception = e,
+        condition = 
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.NOT_TOP_LEVEL",
+        parameters = Map(
+          "attribute" -> "`s`.`part`",
+          "method" -> "fullyPushedFilterAttributes()",
+          "scanClass" -> scanClass,
+          "relationOutput" -> "\"STRUCT<id: INT, s: STRUCT<part: INT, other: 
INT>>\""),
+        sqlState = "KD000")
+    }
+  }
+
+  test("nested filter attribute under a non-struct column -> rejected during 
resolution") {
+    val tbl = s"$catalogName.tbl_malformed_nested_filter_attr"
+    withTable(tbl) {
+      sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED 
BY (part)")
+
+      val scanRelation = sql(s"SELECT * FROM 
$tbl").queryExecution.optimizedPlan.collectFirst {
+        case r: DataSourceV2ScanRelation => r
+      }.getOrElse(fail("Expected a DataSourceV2ScanRelation"))
+      val e = intercept[AnalysisException] {
+        scanRelation.copy(scan = new 
NestedFilterAttributeScan).runtimeFilterAttrs
+      }
+      val scanClass = classOf[NestedFilterAttributeScan].getName
+      checkError(
+        exception = e,
+        condition = 
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE",
+        parameters = Map(
+          "attribute" -> "`part`.`nested`",
+          "method" -> "filterAttributes()",
+          "scanClass" -> scanClass,
+          "relationOutput" -> "\"STRUCT<id: INT, part: INT>\""),
+        sqlState = "KD000")
+      checkError(
+        exception = e.getCause.asInstanceOf[AnalysisException],
+        condition = "INVALID_EXTRACT_BASE_FIELD_TYPE",
+        parameters = Map("base" -> "\"part\"", "other" -> "\"INT\""))
+    }
+  }
+
+  test("missing filter attribute -> rejected") {
+    val tbl = s"$catalogName.tbl_missing_filter_attr"
+    withTable(tbl) {
+      sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED 
BY (part)")
+
+      val scanRelation = sql(s"SELECT * FROM 
$tbl").queryExecution.optimizedPlan.collectFirst {
+        case r: DataSourceV2ScanRelation => r
+      }.getOrElse(fail("Expected a DataSourceV2ScanRelation"))
+      val e = intercept[AnalysisException] {
+        scanRelation.copy(scan = new 
MissingFilterAttributeScan).runtimeFilterAttrs
+      }
+      val scanClass = classOf[MissingFilterAttributeScan].getName
+      checkError(
+        exception = e,
+        condition = 
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE",
+        parameters = Map(
+          "attribute" -> "`missing`",
+          "method" -> "filterAttributes()",
+          "scanClass" -> scanClass,
+          "relationOutput" -> "\"STRUCT<id: INT, part: INT>\""),
+        sqlState = "KD000")
+      checkError(
+        exception = e.getCause.asInstanceOf[AnalysisException],
+        condition = "_LEGACY_ERROR_TEMP_1137",
+        parameters = Map("name" -> "missing", "outputStr" -> "id,part"))
+    }
+  }
+
+  test("missing fully pushed filter attribute -> identifies the declaring 
method") {
+    val tbl = s"$catalogName.tbl_missing_fully_pushed_filter_attr"
+    withTable(tbl) {
+      sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED 
BY (part)")
+
+      val scanRelation = sql(s"SELECT * FROM 
$tbl").queryExecution.optimizedPlan.collectFirst {
+        case r: DataSourceV2ScanRelation => r
+      }.getOrElse(fail("Expected a DataSourceV2ScanRelation"))
+      val e = intercept[AnalysisException] {
+        scanRelation.copy(scan = new MissingFullyPushedFilterAttributeScan)
+          .fullyPushedRuntimeFilterAttrs
+      }
+      val scanClass = classOf[MissingFullyPushedFilterAttributeScan].getName
+      checkError(
+        exception = e,
+        condition = 
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE",
+        parameters = Map(
+          "attribute" -> "`missing`",
+          "method" -> "fullyPushedFilterAttributes()",
+          "scanClass" -> scanClass,
+          "relationOutput" -> "\"STRUCT<id: INT, part: INT>\""),
+        sqlState = "KD000")
+      checkError(
+        exception = e.getCause.asInstanceOf[AnalysisException],
+        condition = "_LEGACY_ERROR_TEMP_1137",
+        parameters = Map("name" -> "missing", "outputStr" -> "id,part"))
+    }
+  }
+
   test("non-deterministic predicate on fully pushed attributes -> evaluated 
after the scan") {
     val tbl = s"$catalogName.tbl_nondeterministic"
     val dim = s"$catalogName.dim_nondeterministic"
@@ -244,40 +355,64 @@ class DataSourceV2CatalystRuntimeFilterSuite extends 
SharedSparkSession {
     }
   }
 
-  test("DPP filter on a nested partition field -> pushed with the nested 
access intact") {
+  test("DPP filter on a nested partition source -> pushed with the nested 
access intact") {
     val fact = s"$catalogName.fact_nested_dpp"
     val dim = s"$catalogName.dim_nested_dpp"
     withTable(fact, dim) {
-      sql(s"CREATE TABLE $fact (id INT, s STRUCT<part: INT>) USING $v2Source " 
+
-        "PARTITIONED BY (s.part)")
-      for (i <- 0 until 5) {
-        sql(s"INSERT INTO $fact VALUES ($i, named_struct('part', $i))")
-      }
-      sql(s"CREATE TABLE $dim (dim_id INT, dim_val STRING) USING $v2Source")
-      sql(s"INSERT INTO $dim VALUES (2, 'two')")
+      sql(s"CREATE TABLE $fact " +
+        "(id INT, derives STRUCT<toStr: STRING, other: STRING>) USING " +
+        s"$v2Source PARTITIONED BY (derives.toStr)")
+      sql(s"INSERT INTO $fact VALUES " +
+        "(1, named_struct('toStr', 'AA', 'other', 'a')), " +
+        "(2, named_struct('toStr', 'BB', 'other', 'b')), " +
+        "(3, named_struct('toStr', 'CC', 'other', 'c'))")
+      sql(s"CREATE TABLE $dim (value STRING, selected INT) USING $v2Source")
+      sql(s"INSERT INTO $dim VALUES ('AA', 0), ('BB', 1)")
 
       withDPPConf {
         val df = sql(
           s"""SELECT f.id FROM $fact f JOIN $dim d
-             |ON f.s.part = d.dim_id WHERE d.dim_val = 'two'""".stripMargin)
+             |ON f.derives.toStr = d.value WHERE d.selected = 1""".stripMargin)
         checkAnswer(df, Row(2))
 
         assertDPPRuntimeFilters(df)
-        // The scan only reports `s`, the struct holding the partition field, 
but the predicate it
-        // receives keeps the nested access, so it can still tell which 
partition to keep.
+        // The scan reports `derives.toStr`, and the predicate it receives 
keeps that nested access.
         val pushed = getPushedCatalystPredicates(df)
         assert(pushed.size === 1, s"expected a single pushed predicate, got 
$pushed")
         assert(pushed.head.exists(_.isInstanceOf[GetStructField]),
           s"expected the pushed predicate to keep the nested access, got 
${pushed.head}")
 
         val batchScan = collectBatchScan(df)
-        assert(batchScan.inputPartitions.size === 5)
+        assert(batchScan.inputPartitions.size === 3)
         assert(batchScan.filteredPartitions.flatten.size === 1,
           s"expected 1 partition after pruning, got 
${batchScan.filteredPartitions.flatten.size}")
       }
     }
   }
 
+  test("sibling of a nested filter attribute remains evaluated after the 
scan") {
+    val fact = s"$catalogName.fact_nested_sibling"
+    val dim = s"$catalogName.dim_nested_sibling"
+    withTable(fact, dim) {
+      sql(s"CREATE TABLE $fact (id INT, s STRUCT<part: INT, other: INT>) USING 
$v2Source " +
+        "PARTITIONED BY (s.part)")
+      sql(s"INSERT INTO $fact VALUES " +
+        "(1, named_struct('part', 1, 'other', 10)), " +
+        "(2, named_struct('part', 1, 'other', 20)), " +
+        "(3, named_struct('part', 2, 'other', 30))")
+      sql(s"CREATE TABLE $dim (value INT) USING $v2Source")
+      sql(s"INSERT INTO $dim VALUES (10)")
+
+      val df = sql(s"SELECT id FROM $fact WHERE s.other = (SELECT max(value) 
FROM $dim)")
+      checkAnswer(df, Row(1))
+
+      // A nested reference currently contributes its root attribute to 
eligibility, so `s.other`
+      // may be routed to a scan that advertises `s.part`. It must remain 
above the scan unless
+      // eligibility becomes path-aware.
+      assertScalarSubqueryEvaluatedAfterScan(df, expected = true)
+    }
+  }
+
   test("scan implementing both runtime filtering interfaces -> rejected") {
     val tbl = s"$catalogName.tbl_both_interfaces"
     withTable(tbl) {
@@ -364,9 +499,9 @@ class DataSourceV2CatalystRuntimeFilterSuite extends 
SharedSparkSession {
       sql(s"CREATE TABLE $dim (val STRING) USING $v2Source")
       sql(s"INSERT INTO $dim VALUES ('tz1')")
 
-      // The scan declares the top-level struct column `s` as its filter 
attribute, so the
-      // predicate qualifies for pushdown even though it reaches into `s.tz`. 
Matching the nested
-      // access against the partition layout is left to the scan, which this 
fixture does.
+      // The scan declares the nested partition source `s.tz` as its filter 
attribute. The
+      // predicate arrives with the nested access intact, and matching it 
against the partition
+      // layout is left to the scan, which this fixture does.
       val df = sql(s"SELECT * FROM $tbl WHERE s.tz = (SELECT max(val) FROM 
$dim)")
       checkAnswer(df, Row(1, Row("tz1")))
 
@@ -414,46 +549,6 @@ class DataSourceV2CatalystRuntimeFilterSuite extends 
SharedSparkSession {
     }
   }
 
-  test("filterAttributes that is not a top-level scan attribute") {
-    val tbl = s"$catalogName.tbl_unresolvable_attr"
-    withTable(tbl) {
-      sql(s"CREATE TABLE $tbl (id INT, part INT, s STRUCT<tz: STRING>) USING 
$v2Source " +
-        "PARTITIONED BY (part)")
-      sql(s"INSERT INTO $tbl VALUES (1, 1, named_struct('tz', 'a'))")
-
-      val scanRelation = sql(s"SELECT * FROM 
$tbl").queryExecution.optimizedPlan.collectFirst {
-        case r: DataSourceV2ScanRelation => r
-      }.getOrElse(fail("Expected a DataSourceV2ScanRelation"))
-
-      // An attribute the read schema does not carry, such as one pruned out 
of the projection.
-      val missing = intercept[AnalysisException] {
-        scanRelation.copy(scan = new 
MissingFilterAttributeScan).runtimeFilterAttrs
-      }
-      checkError(
-        exception = missing,
-        condition = "_LEGACY_ERROR_TEMP_1137",
-        parameters = Map("name" -> "missing", "outputStr" -> "id,part,s"))
-
-      // A nested reference is rejected up front, since `filterAttributes()` 
must return
-      // top-level read-schema attributes. This holds even over an int column 
that could never
-      // carry a nested field.
-      val nested = intercept[SparkException] {
-        scanRelation.copy(scan = new 
NestedFilterAttributeScan).runtimeFilterAttrs
-      }
-      assert(nested.getMessage.contains("must be a top-level attribute"),
-        s"expected the nested reference to be rejected, got 
${nested.getMessage}")
-
-      // Over a struct it is rejected the same way, rather than widening to 
the struct column:
-      // accepting `s.tz` would make runtime filters over every field of `s` 
eligible, not just
-      // `s.tz`.
-      val struct = intercept[SparkException] {
-        scanRelation.copy(scan = new 
StructNestedFilterAttributeScan).runtimeFilterAttrs
-      }
-      assert(struct.getMessage.contains("must be a top-level attribute"),
-        s"expected the nested struct reference to be rejected, got 
${struct.getMessage}")
-    }
-  }
-
   test("no runtime filter -> filter() is never called") {
     val tbl = s"$catalogName.tbl5"
     withTable(tbl) {
@@ -637,8 +732,8 @@ private class BothRuntimeFilteringInterfacesScan
   override def filter(expressions: Array[Expression]): Unit = {}
 }
 
-/** A scan declaring a filter attribute the read schema does not carry. */
-private class MissingFilterAttributeScan extends Scan with 
SupportsRuntimeCatalystFiltering {
+/** A scan declaring a filter attribute that its read schema does not contain. 
*/
+private class MissingFilterAttributeScan extends 
SupportsRuntimeCatalystFiltering {
 
   override def readSchema(): StructType = new StructType().add("part", 
IntegerType)
 
@@ -647,30 +742,26 @@ private class MissingFilterAttributeScan extends Scan 
with SupportsRuntimeCataly
   override def filter(expressions: Array[Expression]): Unit = {}
 }
 
-/**
- * A scan breaking the rule that a filter attribute must be a top level read 
schema column: it
- * reports `part.nested` over the int column `part`, so it is rejected as a 
nested reference.
- */
-private class NestedFilterAttributeScan extends Scan with 
SupportsRuntimeCatalystFiltering {
+/** A scan declaring a fully pushed filter attribute that its relation output 
does not contain. */
+private class MissingFullyPushedFilterAttributeScan extends 
SupportsRuntimeCatalystFiltering {
 
   override def readSchema(): StructType = new StructType().add("part", 
IntegerType)
 
-  override def filterAttributes(): Array[NamedReference] =
-    Array(FieldReference(Seq("part", "nested")))
+  override def filterAttributes(): Array[NamedReference] = 
Array(FieldReference("part"))
+
+  override def fullyPushedFilterAttributes(): Array[NamedReference] =
+    Array(FieldReference("missing"))
 
   override def filter(expressions: Array[Expression]): Unit = {}
 }
 
-/**
- * A scan breaking the same rule over a struct column: it reports `s.tz` where 
`s` is a struct.
- * The nested reference is rejected rather than widening to the struct column 
`s`.
- */
-private class StructNestedFilterAttributeScan extends Scan with 
SupportsRuntimeCatalystFiltering {
+/** A scan declaring a nested runtime-filter attribute beneath an integer 
column. */
+private class NestedFilterAttributeScan extends 
SupportsRuntimeCatalystFiltering {
 
-  override def readSchema(): StructType =
-    new StructType().add("s", new StructType().add("tz", StringType))
+  override def readSchema(): StructType = new StructType().add("part", 
IntegerType)
 
-  override def filterAttributes(): Array[NamedReference] = 
Array(FieldReference(Seq("s", "tz")))
+  override def filterAttributes(): Array[NamedReference] =
+    Array(FieldReference(Seq("part", "nested")))
 
   override def filter(expressions: Array[Expression]): Unit = {}
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
index 76c7902188ed..cc003d440eb5 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
@@ -32,6 +32,7 @@ import org.apache.spark.sql.catalyst.{InternalRow, 
QualifiedTableName, TableIden
 import org.apache.spark.sql.catalyst.CurrentUserContext.CURRENT_USER
 import 
org.apache.spark.sql.catalyst.analysis.{CannotReplaceMissingTableException, 
NoSuchNamespaceException, TableAlreadyExistsException}
 import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, 
CatalogTable, CatalogTableType, CatalogUtils}
+import org.apache.spark.sql.catalyst.expressions.{DynamicPruning, 
GetStructField}
 import org.apache.spark.sql.catalyst.parser.ParseException
 import org.apache.spark.sql.catalyst.plans.logical.ColumnStat
 import org.apache.spark.sql.catalyst.statsEstimation.StatsEstimationTestBase
@@ -45,7 +46,7 @@ import org.apache.spark.sql.execution.FilterExec
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
 import org.apache.spark.sql.execution.columnar.InMemoryRelation
 import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, 
LogicalRelationWithTable}
-import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2ScanRelation}
 import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
 import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
 import org.apache.spark.sql.internal.SQLConf.{PARTITION_OVERWRITE_MODE, 
PartitionOverwriteMode, V2_SESSION_CATALOG_IMPLEMENTATION}
@@ -139,6 +140,44 @@ abstract class DataSourceV2SQLSuite
     }
   }
 
+  test("nested partition source column receives a DPP runtime filter") {
+    val fact = s"${catalogAndNamespace}fact_nested_runtime_filter"
+    val dim = s"${catalogAndNamespace}dim_nested_runtime_filter"
+    withTable(fact, dim) {
+      sql(s"CREATE TABLE $fact " +
+        "(id INT, derives STRUCT<toStr: STRING, other: STRING>) USING " +
+        s"$v2Format PARTITIONED BY (derives.toStr)")
+      sql(s"INSERT INTO $fact VALUES " +
+        "(1, named_struct('toStr', 'AA', 'other', 'a')), " +
+        "(2, named_struct('toStr', 'BB', 'other', 'b')), " +
+        "(3, named_struct('toStr', 'CC', 'other', 'c'))")
+      sql(s"CREATE TABLE $dim (value STRING, selected INT) USING $v2Format")
+      sql(s"INSERT INTO $dim VALUES ('AA', 0), ('BB', 1)")
+
+      withSQLConf(
+        SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+        SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+        SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+        val df = sql(
+          s"""SELECT f.id FROM $fact f JOIN $dim d
+             |ON f.derives.toStr = d.value WHERE d.selected = 1""".stripMargin)
+        checkAnswer(df, Row(2))
+
+        val batchScans = collect(df.queryExecution.executedPlan) {
+          case b: BatchScanExec if b.runtimeFilters.nonEmpty => b
+        }
+        assert(batchScans.nonEmpty,
+          s"expected a scan with runtime filters, got ${df.queryExecution}")
+        val batchScan = batchScans.head
+        
assert(batchScan.runtimeFilters.exists(_.exists(_.isInstanceOf[GetStructField])),
+          s"expected a runtime filter on derives.toStr, got 
${batchScan.runtimeFilters}")
+        assert(batchScan.partitions.size === 3)
+        assert(batchScan.filteredPartitions.flatten.size === 1,
+          s"expected 1 partition after pruning, got 
${batchScan.filteredPartitions.flatten.size}")
+      }
+    }
+  }
+
   private def checkExplain(query: String, relationPattern: Regex): Unit = {
     val explain = spark.sql(s"EXPLAIN EXTENDED $query").head().getString(0)
     val relations = explain.split("\n").filter(_.contains("RelationV2"))
@@ -5440,9 +5479,6 @@ class DataSourceV2SQLSuiteV1Filter
 }
 
 class DataSourceV2SQLSuiteV2Filter extends DataSourceV2SQLSuite {
-  import org.apache.spark.sql.catalyst.expressions.DynamicPruning
-  import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
-
   override protected val catalogAndNamespace = "testv2filter.ns1.ns2."
 
   test("SPARK-56467: scalar subquery filters on partition columns are pushed 
into runtimeFilters") {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedRowLevelOperationCatalystRuntimeFilterSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedRowLevelOperationCatalystRuntimeFilterSuite.scala
index 34b43e2354ab..becdde5aa2f4 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedRowLevelOperationCatalystRuntimeFilterSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedRowLevelOperationCatalystRuntimeFilterSuite.scala
@@ -19,6 +19,8 @@ package org.apache.spark.sql.connector
 
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.connector.catalog.InMemoryTable
+import 
org.apache.spark.sql.connector.expressions.LogicalExpressions.{identity, 
reference}
+import org.apache.spark.sql.connector.expressions.Transform
 import org.apache.spark.sql.connector.write.DeleteSummary
 
 class GroupBasedRowLevelOperationCatalystRuntimeFilterSuite
@@ -49,6 +51,35 @@ class GroupBasedRowLevelOperationCatalystRuntimeFilterSuite
     checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 1)
   }
 
+  test("delete runtime group filtering by a nested attribute") {
+    val schema = "pk INT NOT NULL, id INT, salary INT, " +
+      "dep STRUCT<name: STRING, region: STRING>"
+    createTable(schema, Array[Transform](identity(reference(Seq("dep", 
"name")))))
+    append(schema,
+      """{"pk":1,"id":1,"salary":300,"dep":{"name":"hr","region":"west"}}
+        |{"pk":2,"id":2,"salary":150,"dep":{"name":"software","region":"west"}}
+        |{"pk":3,"id":3,"salary":120,"dep":{"name":"hr","region":"east"}}
+        |""".stripMargin)
+
+    val executedPlan = executeAndKeepPlan {
+      sql(s"DELETE FROM $tableNameAsString WHERE salary IN (300, 400, 500)")
+    }
+    assertCatalystGroupFilter(
+      executedPlan,
+      expectedFilterAttrs = Seq("dep.name"),
+      expectedFilter = GroupFilter(
+        scanSchema = "salary INT, dep STRUCT<name: STRING>", groups = 
Seq("hr")),
+      expectedFilterPaths = Some(Seq(Seq("dep", "name"))))
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(2, 2, 150, Row("software", "west")) ::
+        Row(3, 3, 120, Row("hr", "east")) :: Nil)
+
+    checkReplacedPartitions(Seq("hr"))
+    checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 1)
+  }
+
   private def checkDeleteMetrics(numDeletedRows: Long, numCopiedRows: Long): 
Unit = {
     val t = catalog.loadTable(ident).asInstanceOf[InMemoryTable]
     val summary = t.commits.last.writeSummary.get.asInstanceOf[DeleteSummary]
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationCatalystRuntimeFilterSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationCatalystRuntimeFilterSuiteBase.scala
index 810131eb54ce..43ed7797fb5b 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationCatalystRuntimeFilterSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationCatalystRuntimeFilterSuiteBase.scala
@@ -18,9 +18,11 @@
 package org.apache.spark.sql.connector
 
 import org.apache.spark.sql.Row
-import org.apache.spark.sql.catalyst.expressions.DynamicPruningExpression
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
CreateNamedStruct, DynamicPruningExpression, Expression, GetStructFieldObject}
 import org.apache.spark.sql.catalyst.types.DataTypeUtils
 import org.apache.spark.sql.connector.catalog.{BufferedRows, 
InMemoryRowLevelOperationTable}
+import 
org.apache.spark.sql.connector.expressions.LogicalExpressions.{identity, 
reference}
+import org.apache.spark.sql.connector.expressions.Transform
 import org.apache.spark.sql.execution.InSubqueryExec
 import org.apache.spark.sql.execution.ReusedSubqueryExec
 import org.apache.spark.sql.execution.SparkPlan
@@ -121,6 +123,43 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
     }
   }
 
+  test("merge runtime group filtering by a nested attribute") {
+    withTempView("source") {
+      val schema = "pk INT NOT NULL, id INT, salary INT, " +
+        "dep STRUCT<name: STRING, region: STRING>"
+      createTable(schema, Array[Transform](identity(reference(Seq("dep", 
"name")))))
+      append(schema,
+        """{"pk":1,"id":1,"salary":100,"dep":{"name":"hr","region":"west"}}
+          |{"pk":2,"id":2,"salary":200,"dep":{"name":"hr","region":"east"}}
+          
|{"pk":3,"id":3,"salary":300,"dep":{"name":"software","region":"west"}}
+          |""".stripMargin)
+
+      Seq(1, 2).toDF("pk").createOrReplaceTempView("source")
+
+      val executedPlan = executeAndKeepPlan {
+        sql(
+          s"""MERGE INTO $tableNameAsString t
+             |USING source s
+             |ON t.pk = s.pk
+             |WHEN MATCHED THEN
+             | UPDATE SET t.salary = t.salary + 1
+             |""".stripMargin)
+      }
+      assertCatalystGroupFilter(
+        executedPlan,
+        expectedFilterAttrs = Seq("dep.name"),
+        expectedFilter = GroupFilter(
+          scanSchema = "pk INT, dep STRUCT<name: STRING>", groups = Seq("hr")),
+        expectedFilterPaths = Some(Seq(Seq("dep", "name"))))
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Row(1, 1, 101, Row("hr", "west")) ::
+          Row(2, 2, 201, Row("hr", "east")) ::
+          Row(3, 3, 300, Row("software", "west")) :: Nil)
+    }
+  }
+
   /**
    * Asserts the injected group filter down to its contents: the scan declares
    * `expectedFilterAttrs` in `filterAttributes`, every scan node carries one 
dynamic pruning
@@ -137,7 +176,8 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
   protected def assertCatalystGroupFilter(
       executedPlan: SparkPlan,
       expectedFilterAttrs: Seq[String],
-      expectedFilter: GroupFilter): Unit = {
+      expectedFilter: GroupFilter,
+      expectedFilterPaths: Option[Seq[Seq[String]]] = None): Unit = {
     val batchScans = collect(executedPlan) { case s: BatchScanExec => s }
     assert(batchScans.nonEmpty, "expected a batch scan for the row-level 
operation")
     val scan = catalystScan(batchScans.head)
@@ -147,11 +187,12 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
     val filterAttrs = 
scan.filterAttributes().map(_.fieldNames.mkString(".")).toSeq
     assert(filterAttrs === expectedFilterAttrs,
       s"expected the scan to declare $expectedFilterAttrs as filter 
attributes, got $filterAttrs")
+    val filterPaths = 
expectedFilterPaths.getOrElse(expectedFilterAttrs.map(Seq(_)))
 
     batchScans.foreach { batchScan =>
       batchScan.runtimeFilters match {
         case Seq(DynamicPruningExpression(inSubquery: InSubqueryExec)) =>
-          assertGroupFilter(inSubquery, expectedFilterAttrs, expectedFilter)
+          assertGroupFilter(inSubquery, filterPaths, expectedFilter)
         case other => fail(s"expected a single dynamic pruning group filter, 
got $other")
       }
     }
@@ -163,7 +204,7 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
       s"expected each of the ${batchScans.size} scan node(s) to push the 
filter once, got $pushed")
     pushed.foreach {
       case inSubquery: InSubqueryExec =>
-        assertGroupFilter(inSubquery, expectedFilterAttrs, expectedFilter)
+        assertGroupFilter(inSubquery, filterPaths, expectedFilter)
       case other =>
         fail(s"expected the group filter pushed as an InSubqueryExec, got 
$other")
     }
@@ -181,10 +222,11 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
 
   private def assertGroupFilter(
       filter: InSubqueryExec,
-      expectedFilterAttrs: Seq[String],
+      expectedFilterPaths: Seq[Seq[String]],
       expectedFilter: GroupFilter): Unit = {
-    assert(filter.child.references.toSeq.map(_.name) === expectedFilterAttrs,
-      s"expected the group filter keyed on $expectedFilterAttrs, got 
${filter.child}")
+    assert(fieldPaths(filter.child).contains(expectedFilterPaths),
+      s"expected the group filter keyed on 
${expectedFilterPaths.map(_.mkString("."))}, " +
+        s"got ${filter.child}")
 
     // the second branch of a group-based UPDATE reuses the first branch's 
subquery, and
     // ReusedSubqueryExec is a leaf node, so unwrap it to reach the plan 
underneath
@@ -205,6 +247,19 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
       s"group filter must select the groups holding matching rows, got 
${groups.mkString(", ")}")
   }
 
+  private def fieldPath(expr: Expression): Option[Seq[String]] = expr match {
+    case attr: Attribute => Some(Seq(attr.name))
+    case GetStructFieldObject(child, field) => fieldPath(child).map(_ :+ 
field.name)
+    case _ => None
+  }
+
+  private def fieldPaths(expr: Expression): Option[Seq[Seq[String]]] = expr 
match {
+    case struct: CreateNamedStruct =>
+      val paths = struct.valExprs.map(fieldPath)
+      Option.when(paths.forall(_.isDefined))(paths.flatten)
+    case _ => fieldPath(expr).map(Seq(_))
+  }
+
   /** Asserts no group filter was injected, e.g. because the scan does not 
read the group key. */
   protected def assertNoCatalystGroupFilter(executedPlan: SparkPlan): Unit = {
     val batchScan = collect(executedPlan) { case s: BatchScanExec => s }.head
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala
index 21e27e9b6c1c..c62cc48cffa1 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala
@@ -117,8 +117,17 @@ abstract class RowLevelOperationSuiteBase
     createTable(columns)
   }
 
+  protected def createTable(schemaString: String, transforms: 
Array[Transform]): Unit = {
+    val columns = 
CatalogV2Util.structTypeToV2Columns(StructType.fromDDL(schemaString))
+    createTable(columns, transforms)
+  }
+
   protected def createTable(columns: Array[Column]): Unit = {
     val transforms = Array[Transform](identity(reference(Seq("dep"))))
+    createTable(columns, transforms)
+  }
+
+  protected def createTable(columns: Array[Column], transforms: 
Array[Transform]): Unit = {
     val tableInfo = new TableInfo.Builder()
       .withColumns(columns)
       .withPartitions(transforms)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to