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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -237,4 +253,101 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val expandOutput = generateExpandOutput(attrs, outputs)
     Expand(outputs, expandOutput, matchedRowsPlan)
   }
+
+  /**
+   * Returns the table attributes that these assignments set to a new value, 
skipping identity
+   * assignments.
+   */
+  private def collectUpdatedAttrs(assignments: Seq[Assignment]): 
Seq[AttributeReference] = {
+    assignments.collect {
+      case Assignment(key: AttributeReference, value) if 
!isIdentityAssignment(key, value) => key

Review Comment:
   **66. `updatedColumns()` reports the user's spelling when the SET clause is 
already aligned, so the check that `SupportsColumnUpdates.java:74-76` asks 
builders to make can miss the lookup column.** This line takes the reported 
name from the assignment key, and a key resolves to `a.withName(<typed 
spelling>)` (`expressions/package.scala:197`). Alignment is the only step that 
restores the table's attribute (`AssignmentUtils.scala:91`), and it runs only 
for `!u.aligned` (`ResolveRowLevelCommandAssignments.scala:51`). 
`AssignmentUtils.aligned` compares names with the resolver 
(`AssignmentUtils.scala:402`), so in a case-insensitive session a SET clause 
that lists every column in table order skips it, and "Column names match the 
case used in the table schema" (`RowLevelOperationInfo.java:53`) no longer 
holds. For example:
   
   - With `t(pk INT NOT NULL, salary INT, bonus INT, dep STRING)` and the rows 
(1, 100, 10, hr) and (2, 200, 20, hr) on `column-update-cow-req-attrs = 
pk,salary`, `UPDATE t SET PK = 2, SALARY = 0, BONUS = BONUS, DEP = DEP WHERE PK 
= 1` reports `[PK, SALARY]`. `updatesPk` 
(`InMemoryRowLevelOperationTable.scala:161`) is then false, so the narrow 
operation is built instead of the full-width one, and the commit finds the base 
row by the new `pk = 2`. The updated row becomes (2, 0, 20, hr) instead of (2, 
0, 10, hr), with no error. The same statement in lower case falls back and gets 
it right. A real connector that follows `SupportsColumnUpdates.java:74-76` with 
an exact comparison would look rows up the same way.
   - With `t(pk INT NOT NULL, id INT, dep STRING)` on the default 
`column-update` fixture, `UPDATE t SET PK = PK, ID = -1, DEP = 'eng' WHERE pk = 
1` declares `[pk, dep, ID, DEP]` 
(`InMemoryRowLevelOperationTable.scala:392-396`) and fails with 
`COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE`, while `SET id = -1, dep = 
'eng'` works.
   
   `("ID = -1", Seq("id"), ...)` at 
`DeltaBasedColumnUpdateTableSuite.scala:169` does not cover this, because 
assigning one of three columns goes through alignment. Since `u.aligned` 
guarantees one assignment per column in table order, could this use the 
relation's attributes, e.g. `relation.output.zip(assignments).collect { case 
(attr, Assignment(key: Attribute, value)) if !isIdentityAssignment(key, value) 
=> attr }`, the same idea as the exprId map-back in `resolveRequiredDataAttrs`, 
and could that test get a row with upper-case keys?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -237,4 +253,101 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val expandOutput = generateExpandOutput(attrs, outputs)
     Expand(outputs, expandOutput, matchedRowsPlan)
   }
+
+  /**
+   * Returns the table attributes that these assignments set to a new value, 
skipping identity
+   * assignments.
+   */
+  private def collectUpdatedAttrs(assignments: Seq[Assignment]): 
Seq[AttributeReference] = {
+    assignments.collect {
+      case Assignment(key: AttributeReference, value) if 
!isIdentityAssignment(key, value) => key

Review Comment:
   **67. A metadata key can still reach `updatedColumns()` on a table that 
accepts any schema, so item 56's dead end remains there.** a6e22b24587 removed 
the metadata filter on these keys, and [the reply to item 
56](https://github.com/apache/spark/pull/55518#discussion_r4426725837) says 
SPARK-60721 handles it. That holds for other tables, but SPARK-60721's check 
and SPARK-60667's target exclusion both skip `skipSchemaResolution` tables 
(`ResolveRowLevelCommandAssignments.scala:98` and `Analyzer.scala:1198`). On a 
`column-update=true, accept-any-schema=true` table `t(pk INT NOT NULL, salary 
INT, dep STRING)`, `UPDATE t SET pk = pk, salary = -1, dep = dep, index = 5, 
_partition = 'x'` needs no injected rule: `AddMetadataColumns` widens the 
target to `[pk, salary, dep, index, _partition]`, the assignments are then 
aligned, and this reports `[salary, index, _partition]`. Declaring those fails 
with `COLUMN_UPDATE_METADATA_REQUIRED_DATA_ATTRIBUTE`, leaving them out fails 
with `COLUMN_UPDAT
 E_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS`, and both blame the 
connector for the user's assignment. Before a6e22b24587 this reported 
`[salary]`. It is a rare shape, since the SET clause has to list every data and 
metadata column in output order, and only an error-quality problem. Could this 
line skip metadata keys again with `!MetadataAttribute.isValid(key.metadata)`, 
or could `RowLevelOperationInfo.updatedColumns()` mention the case?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/LogicalWriteInfoImpl.scala:
##########
@@ -29,7 +29,8 @@ private[sql] case class LogicalWriteInfoImpl(
     schema: StructType,
     options: CaseInsensitiveStringMap,
     override val rowIdSchema: Optional[StructType] = 
Optional.empty[StructType],
-    override val metadataSchema: Optional[StructType] = 
Optional.empty[StructType])
+    override val metadataSchema: Optional[StructType] = 
Optional.empty[StructType],
+    override val columnUpdateSchema: Optional[StructType] = 
Optional.empty[StructType])

Review Comment:
   **69. The PR description says "`LogicalWriteInfoImpl.apply` takes a required 
`columnUpdateSchema`", but only the `Option` overload at L43 does.** The 
`Optional` `apply` keeps this default, so master's five-argument shape, as in 
`FileTable.mergedWriteInfo` before this PR, still compiles. An extension under 
`org.apache.spark.sql` that rebuilds the info that way hands the connector an 
empty `schema()` and no `columnUpdateSchema()`, while 
`DataSourceV2Strategy.scala:651` still sends narrow rows. Item 10's default 
throw does not help here, because this class overrides the method. Spark's own 
paths all forward it, and `LogicalWriteInfo.java:83-84` already says a wrapper 
must forward it, so only the description needs a change. Could the 
compatibility paragraph say that the compiler catches the `Option` overload but 
not this one?



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/ColumnUpdateTableSharedTests.scala:
##########
@@ -0,0 +1,427 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.connector
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.{SparkException, SparkRuntimeException}
+import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.catalyst.optimizer.ColumnPruning
+import org.apache.spark.sql.catalyst.plans.logical.Union
+import org.apache.spark.sql.connector.catalog.{InMemoryBaseTable, 
InMemoryRowLevelOperationTable}
+import org.apache.spark.sql.connector.write.{RowLevelOperation, 
RowLevelOperationTable}
+import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation, 
V2Writes}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+// Column-update tests that hold for both delta-based and group-based 
operations; each suite
+// supplies its column-update table properties through `extraTableProps`.
+trait ColumnUpdateTableSharedTests extends UpdateTableSuiteBase {
+
+  // the properties of a column-update table whose connector declares exactly 
`reqAttrs`
+  protected def reqAttrsTableProps(reqAttrs: String): java.util.Map[String, 
String]
+
+  // the properties of a table of the same kind whose operation does not mix 
in column updates
+  protected def fullWidthTableProps: java.util.Map[String, String]
+
+  // the metadata columns the scan of a column-update table reads, in DDL form
+  protected def scanMetadataColumns: String = ", _partition STRING, index INT"
+
+  // the class name of the operation that `reqAttrsTableProps` builds
+  protected def reqAttrsOperationClass: String = if (deltaUpdate) {
+    
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateOperationWithReqAttrs].getName
+  } else {
+    
classOf[InMemoryRowLevelOperationTable#PartitionBasedColumnUpdateOperationWithReqAttrs].getName
+  }
+
+  // `base` with the given table properties added; a given key replaces the 
same key in `base`
+  protected def tablePropsWith(
+      base: java.util.Map[String, String],
+      props: (String, String)*): java.util.Map[String, String] = {
+    (base.asScala ++ props).asJava
+  }
+
+  // t(pk, id, dep, salary, bonus, extra) with CHECK (extra > 0), partitioned 
by dep
+  protected def createAndInitTableWithProps(props: java.util.Map[String, 
String]): Unit = {
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING, salary INT, bonus 
INT, extra INT",
+      """{ "pk": 1, "id": 1, "dep": "hr", "salary": 100, "bonus": 10, "extra": 
1 }
+        |{ "pk": 2, "id": 2, "dep": "hr", "salary": 200, "bonus": 20, "extra": 
1 }
+        |{ "pk": 3, "id": 3, "dep": "software", "salary": 300, "bonus": 30, 
"extra": 1 }
+        |""".stripMargin,
+      props)
+    sql(s"ALTER TABLE $tableNameAsString ADD CONSTRAINT positive_extra CHECK 
(extra > 0)")
+  }
+
+  test("column-update: undeclared columns are not written and keep their 
values") {
+    createAndInitTable("pk INT NOT NULL, salary INT, bonus INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "bonus": 10, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "bonus": 20, "dep": "software" }
+        |{ "pk": 3, "salary": 300, "bonus": 30, "dep": "hr" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+
+    checkLastColumnUpdateSchema("pk", "dep", "salary")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, -1, 10, "hr") ::
+      Row(2, 200, 20, "software") ::
+      Row(3, -1, 30, "hr") :: Nil)
+    checkUpdateMetrics(numUpdatedRows = 2, numCopiedRows = 0)
+  }
+
+  test("column-update: write relation has the declared columns in declared 
order") {
+    createAndInitTableWithProps(reqAttrsTableProps("salary,pk"))
+
+    val write = executeAndKeepAnalyzedWrite {
+      sql(s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep = 
'hr'")
+    }
+
+    assert(!write.query.exists(_.isInstanceOf[Union]))
+    checkColumnUpdateWrite(write, "salary", "pk")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, 1, "hr", 110, 10, 1) ::
+      Row(2, 2, "hr", 220, 20, 1) ::
+      Row(3, 3, "software", 300, 30, 1) :: Nil)
+  }
+
+  test("column-update: the plan writes the declared columns and scans the 
referenced ones") {

Review Comment:
   **73.** Nit: some of the new tests overlap or only exercise the fixture.
   
   - L89 and L105 run the same schema, statement and rows. L89 adds the 
declared order, the original table and the empty `schema()`, and L105 adds the 
exact scan and the node name, so one test with `"salary,pk"` could check all of 
them. The regex at L118 repeats the first assertion of `checkColumnUpdateWrite` 
(`RowLevelOperationSuiteBase.scala:229`).
   - L141 and L292 make the same assertion from `"pk,salary"` and `""`, so 
`Seq(...).foreach` could keep both inputs.
   - `DeltaBasedColumnUpdateTableSuite.scala:504` asserts only the text of the 
fixture's own exception. DELETE is never narrow (`V2Writes.scala:144`), so it 
covers no Spark behavior.
   - `DeltaBasedColumnUpdateTableSuite.scala:572` cannot fail on its CHECK, 
since `extra > 0` holds for every value it writes. It shows that the column 
resolves rather than that the constraint is enforced.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedColumnUpdateTableSuite.scala:
##########
@@ -0,0 +1,607 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.connector
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.{SparkRuntimeException, 
SparkUnsupportedOperationException}
+import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.catalyst.analysis.RewriteRowLevelCommand
+import org.apache.spark.sql.catalyst.optimizer.ColumnPruning
+import org.apache.spark.sql.catalyst.plans.logical.WriteDelta
+import 
org.apache.spark.sql.catalyst.util.RowDeltaUtils.ORIGINAL_ROW_ID_VALUE_PREFIX
+import org.apache.spark.sql.connector.catalog.{Delete, InMemoryBaseTable, 
InMemoryRowLevelOperationTable, Reinsert}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.write.LogicalWriteInfo
+import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Tests for UPDATE statements targeting connectors that mix in
+ * [[org.apache.spark.sql.connector.write.SupportsColumnUpdates]].
+ *
+ * When a connector supports column updates, updated rows contain only the 
declared columns
+ * (LogicalWriteInfo.columnUpdateSchema()) rather than the full table row.
+ */
+class DeltaBasedColumnUpdateTableSuite extends DeltaBasedUpdateTableSuiteBase
+  with ColumnUpdateTableSharedTests {
+
+  override protected lazy val extraTableProps: java.util.Map[String, String] = 
{
+    val props = new java.util.HashMap[String, String]()
+    props.put("column-update", "true")
+    props
+  }
+
+  override protected def reqAttrsTableProps(reqAttrs: String): 
java.util.Map[String, String] = {
+    java.util.Map.of("column-update-req-attrs", reqAttrs)
+  }
+
+  override protected def fullWidthTableProps: java.util.Map[String, String] = {
+    java.util.Map.of("supports-deltas", "true")
+  }
+
+  override protected def scanMetadataColumns: String = ", _partition STRING"
+
+  private val splitOperationClass =
+    
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperation].getName
+
+  private val splitWithRowIdOperationClass =
+    
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperationWithRowId].getName
+
+  private def splitReqAttrsTableProps(reqAttrs: String): java.util.Map[String, 
String] = {
+    tablePropsWith(reqAttrsTableProps(reqAttrs), "split-updates" -> "true")
+  }
+
+  private def createAndInitTableWithReqAttrs(
+      reqAttrs: String,
+      schemaString: String,
+      jsonData: String): Unit = {
+    createAndInitTable(schemaString, jsonData, reqAttrsTableProps(reqAttrs))
+  }
+
+  private val splitTableProps = tablePropsWith(extraTableProps, 
"split-updates" -> "true")
+
+  private val splitPartitionRowIdTableProps =
+    java.util.Map.of("column-update-split-row-id", "_partition")
+
+  private def createAndInitTableSplit(schemaString: String, jsonData: String): 
Unit = {
+    createAndInitTable(schemaString, jsonData, splitTableProps)
+  }
+
+  test("column-update: column update schema contains a single assigned 
column") {
+    Seq(extraTableProps, splitTableProps).foreach { props =>
+      withTable(tableNameAsString) {
+        createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+          """{ "pk": 1, "id": 1, "dep": "hr" }
+            |{ "pk": 2, "id": 2, "dep": "software" }
+            |{ "pk": 3, "id": 3, "dep": "hr" }
+            |""".stripMargin,
+          props)
+
+        sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+
+        checkLastWriteInfo(
+          expectedRowIdSchema = Some(StructType(Array(PK_FIELD))),
+          expectedMetadataSchema = Some(StructType(Array(PARTITION_FIELD, 
INDEX_FIELD_NULLABLE))),
+          expectedColumnUpdateSchema = Some(StructType(Seq(
+            PK_FIELD,
+            DEP_FIELD,
+            StructField("id", IntegerType, nullable = false)
+          ))))
+      }
+    }
+  }
+
+  test("column-update: update handles metadata columns correctly") {
+    Seq(
+      "id IN (1, 100)",
+      "id IN (1, 20) AND dep IN (SELECT * FROM updated_dep)"
+    ).foreach { condition =>
+      withTable(tableNameAsString) {
+        withTempView("updated_dep") {
+          createAndInitTable("pk INT NOT NULL, id INT, salary INT, dep STRING",
+            """{ "pk": 1, "id": 1, "salary": 100, "dep": "hr" }
+              |{ "pk": 2, "id": 2, "salary": 200, "dep": "software" }
+              |{ "pk": 3, "id": 3, "salary": 300, "dep": "hr" }
+              |""".stripMargin)
+          import testImplicits._
+          Seq(Some("hr"), 
Some("it")).toDF().createOrReplaceTempView("updated_dep")
+
+          sql(s"UPDATE $tableNameAsString SET id = -1 WHERE $condition")
+
+          checkLastColumnUpdateWriteLog(
+            updateWriteLogEntry(id = 1, metadata = Row("hr", null), data = 
Row(1, "hr", -1)))
+          checkAnswer(
+            sql(s"SELECT * FROM $tableNameAsString"),
+            Row(1, -1, 100, "hr") :: Row(2, 2, 200, "software") :: Row(3, 3, 
300, "hr") :: Nil)
+        }
+      }
+    }
+  }
+
+  test("column-update: CHAR and VARCHAR self-assignments are reported as 
updated") {
+    createTable("pk INT NOT NULL, id INT, c CHAR(3), v VARCHAR(10), dep 
STRING")
+    sql(s"INSERT INTO $tableNameAsString VALUES (1, 1, 'a', 'x', 'hr')")
+
+    sql(s"UPDATE $tableNameAsString SET id = id, c = c, v = v WHERE pk = 1")
+
+    checkLastUpdatedColumns("c", "v")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, 1, "a  ", "x", "hr") :: Nil)
+  }
+
+  test("column-update: a LogicalWriteInfo that does not implement 
columnUpdateSchema throws") {
+    val info = new LogicalWriteInfo {
+      override def options(): CaseInsensitiveStringMap = 
CaseInsensitiveStringMap.empty()
+      override def queryId(): String = "query"
+      override def schema(): StructType = new StructType()
+    }
+
+    checkError(
+      exception = 
intercept[SparkUnsupportedOperationException](info.columnUpdateSchema()),
+      condition = "DATA_SOURCE_COLUMN_UPDATE_SCHEMA_NOT_IMPLEMENTED",
+      parameters = Map("class" -> info.getClass.getName))
+  }
+
+  test("column-update: updatedColumns and the column update schema follow the 
non-identity " +
+    "assignments") {
+    Seq(
+      ("id = -1", Seq("id"), Seq("pk", "dep", "id"), Row(1, -1, "hr")),
+      ("ID = -1", Seq("id"), Seq("pk", "dep", "id"), Row(1, -1, "hr")),
+      ("id = -1, dep = 'eng'", Seq("id", "dep"), Seq("pk", "dep", "id"), 
Row(1, -1, "eng")),
+      ("id = -1, dep = dep", Seq("id"), Seq("pk", "dep", "id"), Row(1, -1, 
"hr")),
+      ("id = id, dep = 'eng'", Seq("dep"), Seq("pk", "dep"), Row(1, 10, 
"eng")),
+      ("id = id, dep = dep", Seq.empty, Seq("pk", "dep"), Row(1, 10, "hr")),
+      ("id = pk", Seq("id"), Seq("pk", "dep", "id"), Row(1, 1, "hr"))
+    ).foreach { case (assignments, updatedColumns, columnUpdateNames, 
updatedRow) =>
+      withTable(tableNameAsString) {
+        createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+          """{ "pk": 1, "id": 10, "dep": "hr" }
+            |""".stripMargin)
+
+        sql(s"UPDATE $tableNameAsString SET $assignments WHERE pk = 1")
+
+        checkLastUpdatedColumns(updatedColumns: _*)
+        checkLastColumnUpdateSchema(columnUpdateNames: _*)
+        checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), updatedRow :: 
Nil)
+      }
+    }
+  }
+
+  test("column-update split: row-level DELETE falls back to full-width rows") {
+    createAndInitTableSplit("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "hr" }
+        |""".stripMargin)
+    checkDeleteFallsBackToFullWidthRows(Row(2, 200, "hr"))
+  }
+
+  test("column-update split: row-level DELETE by a metadata row ID falls back 
to full-width " +
+    "rows") {
+    // Each partition holds one row, so `_partition` identifies it.
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |""".stripMargin,
+      splitPartitionRowIdTableProps)
+    checkDeleteFallsBackToFullWidthRows(Row(2, 200, "software"))
+  }
+
+  test("column-update split: MERGE falls back to full-width rows") {
+    createAndInitTableSplit("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |""".stripMargin)
+    checkMergeFallsBackToFullWidthRows()
+  }
+
+  test("column-update: undeclared write requirement columns are rejected") {
+    val excludeColumnPruning = SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
ColumnPruning.ruleName
+    val splitReqAttrsOperationClass =
+      
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperationWithReqAttrs]
+        .getName
+    Seq(
+      (reqAttrsTableProps("pk,salary"), "dep", reqAttrsOperationClass, 
"[`dep`]"),
+      (splitReqAttrsTableProps("pk,salary"), "dep", 
splitReqAttrsOperationClass, "[`dep`]"),
+      (tablePropsWith(reqAttrsTableProps("pk,salary"),
+        "column-update-cluster-by-nested-transform" -> "true"), "dep", 
reqAttrsOperationClass,
+        "[`dep`]"),
+      (reqAttrsTableProps("pk,salary"), "id,dep", reqAttrsOperationClass, 
"[`dep`, `id`]"),
+      (reqAttrsTableProps("pk,salary,s"), "s.missing", reqAttrsOperationClass,
+        "[`s`.`missing` (FIELD_NOT_FOUND)]")
+    ).foreach { case (props, clusterBy, operationClass, columns) =>
+      withTable(tableNameAsString) {
+        val tableProps = tablePropsWith(props, "column-update-cluster-by" -> 
clusterBy)
+        createAndInitTable("pk INT NOT NULL, salary INT, id INT, s STRUCT<c1: 
INT>, dep STRING",
+          """{ "pk": 1, "salary": 100, "id": 1, "s": { "c1": 1 }, "dep": "hr" }
+            |""".stripMargin,
+          tableProps)
+
+        Seq(Nil, Seq(excludeColumnPruning)).foreach { confs =>
+          withSQLConf(confs: _*) {
+            checkError(
+              exception = intercept[AnalysisException] {
+                sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+              },
+              condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+              parameters = Map("connector" -> operationClass, "columns" -> 
columns))
+          }
+        }
+      }
+    }
+  }
+
+  test("column-update: an empty write requirement column name is reported as 
<empty>") {
+    checkError(
+      exception = 
QueryCompilationErrors.undeclaredWriteRequirementColumnsError(
+        reqAttrsOperationClass, Seq(Seq.empty[String] -> 
None)).asInstanceOf[AnalysisException],
+      condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+      parameters = Map("connector" -> reqAttrsOperationClass, "columns" -> 
"[<empty>]"))
+  }
+
+  test("column-update: distribution by an undeclared row ID column is 
allowed") {
+    Seq("salary = -1", "salary = -1, pk = pk").foreach { assignments =>
+      withTable(tableNameAsString) {
+        createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+          """{ "pk": 1, "salary": 100, "dep": "hr" }
+            |{ "pk": 2, "salary": 200, "dep": "hr" }
+            |""".stripMargin,
+          tablePropsWith(reqAttrsTableProps("salary,dep"), 
"column-update-cluster-by" -> "pk"))
+
+        sql(s"UPDATE $tableNameAsString SET $assignments WHERE pk = 1")
+
+        checkLastUpdatedColumns("salary")
+        checkAnswer(
+          sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+          Row(1, -1, "hr") :: Row(2, 200, "hr") :: Nil)
+      }
+    }
+  }
+
+  test("column-update: an undeclared, unreferenced partition column is not 
read") {
+    createAndInitTableWithReqAttrs("pk,id", "pk INT NOT NULL, id INT, dep 
STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "software" }
+        |{ "pk": 3, "id": 3, "dep": "hr" }
+        |""".stripMargin)
+
+    executeAndCheckScan(
+      s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1", "pk INT, 
_partition STRING")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, -1, "hr") :: Row(2, 2, "software") :: Row(3, 3, "hr") :: Nil)
+  }
+
+  test("column-update: requiredDataAttributes resolves case-insensitively 
without adopting the " +
+    "declared spelling") {
+    createAndInitTableWithReqAttrs("PK,SALARY,dep", "pk INT NOT NULL, salary 
INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET salary = salary + 1 WHERE pk = 1")
+
+    checkLastColumnUpdateSchema("pk", "salary", "dep")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, 101, "hr") :: Row(2, 200, "software") :: Nil)
+  }
+
+  test("column-update: DELETE on a scan that returns unrequested columns is 
unaffected") {
+    createAndInitTable("pk INT NOT NULL, salary INT, bonus INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "bonus": 10, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "bonus": 20, "dep": "software" }
+        |""".stripMargin,
+      tablePropsWith(extraTableProps, 
InMemoryBaseTable.SIMULATE_PARTIAL_COLUMN_PRUNING -> "true"))
+
+    sql(s"DELETE FROM $tableNameAsString WHERE pk < 2")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(2, 200, 20, "software") :: Nil)
+  }
+
+  test("column-update: case-sensitive analysis accepts columns that differ 
only in case") {
+    withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+      createAndInitTableWithReqAttrs("pk,salary,SALARY,dep",
+        "pk INT NOT NULL, salary INT, SALARY INT, dep STRING",
+        """{ "pk": 1, "salary": 100, "SALARY": 200, "dep": "hr" }
+          |""".stripMargin)
+
+      sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+      checkLastColumnUpdateSchema("pk", "salary", "SALARY", "dep")
+      checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(1, -1, 200, 
"hr") :: Nil)
+    }
+  }
+
+  test("column-update: invalid requiredDataAttributes are rejected") {
+    Seq(
+      ("pk,s.c1,dep", "COLUMN_UPDATE_NESTED_REQUIRED_DATA_ATTRIBUTE",
+        "nestedAttributes" -> "[`s`.`c1`]"),
+      ("pk,pk,salary", "COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE",
+        "duplicateAttributes" -> "[`pk`]"),
+      ("salary,PK,SALARY,pk", 
"COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE",
+        "duplicateAttributes" -> "[`salary`, `PK`]"),
+      ("pk,_partition,salary", 
"COLUMN_UPDATE_METADATA_REQUIRED_DATA_ATTRIBUTE",
+        "metadataAttributes" -> "[`_partition`]"),
+      ("pk,nonexistent_col,salary", 
"COLUMN_UPDATE_UNKNOWN_REQUIRED_DATA_ATTRIBUTE",
+        "unknownAttributes" -> "[`nonexistent_col`]"),
+      ("pk,salary", 
"COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS",
+        "missingColumns" -> "[`a.b`]")
+    ).foreach { case (reqAttrs, condition, columns) =>
+      withTable(tableNameAsString) {
+        createAndInitTableWithReqAttrs(reqAttrs,
+          "pk INT NOT NULL, salary INT, s STRUCT<c1: INT, c2: INT>, `a.b` INT, 
dep STRING",
+          """{ "pk": 1, "salary": 100, "s": { "c1": 1, "c2": 2 }, "a.b": 1, 
"dep": "hr" }
+            |""".stripMargin)
+
+        checkError(
+          exception = intercept[AnalysisException] {
+            sql(s"UPDATE $tableNameAsString SET salary = -1, `a.b` = -1 WHERE 
pk = 1")
+          },
+          condition = condition,
+          parameters = Map("connector" -> reqAttrsOperationClass, columns))
+      }
+    }
+  }
+
+  test("column-update: a declared column with no name parts is reported as 
<empty>") {
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |""".stripMargin)
+    val t = table
+    val operation = new t.DeltaBasedColumnUpdateOperation(UPDATE) {
+      override def requiredDataAttributes(): Array[NamedReference] =
+        Array(FieldReference(Seq.empty[String]), FieldReference("pk"),
+          FieldReference(Seq.empty[String]))
+    }
+
+    checkError(
+      exception = intercept[AnalysisException] {
+        RewriteRowLevelCommand.resolveRequiredDataAttrs(
+          spark.table(tableNameAsString).queryExecution.analyzed, operation)
+      },
+      condition = "COLUMN_UPDATE_UNKNOWN_REQUIRED_DATA_ATTRIBUTE",
+      parameters = Map(
+        "connector" -> operation.getClass.getName, "unknownAttributes" -> 
"[<empty>, <empty>]"))
+  }
+
+  test("column-update split: delete and reinsert carry the row ID and 
preserved metadata") {
+    createAndInitTableSplit("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "hr" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+
+    checkLastColumnUpdateWriteLog(
+      deleteWriteLogEntry(id = 1, metadata = Row("hr", null)),
+      reinsertWriteLogEntry(metadata = Row("hr", null), data = Row(1, "hr", 
-1)))
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, -1, "hr") :: Row(2, 2, "hr") :: Nil)
+  }
+
+  test("column-update split: row-ID reassignment on narrow write is rejected") 
{
+    // the declared [pk, dep, salary] leaves out `extra`, so the write is 
narrow
+    createAndInitTableSplit("pk INT NOT NULL, salary INT, dep STRING, extra 
STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr", "extra": "x" }
+        |{ "pk": 2, "salary": 200, "dep": "software", "extra": "y" }
+        |""".stripMargin)
+
+    checkError(
+      exception = intercept[AnalysisException] {
+        sql(s"UPDATE $tableNameAsString SET pk = pk + 10, salary = -1 WHERE 
dep = 'hr'")
+      },
+      condition = "COLUMN_UPDATE_SPLIT_ROW_ID_REASSIGNMENT",
+      parameters = Map("connector" -> splitOperationClass, "rowIds" -> 
"[`pk`]"))
+  }
+
+  test("column-update split: row-ID reassignment is allowed when 
requiredDataAttributes " +
+    "covers every table column") {
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |""".stripMargin,
+      splitReqAttrsTableProps("pk,salary,dep"))
+
+    sql(s"UPDATE $tableNameAsString SET pk = pk + 10, salary = -1 WHERE dep = 
'hr'")
+
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(2, 200, "software") :: Row(11, -1, "hr") :: Nil)
+  }
+
+  test("column-update split: row-ID reassignment is allowed when 
requiredDataAttributes " +
+    "covers every data column of a relation with metadata columns") {
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |""".stripMargin,
+      splitReqAttrsTableProps("pk,salary,dep"))
+
+    // `_partition` and the coerced `salary > 0.5` make the analyzer add the 
metadata columns to

Review Comment:
   **71. This comment is no longer true since SPARK-60667, so the test no 
longer covers what its name says.** With the merge of master, 
`AddMetadataColumns` skips the target of a row-level command 
(`Analyzer.scala:1144` and `Analyzer.scala:1197-1198`), so the relation stays 
`[pk, salary, dep]` with no metadata columns in it. The statement should also 
run now: the delete and reinsert outputs of `buildDeletesAndInserts` have the 
same width, 6, and SPARK-60667's commit message lists this `ExpandExec` failure 
as fixed. `DeltaBasedUpdateAsDeleteAndInsertTableSuite.scala:98-128` already 
runs the same shape. As a result:
   
   - The only assertion, at L453, checks the declaration, and the relation is 
now the same as in the test at L420. Removing the `filterNot` at 
`RewriteUpdateTable.scala:319`, the fix for [item 
21](https://github.com/apache/spark/pull/55518#discussion_r4316536100), fails 
no test. The filter now matters only on a table that accepts any schema, where 
it swaps `COLUMN_UPDATE_SPLIT_ROW_ID_REASSIGNMENT` for an `ExpandExec` failure.
   - No narrow suite executes a later-iteration UPDATE that references a 
metadata column, because the SPARK-60667 tests are in concrete suites that the 
narrow suites do not extend.
   - `executePlan(parsed).analyzed` at L449 leaves a transaction open, because 
the default mode begins one (`QueryExecution.scala:145-151`) and nothing 
commits or aborts it. It is harmless today only because `after` resets the 
catalog manager.
   
   a6e22b24587 removed two other tests that carried this premise, and this one 
stayed. Could this test run the statement with `sql(...)` and check `Row(2, 
200, "software") :: Row(11, -1, "hr") :: Nil`, with the name and the comment 
updated? `dataAttrs` at `RewriteUpdateTable.scala:319` could then be 
`relation.output`, which keeps the row-ID error on such a table.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -372,6 +875,66 @@ private class DeltaBufferWriter(schema: StructType) 
extends BufferWriter(schema)
   override def commit(): WriterCommitMessage = buffer
 }
 
+// Test-only: a writer factory for a connector that never overrides 
writeColumnUpdate(), to
+// exercise the DataWriter#writeColumnUpdate default that throws
+// DATA_SOURCE_WRITE_COLUMN_UPDATE_NOT_IMPLEMENTED.
+// Declared top-level (no reference to the enclosing table) so it stays 
task-serializable.
+private class NoWriteColumnUpdateOverrideWriterFactory extends 
DataWriterFactory {

Review Comment:
   **78.** Nit: since item 58 moved the `writeColumnUpdate` overrides off 
`BufferWriter`, this writer and its factory duplicate the plain writer that 
`TestBatchWrite` already creates (`InMemoryBaseTable.scala:1009-1011`), which 
ends in the same throw at `DataWriter.java:138-142`. L730 could be 
`super.createBatchWriterFactory(info)`, with L878-893 gone and 
`GroupBasedColumnUpdateTableSuite.scala:167` expecting 
`org.apache.spark.sql.connector.catalog.BufferWriter`. That test would then 
also fail if the overrides came back to `BufferWriter`, which no test catches 
today.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupBasedRowLevelOperationScanPlanning.scala:
##########
@@ -65,7 +65,9 @@ object GroupBasedRowLevelOperationScanPlanning extends 
Rule[LogicalPlan] with Pr
           .mkString(", ")
       }
 
-      val (scan, output) = PushDownUtils.pruneColumns(scanBuilder, relation, 
relation.output, Nil)
+      val requiredAttrs =
+        if (V2Writes.hasNarrowRows(rd)) readAttrs(rd.query, relation) else 
relation.output

Review Comment:
   **77.** Nit, and fine to leave as is or to do separately: this gate ties a 
scan rule to the write layout, although `readAttrs` gives the same columns for 
wide writes unless the condition folds to true. Copied rows of DELETE and MERGE 
reference every read column, and for UPDATE only `If(TrueLiteral, trueValue, _) 
=> trueValue` drops one (`optimizer/expressions.scala:713`), in which case no 
COPY row needs it. So a wide `UPDATE t SET big_col = NULL` with no WHERE clause 
reads `big_col` for every row, while delta and narrow copy-on-write scans do 
not. `val requiredAttrs = readAttrs(rd.query, relation)` would remove that 
difference and match `ColumnPruning`, which applies to every write since item 
11. It is not free of behavior change, though: a scan with unconditional 
`filterAttributes()` would then fail `UPDATE t SET dep = 'x'` with 
`DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE` 
(`DataSourceV2Strategy.scala:207`), where master runs it.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -354,3 +364,69 @@ trait RewriteRowLevelCommand extends Rule[LogicalPlan] {
     }
   }
 }
+
+object RewriteRowLevelCommand extends SQLConfHelper {
+
+  /**
+   * Whether the operation narrows the write: it mixes in 
`SupportsColumnUpdates` and declares at
+   * least one column. An operation that declares none receives full-width 
rows.
+   */
+  private[sql] def narrowsWrite(operation: RowLevelOperation): Boolean = 
operation match {
+    case op: SupportsColumnUpdates => op.requiredDataAttributes.nonEmpty
+    case _ => false
+  }
+
+  /**
+   * Resolves the connector-declared required data attributes for 
column-update writes against
+   * the given relation, in declared order and with the relation's spelling. 
Nested fields,
+   * duplicate columns, columns that do not exist in the relation (including a 
reference with no
+   * name parts) and metadata columns are rejected with an `AnalysisException`.
+   */
+  private[sql] def resolveRequiredDataAttrs(
+      relation: LogicalPlan,
+      operation: SupportsColumnUpdates): Seq[AttributeReference] = {
+    val refs = operation.requiredDataAttributes.toImmutableArraySeq
+    val nameless = refs.filter(_.fieldNames.isEmpty).map(_ => 
Seq.empty[String])
+    if (nameless.nonEmpty) {
+      throw QueryCompilationErrors.unknownRequiredDataAttributeError(
+        operation.getClass.getName, nameless)
+    }
+    val nested = refs.filter(_.fieldNames.length > 1).map(_.fieldNames.toSeq)
+    if (nested.nonEmpty) {
+      throw QueryCompilationErrors.nestedRequiredDataAttributeError(
+        operation.getClass.getName, nested)
+    }
+    val normalizedNames =
+      refs.map(ref => SchemaUtils.foldName(ref.fieldNames.head, 
conf.caseSensitiveAnalysis))

Review Comment:
   **68. The duplicate check treats `foldName` as column identity, which its 
Scaladoc says it is not.** `SchemaUtils.scala:541-549` says "The fold alone is 
therefore not an identity" and "Callers must narrow a multi-candidate match 
with the resolver". In a case-insensitive session, a column named U+0130 and a 
column named `i` plus U+0307 fold to the same string while `equalsIgnoreCase` 
keeps them apart. Each reference resolves to its own column at L410, yet 
declaring both fails here with 
`COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE`, whose message says "Each 
column must be declared at most once". This is rare and it fails loudly, so it 
is low priority. Moving the check after L418 and counting `resolved` by exprId, 
filtering `refs.zip(resolved)` in order, would use the resolver's answer and 
keep `` [`salary`, `PK`] ``. Two messages would change, and no test pins 
either: `x,x` would become `COLUMN_UPDATE_UNKNOWN_REQUIRED_DATA_ATTRIBUTE`, and 
case-insensitive `salary,SALARY` on a ta
 ble with both columns would become `AMBIGUOUS_REFERENCE` from L410.



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/write/RowLevelOperationInfo.java:
##########
@@ -37,4 +38,22 @@ public interface RowLevelOperationInfo {
    * Returns the row-level SQL command (e.g. DELETE, UPDATE, MERGE).
    */
   Command command();
+
+  /**
+   * Returns the columns being updated by this operation. Currently only 
UPDATE populates it;
+   * other commands report an empty array.
+   * <p>
+   * A column is reported unless its assigned value is the column itself, so 
identity assignments
+   * such as {@code SET a = a} are excluded. With the default CHAR and VARCHAR 
handling, a
+   * self-assignment on a CHAR or VARCHAR column is still reported, because 
Spark pads or
+   * length-checks the assigned value. Nested struct field updates are 
reported at root-column
+   * granularity (e.g. {@code SET s.c1 = -1} returns {@code s}), even when the 
field is assigned to

Review Comment:
   **70. The PR description's exception list for identity assignments leaves 
out nested self-assignments.** It says "Identity assignments such as `SET a = 
a` are excluded, except self-assignments on CHAR or VARCHAR columns with the 
default CHAR and VARCHAR handling", which reads as a closed list, but `SET s.c1 
= s.c1` is reported as `s`, as this Javadoc and 
`ColumnUpdateTableSharedTests.scala:362-371` say. An operation that declares 
`[pk, dep]` therefore rejects that statement with 
`COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS`. Could the 
description mention it? Reporting `s` looks right to me: 
`applyFieldAssignments` (`AssignmentUtils.scala:222`) has no null guard, so a 
NULL `s` becomes `{null, null}`, as "update fields inside NULL structs" 
(`UpdateTableSuiteBase.scala:456-467`) implies. For the same reason, "although 
its values don't change" at `ColumnUpdateTableSharedTests.scala:367-368` holds 
only for a non-NULL `s`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -159,14 +164,24 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
       relation: DataSourceV2Relation,
       operationTable: RowLevelOperationTable,
       assignments: Seq[Assignment],
-      cond: Expression): WriteDelta = {
+      cond: Expression,
+      writeAttrs: Seq[AttributeReference],
+      updatedAttrs: Seq[AttributeReference]): WriteDelta = {
 
     val operation = operationTable.operation.asInstanceOf[SupportsDelta]
 
     // resolve all needed attrs (e.g. row ID and any required metadata attrs)
-    val rowAttrs = relation.output
     val rowIdAttrs = resolveRowIdAttrs(relation, operation)
     val metadataAttrs = resolveRequiredMetadataAttrs(relation, operation)
+    val splitUpdates = operation.representUpdateAsDeleteAndInsert
+
+    operation match {
+      case op: SupportsColumnUpdates if splitUpdates && 
RewriteRowLevelCommand.narrowsWrite(op) =>

Review Comment:
   **72. No test covers `&& RewriteRowLevelCommand.narrowsWrite(op)`.** Every 
test of an empty declaration (`reqAttrsTableProps("")` at 
`ColumnUpdateTableSharedTests.scala:237` and 
`ColumnUpdateTableSharedTests.scala:293`) is non-split, and every split test 
declares at least one column, so reverting this line to `case op: 
SupportsColumnUpdates if splitUpdates =>` fails nothing. The difference shows 
on `t(pk INT NOT NULL, salary INT, dep STRING)` with 
`column-update-split-row-id = _partition, no-metadata = true`: `UPDATE t SET 
salary = salary WHERE pk = 1` analyzes to a full-width `WriteDelta` with the 
guard, and fails with `COLUMN_UPDATE_SPLIT_ROW_ID_NOT_DECLARED` without it. 
This is the `DeltaBasedColumnUpdateSplitOperationWithRowId` shape from item 55. 
The fixture's commit throws `IllegalStateException` for a matching row 
(`InMemoryRowLevelOperationTable.scala:561-564`), so a test would have to check 
the analyzed plan.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2Writes.scala:
##########
@@ -109,22 +112,95 @@ object V2Writes extends Rule[LogicalPlan] with 
PredicateHelper {
       WriteToDataSourceV2(Some(r), microBatchWrite, newQuery, customMetrics)
 
     case rd @ ReplaceData(r: DataSourceV2Relation, _, query, _, projections, 
_, None) =>
-      val rowSchema = projections.rowProjection.schema
+      validateWriteRelation(rd)
+      val (rowSchema, columnUpdateSchema) =
+        writeSchemas(projections.rowProjection.schema, hasNarrowRows(rd))
       val metadataSchema = projections.metadataProjection.map(_.schema)
       val writeOptions = mergeOptions(Map.empty, 
r.options.asCaseSensitiveMap.asScala.toMap)
-      val writeBuilder = newWriteBuilder(r.table, writeOptions, rowSchema, 
metadataSchema)
+      val writeBuilder = newWriteBuilder(r.table, writeOptions, rowSchema, 
metadataSchema,
+        columnUpdateSchema)
       val write = writeBuilder.build()
+      validateColumnUpdateWrite(rd, write)
       val newQuery = DistributionAndOrderingUtils.prepareQuery(write, query, 
r.funCatalog)
       rd.copy(write = Some(write), query = newQuery)
 
     case wd @ WriteDelta(r: DataSourceV2Relation, _, query, _, projections, _, 
None) =>
+      validateWriteRelation(wd)
       val writeOptions = mergeOptions(Map.empty, 
r.options.asCaseSensitiveMap.asScala.toMap)
-      val deltaWriteBuilder = newDeltaWriteBuilder(r.table, writeOptions, 
projections)
+      val deltaWriteBuilder =
+        newDeltaWriteBuilder(r.table, writeOptions, projections, 
hasNarrowRows(wd))
       val deltaWrite = deltaWriteBuilder.build()
+      validateColumnUpdateWrite(wd, deltaWrite)
       val newQuery = DistributionAndOrderingUtils.prepareQuery(deltaWrite, 
query, r.funCatalog)
       wd.copy(write = Some(deltaWrite), query = newQuery)
   }
 
+  /**
+   * Whether a row-level write delivers narrow rows for updated, copied, and 
reinserted records.
+   * This must match the writes whose write relation RewriteUpdateTable 
narrows, even when the
+   * declared columns cover the whole table.
+   */
+  private[v2] def hasNarrowRows(write: RowLevelWrite): Boolean = {
+    write.operation.command == UPDATE && 
RewriteRowLevelCommand.narrowsWrite(write.operation)
+  }
+
+  /**
+   * Checks that the write relation matches the current operation: a write 
that delivers narrow
+   * rows must have exactly the declared columns, in declared order, and any 
other write the full
+   * table columns. A rule that replaces the operation after the rewrite can 
break this, and the
+   * connector would then decode the rows with the wrong layout.
+   */
+  private def validateWriteRelation(rowLevelWrite: RowLevelWrite): Unit = {
+    val writeNames = rowLevelWrite.table.output.map(_.name)
+    val expectedNames = rowLevelWrite.operation match {
+      case op: SupportsColumnUpdates if hasNarrowRows(rowLevelWrite) =>
+        
RewriteRowLevelCommand.resolveRequiredDataAttrs(rowLevelWrite.originalTable, op)
+          .map(_.name)
+      case _ =>
+        rowLevelWrite.originalTable.output.map(_.name)
+    }
+    if (writeNames != expectedNames) {
+      throw SparkException.internalError(
+        s"The write relation of operation 
${rowLevelWrite.operation.getClass.getName} has " +
+          s"columns ${writeNames.mkString("[", ", ", "]")}, but the operation 
expects " +
+          s"${expectedNames.mkString("[", ", ", "]")}.")
+    }
+  }
+
+  /**
+   * Checks that the distribution and ordering of a write that delivers narrow 
rows reference only
+   * columns passed to the write. Other columns stay in the query only until 
column pruning removes
+   * them, so they are rejected here whether or not pruning ran.
+   */
+  private def validateColumnUpdateWrite(rowLevelWrite: RowLevelWrite, write: 
Write): Unit = {
+    write match {
+      case w: RequiresDistributionAndOrdering if hasNarrowRows(rowLevelWrite) 
=>
+        val distribution =
+          
DistributionAndOrderingUtils.distributionExpressions(w.requiredDistribution)
+        val readAttrs = rowLevelWrite.references.toSeq.distinctBy(_.name)

Review Comment:
   **75.** Nit: `distinctBy(_.name)` and the `sortBy` at L184 do nothing this 
check needs. Every read ordinal comes from `indexWhere` with the resolver 
(`RewriteRowLevelCommand.scala:332`), so two attributes in `references` cannot 
share a name, and `LocalRelation(rowLevelWrite.references.toSeq)`, as in item 
7, is enough. If such a path ever appeared, `distinctBy` would let the column 
pass here and fail with `AMBIGUOUS_REFERENCE` in `prepareQuery`. 
`unresolvableReferences` already walks the expressions in order and ends with 
`.distinct`, so the list is deterministic without the sort, and the sort makes 
this the only `COLUMN_UPDATE_*` list not in the connector's or the assignments' 
order: `column-update-cluster-by = id,dep` reports `` [`dep`, `id`] ``. 
Dropping it changes the expected value at 
`DeltaBasedColumnUpdateTableSuite.scala:228`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -237,4 +253,101 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
     val expandOutput = generateExpandOutput(attrs, outputs)
     Expand(outputs, expandOutput, matchedRowsPlan)
   }
+
+  /**
+   * Returns the table attributes that these assignments set to a new value, 
skipping identity
+   * assignments.
+   */
+  private def collectUpdatedAttrs(assignments: Seq[Assignment]): 
Seq[AttributeReference] = {
+    assignments.collect {
+      case Assignment(key: AttributeReference, value) if 
!isIdentityAssignment(key, value) => key
+    }
+  }
+
+  /**
+   * Returns the output of the write relation, which is the layout of the data 
rows the connector
+   * receives. If the operation opts into column updates, this is the 
connector's
+   * `requiredDataAttributes()`. An empty declaration yields the full relation 
output, as does any
+   * operation without the mix-in. A mixed-in operation's declaration must 
cover every updated
+   * column, even an empty one.
+   */
+  private def resolveWriteAttrs(
+      relation: DataSourceV2Relation,
+      operation: RowLevelOperation,
+      updatedAttrs: Seq[AttributeReference]): Seq[AttributeReference] = 
operation match {
+    case op: SupportsColumnUpdates =>
+      val attrs = if (RewriteRowLevelCommand.narrowsWrite(op)) {

Review Comment:
   **76.** Nit: this branch is no longer needed. Since 8d9856dd55b removed the 
`refs.isEmpty` throw, `resolveRequiredDataAttrs` returns an empty `Seq` for an 
empty declaration, so L279-283 can be `val attrs = 
RewriteRowLevelCommand.resolveRequiredDataAttrs(relation, op)`, with L284-285 
unchanged. Similarly, the `operation match` at L178-184 can be `if 
(splitUpdates && RewriteRowLevelCommand.narrowsWrite(operation))`, because 
`narrowsWrite` takes a `RowLevelOperation` and both validators use the 
operation only for its class name. `writeRelation.output` at L88, L127 and L203 
is `writeAttrs`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala:
##########
@@ -111,10 +112,10 @@ class 
RowLevelOperationRuntimeGroupFiltering(optimizeSubqueries: Rule[LogicalPla
         // and must transform the runtime filter condition to use correct expr 
IDs for each relation
         // note this only applies to group-based row-level operations (i.e. 
ReplaceData)
         // see RewriteUpdateTable for more details
-        // the condition may also reference metadata columns resolved from the 
metadata output
-        // of the target relation, which are not in the table output and must 
be mapped too
-        val metadataAttrs = (cond.references -- write.table.output).toSeq
-        val attrMap = buildTableToScanAttrMap(write.table.output ++ 
metadataAttrs, relation.output)
+        // the write table may leave out data columns, so map those of the 
original table

Review Comment:
   **74.** Nit: since SPARK-60664 this hunk no longer changes the result, and 
it dropped master's comment. `attrMap` only rewrites attributes in `cond` 
(L119-121), and master's `cond.references -- write.table.output` already 
includes an undeclared column such as `id`, so 
`GroupBasedColumnUpdateTableSuite.scala:193` should behave the same without 
this change. Master's "the condition may also reference metadata columns 
resolved from the metadata output of the target relation, which are not in the 
table output and must be mapped too" is gone although L117 still does that, and 
[the reply to item 
57](https://github.com/apache/spark/pull/55518#discussion_r4426726027) covers 
the code but not the comment. Could this be `val attrMap = 
buildTableToScanAttrMap(cond.references.toSeq, relation.output)`, which needs 
neither `tableAttrs`, `metadataAttrs` nor either comment? Keeping these lines 
and restoring the comment works too.



-- 
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