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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -343,3 +353,63 @@ trait RewriteRowLevelCommand extends Rule[LogicalPlan] {
     }
   }
 }
+
+object RewriteRowLevelCommand extends SQLConfHelper {
+
+  /**
+   * Resolves the connector-declared required data attributes for 
column-update writes against
+   * the given relation, in declared order and with the relation's spelling. 
An empty array,
+   * 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
+    if (refs.isEmpty) {

Review Comment:
   **55. An operation whose declaration is exactly `updatedColumns()` fails an 
UPDATE whose assignments are all identity assignments, and the new fixture arms 
do not guard against it.** This is my answer to your question in the [latest 
summary](https://github.com/apache/spark/pull/55518#pullrequestreview-5672546710).
 `collectUpdatedAttrs` skips identity assignments 
(`RewriteUpdateTable.scala:252-256`), so `UPDATE t SET salary = salary WHERE pk 
= 1` reports no updated columns. An operation that declares exactly 
`info.updatedColumns()` then fails here at analysis with 
`COLUMN_UPDATE_EMPTY_REQUIRED_DATA_ATTRIBUTES`, while the same statement works 
without the mix-in. That declaration is what `SupportsColumnUpdates.java:59-61` 
asks for when no lookup or routing column is needed, e.g. for a delta operation 
whose row ID arrives through the row ID projection, and the suite's own 
`DeltaBasedColumnUpdateSplitOperationWithRowId` 
(`InMemoryRowLevelOperationTable.scala:623-631`) is one. It keeps 
 the mix-in for UPDATE (`InMemoryRowLevelOperationTable.scala:204-207`).
   
   8b1771ad6a7 answers this with a builder-side "should" 
(`SupportsColumnUpdates.java:65-66`) and with `updatesNothing` arms in the 
default fixtures (`InMemoryRowLevelOperationTable.scala:167-170` and `:190`). 
However, `declaredColumns` always starts with `pk` 
(`InMemoryRowLevelOperationTable.scala:395-400`), and every table those 
fixtures build has `pk`, so the arms prevent no failure. They only switch to a 
full-width operation, which the `extraTableProps -> None` arm of the new shared 
test (`ColumnUpdateTableSharedTests.scala:235`) then checks. The case `("id = 
id, dep = dep", Seq.empty, Seq("pk", "dep"), Row(1, 10, "hr"))`, which passed 
on d8a4641, was removed for the same reason.
   
   I'd prefer that Spark not narrow when the declaration is empty, rather than 
asking every builder to branch:
   - `resolveWriteAttrs` (`RewriteUpdateTable.scala:268-271`) keeps 
`validateUpdatedColumnsSubset`, so a non-identity UPDATE with an empty 
declaration still fails with 
`COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS`, and it 
returns `relation.output` when the declaration is empty.
   - `V2Writes.hasNarrowRows` (`V2Writes.scala:144-146`) uses the same 
predicate. Otherwise `validateWriteRelation` (`V2Writes.scala:157-158`) 
re-resolves the empty declaration and fails.
   - This check, its error condition, the Javadoc at 
`SupportsColumnUpdates.java:65-66` and the fixture arms can then go, and the 
removed case can come back.
   
   Removing L369-371 alone is not enough. With an empty write relation, 
`buildWriteDeltaProjections` returns `rowProjection = None` (L246-251), the 
DELETE shape, and `DeltaWritingSparkTask` calls `project` on it for every 
UPDATE row (`WriteToDataSourceV2Exec.scala:880` and `:900`). Narrowing only 
when `updatedColumns()` is non-empty would also work, but it stops narrowing 
identity-only UPDATEs for operations that declare columns (the `Some(Seq("pk", 
"salary"))` arm at `ColumnUpdateTableSharedTests.scala:236`), and 
`hasNarrowRows` would need `RowLevelOperationTable.operationInfo`, which 
`RowLevelWrite` does not expose. If the Javadoc rule stays instead, it needs 
the UPDATE scope from [this 
comment](https://github.com/apache/spark/pull/55518#discussion_r4395825050), 
and it should say that an operation whose declaration can be empty must drop 
the mix-in, since keeping the mix-in does not work for such an operation.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala:
##########
@@ -110,8 +111,10 @@ class 
RowLevelOperationRuntimeGroupFiltering(optimizeSubqueries: Rule[LogicalPla
         // this rule assigns runtime filters to both scan relations (will be 
shared at runtime)
         // 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)
+        // the map is built from the original table as the write table may be 
narrower than the

Review Comment:
   **57.** Nit: this comment reads as if the original table covers every column 
the condition references, which does not hold for metadata columns. When the 
UPDATE resolves in one analyzer iteration, `AddMetadataColumns` never widens 
the target relation, so `write.originalTable.output` has only the data columns, 
while a condition such as `_partition = 'hr' AND pk IN (SELECT pk FROM s)` 
references `_partition` from `metadataOutput`, which this map does not cover 
either. Could the comment say that the map covers the table's data columns, 
which the write table may leave out? The metadata-column case behaves the same 
on master with `write.table.output`, so I'll look into that part separately.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -228,4 +244,96 @@ 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:
   **56. `updatedColumns()` can name a metadata column, which no operation that 
keeps the mix-in can then declare.** This `collect` keeps metadata keys. When 
an UPDATE resolves only in a later Resolution iteration, for example because 
`salary > 0.5` needs a cast, `AddMetadataColumns` adds the metadata columns to 
the target relation (the shape that 
`DeltaBasedColumnUpdateTableSuite.scala:450-469` analyzes), and the alignment 
over that output (`ResolveRowLevelCommandAssignments.scala:48`) keeps an 
assignment to a metadata column. So `UPDATE t SET _partition = 'x' WHERE salary 
> 0.5` on a `column-update` or `column-update-cow` table reports 
`updatedColumns()` as `[_partition]`:
   - The default fixtures follow `SupportsColumnUpdates.java:59-61` and declare 
`[pk, dep, _partition]`, which `resolveRequiredDataAttrs` rejects with 
`COLUMN_UPDATE_METADATA_REQUIRED_DATA_ATTRIBUTE` 
(`RewriteRowLevelCommand.scala:402-408`), as `SupportsColumnUpdates.java:77-80` 
says it does.
   - An operation that leaves `_partition` out fails 
`validateUpdatedColumnsSubset` (L284-287) with 
`COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS` for 
`[_partition]`.
   
   Both messages blame the connector, while on a full-width table the same 
statement analyzes and the value goes through the metadata projection. Whether 
such an assignment survives the alignment depends on the analyzer iteration 
regardless of this PR (with `salary > 0` it is dropped silently), but the dead 
end between the two checks is new. Could `collectUpdatedAttrs` skip 
`MetadataAttribute` keys, or could the rewrite reject an assignment to a 
metadata column with an error that names the user's assignment?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala:
##########
@@ -1500,6 +1501,21 @@ private class BufferWriter(schema: StructType) extends 
DataWriter[InternalRow] {
     buffer.log.append(logEntry)
   }
 
+  // Tag UPDATE/COPY rows distinctly so tests can verify that Spark dispatches
+  // through writeColumnUpdate (the SupportsColumnUpdates path) rather than 
write.
+  override def writeColumnUpdate(metadata: InternalRow, row: InternalRow): 
Unit = {

Review Comment:
   **58. These overrides are on the `BufferWriter` that every in-memory writer 
uses, so a dispatch that sends full-width rows to `writeColumnUpdate` passes 
every suite.** A writer that does not override `writeColumnUpdate`, as for any 
operation that does not opt in, throws 
`DATA_SOURCE_WRITE_COLUMN_UPDATE_NOT_IMPLEMENTED` there 
(`DataWriter.java:138-142`). With these overrides, every in-memory writer 
appends the row exactly like `write`, and only the log tag differs. The commits 
do not look at the tag: `PartitionBasedReplaceData.doCommit` commits 
`buffer.rows` (`InMemoryRowLevelOperationTable.scala:269-278`), and 
`PartitionBasedNarrowReplaceData.doCommit` overlays each tagged row on its 
original row with `columnUpdateSchema().orElse(schema())` 
(`InMemoryRowLevelOperationTable.scala:703-704` and `:756-769`), which rebuilds 
the same full row. So neither of these regressions would be noticed:
   - `DataWithProjectionWritingSparkTask` calls `writeColumnUpdate` regardless 
of `useWriteColumnUpdate` (`WriteToDataSourceV2Exec.scala:836-837`). 
GroupBasedNoMetadataUpdateTableSuite, GroupBasedNoMetadataDeleteFromTableSuite 
and GroupBasedNoMetadataMergeIntoTableSuite do not check the write log, so they 
stay green.
   - `DataSourceV2Strategy.scala:629` checks only 
`isInstanceOf[SupportsColumnUpdates]`, while `V2Writes.hasNarrowRows` keeps 
`command == UPDATE` (`V2Writes.scala:144-146`). The DELETE and MERGE rows of a 
`column-update-cow` table then go to `writeColumnUpdate`, and 
`checkDeleteFallsBackToFullWidthRows` and `checkMergeFallsBackToFullWidthRows` 
(`ColumnUpdateTableSharedTests.scala:278-320`) still pass, since they check 
only `columnUpdateSchema()`, `updatedColumns()` and the final rows.
   
   Only the metadata-path tests that assert `Write` tags 
(`GroupBasedUpdateTableSuite.scala:48-50`, 
`GroupBasedDeleteFromTableSuite.scala:47-49` and 
`GroupBasedMergeIntoTableSuite.scala:75-81`) would catch the same mistake in 
`DataAndMetadataWritingSparkTask`. Could these overrides move to a writer that 
only the column-update fixtures create, e.g. a `BufferWriter` subclass used by 
`PartitionBasedNarrowReplaceData` and as the delegate of 
`RecordOnlyColumnUpdateWriter` (`InMemoryRowLevelOperationTable.scala:911`)? 
Alternatively, the two fallback helpers could also assert that the write log 
has no `WriteColumnUpdate` entry.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -273,6 +345,440 @@ class InMemoryRowLevelOperationTable private (
     override def abort(messages: Array[WriterCommitMessage]): Unit = {}
   }
 
+  // Finds the one current row with the given row-ID values, matching a data 
column by value and
+  // `_partition` by the partition key.
+  private def currentRowByRowId(values: Seq[(String, Any)]): InternalRow = {
+    val matches = dataMap.values.iterator.flatten.flatMap { buf =>
+      buf.rows.filter { row =>
+        values.forall {
+          case (name, value) if name == PartitionKeyColumn.name => 
buf.keyString() == value.toString
+          case (name, value) =>
+            val i = schema.fieldIndex(name)
+            row.get(i, schema.fields(i).dataType) == value
+        }
+      }
+    }.toSeq
+    matches match {
+      case Seq(row) => row
+      case _ => throw new IllegalStateException(
+        s"Expected one row with row ID $values, found ${matches.size}")
+    }
+  }
+
+  // Rebuilds a full table row for a narrow column-update row: the base row's 
values (or the
+  // existence default for a column added after the base row was written), 
overlaid with the
+  // narrow row's columns.
+  private def overlayNarrowRow(
+      baseRow: Option[InternalRow],
+      narrowRow: InternalRow,
+      narrowFieldIdx: Map[String, Int]): InternalRow = {
+    val fullRow = new GenericInternalRow(schema.length)
+    baseRow.foreach { base =>
+      for (i <- schema.fields.indices) {
+        val field = schema.fields(i)
+        val value = if (i < base.numFields) {
+          base.get(i, field.dataType)

Review Comment:
   **59. The narrow commits read stored rows by the current schema's position 
and type, so they rebuild wrong rows after a column move or a type change.** 
`dataMap` keeps each row in the schema it was written with 
(`alterTableWithData` keeps the old schema unless a column is dropped, 
`InMemoryBaseTable.scala:491-495`), and the reader maps fields by name against 
that schema and casts widened types (`InMemoryBaseTable.scala:1312-1313`, 
`:1331` and `:1377-1378`). This overlay, `currentRowByRowId` (L356-357) and the 
copy-on-write `pk` lookup (L762) use the current ordinal and type instead. For 
example, with `(pk INT NOT NULL, salary INT, bonus INT, dep STRING)` and the 
rows (1, 100, 10, hr), (2, 200, 20, software) and (3, 300, 30, hr):
   - After `ALTER TABLE t ALTER COLUMN salary AFTER bonus`, `UPDATE t SET 
salary = -1 WHERE pk = 1` would leave `bonus` = 100 for pk 1 on a 
`column-update` table, and `bonus` = 300 for the copied pk 3 on a 
`column-update-cow` table.
   - After `ALTER COLUMN bonus TYPE DOUBLE`, the overlay reads the stored INT 
slot as a DOUBLE, so `bonus` 10 would come back as 4.9E-323 instead of 10.0.
   - After moving `pk`, the first narrow UPDATE would fail at commit with 
"found 0".
   
   The full-width fixtures keep the right values in all three cases. No test 
does this today, but since finding 25 the whole `UpdateTableSuiteBase` battery 
runs on these fixtures, so the next schema-evolution UPDATE test added there 
would fail only on the narrow suites. Could these helpers read stored rows by 
name through the row's own `BufferedRows.schema`, as `BufferedRowsReader` does?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2Writes.scala:
##########
@@ -109,22 +113,98 @@ 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.isInstanceOf[SupportsColumnUpdates] && 
write.operation.command == UPDATE
+  }
+
+  /**
+   * 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 = w.requiredDistribution match {
+          case d: ClusteredDistribution => d.clustering.toImmutableArraySeq
+          case d: OrderedDistribution => d.ordering.toImmutableArraySeq
+          case _ => Seq.empty

Review Comment:
   **61.** Nit: this repeats the distribution match of 
`DistributionAndOrderingUtils.prepareQuery` with a different fallback. 
`prepareQuery` (`DistributionAndOrderingUtils.scala:41-49`), which runs right 
after this check (L124-125 and L134-135), matches `OrderedDistribution`, 
`ClusteredDistribution` and `UnspecifiedDistribution` without a wildcard, while 
this match ends with `case _ => Seq.empty`. If a distribution kind is added and 
only `prepareQuery` learns it, this check would skip that kind's columns, and 
an undeclared clustering column would again pass or fail depending on whether 
ColumnPruning ran, which item 7 removed. Could both use one helper that returns 
the distribution's expressions?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -72,10 +72,12 @@ trait RewriteRowLevelCommand extends Rule[LogicalPlan] {
   protected def buildOperationTable(
       table: SupportsRowLevelOperations,
       command: Command,
-      options: CaseInsensitiveStringMap): RowLevelOperationTable = {
-    val info = RowLevelOperationInfoImpl(command, options)
+      options: CaseInsensitiveStringMap,
+      updatedAttrs: Seq[AttributeReference] = Nil): RowLevelOperationTable = {
+    val updatedColumns = updatedAttrs.map(attr => 
FieldReference(Seq(attr.name)))

Review Comment:
   **60. Nothing tests that a top-level column with a dot in its name reaches 
`updatedColumns()` as one part.** This line uses 
`FieldReference(Seq(attr.name))`, i.e. `FieldReference.column(attr.name)`, so 
that a column such as `` `a.b` `` is not parsed into the nested reference `` 
`a`.`b` `` ([earlier 
thread](https://github.com/apache/spark/pull/55518#discussion_r3634079860)). 
However, no `checkLastUpdatedColumns` call uses a dotted name, and the only `` 
`a.b` `` UPDATE test (`DeltaBasedColumnUpdateTableSuite.scala:364-375`) runs on 
the req-attrs fixture, which ignores `updatedColumns()` and builds its expected 
message from `a.name` (`RewriteUpdateTable.scala:284`). So a revert to 
`FieldReference(attr.name)` would pass every suite, while connectors would 
receive `` `a`.`b` `` again and the default `column-update` fixtures, which 
declare the reported columns, would reject ``SET `a.b` = -1`` with 
`COLUMN_UPDATE_NESTED_REQUIRED_DATA_ATTRIBUTE`. Could a test on the default 
fixture update
  `` `a.b` `` and check `` checkLastUpdatedColumns("`a.b`") ``? `describe()` 
quotes a dotted part, so the check tells the two forms apart. 
`FieldReference.column(attr.name)` would also make the intent visible here.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -128,12 +151,61 @@ class InMemoryRowLevelOperationTable private (
     copied.replacedPartitions = replacedPartitions
     copied.lastWriteInfo = lastWriteInfo
     copied.lastWriteLog = lastWriteLog
+    copied.lastUpdatedColumns = lastUpdatedColumns
     copied
   }
 
   override def newRowLevelOperationBuilder(
       info: RowLevelOperationInfo): RowLevelOperationBuilder = {
-    if (properties.getOrDefault(SUPPORTS_DELTAS, "false") == "true") {
+    lastUpdatedColumns = info.updatedColumns()
+    val splitUpdates = properties.getOrDefault(SPLIT_UPDATES, 
"false").toBoolean
+    // the copy-on-write commit finds the original row by `pk`
+    val updatesPk = info.updatedColumns().exists(_.describe() == "pk")
+    // The default column-update fixtures drop the mix-in for an UPDATE with 
no updated columns,
+    // as SupportsColumnUpdates asks. The fixed-declaration fixtures, the 
split row-ID fixture and
+    // all fixtures for DELETE and MERGE keep it.
+    val updatesNothing = info.command == Command.UPDATE && 
info.updatedColumns().isEmpty
+    if (properties.getOrDefault(COLUMN_UPDATE, "false") == "true") {
+      if (updatesNothing) {
+        () => DeltaBasedOperation(info.command, info.options)
+      } else if (splitUpdates) {
+        () => new DeltaBasedColumnUpdateSplitOperation(
+          info.command, info.updatedColumns().toSeq, info.options)
+      } else {
+        () => new DeltaBasedColumnUpdateOperation(
+          info.command, info.updatedColumns().toSeq, info.options)
+      }
+    } else if (properties.containsKey(COLUMN_UPDATE_REQ_ATTRS)) {
+      val reqCols = 
properties.get(COLUMN_UPDATE_REQ_ATTRS).split(",").map(_.trim)
+      if (splitUpdates) {
+        () => new DeltaBasedColumnUpdateSplitOperationWithReqAttrs(
+          info.command, reqCols, info.options)
+      } else {
+        () => new DeltaBasedColumnUpdateOperationWithReqAttrs(info.command, 
reqCols, info.options)
+      }
+    } else if (properties.getOrDefault(COLUMN_UPDATE_EMPTY_REQ_ATTRS, "false") 
== "true") {

Review Comment:
   **62.** Nit: `column-update-empty-req-attrs` (L100) builds the same 
operation as `column-update-req-attrs` with an empty list. This branch exists 
only because `"".split(",")` returns `Array("")` at L179. With 
`.filter(_.nonEmpty)` there and at L197, "empty requiredDataAttributes throws 
AnalysisException" (`DeltaBasedColumnUpdateTableSuite.scala:336-350`) could use 
`reqAttrsTableProps("")`. With item 55's suggestion, this key would go away 
anyway.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RowLevelWriteColumnPruningSuite.scala:
##########
@@ -0,0 +1,278 @@
+/*
+ * 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.catalyst.optimizer
+
+import java.util
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.ProjectingInternalRow
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
AttributeReference, EqualNullSafe, Expression, If, Literal, Not}
+import org.apache.spark.sql.catalyst.plans.{LeftOuter, PlanTest}
+import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.plans.logical.MergeRows.{Copy, Keep, 
Update}
+import org.apache.spark.sql.catalyst.rules.RuleExecutor
+import org.apache.spark.sql.catalyst.util.{ReplaceDataProjections, 
WriteDeltaProjections}
+import org.apache.spark.sql.catalyst.util.RowDeltaUtils._
+import org.apache.spark.sql.connector.catalog.{Column, Table, TableCapability}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Tests the [[ColumnPruning]] case that prunes the query columns of a 
[[RowLevelWrite]] that its
+ * projections don't read and moves the projections to the ordinals of the 
pruned query.
+ */
+class RowLevelWriteColumnPruningSuite extends PlanTest {
+
+  object Optimize extends RuleExecutor[LogicalPlan] {
+    val batches = Batch("Column Pruning", FixedPoint(10),
+      ColumnPruning, CollapseProject, RemoveNoopOperators) :: Nil
+  }
+
+  // t(pk, id, dep, salary, bonus, extra) with the metadata column _partition
+  private val read = LocalRelation(
+    $"pk".int.notNull, $"id".int, $"dep".string, $"salary".int, $"bonus".int, 
$"extra".int,
+    $"_partition".string.notNull)
+
+  // the write and original table relations; the pruning never inspects them
+  private val tableRelation: DataSourceV2Relation = {

Review Comment:
   **64.** Nit: `TestRelation` could replace this placeholder. As L53 says, the 
pruning never inspects these relations, but the placeholder needs an anonymous 
`Table`, `DataSourceV2Relation.create` and the imports at L20, L32, L33 and 
L35. Catalyst tests already have a minimal `NamedRelation`, `TestRelation` 
(`V2WriteAnalysisSuite.scala:137`), which `InlineCTESuite.scala:39` also uses, 
so `TestRelation(Nil)` could serve as both `table` and `originalTable`.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -273,6 +345,440 @@ class InMemoryRowLevelOperationTable private (
     override def abort(messages: Array[WriterCommitMessage]): Unit = {}
   }
 
+  // Finds the one current row with the given row-ID values, matching a data 
column by value and
+  // `_partition` by the partition key.
+  private def currentRowByRowId(values: Seq[(String, Any)]): InternalRow = {
+    val matches = dataMap.values.iterator.flatten.flatMap { buf =>
+      buf.rows.filter { row =>
+        values.forall {
+          case (name, value) if name == PartitionKeyColumn.name => 
buf.keyString() == value.toString
+          case (name, value) =>
+            val i = schema.fieldIndex(name)
+            row.get(i, schema.fields(i).dataType) == value
+        }
+      }
+    }.toSeq
+    matches match {
+      case Seq(row) => row
+      case _ => throw new IllegalStateException(
+        s"Expected one row with row ID $values, found ${matches.size}")
+    }
+  }
+
+  // Rebuilds a full table row for a narrow column-update row: the base row's 
values (or the
+  // existence default for a column added after the base row was written), 
overlaid with the
+  // narrow row's columns.
+  private def overlayNarrowRow(
+      baseRow: Option[InternalRow],
+      narrowRow: InternalRow,
+      narrowFieldIdx: Map[String, Int]): InternalRow = {
+    val fullRow = new GenericInternalRow(schema.length)
+    baseRow.foreach { base =>
+      for (i <- schema.fields.indices) {
+        val field = schema.fields(i)
+        val value = if (i < base.numFields) {
+          base.get(i, field.dataType)
+        } else {
+          ResolveDefaultColumns.getExistenceDefaultValue(field)
+        }
+        fullRow.update(i, value)
+      }
+    }
+    schema.fields.zipWithIndex.foreach { case (field, i) =>
+      narrowFieldIdx.get(field.name).foreach { j =>
+        fullRow.update(i, narrowRow.get(j, field.dataType))
+      }
+    }
+    fullRow
+  }
+
+  // The base columns `pk` and `dep`, where the table has them, followed by 
the updated columns.
+  private def declaredColumns(updatedCols: Seq[NamedReference]): 
Array[NamedReference] = {
+    val base = Seq("pk", 
"dep").filter(schema.fieldNames.contains).map(FieldReference(_))
+    val baseNames = base.map(_.describe()).toSet
+    (base ++ updatedCols.filterNot(r => 
baseNames.contains(r.describe()))).toArray
+  }
+
+  // A delta-based operation that supports column-level updates: Spark sends 
only the declared
+  // columns in the row projection instead of the full row schema. It declares 
`pk` (the
+  // row-lookup key) and `dep`, where the table has them, plus whatever 
columns Spark reports as
+  // being assigned via `RowLevelOperationInfo#updatedColumns()`.
+  class DeltaBasedColumnUpdateOperation(
+      command: Command,
+      updatedCols: Seq[NamedReference] = Nil,
+      options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty())
+      extends DeltaBasedOperation(command, options)
+        with SupportsColumnUpdates {
+    override def representUpdateAsDeleteAndInsert(): Boolean = false
+    override def requiredDataAttributes(): Array[NamedReference] = 
declaredColumns(updatedCols)
+
+    // The case-class equality of DeltaBasedOperation would make this 
operation equal to a wide
+    // one, or to another column-update operation, with the same command and 
options.
+    override def equals(other: Any): Boolean = other match {
+      case ref: AnyRef => this eq ref
+      case _ => false
+    }
+    override def hashCode(): Int = System.identityHashCode(this)
+    override def canEqual(other: Any): Boolean = false
+
+    private val clusterExprs: Seq[Expression] = {
+      val refs = Option(properties.get(COLUMN_UPDATE_CLUSTER_BY))
+        .map(_.split(",").map(name => FieldReference(name.trim)).toSeq)
+        .getOrElse(Seq(PARTITION_COLUMN_REF))
+      if (properties.getOrDefault(COLUMN_UPDATE_CLUSTER_BY_NESTED_TRANSFORM, 
"false") == "true") {
+        refs.map(ref => LogicalExpressions.apply("f", 
LogicalExpressions.apply("g", ref)))
+      } else {
+        refs
+      }
+    }
+
+    override def newWriteBuilder(info: LogicalWriteInfo): DeltaWriteBuilder = {
+      lastWriteInfo = info
+      val writeSchema = info.columnUpdateSchema().orElse(info.schema())
+      val batchWrite = new TestBatchWrite with DeltaBatchWrite {
+        override def createBatchWriterFactory(
+            physicalInfo: PhysicalWriteInfo): DeltaWriterFactory = {
+          newWriterFactory(info, writeSchema)
+        }
+
+        override protected def doCommit(messages: Array[WriterCommitMessage]): 
Unit =
+          dataMap.synchronized {
+            val newData = messages.map(_.asInstanceOf[BufferedRows])
+            commitColumnUpdate(info, writeSchema, newData)
+          }
+
+        override def abort(messages: Array[WriterCommitMessage]): Unit = {}
+      }
+      new DeltaWriteBuilder {
+        override def build(): DeltaWrite =
+          if (noMetadata && !properties.containsKey(COLUMN_UPDATE_CLUSTER_BY)) 
{
+            new DeltaWrite {
+              override def toBatch: DeltaBatchWrite = batchWrite
+            }
+          } else {
+            new DeltaWrite with RequiresDistributionAndOrdering {
+
+              override def requiredDistribution(): Distribution = {
+                Distributions.clustered(clusterExprs.toArray)
+              }
+
+              override def requiredOrdering(): Array[SortOrder] = {
+                clusterExprs.map { expr =>
+                  LogicalExpressions.sort(
+                    expr,
+                    SortDirection.ASCENDING,
+                    SortDirection.ASCENDING.defaultNullOrdering()): SortOrder
+                }.toArray
+              }
+
+              override def toBatch: DeltaBatchWrite = batchWrite
+            }
+          }
+      }
+    }
+
+    protected def newWriterFactory(
+        info: LogicalWriteInfo,
+        writeSchema: StructType): DeltaWriterFactory = {
+      new DeltaBufferedRowsWriterFactory(writeSchema)
+    }
+
+    // For column-update writes, updated rows contain only the declared columns
+    // (columnUpdateSchema from LogicalWriteInfo). We expand each row to the 
full
+    // table schema by overlaying them on the base row found by pk.
+    protected def commitColumnUpdate(
+        info: LogicalWriteInfo,
+        writeSchema: StructType,
+        newData: Array[BufferedRows]): Unit = {
+      val writeFieldIdx = writeSchema.fieldNames.zipWithIndex.toMap

Review Comment:
   **65.** Nit: `StructType.getFieldIndex` already provides this name-to-index 
map. The three commits build `writeSchema.fieldNames.zipWithIndex.toMap` here, 
at L555 and at L751, and pass it to `overlayNarrowRow` (L371-374), which has to 
trust that the map belongs to `narrowRow`. Passing the narrow `StructType` and 
calling `getFieldIndex` (cached; `BufferedRowsReader` uses it at 
`InMemoryBaseTable.scala:1331`) would drop the three copies and tie the index 
to the row's schema. The `fieldNames.contains(name)` plus `fieldIndex(name)` 
pair at L567-571 would also become one `getFieldIndex` call.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/ColumnUpdateTableSharedTests.scala:
##########
@@ -0,0 +1,387 @@
+/*
+ * 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") {
+    createAndInitTableWithProps(reqAttrsTableProps("pk,salary"))
+
+    // `id` is neither declared nor referenced; `dep`, `bonus` and `extra` are 
read for the
+    // condition, the assignment and the CHECK constraint
+    val write = executeAndKeepAnalyzedWrite {
+      executeAndCheckScan(
+        s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep = 
'hr'",
+        s"pk INT, dep STRING, salary INT, bonus INT, extra 
INT$scanMetadataColumns")
+    }
+
+    val writeNodeName = if (deltaUpdate) "WriteDelta" else "ReplaceData"
+    val header = write.simpleString(Int.MaxValue)
+    assert(header.matches(s"""$writeNodeName RelationV2\\[pk#\\d+, 
salary#\\d+\\] .*"""), header)
+    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: undeclared, differently-cased columns in the RHS and 
WHERE clause are " +
+    "read with the table's own spelling") {
+    createAndInitTable("pk INT NOT NULL, salary INT, bonus INT, extra INT, dep 
STRING",
+      """{ "pk": 1, "salary": 100, "bonus": 10, "extra": 5, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "bonus": 20, "extra": 5, "dep": "hr" }
+        |""".stripMargin)
+
+    executeAndCheckScan(
+      s"UPDATE $tableNameAsString SET salary = salary + BONUS WHERE pk = 1 AND 
EXTRA > 3",
+      s"pk INT, salary INT, bonus INT, extra INT, dep 
STRING$scanMetadataColumns")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, 110, 10, 5, "hr") :: Row(2, 200, 20, 5, "hr") :: Nil)
+  }
+
+  test("column-update: assigned column outside requiredDataAttributes is 
rejected") {
+    createAndInitTableWithProps(reqAttrsTableProps("pk,salary"))
+
+    checkError(
+      exception = intercept[AnalysisException] {
+        sql(s"UPDATE $tableNameAsString SET bonus = 0 WHERE dep = 'hr'")
+      },
+      condition = 
"COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS",
+      parameters = Map("connector" -> reqAttrsOperationClass, "missingColumns" 
-> "[`bonus`]"))
+  }
+
+  test("column-update: an operation replaced after the rewrite is rejected") {
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |""".stripMargin)
+
+    val write = executeAndKeepAnalyzedWrite {
+      sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+    }
+    val relation = write.table.asInstanceOf[DataSourceV2Relation]
+    val operationTable = relation.table.asInstanceOf[RowLevelOperationTable]
+    val t = operationTable.table.asInstanceOf[InMemoryRowLevelOperationTable]
+    val replacements: Seq[(RowLevelOperation, String)] = if (deltaUpdate) {
+      Seq(
+        t.DeltaBasedOperation(UPDATE, CaseInsensitiveStringMap.empty()) -> 
"[pk, salary, dep]",
+        new t.DeltaBasedColumnUpdateOperation(UPDATE) -> "[pk, dep]")
+    } else {
+      Seq(
+        t.PartitionBasedOperation(UPDATE, CaseInsensitiveStringMap.empty()) -> 
"[pk, salary, dep]",
+        new t.PartitionBasedColumnUpdateOperation(UPDATE) -> "[pk, dep]")
+    }
+    replacements.foreach { case (operation, expectedNames) =>
+      assert(operation != operationTable.operation)
+      val replaced = write.withNewTable(
+        relation.copy(table = operationTable.copy(operation = operation)(
+          operationTable.operationInfo)))
+      checkError(
+        exception = intercept[SparkException](V2Writes(replaced)),
+        condition = "INTERNAL_ERROR",
+        parameters = Map("message" ->
+          (s"The write relation of operation ${operation.getClass.getName} has 
columns " +
+            s"[pk, dep, salary], but the operation expects $expectedNames.")))
+    }
+  }
+
+  test("column-update: nested struct field update narrows to the root struct 
column") {
+    createAndInitTable("pk INT NOT NULL, s STRUCT<c1: INT, c2: INT>, dep 
STRING",
+      """{ "pk": 1, "s": { "c1": 1, "c2": 2 }, "dep": "hr" }
+        |{ "pk": 2, "s": { "c1": 3, "c2": 4 }, "dep": "hr" }
+        |""".stripMargin)
+
+    sql(s"UPDATE $tableNameAsString SET s.c1 = -1 WHERE pk = 1")
+
+    checkLastUpdatedColumns("s")
+    checkLastColumnUpdateSchema("pk", "dep", "s")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+      Row(1, Row(-1, 2), "hr") :: Row(2, Row(3, 4), "hr") :: Nil)
+  }
+
+  test("column-update: excluding ColumnPruning keeps the write narrow") {
+    createAndInitTableWithProps(reqAttrsTableProps("pk,salary"))
+
+    withSQLConf(SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
ColumnPruning.ruleName) {
+      executeAndCheckScan(
+        s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep = 
'hr'",
+        s"pk INT, id INT, dep STRING, salary INT, bonus INT, extra 
INT$scanMetadataColumns")
+    }
+
+    checkLastColumnUpdateSchema("pk", "salary")
+    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: reassigning a declared row-ID column keeps the row's 
other columns") {
+    createAndInitTableWithProps(reqAttrsTableProps("pk,salary"))
+
+    sql(s"UPDATE $tableNameAsString SET pk = 2, salary = 0 WHERE pk = 1")
+
+    // the copy-on-write fixture finds original rows by `pk`, so it falls back 
to full-width rows
+    assert(table.lastWriteInfo.columnUpdateSchema().isPresent == deltaUpdate)
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString ORDER BY pk, id"),
+      Row(2, 1, "hr", 0, 10, 1) ::
+      Row(2, 2, "hr", 200, 20, 1) ::
+      Row(3, 3, "software", 300, 30, 1) :: Nil)
+  }
+
+  test("column-update: an UPDATE whose assignments are all identity 
assignments is narrowed " +
+    "only if the operation keeps the mix-in") {
+    Seq(
+      extraTableProps -> None,
+      reqAttrsTableProps("pk,salary") -> Some(Seq("pk", "salary"))
+    ).foreach { case (props, columnUpdateNames) =>
+      withTable(tableNameAsString) {
+        createAndInitTableWithProps(props)
+
+        sql(s"UPDATE $tableNameAsString SET salary = salary, dep = dep WHERE 
pk = 1")
+
+        checkLastUpdatedColumns()
+        columnUpdateNames match {
+          case Some(names) => checkLastColumnUpdateSchema(names: _*)
+          case None =>
+            assert(!table.lastWriteInfo.columnUpdateSchema().isPresent,

Review Comment:
   **63.** Nit: the full-width check is written out three times with three 
messages. L247-248, L281-283 and L312-314 repeat 
`assert(!table.lastWriteInfo.columnUpdateSchema().isPresent, ...)`, and L224 
checks `isPresent == deltaUpdate` without a message. Could 
`RowLevelOperationSuiteBase` get a `checkLastWriteIsFullWidth()` next to 
`checkLastColumnUpdateSchema` (item 45), with L224 picking one of the two by 
`deltaUpdate`?



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