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

dongjoon-hyun 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 9686bfff3ea0 [SPARK-60673][SQL] Read all referenced metadata columns 
in DSv2 DELETE, UPDATE and MERGE
9686bfff3ea0 is described below

commit 9686bfff3ea06ad00c0416e5bee48fa5fbfd0d99
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Thu Oct 29 06:44:26 2026 -0700

    [SPARK-60673][SQL] Read all referenced metadata columns in DSv2 DELETE, 
UPDATE and MERGE
    
    ### What changes were proposed in this pull request?
    
    This PR makes the rewrite of DSv2 `DELETE`, `UPDATE` and `MERGE` read every 
target metadata column that the command references, not only the ones that the 
row-level operation requests through `requiredMetadataAttributes()` and, for 
delta-based operations, `rowId()`.
    
    `RewriteRowLevelCommand.buildRelationWithAttrs` builds the read relation 
from the data columns, the row ID columns and the required metadata columns. 
This relation reads `RowLevelOperationTable`, which exposes no metadata 
columns, so `AddMetadataColumns` cannot add a referenced metadata column after 
the rewrite. A condition or an assignment that references any other metadata 
column of the target then refers to an attribute that the read relation does 
not produce.
    
    - `RewriteRowLevelCommand` has a new helper, 
`resolveReferencedMetadataAttrs`, that returns the target metadata columns 
(`relation.metadataOutput`) that the given expressions reference and that the 
relation does not output yet.
    - `RewriteDeleteFromTable`, `RewriteUpdateTable` and 
`RewriteMergeIntoTable` add these columns to the read relation. They take them 
from the condition for `DELETE`, from the condition and the assignments for 
`UPDATE`, and from the `ON` condition and all clauses for `MERGE`.
    - A group-based `MERGE` copies carried-over target rows with all read 
columns (`Keep(Copy, ...)` over `readRelation.output`), so every `MergeRows` 
instruction now produces all read metadata columns instead of only the required 
ones.
    
    The write does not change. `ReplaceDataProjections` and 
`WriteDeltaProjections` still project only the required metadata columns, so 
`LogicalWriteInfo` is the same as before and the extra columns are only read. 
Spark requests them from the row-level operation scan through `pruneColumns`, 
in the same way as the required metadata columns and the row ID columns, and as 
it already requests all metadata columns when `AddMetadataColumns` widens the 
target in a later analyzer iteration. `Sup [...]
    
    This PR does not change the runtime group filters. If a target metadata 
column appears in the `ON` condition of a `MERGE`, or in the condition of a 
group-based `UPDATE` with a subquery, the runtime group filter still fails in 
the same way as when the operation requests that column. apache/spark#59896 
(SPARK-60668) and apache/spark#59893 (SPARK-60664) fix those cases.
    
    ### Why are the changes needed?
    
    A DSv2 row-level command fails during analysis with an internal error 
(SQLSTATE `XX000`) when it references a target metadata column that the 
operation does not request. For example, with the in-memory test table whose 
operations request no metadata columns (`no-metadata=true`, columns `pk INT NOT 
NULL, id INT, dep STRING`, partitioned by `dep`):
    
    ```sql
    DELETE FROM cat.ns1.test_table WHERE _partition = 'hr' AND id < 2
    ```
    
    ```
    org.apache.spark.sql.catalyst.ExtendedAnalysisException: 
[MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_MISSING_FROM_INPUT] Resolved 
attribute(s) "_partition" missing from "pk", "id", "dep" in operator !Filter 
((_partition#89 = hr) AND (id#86 < 2)).  SQLSTATE: XX000
    ```
    
    `UPDATE` and `MERGE` fail the same way. The same statements work when the 
operation requests the referenced columns, so whether a valid statement runs 
depends on which metadata columns the connector needs for its write. Connectors 
usually request only the metadata columns that they need to write the data 
back, for example a file path, while users can reference any metadata column 
that the table exposes.
    
    The read relation has included only the requested metadata columns since 
these rewrites were added (`DELETE` in Apache Spark 3.4.0, `UPDATE` and `MERGE` 
in 3.5.0).
    
    This fix is also a prerequisite for apache/spark#59895 (SPARK-60667), which 
stops `AddMetadataColumns` from adding all metadata columns to the target of 
these commands when they are resolved in a later analyzer iteration, for 
example because of type coercion. On master, `DELETE ... WHERE _partition = 
'hr' AND id < 1.5` succeeds only through that widening, and the group-based 
variant then writes `index` and `_partition` as data columns, while `... AND id 
< 2` fails as shown above. apac [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. DSv2 `DELETE`, `UPDATE` and `MERGE` statements that reference a target 
metadata column that the row-level operation does not request now run instead 
of failing with `MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_MISSING_FROM_INPUT`. 
Statements that reference only requested metadata columns, or none, build the 
same plans as before.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    
    - `DeleteFromTableSuiteBase`, `UpdateTableSuiteBase` and 
`MergeIntoTableSuiteBase` reference `_partition` and `index` in a `DELETE` 
condition, in an `UPDATE` condition and assignment, and in the `ON` condition, 
clause conditions and an assignment of a `MERGE` whose `NOT MATCHED BY SOURCE` 
clause disables the runtime group filter. They exercise this change in the 
`*NoMetadata*` suites, whose operations request no metadata columns. In the 
other suites, the operations request both column [...]
    - The `*NoMetadata*` suites check that such columns are read but not 
written, using the scan schema for `DELETE`, the `LogicalWriteInfo` and the 
write log. This includes `DELETE ... WHERE _partition = 'hr' AND id < 2` from 
the review of apache/spark#59895, a `MERGE` that references `_partition` only 
in the `ON` condition and `index` only in the condition of a `NOT MATCHED BY 
SOURCE` clause, and a group-based `UPDATE` whose subquery condition makes the 
rewrite read the table twice, wit [...]
    
    Without this fix, the new test cases fail in the `*NoMetadata*` suites with 
`MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_MISSING_FROM_INPUT`. They pass in the 
other suites, whose operations request both metadata columns.
    
    ```
    $ build/sbt "sql/testOnly 
org.apache.spark.sql.connector.*DeleteFromTable*Suite* 
org.apache.spark.sql.connector.*UpdateTable*Suite* 
org.apache.spark.sql.connector.*UpdateAsDeleteAndInsertTableSuite 
org.apache.spark.sql.connector.*MergeIntoTable*Suite* -- -z SPARK-60673"
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #59900 from dongjoon-hyun/SPARK-60673.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit ded9b617ef96dd6dcdced9533fa01f8c86881ca6)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../catalyst/analysis/RewriteDeleteFromTable.scala | 10 ++-
 .../catalyst/analysis/RewriteMergeIntoTable.scala  | 15 +++-
 .../catalyst/analysis/RewriteRowLevelCommand.scala | 11 +++
 .../sql/catalyst/analysis/RewriteUpdateTable.scala | 15 +++-
 .../sql/connector/DeleteFromTableSuiteBase.scala   | 21 +++++
 .../DeltaBasedNoMetadataDeleteFromTableSuite.scala | 24 ++++++
 .../DeltaBasedNoMetadataMergeIntoTableSuite.scala  | 89 ++++++++++++++++++++++
 .../DeltaBasedNoMetadataUpdateTableSuite.scala     | 25 ++++++
 .../GroupBasedNoMetadataDeleteFromTableSuite.scala | 23 ++++++
 .../GroupBasedNoMetadataMergeIntoTableSuite.scala  | 78 +++++++++++++++++++
 .../GroupBasedNoMetadataUpdateTableSuite.scala     | 51 +++++++++++++
 .../sql/connector/MergeIntoTableSuiteBase.scala    | 35 +++++++++
 .../spark/sql/connector/UpdateTableSuiteBase.scala | 23 ++++++
 13 files changed, 412 insertions(+), 8 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteDeleteFromTable.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteDeleteFromTable.scala
index 8bb55b1822b1..ee8e2c3388c7 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteDeleteFromTable.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteDeleteFromTable.scala
@@ -74,7 +74,10 @@ object RewriteDeleteFromTable extends RewriteRowLevelCommand 
{
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, 
operationTable.operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs)
+    // as well as metadata columns referenced in the condition
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
Seq(cond))
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs)
 
     // construct a plan that contains unmatched rows in matched groups that 
must be carried over
     // such rows do not match the condition but have to be copied over as the 
source can replace
@@ -105,7 +108,10 @@ object RewriteDeleteFromTable extends 
RewriteRowLevelCommand {
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs, rowIdAttrs)
+    // as well as metadata columns referenced in the condition
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
Seq(cond))
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs, 
rowIdAttrs)
 
     // construct a plan that only contains records to delete
     val deletedRowsPlan = Filter(cond, readRelation)
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala
index 496514a839db..fd348e92a6dc 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala
@@ -161,7 +161,12 @@ object RewriteMergeIntoTable extends 
RewriteRowLevelCommand with PredicateHelper
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, 
operationTable.operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs)
+    // as well as metadata columns referenced in the condition or actions
+    // carried over rows contain all read metadata columns, so every 
instruction must produce them
+    val actions = matchedActions ++ notMatchedActions ++ 
notMatchedBySourceActions
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
cond +: actions)
+    val readMetadataAttrs = dedupAttrs(metadataAttrs ++ 
referencedMetadataAttrs)
+    val readRelation = buildRelationWithAttrs(relation, operationTable, 
readMetadataAttrs)
 
     val checkCardinality = shouldCheckCardinality(matchedActions)
 
@@ -172,7 +177,7 @@ object RewriteMergeIntoTable extends RewriteRowLevelCommand 
with PredicateHelper
 
     val mergeRowsPlan = buildReplaceDataMergeRowsPlan(
       readRelation, joinPlan, matchedActions, notMatchedActions,
-      notMatchedBySourceActions, metadataAttrs, checkCardinality)
+      notMatchedBySourceActions, readMetadataAttrs, checkCardinality)
 
     // predicates of the ON condition can be used to filter the target table 
(planning & runtime)
     // only if there is no NOT MATCHED BY SOURCE clause
@@ -278,7 +283,11 @@ object RewriteMergeIntoTable extends 
RewriteRowLevelCommand with PredicateHelper
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs, rowIdAttrs)
+    // as well as metadata columns referenced in the condition or actions
+    val actions = matchedActions ++ notMatchedActions ++ 
notMatchedBySourceActions
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
cond +: actions)
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs, 
rowIdAttrs)
 
     // if there is no NOT MATCHED BY SOURCE clause, predicates of the ON 
condition that
     // reference only the target table can be pushed down
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala
index a64bdb6cbf58..3acd090b6d06 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala
@@ -109,6 +109,17 @@ trait RewriteRowLevelCommand extends Rule[LogicalPlan] {
       relation)
   }
 
+  // metadata attrs referenced in the command (e.g. in the condition) must be 
read even if they are
+  // not required by the operation as the row-level operation table exposes no 
metadata columns
+  // such attrs are only read, the write projections include only required 
metadata attrs
+  protected def resolveReferencedMetadataAttrs(
+      relation: DataSourceV2Relation,
+      exprs: Seq[Expression]): Seq[AttributeReference] = {
+
+    val references = AttributeSet(exprs.flatMap(_.references)) -- 
relation.outputSet
+    relation.metadataOutput.filter(references.contains)
+  }
+
   protected def resolveRowIdAttrs(
       relation: DataSourceV2Relation,
       operation: SupportsDelta): Seq[AttributeReference] = {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala
index 352ddc0b0acb..03c902d8cb91 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala
@@ -71,7 +71,10 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, 
operationTable.operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs)
+    // as well as metadata columns referenced in the condition or assignments
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
cond +: assignments)
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs)
 
     // build a plan with updated and copied over records
     val query = buildReplaceDataUpdateProjection(readRelation, assignments, 
cond)
@@ -95,9 +98,12 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, 
operationTable.operation)
 
     // construct a read relation and include all required metadata columns
+    // as well as metadata columns referenced in the condition or assignments
     // the same read relation will be used to read records that must be 
updated and copied over
     // the analyzer will take care of duplicated attr IDs
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs)
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
cond +: assignments)
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs)
 
     // build a plan for updated records that match the condition
     val matchedRowsPlan = Filter(cond, readRelation)
@@ -163,7 +169,10 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
 
     // construct a read relation and include all required metadata columns
-    val readRelation = buildRelationWithAttrs(relation, operationTable, 
metadataAttrs, rowIdAttrs)
+    // as well as metadata columns referenced in the condition or assignments
+    val referencedMetadataAttrs = resolveReferencedMetadataAttrs(relation, 
cond +: assignments)
+    val readRelation = buildRelationWithAttrs(
+      relation, operationTable, metadataAttrs ++ referencedMetadataAttrs, 
rowIdAttrs)
 
     // build a plan for updated records that match the condition
     val matchedRowsPlan = Filter(cond, readRelation)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
index 22ac338fbd6e..92e427d3de0c 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
@@ -360,6 +360,27 @@ abstract class DeleteFromTableSuiteBase extends 
RowLevelOperationSuiteBase {
     checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 0)
   }
 
+  test("SPARK-60673: delete with metadata columns in the condition") {
+    // the metadata columns must be read even if the operation does not 
require them
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |{ "pk": 3, "id": 1, "dep": "software" }
+        |""".stripMargin)
+
+    sql(s"DELETE FROM $tableNameAsString WHERE _partition = 'hr' AND id < 2")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+    checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 1)
+
+    sql(s"DELETE FROM $tableNameAsString t WHERE t.index = 0 AND t.id > 1")
+
+    checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(3, 1, 
"software") :: Nil)
+    checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 0)
+  }
+
   test("delete with IN subqueries") {
     withTempView("deleted_id", "deleted_dep") {
       createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataDeleteFromTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataDeleteFromTableSuite.scala
index b33cf87402b3..086a696aa8a4 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataDeleteFromTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataDeleteFromTableSuite.scala
@@ -17,6 +17,9 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.StructType
+
 class DeltaBasedNoMetadataDeleteFromTableSuite extends 
DeleteFromTableSuiteBase {
 
   override protected def deltaDelete: Boolean = true
@@ -29,4 +32,25 @@ class DeltaBasedNoMetadataDeleteFromTableSuite extends 
DeleteFromTableSuiteBase
   }
 
   override def enforceCheckConstraintOnDelete: Boolean = false
+
+  test("SPARK-60673: delete reads non-required metadata columns without 
writing them") {
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |{ "pk": 3, "id": 1, "dep": "software" }
+        |""".stripMargin)
+
+    executeAndCheckScan(
+      s"DELETE FROM $tableNameAsString WHERE _partition = 'hr' AND id < 2",
+      // `pk` is used to encode deletes
+      // `id` and `_partition` are used in the condition
+      expectedScanSchema = "pk INT, id INT, _partition STRING")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+
+    checkLastWriteInfo(expectedRowIdSchema = Some(StructType(Array(PK_FIELD))))
+    checkLastWriteLog(deleteWriteLogEntry(id = 1, metadata = null))
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataMergeIntoTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataMergeIntoTableSuite.scala
index d6e148425313..379c45a6d8e8 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataMergeIntoTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataMergeIntoTableSuite.scala
@@ -17,8 +17,13 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.StructType
+
 class DeltaBasedNoMetadataMergeIntoTableSuite extends MergeIntoTableSuiteBase {
 
+  import testImplicits._
+
   override protected def deltaMerge = true
 
   override protected def extraTableProps: java.util.Map[String, String] = {
@@ -27,4 +32,88 @@ class DeltaBasedNoMetadataMergeIntoTableSuite extends 
MergeIntoTableSuiteBase {
     props.put("no-metadata", "true")
     props
   }
+
+  test("SPARK-60673: merge reads non-required metadata columns without writing 
them") {
+    withTempView("source") {
+      createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "dep": "hr" }
+          |{ "pk": 2, "salary": 200, "dep": "software" }
+          |{ "pk": 3, "salary": 300, "dep": "hr" }
+          |""".stripMargin)
+
+      val sourceDF = Seq(1, 2, 5).toDF("pk")
+      sourceDF.createOrReplaceTempView("source")
+
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk
+           |WHEN MATCHED AND t._partition = 'hr' THEN
+           | UPDATE SET t.salary = t.index + 10
+           |WHEN NOT MATCHED THEN
+           | INSERT (pk, salary, dep) VALUES (s.pk, 0, 'hr')
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, 10, "hr"), // update
+          Row(2, 200, "software"), // unchanged
+          Row(3, 300, "hr"), // unchanged
+          Row(5, 0, "hr"))) // insert
+
+      checkLastWriteInfo(
+        expectedRowSchema = StructType(table.schema.map {
+          case attr if attr.name == "salary" => attr.copy(nullable = false) // 
input is not nullable
+          case attr => attr
+        }),
+        expectedRowIdSchema = Some(StructType(Array(PK_FIELD))))
+
+      checkLastWriteLog(
+        updateWriteLogEntry(id = 1, metadata = null, data = Row(1, 10, "hr")),
+        insertWriteLogEntry(data = Row(5, 0, "hr")))
+    }
+  }
+
+  test("SPARK-60673: merge reads non-required metadata columns only used in 
conditions") {
+    withTempView("source") {
+      createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "dep": "hr" }
+          |{ "pk": 2, "salary": 200, "dep": "software" }
+          |{ "pk": 3, "salary": 300, "dep": "hr" }
+          |""".stripMargin)
+
+      val sourceDF = Seq(1, 2).toDF("pk")
+      sourceDF.createOrReplaceTempView("source")
+
+      // `_partition` is only in the ON condition and `index` is only in the 
condition of
+      // the NOT MATCHED BY SOURCE clause, which also disables the runtime 
group filter
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk AND t._partition = 'hr'
+           |WHEN MATCHED THEN
+           | UPDATE SET t.salary = -1
+           |WHEN NOT MATCHED BY SOURCE AND t.index = 1 THEN
+           | DELETE
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, -1, "hr"), // update
+          Row(2, 200, "software"))) // unchanged
+
+      checkLastWriteInfo(
+        expectedRowSchema = StructType(table.schema.map {
+          case attr if attr.name == "salary" => attr.copy(nullable = false) // 
input is a constant
+          case attr => attr
+        }),
+        expectedRowIdSchema = Some(StructType(Array(PK_FIELD))))
+
+      checkLastWriteLog(
+        updateWriteLogEntry(id = 1, metadata = null, data = Row(1, -1, "hr")),
+        deleteWriteLogEntry(id = 3, metadata = null))
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataUpdateTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataUpdateTableSuite.scala
index bf657806a30d..4c738ac13a96 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataUpdateTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedNoMetadataUpdateTableSuite.scala
@@ -17,6 +17,9 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.StructType
+
 class DeltaBasedNoMetadataUpdateTableSuite extends UpdateTableSuiteBase {
 
   override protected def deltaUpdate: Boolean = true
@@ -27,4 +30,26 @@ class DeltaBasedNoMetadataUpdateTableSuite extends 
UpdateTableSuiteBase {
     props.put("no-metadata", "true")
     props
   }
+
+  test("SPARK-60673: update reads non-required metadata columns without 
writing them") {
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |{ "pk": 3, "id": 1, "dep": "software" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET id = index + 10 WHERE _partition = 
'hr' AND id < 2")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, 10, "hr") :: Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+
+    checkLastWriteInfo(
+      expectedRowSchema = StructType(table.schema.map {
+        case attr if attr.name == "id" => attr.copy(nullable = false) // input 
is not nullable
+        case attr => attr
+      }),
+      expectedRowIdSchema = Some(StructType(Array(PK_FIELD))))
+    checkLastWriteLog(updateWriteLogEntry(id = 1, metadata = null, data = 
Row(1, 10, "hr")))
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataDeleteFromTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataDeleteFromTableSuite.scala
index 6ac4f6e32fb1..219305294ce2 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataDeleteFromTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataDeleteFromTableSuite.scala
@@ -17,6 +17,8 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+
 class GroupBasedNoMetadataDeleteFromTableSuite extends 
DeleteFromTableSuiteBase {
 
   override protected def extraTableProps: java.util.Map[String, String] = {
@@ -24,4 +26,25 @@ class GroupBasedNoMetadataDeleteFromTableSuite extends 
DeleteFromTableSuiteBase
     props.put("no-metadata", "true")
     props
   }
+
+  test("SPARK-60673: delete reads non-required metadata columns without 
writing them") {
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |{ "pk": 3, "id": 1, "dep": "software" }
+        |""".stripMargin)
+
+    executeAndCheckScans(
+      s"DELETE FROM $tableNameAsString WHERE _partition = 'hr' AND id < 2",
+      primaryScanSchema = "pk INT, id INT, dep STRING, _partition STRING",
+      groupFilterScanSchema = Some("id INT, dep STRING, _partition STRING"))
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+
+    checkReplacedPartitions(Seq("hr"))
+    checkLastWriteInfo(expectedRowSchema = table.schema)
+    checkLastWriteLog(writeLogEntry(data = Row(2, 2, "hr")))
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataMergeIntoTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataMergeIntoTableSuite.scala
index 5feadcdec23e..9fca5542c691 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataMergeIntoTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataMergeIntoTableSuite.scala
@@ -17,11 +17,89 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+
 class GroupBasedNoMetadataMergeIntoTableSuite extends MergeIntoTableSuiteBase {
 
+  import testImplicits._
+
   override protected def extraTableProps: java.util.Map[String, String] = {
     val props = new java.util.HashMap[String, String]()
     props.put("no-metadata", "true")
     props
   }
+
+  test("SPARK-60673: merge reads non-required metadata columns without writing 
them") {
+    withTempView("source") {
+      createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "dep": "hr" }
+          |{ "pk": 2, "salary": 200, "dep": "software" }
+          |{ "pk": 3, "salary": 300, "dep": "hr" }
+          |""".stripMargin)
+
+      val sourceDF = Seq(1, 2, 5).toDF("pk")
+      sourceDF.createOrReplaceTempView("source")
+
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk
+           |WHEN MATCHED AND t._partition = 'hr' THEN
+           | UPDATE SET t.salary = t.index + 10
+           |WHEN NOT MATCHED THEN
+           | INSERT (pk, salary, dep) VALUES (s.pk, 0, 'hr')
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, 10, "hr"), // update
+          Row(2, 200, "software"), // unchanged
+          Row(3, 300, "hr"), // unchanged
+          Row(5, 0, "hr"))) // insert
+
+      checkLastWriteInfo(expectedRowSchema = table.schema)
+      checkLastWriteLog(
+        writeLogEntry(data = Row(1, 10, "hr")),
+        writeLogEntry(data = Row(2, 200, "software")),
+        writeLogEntry(data = Row(3, 300, "hr")),
+        writeLogEntry(data = Row(5, 0, "hr")))
+    }
+  }
+
+  test("SPARK-60673: merge reads non-required metadata columns only used in 
conditions") {
+    withTempView("source") {
+      createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "dep": "hr" }
+          |{ "pk": 2, "salary": 200, "dep": "software" }
+          |{ "pk": 3, "salary": 300, "dep": "hr" }
+          |""".stripMargin)
+
+      val sourceDF = Seq(1, 2).toDF("pk")
+      sourceDF.createOrReplaceTempView("source")
+
+      // `_partition` is only in the ON condition and `index` is only in the 
condition of
+      // the NOT MATCHED BY SOURCE clause, which also disables the runtime 
group filter
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk AND t._partition = 'hr'
+           |WHEN MATCHED THEN
+           | UPDATE SET t.salary = -1
+           |WHEN NOT MATCHED BY SOURCE AND t.index = 1 THEN
+           | DELETE
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, -1, "hr"), // update
+          Row(2, 200, "software"))) // unchanged
+
+      checkLastWriteInfo(expectedRowSchema = table.schema)
+      checkLastWriteLog(
+        writeLogEntry(data = Row(1, -1, "hr")),
+        writeLogEntry(data = Row(2, 200, "software")))
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataUpdateTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataUpdateTableSuite.scala
index 31db56b8d559..da2955827329 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataUpdateTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedNoMetadataUpdateTableSuite.scala
@@ -17,11 +17,62 @@
 
 package org.apache.spark.sql.connector
 
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.internal.SQLConf
+
 class GroupBasedNoMetadataUpdateTableSuite extends UpdateTableSuiteBase {
 
+  import testImplicits._
+
   override protected def extraTableProps: java.util.Map[String, String] = {
     val props = new java.util.HashMap[String, String]()
     props.put("no-metadata", "true")
     props
   }
+
+  test("SPARK-60673: update reads non-required metadata columns without 
writing them") {
+    withTempView("updated_pk") {
+      createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+        """{ "pk": 1, "id": 1, "dep": "hr" }
+          |{ "pk": 2, "id": 2, "dep": "hr" }
+          |{ "pk": 3, "id": 1, "dep": "software" }
+          |""".stripMargin)
+
+      sql(s"UPDATE $tableNameAsString SET id = index + 10 WHERE _partition = 
'hr' AND id < 2")
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Row(1, 10, "hr") :: Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+
+      checkReplacedPartitions(Seq("hr"))
+      checkLastWriteInfo(expectedRowSchema = table.schema)
+      checkLastWriteLog(
+        writeLogEntry(data = Row(1, 10, "hr")),
+        writeLogEntry(data = Row(2, 2, "hr")))
+
+      // UPDATEs with subqueries are rewritten as a UNION of two reads
+      // the runtime group filter is disabled to test only the reads
+      val updatedPkDF = Seq(3).toDF()
+      updatedPkDF.createOrReplaceTempView("updated_pk")
+
+      withSQLConf(SQLConf.RUNTIME_ROW_LEVEL_OPERATION_GROUP_FILTER_ENABLED.key 
-> "false") {
+        sql(
+          s"""UPDATE $tableNameAsString
+             |SET id = index + 20
+             |WHERE _partition = 'software' AND pk IN (SELECT * FROM 
updated_pk)
+             |""".stripMargin)
+      }
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Row(1, 10, "hr") :: Row(2, 2, "hr") :: Row(3, 20, "software") :: Nil)
+
+      checkReplacedPartitions(Seq("hr", "software"))
+      checkLastWriteInfo(expectedRowSchema = table.schema)
+      checkLastWriteLog(
+        writeLogEntry(data = Row(1, 10, "hr")),
+        writeLogEntry(data = Row(2, 2, "hr")),
+        writeLogEntry(data = Row(3, 20, "software")))
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/MergeIntoTableSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/MergeIntoTableSuiteBase.scala
index 93338ba043d9..37983b75faf4 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/MergeIntoTableSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/MergeIntoTableSuiteBase.scala
@@ -893,6 +893,41 @@ abstract class MergeIntoTableSuiteBase extends 
RowLevelOperationSuiteBase
     }
   }
 
+  test("SPARK-60673: merge with metadata columns in conditions and 
assignments") {
+    // the metadata columns must be read even if the operation does not 
require them
+    withTempView("source") {
+      createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "dep": "hr" }
+          |{ "pk": 2, "salary": 200, "dep": "software" }
+          |{ "pk": 3, "salary": 300, "dep": "hr" }
+          |{ "pk": 4, "salary": 400, "dep": "software" }
+          |""".stripMargin)
+
+      val sourceDF = Seq((1, "hr"), (2, "hr"), (5, "hr")).toDF("pk", "dep")
+      sourceDF.createOrReplaceTempView("source")
+
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk AND t._partition = s.dep
+           |WHEN MATCHED AND t.index = 0 THEN
+           | UPDATE SET t.salary = t.index + 10
+           |WHEN NOT MATCHED THEN
+           | INSERT (pk, salary, dep) VALUES (s.pk, 0, s.dep)
+           |WHEN NOT MATCHED BY SOURCE AND t._partition = 'software' THEN
+           | DELETE
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, 10, "hr"), // update
+          Row(2, 0, "hr"), // insert
+          Row(3, 300, "hr"), // unchanged
+          Row(5, 0, "hr"))) // insert
+    }
+  }
+
   test("merge with one NOT MATCHED BY SOURCE clause") {
     withTempView("source") {
       createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
index c0506ca03dcc..c2b6c1f7ec94 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
@@ -523,6 +523,29 @@ abstract class UpdateTableSuiteBase extends 
RowLevelOperationSuiteBase {
     checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
   }
 
+  test("SPARK-60673: update with metadata columns in the condition and 
assignments") {
+    // the metadata columns must be read even if the operation does not 
require them
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |{ "pk": 3, "id": 1, "dep": "software" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET id = -1 WHERE _partition = 'hr' AND id 
< 2")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, -1, "hr") :: Row(2, 2, "hr") :: Row(3, 1, "software") :: Nil)
+    checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 1)
+
+    sql(s"UPDATE $tableNameAsString t SET t.id = t.index + 10 WHERE t.pk = 3")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, -1, "hr") :: Row(2, 2, "hr") :: Row(3, 10, "software") :: Nil)
+    checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
+  }
+
   test("update with IN subqueries") {
     withTempView("updated_id", "updated_dep") {
       createAndInitTable("pk INT NOT NULL, id INT, dep STRING",


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

Reply via email to