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 8aff800d525d [SPARK-60708][SQL] Fix DSv2 UPDATE and MERGE that 
reference metadata columns of a table with CHAR/VARCHAR columns
8aff800d525d is described below

commit 8aff800d525d4669648e4e3ccda755c46ec87457
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Thu Oct 29 10:43:20 2026 -0700

    [SPARK-60708][SQL] Fix DSv2 UPDATE and MERGE that reference metadata 
columns of a table with CHAR/VARCHAR columns
    
    ### What changes were proposed in this pull request?
    
    This PR fixes `ResolveRowLevelCommandAssignments` so that DSv2 `UPDATE` and 
`MERGE` can reference metadata columns (e.g. `_partition`) of a target table 
that has `CHAR` or `VARCHAR` columns.
    
    When the rule aligns the assignments of a rewritable `UPDATE` or `MERGE`, 
`cleanAttrMetadata` replaces the target `DataSourceV2Relation` with a copy 
whose output has no `__CHAR_VARCHAR_TYPE_STRING` metadata. The copy is a new 
instance, so its lazy `metadataOutput` creates the metadata attributes again 
with new expression IDs. The condition and the assignments of the command still 
refer to the metadata attributes of the original relation. However, 
`RewriteUpdateTable` and `RewriteMerge [...]
    
    This PR makes `cleanAttrMetadata` also return a map from the metadata 
output of the original relation to that of the copy. The rule then rewrites the 
references of the command with `QueryPlan.rewriteAttrs`, which also updates 
outer references in subqueries. `ResolveSchemaEvolution.replaceWriteTarget` 
uses the same approach for the output when it replaces the target relation of a 
write. When there is nothing to clean, the relation is kept and the map is 
empty.
    
    Alternatives that were not taken:
    - Keep the original relation: the cleaned output is what makes 
`AssignmentUtils.aligned` accept the aligned assignments, because it compares 
the assignment value type with the raw `CHAR`/`VARCHAR` type while the metadata 
is present.
    - Keep the original metadata attributes in the copy: each 
`DataSourceV2Relation` instance derives its metadata output from the table, and 
other copies such as `newInstance()` rely on getting new expression IDs.
    - Resolve the referenced metadata columns by name in the rewrite rules: the 
original relation is gone at that point, and a name alone cannot tell a target 
metadata column from a metadata column of a DSv2 source.
    
    ### Why are the changes needed?
    
    To fix a bug. For example, take a DSv2 table that supports row-level 
operations, exposes the metadata column `_partition`, and has the schema `pk 
INT NOT NULL, id INT, dep STRING, name VARCHAR(10)` (partitioned by `dep`):
    
    ```sql
    UPDATE t SET id = -1 WHERE id IN (1, 2) AND _partition = 'hr'
    ```
    
    ```
    [MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_APPEAR_IN_OPERATION] Resolved 
attribute(s) "_partition" missing from "pk", "id", "dep", "name", "_partition", 
"index" in operator !Filter (id#80 IN (1,2) AND (_partition#84 = hr)). 
Attribute(s) with the same name appear in the operation: "_partition".
    ```
    
    The same statements succeed when `name` is a `STRING` column. The failure 
happens for group-based and delta-based tables (including split updates) in 
these cases:
    - `UPDATE` whose condition or assignment values reference a metadata column
    - `MERGE` with `UPDATE` or `INSERT` actions whose clause conditions or 
assignment values reference a target metadata column
    - insert-only `MERGE` whose `ON` condition references a target metadata 
column (`MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_MISSING_FROM_INPUT`)
    
    If the operation requests none of the referenced metadata columns, the 
first two cases fail with 
`MISSING_ATTRIBUTES.RESOLVED_ATTRIBUTE_MISSING_FROM_INPUT` instead.
    
    `DELETE` and a `MERGE` with only `DELETE` actions are not affected because 
Spark does not align them and keeps the original relation.
    
    By code reading, the same code path exists since Apache Spark 3.5.0, where 
`ResolveRowLevelCommandAssignments` started to align the assignments of 
`UPDATE` (SPARK-42151) and `MERGE` (SPARK-43204) for tables that support 
row-level operations.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. On a DSv2 table with `CHAR`/`VARCHAR` columns, the `UPDATE` and 
`MERGE` statements above no longer fail with `MISSING_ATTRIBUTES` and behave as 
they do on a table without such columns. Statements that worked before are not 
affected.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    - `UpdateTableSuiteBase`: `SPARK-60708: update char/varchar table with 
metadata columns` and `SPARK-60708: update char/varchar table with an outer 
metadata reference`
    - `MergeIntoTableSuiteBase`: `SPARK-60708: merge into char/varchar table 
with metadata columns` and `SPARK-60708: insert-only merge into char/varchar 
table with metadata column in ON`
    
    They run in the group-based, delta-based, split-update and no-metadata 
suites (and the NoCodegen MERGE suites), and they fail without this fix. I also 
checked manually that a `CHAR` column and a `MERGE` with multiple `NOT MATCHED` 
clauses behave as they do on a table without `CHAR`/`VARCHAR` columns.
    
    I also ran these suites locally, and all of them passed: the UPDATE, MERGE 
(including schema evolution) and DELETE suites, `MetadataColumnSuite`, 
`PlanResolutionSuite`, `AlignUpdateAssignmentsSuite`, 
`AlignMergeAssignmentsSuite`, the row-level operation runtime filter suites, 
`MergeIntoDataFrameSuite`, and the CHAR/VARCHAR test suites.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #59918 from dongjoon-hyun/SPARK-60708.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit d443cc524904e47f0076923422f330cdf94ed4d9)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../ResolveRowLevelCommandAssignments.scala        | 31 ++++++++---
 .../sql/connector/MergeIntoTableSuiteBase.scala    | 64 ++++++++++++++++++++++
 .../spark/sql/connector/UpdateTableSuiteBase.scala | 44 +++++++++++++++
 3 files changed, 131 insertions(+), 8 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
index a31ac092e279..229559d96325 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
@@ -17,8 +17,10 @@
 
 package org.apache.spark.sql.catalyst.analysis
 
+import scala.collection.mutable
+
 import org.apache.spark.sql.AnalysisException
-import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, Cast}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeReference, Cast}
 import org.apache.spark.sql.catalyst.expressions.objects.AssertNotNull
 import org.apache.spark.sql.catalyst.plans.logical.{Assignment, DeleteAction, 
InsertAction, LogicalPlan, MergeAction, MergeIntoTable, UpdateAction, 
UpdateTable}
 import org.apache.spark.sql.catalyst.rules.Rule
@@ -44,11 +46,11 @@ object ResolveRowLevelCommandAssignments extends 
Rule[LogicalPlan] {
     _.containsPattern(COMMAND), ruleId) {
     case u: UpdateTable if !u.skipSchemaResolution && u.resolved && 
u.rewritable && !u.aligned =>
       validateStoreAssignmentPolicy()
-      val newTable = cleanAttrMetadata(u.table)
+      val (newTable, metadataAttrMap) = cleanAttrMetadata(u.table)
       val newAssignments = 
AssignmentUtils.alignUpdateAssignments(u.table.output, u.assignments,
         fromStar = false, coerceNestedTypes = false,
         ansiStoreAssignmentCastCheck = ansiStoreAssignmentCastCheck(u.table))
-      u.copy(table = newTable, assignments = newAssignments)
+      u.copy(table = newTable, assignments = 
newAssignments).rewriteAttrs(metadataAttrMap)
 
     case u: UpdateTable if !u.skipSchemaResolution && u.resolved && !u.aligned 
=>
       resolveAssignments(u)
@@ -57,8 +59,9 @@ object ResolveRowLevelCommandAssignments extends 
Rule[LogicalPlan] {
       validateStoreAssignmentPolicy()
       val coerceNestedTypes = conf.coerceMergeNestedTypes && 
m.withSchemaEvolution
       val castCheck = ansiStoreAssignmentCastCheck(m.targetTable)
+      val (newTargetTable, metadataAttrMap) = cleanAttrMetadata(m.targetTable)
       m.copy(
-        targetTable = cleanAttrMetadata(m.targetTable),
+        targetTable = newTargetTable,
         matchedActions = alignActions(
           m.targetTable.output,
           m.matchedActions,
@@ -73,7 +76,7 @@ object ResolveRowLevelCommandAssignments extends 
Rule[LogicalPlan] {
           m.targetTable.output,
           m.notMatchedBySourceActions,
           coerceNestedTypes,
-          castCheck))
+          castCheck)).rewriteAttrs(metadataAttrMap)
 
     case m: MergeIntoTable if shouldAlignAssignments(m) && 
containsFinalSchema(m) =>
       resolveAssignments(m)
@@ -101,11 +104,23 @@ object ResolveRowLevelCommandAssignments extends 
Rule[LogicalPlan] {
     }
   }
 
-  private def cleanAttrMetadata(table: LogicalPlan): LogicalPlan = {
-    table.transform {
+  // A cleaned relation is a new instance whose metadata output has new 
expression IDs, so this
+  // also returns a map from the metadata output of the original relation, 
which the command may
+  // reference, to the new one. A relation with nothing to clean is kept as is.
+  private def cleanAttrMetadata(table: LogicalPlan): (LogicalPlan, 
AttributeMap[Attribute]) = {
+    val metadataAttrMapping = mutable.ArrayBuffer.empty[(Attribute, Attribute)]
+    val newTable = table.transform {
       case r: DataSourceV2Relation =>
-        r.copy(output = r.output.map(CharVarcharUtils.cleanAttrMetadata))
+        val newOutput = r.output.map(CharVarcharUtils.cleanAttrMetadata)
+        if (newOutput == r.output) {
+          r
+        } else {
+          val newRelation = r.copy(output = newOutput)
+          metadataAttrMapping ++= 
r.metadataOutput.zip(newRelation.metadataOutput)
+          newRelation
+        }
     }
+    (newTable, AttributeMap(metadataAttrMapping.toSeq))
   }
 
   private def resolveAssignments(p: LogicalPlan): LogicalPlan = {
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 37983b75faf4..ea5d01c77180 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
@@ -2030,6 +2030,70 @@ abstract class MergeIntoTableSuiteBase extends 
RowLevelOperationSuiteBase
     }
   }
 
+  test("SPARK-60708: merge into char/varchar table with metadata columns") {
+    withTempView("source") {
+      createTable("pk INT NOT NULL, id INT, dep STRING, name VARCHAR(10)")
+
+      append("pk INT NOT NULL, id INT, dep STRING, name STRING",
+        """{ "pk": 1, "id": 1, "dep": "hr", "name": "a" }
+          |{ "pk": 2, "id": 2, "dep": "software", "name": "b" }
+          |{ "pk": 3, "id": 3, "dep": "hr", "name": "c" }
+          |{ "pk": 4, "id": 4, "dep": "software", "name": "d" }
+          |""".stripMargin)
+
+      Seq(1, 2, 5).toDF("pk").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.id = t.index + 10
+           |WHEN NOT MATCHED THEN
+           | INSERT (pk, id, dep, name) VALUES (s.pk, 0, 'hr', 'e')
+           |WHEN NOT MATCHED BY SOURCE AND t._partition = 'software' THEN
+           | DELETE
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, 10, "hr", "a"), // update (matched)
+          Row(2, 2, "software", "b"), // unchanged
+          Row(3, 3, "hr", "c"), // unchanged
+          Row(5, 0, "hr", "e"))) // insert
+    }
+  }
+
+  test("SPARK-60708: insert-only merge into char/varchar table with metadata 
column in ON") {
+    withTempView("source") {
+      createTable("pk INT NOT NULL, id INT, dep STRING, name VARCHAR(10)")
+
+      append("pk INT NOT NULL, id INT, dep STRING, name STRING",
+        """{ "pk": 1, "id": 1, "dep": "hr", "name": "a" }
+          |{ "pk": 2, "id": 2, "dep": "software", "name": "b" }
+          |""".stripMargin)
+
+      Seq(1, 2, 3).toDF("pk").createOrReplaceTempView("source")
+
+      sql(
+        s"""MERGE INTO $tableNameAsString t
+           |USING source s
+           |ON t.pk = s.pk AND t._partition = 'hr'
+           |WHEN NOT MATCHED THEN
+           | INSERT (pk, id, dep, name) VALUES (s.pk, 0, 'hr', 'new')
+           |""".stripMargin)
+
+      checkAnswer(
+        sql(s"SELECT * FROM $tableNameAsString"),
+        Seq(
+          Row(1, 1, "hr", "a"), // unchanged (matched)
+          Row(2, 2, "software", "b"), // unchanged
+          Row(2, 0, "hr", "new"), // insert (not matched in hr)
+          Row(3, 0, "hr", "new"))) // insert
+    }
+  }
+
   test("merge with NOT NULL checks") {
     withTempView("source") {
       createAndInitTable("pk INT NOT NULL, s STRUCT<n_i: INT NOT NULL, n_l: 
LONG>, 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 c2b6c1f7ec94..f52ff2406755 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
@@ -915,6 +915,50 @@ abstract class UpdateTableSuiteBase extends 
RowLevelOperationSuiteBase {
     checkUpdateMetrics(numUpdatedRows = 2, numCopiedRows = 0)
   }
 
+  test("SPARK-60708: update char/varchar table with metadata columns") {
+    createTable("pk INT NOT NULL, id INT, dep STRING, name VARCHAR(10)")
+
+    append("pk INT NOT NULL, id INT, dep STRING, name STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr", "name": "a" }
+        |{ "pk": 2, "id": 2, "dep": "software", "name": "b" }
+        |{ "pk": 3, "id": 3, "dep": "hr", "name": "c" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET id = -1 WHERE id IN (1, 2) AND 
_partition = 'hr'")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, -1, "hr", "a") :: Row(2, 2, "software", "b") :: Row(3, 3, "hr", 
"c") :: Nil)
+    checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 1)
+
+    sql(s"UPDATE $tableNameAsString t SET t.id = t.index + 10 WHERE t.pk = 2")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, -1, "hr", "a") :: Row(2, 10, "software", "b") :: Row(3, 3, "hr", 
"c") :: Nil)
+    checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
+  }
+
+  test("SPARK-60708: update char/varchar table with an outer metadata 
reference") {
+    createTable("pk INT NOT NULL, id INT, dep STRING, name VARCHAR(10)")
+
+    append("pk INT NOT NULL, id INT, dep STRING, name STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr", "name": "a" }
+        |{ "pk": 2, "id": 2, "dep": "software", "name": "b" }
+        |{ "pk": 3, "id": 3, "dep": "hr", "name": "c" }
+        |""".stripMargin)
+
+    withTempView("deps") {
+      Seq("hr").toDF("d").createOrReplaceTempView("deps")
+      sql(s"UPDATE $tableNameAsString t SET id = -1 " +
+        "WHERE id IN (1, 2) AND EXISTS (SELECT 1 FROM deps WHERE deps.d = 
t._partition)")
+    }
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, -1, "hr", "a") :: Row(2, 2, "software", "b") :: Row(3, 3, "hr", 
"c") :: Nil)
+  }
+
   test("update with NOT NULL checks") {
     createAndInitTable("pk INT NOT NULL, s STRUCT<n_i: INT NOT NULL, n_l: 
LONG>, dep STRING",
       """{ "pk": 1, "s": { "n_i": 1, "n_l": 11 }, "dep": "hr" }


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

Reply via email to