dongjoon-hyun commented on code in PR #58370:
URL: https://github.com/apache/spark/pull/58370#discussion_r3884760509


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala:
##########
@@ -143,13 +144,26 @@ 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)
+    // Nested references resolve to aliases over field-extraction expressions. 
Materialize those
+    // aliases before aggregation so grouping-expression cleanup preserves the 
subquery schema.
+    val buildKeyAliases = buildKeys.collect { case alias: Alias => alias }
+    val buildPlan = if (buildKeyAliases.nonEmpty) {
+      Project(matchingRowsPlan.output ++ buildKeyAliases, matchingRowsPlan)
+    } else {
+      matchingRowsPlan
+    }
+    val buildKeyAttrs = buildKeys.map(_.toAttribute)
+    val buildQuery = Aggregate(buildKeyAttrs, buildKeyAttrs, buildPlan)

Review Comment:
   I gave this a try without the manual `Project`, using the shape the analyzer 
itself produces for `GROUP BY s.a`:
   
   ```scala
   def unalias(e: NamedExpression): Expression = e match {
     case alias: Alias => alias.child
     case other => other
   }
   val buildQuery = Aggregate(buildKeys.map(unalias), buildKeys, 
matchingRowsPlan)
   DynamicPruningExpression(
     InSubquery(pruningKeys.map(unalias), ListQuery(buildQuery, numCols = 
buildQuery.output.length)))
   ```
   
   The row-level suites all still pass with that: 
`GroupBasedRowLevelOperationCatalystRuntimeFilterSuite`, 
`DeltaBasedRowLevelOperationCatalystRuntimeFilterSuite`, 
`GroupBasedDeleteFromTableSuite`, `DeltaBasedDeleteFromTableSuite`, 
`GroupBasedUpdateTableSuite`, `GroupBasedMergeIntoTableSuite` -- 236/236, 
including your new nested DELETE test.
   
   My reading is that `PullOutGroupingExpressions` already covers this: it is 
in the `Finish Analysis` batch, `OptimizeSubqueries` re-runs the whole 
optimizer on the subquery, and it inserts the equivalent `Project` while 
preserving the alias expr IDs. If the hand-rolled version is guarding against 
something my run does not reach, could the comment name it? Otherwise the 
simpler form seems nicer.
   
   Minor either way: if it stays, `Project(buildKeys, matchingRowsPlan)` is 
enough -- carrying all of `matchingRowsPlan.output` is not needed to build 
`buildKeyAttrs`.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedRowLevelOperationCatalystRuntimeFilterSuite.scala:
##########
@@ -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")),
+      expectedFilterRefs = Seq("dep"))

Review Comment:
   Nice test. One coverage thought: this is the only nested row-level case, and 
it only exercises the group-based DELETE path. Delta-based and MERGE reach 
`buildDynamicPruningCond` with different `matchingRowsPlan` shapes -- MERGE 
goes through `RewritePredicateSubquery`, so the new `Project` would land over a 
join. Since that is the genuinely new code in this PR, one more nested case 
there would be reassuring.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationCatalystRuntimeFilterSuiteBase.scala:
##########
@@ -137,7 +137,8 @@ abstract class 
RowLevelOperationCatalystRuntimeFilterSuiteBase
   protected def assertCatalystGroupFilter(
       executedPlan: SparkPlan,
       expectedFilterAttrs: Seq[String],
-      expectedFilter: GroupFilter): Unit = {
+      expectedFilter: GroupFilter,
+      expectedFilterRefs: Seq[String] = Seq.empty): Unit = {

Review Comment:
   Nit: `Seq.empty` meaning "fall back to `expectedFilterAttrs`" took me a 
second -- `Option[Seq[String]] = None` would say that more directly.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala:
##########
@@ -290,12 +286,22 @@ 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 _: SupportsRuntimeCatalystFiltering =>
+        declaredFullyPushedRuntimeFilterAttrs.find(_.fieldNames.length > 
1).foreach { ref =>
+          throw SparkException.internalError(
+            "Fully pushed runtime filter attribute " +
+            s"'${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.")
+        }

Review Comment:
   Small readability thought: this method now does two fairly different jobs -- 
validating the interface combination, and validating the shape of the 
fully-pushed references. Since `DataSourceV2Strategy` always touches 
`runtimeFilterAttrs`, it also means `fullyPushedFilterAttributes()` is now 
called on every Catalyst-filtering scan at planning time, even when nothing is 
being filtered.
   
   The behavior is fine (and your new test depends on it), but would a separate 
`checkFullyPushedFilterAttrs()` called from both lazy vals read better? The 
current name no longer quite describes what happens inside.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryCatalystRuntimeFilterTable.scala:
##########
@@ -90,29 +91,21 @@ 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.toString)))

Review Comment:
   Tiny inconsistency: this fixture matches the table properties against 
`ref.toString`, which back-quotes any name needing quoting, while 
`InMemoryBaseTable`, `InMemoryTableWithV2Filter`, 
`InMemoryRowLevelOperationTable` and `assertCatalystGroupFilter` all use 
`fieldNames.mkString(".")`. A column such as `a b` would silently stop matching 
here (same on line 108). Probably worth picking one spelling.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala:
##########
@@ -116,6 +116,57 @@ 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 e = intercept[SparkException] {
+        sql(s"SELECT * FROM $tbl").queryExecution.executedPlan
+      }
+      assert(e.getMessage.contains("must be a top-level attribute"),
+        s"expected the nested fully pushed reference to be rejected, got 
${e.getMessage}")
+    }
+  }
+
+  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
+      }
+      checkError(
+        exception = e,
+        condition = "INVALID_EXTRACT_BASE_FIELD_TYPE",
+        parameters = Map("base" -> "\"part\"", "other" -> "\"INT\""))

Review Comment:
   Not blocking, but this pins a diagnosability regression that might be worth 
a second look: #58296 rejected this case with an internal error naming the 
offending scan class, and now a connector author gets 
`INVALID_EXTRACT_BASE_FIELD_TYPE` with `base` = `"part"` / `other` = `"INT"`, 
with no hint that runtime filtering or their `filterAttributes()` is involved.
   
   Could `resolveFilterAttrs` catch the resolution failure and re-throw with 
the scan class name attached? Then this test could assert on that instead.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to