dongjoon-hyun commented on code in PR #55518:
URL: https://github.com/apache/spark/pull/55518#discussion_r4354576399
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala:
##########
@@ -58,4 +63,13 @@ private[sql] case class RowLevelOperationTable(
override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
operation.newWriteBuilder(info)
}
+
+ /**
+ * Builds the operation for another table from the same info, for a rule
that redirects the
+ * write after the rewrite, such as to a branch of the table.
+ */
+ def withTable(newTable: Table with SupportsRowLevelOperations):
RowLevelOperationTable = {
Review Comment:
**26. `withTable` does not say that its one result must replace the write
relation and every read of the old table.** Each call builds a new operation
and a new `RowLevelOperationTable`, while scan planning pairs the reads with
the write by reference: `GroupBasedRowLevelOperation` collects the reads with
`r.table eq table` (`patterns.scala:480-483`), and
`GroupBasedRowLevelOperationScanPlanning` replaces only reads of that instance
(`GroupBasedRowLevelOperationScanPlanning.scala:93`).
`V2Writes.validateWriteRelation` compares only column names, so it does not
notice a mismatch.
If a rule swaps only the write relation, as the new test does before running
`V2Writes` alone (`GroupBasedColumnUpdateTableSuite.scala:551-555`), or calls
`withTable` once per relation, the write finds no read relation, so its
copy-on-write scan is never configured and runtime group filtering is skipped.
The in-memory fixture then fails on `scan.data` at commit
(`InMemoryRowLevelOperationTable.scala:728`). In Iceberg,
`CopyOnWriteOperation.overwrittenFiles()` returns an empty set when the scan is
null ([SparkWrite.java
L450-452](https://github.com/apache/iceberg/blob/9303e4c89705f139290ae8c1266fb4485785e279/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java#L450-L452)),
so the commit adds the rewritten files without removing the old ones, and the
rows are duplicated silently. Iceberg's `ResolveBranch` builds one instance and
uses it for both the write and the query, which is the right shape. This hazard
is not new with `withTable`, but after item 9 the
PR description points rule authors to this helper.
Could the Scaladoc say that a rule should call `withTable` once and use the
result for the write relation and every read of the old table?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/WriteToDataSourceV2Exec.scala:
##########
@@ -380,14 +380,17 @@ case class ReplaceDataExec(
write: Write,
rowLevelCommand: RowLevelOperation.Command,
tableName: String,
+ useWriteColumnUpdate: Boolean = false,
Review Comment:
**27.** Minor: this default is new in 22332b3 (the previous commit, 9b6655a,
had `useWriteColumnUpdate: Boolean,`), no caller uses it, and `false` is the
wrong value for every narrow write. The only construction passes the flag by
name (`DataSourceV2Strategy.scala:594`), and `withTransaction` and
`withNewChildInternal` copy it. A construction in the pre-PR six-argument shape
still compiles and sends narrow UPDATE and COPY rows to
`DataWriter.write(...)`, whose layout is the empty `schema()`, and nothing
fails, because the flag cannot be derived from `write`.
`DataAndMetadataWritingSparkTask` and `DataWithProjectionWritingSparkTask`
already require it. Could the default be dropped?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -128,12 +153,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
+ if (properties.getOrDefault(COLUMN_UPDATE, "false") == "true") {
+ 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") {
+ // Test-only: returns an empty requiredDataAttributes() so we can verify
Spark rejects it.
+ () => new DeltaBasedColumnUpdateOperationWithReqAttrs(
+ info.command, Array.empty, info.options)
+ } else if (properties.getOrDefault(COLUMN_UPDATE_COW, "false") == "true") {
+ if (info.updatedColumns().exists(_.describe() == "pk")) {
+ () => PartitionBasedOperation(info.command, info.options)
+ } else {
+ () => new PartitionBasedColumnUpdateOperation(
+ info.command, info.updatedColumns().toSeq, info.options)
+ }
+ } else if (properties.containsKey(COLUMN_UPDATE_COW_REQ_ATTRS)) {
Review Comment:
**29. Item 5's fallback covers only `column-update-cow`, so the req-attrs
copy-on-write flavor still commits wrong rows when an UPDATE assigns `pk`.**
L185-186 build a full-width operation when `updatedColumns()` contains `pk`, as
`SupportsColumnUpdates.java:62-67` now asks, but this branch always builds the
narrow operation, and its commit looks the base row up by the new `pk` value
with `find` (L747). With `reqAttrsTableProps("pk,salary")` on the shared rows
(1, 1, hr, 100, 10, 1), (2, 2, hr, 200, 20, 1) and (3, 3, software, 300, 30,
1), `UPDATE t SET pk = 2, salary = 0 WHERE pk = 1` is accepted because `pk` is
declared. The narrow row [2, 0] is then overlaid on the existing `pk = 2` row,
so the commit writes (2, 2, hr, 0, 20, 1) instead of (2, 1, hr, 0, 10, 1). With
duplicate `pk` values, the overlay uses whichever row comes first, while the
delta commit now fails unless exactly one row matches. No test assigns `pk` on
this flavor. Could this branch use the same fallback (or fa
il clearly), and could the copy-on-write commit also require exactly one match?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -321,23 +810,61 @@ class InMemoryRowLevelOperationTable private (
}
}
-private class DeltaBufferedRowsWriterFactory(schema: StructType) extends
DeltaWriterFactory {
+private class DeltaBufferedRowsWriterFactory(
+ schema: StructType,
+ intRowId: Boolean = true) extends DeltaWriterFactory {
override def createWriter(partitionId: Int, taskId: Long):
DeltaWriter[InternalRow] = {
- new DeltaBufferWriter(schema)
+ new DeltaBufferWriter(schema, intRowId)
+ }
+}
+
+// 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 {
+ override def createWriter(partitionId: Int, taskId: Long):
DataWriter[InternalRow] = {
+ new NoWriteColumnUpdateOverrideWriter
}
}
-private class DeltaBufferWriter(schema: StructType) extends
BufferWriter(schema)
- with DeltaWriter[InternalRow] {
+private class NoWriteColumnUpdateOverrideWriter extends
DataWriter[InternalRow] {
+ override def write(record: InternalRow): Unit = {}
+ override def commit(): WriterCommitMessage = new BufferedRows(Seq.empty,
StructType(Nil))
+ override def abort(): Unit = {}
+ override def close(): Unit = {}
+}
+
+// Test-only: a writer that overrides writeColumnUpdate(record) but not
+// writeColumnUpdate(metadata, record), so rows with metadata reach it through
the default.
+private class RecordOnlyColumnUpdateWriterFactory(schema: StructType) extends
DataWriterFactory {
+ override def createWriter(partitionId: Int, taskId: Long):
DataWriter[InternalRow] = {
+ new RecordOnlyColumnUpdateWriter(schema)
+ }
+}
+
+private class RecordOnlyColumnUpdateWriter(schema: StructType) extends
DataWriter[InternalRow] {
+ private val delegate = new BufferWriter(schema)
+ override def write(record: InternalRow): Unit = delegate.write(record)
+ override def writeColumnUpdate(record: InternalRow): Unit =
delegate.writeColumnUpdate(record)
+ override def commit(): WriterCommitMessage = delegate.commit()
+ override def abort(): Unit = delegate.abort()
+ override def close(): Unit = delegate.close()
+}
+
+// Logs the row ID of a DELETE as an integer when `intRowId` is set and as
null otherwise; the
+// commit then resolves such a DELETE from its metadata.
+private class DeltaBufferWriter(schema: StructType, intRowId: Boolean = true)
+ extends BufferWriter(schema) with DeltaWriter[InternalRow] {
private final val DELETE = UTF8String.fromString(Delete.toString)
private final val UPDATE = UTF8String.fromString(Update.toString)
private final val REINSERT = UTF8String.fromString(Reinsert.toString)
private final val INSERT = UTF8String.fromString(Insert.toString)
override def delete(meta: InternalRow, id: InternalRow): Unit = {
- val pk = id.getInt(0)
- buffer.deletes += pk
+ val pk: Any = if (intRowId) id.getInt(0) else null
Review Comment:
**30.** Minor: items 14 and 23 made the split commit locate rows by
`rowId()`, but this writer still drops a row ID that is not an `INT` and treats
an `INT` row ID as `pk`. With `no-metadata`, a delete arrives as `delete(null,
rowIdProjection)` (`WriteToDataSourceV2Exec.scala:890`). So on the suite's own
`column-update-split-row-id=_partition` + `no-metadata` table
(`DeltaBasedColumnUpdateTableSuite.scala:865-868`, which today runs only a
rejected UPDATE), `DELETE FROM t WHERE pk = 1` logs `(DELETE, null, null,
null)`, and the null-ID branch (L572-574) calls
`metadataSchema.fieldIndex("_partition")` on an empty schema (L559), which
fails with `FIELD_NOT_FOUND`. A MERGE with `WHEN MATCHED THEN DELETE` fails the
same way. With `column-update-split-row-id=id` on `pk INT NOT NULL, id INT NOT
NULL, dep STRING` and rows (1, 2, hr) and (2, 1, hr), `DELETE ... WHERE pk = 1`
adds 2 to `buffer.deletes`, and `InMemoryBaseTable.scala:416` removes `pk = 2`
instead. Nothing fails today, but the
fixture accepts both configurations. Could the writer keep a copy of the row
ID beside the logged entry (leaving the asserted `id` slot as it is) and
resolve such DELETEs through `currentRowByRowId`, or fail clearly for row-ID
setups it does not support?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala:
##########
@@ -141,6 +153,19 @@ abstract class RowLevelOperationSuiteBase
append(schemaString, jsonData)
}
+ protected def createAndInitTable(
Review Comment:
**39.** Nit: this overload re-inlines the column conversion (L117), the
default `dep` partitioning (L127) and the `TableInfo` build of
`createTable(columns, transforms)` (L142-149); only
`.withProperties(tableProps)` differs, and about 30 call sites create their
tables through it. `UpdateTableSuiteBase.scala:162-166` likewise rebuilds what
`createTable(columns, Array.empty[Transform])` does. Round 4
([r4074793101](https://github.com/apache/spark/pull/55518#discussion_r4074793101))
suggested a props parameter on the base helper. Could `createTable(columns,
transforms)` take `tableProps: java.util.Map[String, String] =
extraTableProps`, with both `createAndInitTable` overloads and
`UpdateTableSuiteBase` going through it?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2Commands.scala:
##########
@@ -356,6 +357,32 @@ trait RowLevelWrite extends V2WriteCommand with
SupportsSubquery {
def condition: Expression
def originalTable: NamedRelation
+ /** The projections that read query columns by ordinal. */
+ protected def queryProjections: Seq[ProjectingInternalRow]
+
+ /**
+ * The write reads the operation column at ordinal 0 and other query columns
only through
+ * projections, by ordinal. Any other query column can be pruned with
[[withPrunedQuery]].
+ */
+ override lazy val references: AttributeSet = {
+ val output = query.output
Review Comment:
**28. `references` walks the `List` behind `query.output` once per read
ordinal, which is quadratic in the query width.** `query.output` is a `List` in
the rewrite shapes (`Project.output` is `projectList.map`, and the relation
output comes from `StructType.map`), so `output.length` (L371) and
`output(ordinal)` (L376) are linear and run once per ordinal. A full-row write
reads every column, so one evaluation takes about 1.5 * W^2 list steps for a
W-column query, e.g. 37.5M at 5,000 columns. This now applies to every DSv2
DELETE, UPDATE and MERGE, not only to column updates: `CheckAnalysis` forces it
through `missingInput`, and the new `ColumnPruning` guard forces it again on
every new `ReplaceData` or `WriteDelta` copy. Master used the cached
`query.outputSet`, and 9b6655a's `query.output.take(lastReadOrdinal + 1)` was
linear. `Optimizer.scala:1541` repeats `output(i)` inside
`output.indices.filter`.
Also, L371 checks only the upper bound, so a `-1` ordinal from a third-party
builder escapes as a raw `IndexOutOfBoundsException` instead of this internal
error.
Could this use `val output = query.output.toIndexedSeq` with
`!output.isDefinedAt(ordinal)`, and could the `ColumnPruning` case use
`w.query.output.zipWithIndex.collect { case (a, i) if w.references.contains(a)
=> i }`?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DeltaBasedColumnUpdateTableSuite.scala:
##########
@@ -0,0 +1,1114 @@
+/*
+ * 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 org.apache.spark.{SparkException, SparkRuntimeException,
SparkUnsupportedOperationException}
+import org.apache.spark.sql.{AnalysisException, Row}
+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.write.{LogicalWriteInfo,
RowLevelOperationTable}
+import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
V2Writes}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StringType, 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 reqAttrsOperationClass =
+
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateOperationWithReqAttrs].getName
+
+ private val splitOperationClass =
+
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperation].getName
+
+ private val splitReqAttrsOperationClass =
+
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperationWithReqAttrs]
+ .getName
+
+ private val splitWithRowIdOperationClass =
+
classOf[InMemoryRowLevelOperationTable#DeltaBasedColumnUpdateSplitOperationWithRowId].getName
+
+ test("column-update: column update schema contains a single assigned
column") {
+ 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)
+
+ 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: column update schema contains multiple assigned
columns") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET id = -1, dep = 'engineering' 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,
+ StructField("dep", StringType, nullable = false),
+ StructField("id", IntegerType, nullable = false)
+ ))))
+ }
+
+ test("column-update: column update schema is pk and dep for a full identity
update") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET id = id, dep = dep WHERE pk = 1")
+
+ // All assignments are identity, so updatedColumns is empty; pk and dep
remain because the
+ // connector declares them as base columns.
+ checkLastWriteInfo(
+ expectedRowIdSchema = Some(StructType(Array(PK_FIELD))),
+ expectedMetadataSchema = Some(StructType(Array(PARTITION_FIELD,
INDEX_FIELD_NULLABLE))),
+ expectedColumnUpdateSchema = Some(StructType(Array(PK_FIELD,
DEP_FIELD))))
+ }
+
+ test("column-update: row filter condition is orthogonal to column
narrowing") {
+ 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)
+
+ sql(s"UPDATE $tableNameAsString SET dep = 'engineering' WHERE pk IN (1,
3)")
+
+ checkLastWriteInfo(
+ expectedRowIdSchema = Some(StructType(Array(PK_FIELD))),
+ expectedMetadataSchema = Some(StructType(Array(PARTITION_FIELD,
INDEX_FIELD_NULLABLE))),
+ expectedColumnUpdateSchema = Some(StructType(Seq(
+ PK_FIELD,
+ StructField("dep", StringType, nullable = false)
+ ))))
+ }
+
+ test("column-update: update all rows (no WHERE clause)") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |{ "pk": 3, "salary": 300, "dep": "hr" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = salary * 2")
+
+ 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("salary", IntegerType, nullable = true)
+ ))))
+ }
+
+ test("column-update: column update schema excludes identity assignments in a
mixed UPDATE") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET id = id, dep = 'engineering' 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,
+ StructField("dep", StringType, nullable = false)
+ ))))
+ }
+
+ 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 is empty for DELETE") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"DELETE FROM $tableNameAsString WHERE dep = 'hr'")
+
+ checkLastUpdatedColumns()
+ }
+
+ test("column-update split: row-level DELETE falls back to full-width rows") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split", "true"))
+ 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,
+ java.util.Map.of("column-update-split-row-id", "_partition"))
+ checkDeleteFallsBackToFullWidthRows(Row(2, 200, "software"))
+ }
+
+ test("column-update split: MERGE falls back to full-width rows") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split", "true"))
+ checkMergeFallsBackToFullWidthRows()
+ }
+
+ test("column-update: data correctness -- single column update") {
+ 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)
+
+ sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, "hr") :: Row(2, 2, "software") :: Row(3, 3, "hr") :: Nil)
+ checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
+ }
+
+ test("column-update: data correctness -- update all rows") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |{ "pk": 3, "salary": 300, "dep": "hr" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = salary * 2")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 200, "hr") :: Row(2, 400, "software") :: Row(3, 600, "hr") :: Nil)
+ checkUpdateMetrics(numUpdatedRows = 3, numCopiedRows = 0)
+ }
+
+ test("column-update: data correctness -- mixed identity and real
assignments") {
+ 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)
+
+ sql(s"UPDATE $tableNameAsString SET id = id, dep = 'engineering' WHERE pk
= 1")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 1, "engineering") :: Row(2, 2, "software") :: Row(3, 3, "hr") ::
Nil)
+ checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
+ }
+
+ private def createAndInitTableWithReqAttrs(
+ reqAttrs: String,
+ schemaString: String,
+ jsonData: String): Unit = {
+ createAndInitTable(schemaString, jsonData,
+ java.util.Map.of("column-update-req-attrs", reqAttrs))
+ }
+
+ test("column-update: requiredDataAttributes - data correctness") {
+ createAndInitTableWithReqAttrs("dep,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)
+
+ sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+
+ 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: undeclared distribution column is rejected") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "pk,salary",
+ "column-update-cluster-by", "dep"))
+
+ val excludeColumnPruning = SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
ColumnPruning.ruleName
+ 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" -> reqAttrsOperationClass, "columns" ->
"[`dep`]"))
+ }
+ }
+ }
+
+ test("column-update split: undeclared distribution column is rejected") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-split-req-attrs", "pk,salary",
+ "column-update-cluster-by", "dep"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+ parameters = Map("connector" -> splitReqAttrsOperationClass, "columns"
-> "[`dep`]"))
+ }
+
+ test("column-update: undeclared write requirement columns are listed once
each, sorted") {
+ createAndInitTable("pk INT NOT NULL, salary INT, id INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "id": 1, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "pk,salary",
+ "column-update-cluster-by", "id,dep"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+ parameters = Map("connector" -> reqAttrsOperationClass, "columns" ->
"[`dep`, `id`]"))
+ }
+
+ test("column-update: a write requirement on a missing nested field reports
why") {
+ createAndInitTable("pk INT NOT NULL, s STRUCT<c1: INT>, dep STRING",
+ """{ "pk": 1, "s": { "c1": 1 }, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "pk,s",
+ "column-update-cluster-by", "s.missing"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET s.c1 = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+ parameters = Map(
+ "connector" -> reqAttrsOperationClass,
+ "columns" -> "[`s`.`missing` (FIELD_NOT_FOUND)]"))
+ }
+
+ 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: undeclared column inside nested transforms is
rejected") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "pk,salary",
+ "column-update-cluster-by", "dep",
+ "column-update-cluster-by-nested-transform", "true"))
+
+ val excludeColumnPruning = SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
ColumnPruning.ruleName
+ 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" -> reqAttrsOperationClass, "columns" ->
"[`dep`]"))
+ }
+ }
+ }
+
+ test("column-update: distribution by an undeclared row ID column is
allowed") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "salary,dep",
+ "column-update-cluster-by", "pk"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, "hr") :: Row(2, 200, "hr") :: Nil)
+ }
+
+ test("column-update: distribution by an undeclared row ID column assigned to
itself is " +
+ "allowed") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-req-attrs", "salary,dep",
+ "column-update-cluster-by", "pk"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1, pk = pk 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: partition source column not in requiredDataAttributes")
{
+ 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)
+
+ executeAndCheckScanExcludes(s"UPDATE $tableNameAsString SET id = -1 WHERE
pk = 1", "dep")
+ 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 on
the row-ID " +
+ "column 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")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "salary", "dep"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 101, "hr") :: Row(2, 200, "software") :: Nil)
+ }
+
+ test("column-update: undeclared, differently-cased columns in the RHS and
WHERE clause " +
+ "are read with the table's own spelling") {
+ createAndInitTableWithReqAttrs("pk,dep,salary",
+ "pk INT NOT NULL, salary INT, bonus INT, extra INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "bonus": 10, "extra": 5, "dep": "hr" }
+ |""".stripMargin)
+
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = salary + BONUS WHERE pk = 1 AND
EXTRA > 3",
+ "pk INT, salary INT, bonus INT, extra INT, dep STRING, _partition
STRING")
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString"),
+ Row(1, 110, 10, 5, "hr") :: 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,
+ java.util.Map.of(
+ "column-update", "true",
+ 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: requiredDataAttributes resolves case-insensitively on
an assigned " +
+ "column 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")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "salary", "dep"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 101, "hr") :: Row(2, 200, "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")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "salary", "SALARY", "dep"))
+ checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(1, -1, 200,
"hr") :: Nil)
+ }
+ }
+
+ test("column-update: empty requiredDataAttributes throws AnalysisException")
{
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }""".stripMargin,
+ java.util.Map.of("column-update-empty-req-attrs", "true"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_EMPTY_REQUIRED_DATA_ATTRIBUTES",
+ parameters = Map("connector" -> reqAttrsOperationClass))
+ }
+
+ test("column-update: nested requiredDataAttributes throws
AnalysisException") {
+ createAndInitTableWithReqAttrs("pk,s.c1,dep",
+ "pk INT NOT NULL, s STRUCT<c1: INT, c2: INT>, dep STRING",
+ """{ "pk": 1, "s": { "c1": 1, "c2": 2 }, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET s.c1 = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_NESTED_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map("connector" -> reqAttrsOperationClass,
"nestedAttributes" -> "[`s`.`c1`]"))
+ }
+
+ test("column-update: duplicate requiredDataAttributes throws
AnalysisException") {
+ createAndInitTableWithReqAttrs("pk,pk,salary", "pk INT NOT NULL, salary
INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + 1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map("connector" -> reqAttrsOperationClass,
"duplicateAttributes" -> "[`pk`]"))
+ }
+
+ test("column-update: case-only duplicate requiredDataAttributes throws
AnalysisException") {
+ createAndInitTableWithReqAttrs("PK,pk,salary", "pk INT NOT NULL, salary
INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + 1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map("connector" -> reqAttrsOperationClass,
"duplicateAttributes" -> "[`PK`]"))
+ }
+
+ test("column-update: duplicate requiredDataAttributes are reported in
declared order") {
+ createAndInitTableWithReqAttrs("salary,pk,SALARY,PK", "pk INT NOT NULL,
salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + 1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_DUPLICATE_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map(
+ "connector" -> reqAttrsOperationClass, "duplicateAttributes" ->
"[`salary`, `pk`]"))
+ }
+
+ test("column-update: metadata column in requiredDataAttributes throws
AnalysisException") {
+ createAndInitTableWithReqAttrs("pk,_partition,dep", "pk INT NOT NULL,
salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET dep = 'x' WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_METADATA_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map(
+ "connector" -> reqAttrsOperationClass,
+ "metadataAttributes" -> "[`_partition`]"))
+ }
+
+ test("column-update split: metadata column in requiredDataAttributes throws
" +
+ "AnalysisException") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split-req-attrs", "pk,_partition,dep"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET dep = 'x' WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_METADATA_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map(
+ "connector" -> splitReqAttrsOperationClass,
+ "metadataAttributes" -> "[`_partition`]"))
+ }
+
+ test("column-update: requiredDataAttributes rejects a column that does not
exist") {
+ createAndInitTableWithReqAttrs("pk,nonexistent_col,id", "pk INT NOT NULL,
id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET id = -1 WHERE pk = 1")
+ },
+ condition = "COLUMN_UPDATE_UNKNOWN_REQUIRED_DATA_ATTRIBUTE",
+ parameters = Map(
+ "connector" -> reqAttrsOperationClass,
+ "unknownAttributes" -> "[`nonexistent_col`]"))
+ }
+
+ test("column-update: column update schema excludes undeclared columns") {
+ createAndInitTable("pk INT NOT NULL, salary INT, id INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "id": 10, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "id": 20, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "dep", "salary"))
+ }
+
+ test("column-update: data correctness") {
+ createAndInitTable("pk INT NOT NULL, salary INT, id INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "id": 10, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "id": 20, "dep": "software" }
+ |{ "pk": 3, "salary": 300, "id": 30, "dep": "hr" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+
+ 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)
+ }
+
+ private def createAndInitTableSplit(schemaString: String, jsonData: String):
Unit = {
+ createAndInitTable(schemaString, jsonData,
+ java.util.Map.of("column-update-split", "true"))
+ }
+
+ test("column-update split: column update schema has the declared columns") {
+ createAndInitTableSplit("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "software" }
+ |""".stripMargin)
+
+ 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 split: data correctness") {
+ createAndInitTableSplit("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)
+
+ sql(s"UPDATE $tableNameAsString SET id = -1 WHERE dep = 'hr'")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, "hr") :: Row(2, 2, "software") :: Row(3, -1, "hr") :: Nil)
+ }
+
+ test("column-update: split-updates sends a narrow UPDATE as delete and
reinsert") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update", "true", "split-updates", "true"))
+
+ 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: 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")
{
+ // `extra` is neither declared, updated, nor referenced, so
requiredDataAttributes()
+ // ([pk, dep, salary]) doesn't cover every table column -- the write stays
genuinely narrow
+ // and reassigning the row ID must be rejected.
+ 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") {
+ createAndInitTableSplitWithReqAttrs("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 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") {
+ createAndInitTableSplitWithReqAttrs("pk,salary,dep",
+ "pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |""".stripMargin)
+
+ // `_partition` and the coerced `salary > 0.5` make the analyzer add the
metadata columns to
+ // the target relation in a later iteration. Only analyzes the plan.
+ val parsed = spark.sessionState.sqlParser.parsePlan(
+ s"UPDATE $tableNameAsString SET pk = pk + 10, salary = -1 " +
+ s"WHERE dep = 'hr' AND _partition = 'hr' AND salary > 0.5")
+ val write = spark.sessionState.executePlan(parsed).analyzed.collectFirst {
+ case w: WriteDelta => w
+ }.getOrElse(fail("couldn't find WriteDelta in analyzed plan"))
+
+ assert(write.table.output.map(_.name) == Seq("pk", "salary", "dep"))
+ }
+
+ private def createAndInitTableSplitWithReqAttrs(
+ reqAttrs: String,
+ schemaString: String,
+ jsonData: String): Unit = {
+ createAndInitTable(schemaString, jsonData,
+ java.util.Map.of("column-update-split-req-attrs", reqAttrs))
+ }
+
+ test("column-update split: undeclared row-ID column on narrow write is
rejected") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split-row-id", "pk"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+ },
+ condition = "COLUMN_UPDATE_SPLIT_ROW_ID_NOT_DECLARED",
+ parameters = Map("connector" -> splitWithRowIdOperationClass, "rowIds"
-> "[`pk`]"))
+ }
+
+ test("column-update split: metadata row ID preserved on reinsert need not be
declared") {
+ // `_partition` is a required metadata attribute with
PRESERVE_ON_REINSERT, so the REINSERT
+ // row carries it through the metadata projection.
+ // 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,
+ java.util.Map.of("column-update-split-row-id", "_partition"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
Seq("salary"))
+ checkLastColumnUpdateWriteLog(
+ WriteLogEntry(operation = Delete, metadata = Some(Row("hr", null))),
+ reinsertWriteLogEntry(metadata = Row("hr", null), data = Row(-1)))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, "hr") :: Row(2, 200, "software") :: Nil)
+ }
+
+ test("column-update split: metadata row ID not preserved on reinsert must be
declared") {
+ // `index` is a required metadata attribute, but its REINSERT value is
null.
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split-row-id", "index"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+ },
+ condition = "COLUMN_UPDATE_SPLIT_ROW_ID_NOT_DECLARED",
+ parameters = Map("connector" -> splitWithRowIdOperationClass, "rowIds"
-> "[`index`]"))
+ }
+
+ test("column-update split: metadata row ID outside
requiredMetadataAttributes must be " +
+ "declared") {
+ // Without required metadata attributes, nothing projects `_partition`
into the REINSERT row.
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-split-row-id", "_partition",
"no-metadata", "true"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+ },
+ condition = "COLUMN_UPDATE_SPLIT_ROW_ID_NOT_DECLARED",
+ parameters = Map("connector" -> splitWithRowIdOperationClass, "rowIds"
-> "[`_partition`]"))
+ }
+
+ //
---------------------------------------------------------------------------
+ // Write relation tests: verify that only the write relation is narrowed to
the
+ // declared columns, for in-place and delete+insert deltas.
+ //
---------------------------------------------------------------------------
+
+ test("column-update: write relation has the declared columns in declared
order") {
Review Comment:
**36.** Minor: item 18's `ColumnUpdateTableSharedTests` took the tests
listed there, but eight more UPDATE tests are still copied between this suite
and `GroupBasedColumnUpdateTableSuite` (this suite's line / the group suite's
line):
- "write relation has the declared columns in declared order" (L883 / L245)
- "scan reads only the referenced columns" (L915 / L307), which differ only
by the trait's `scanMetadataColumns`
- "analyzed plan shows the declared columns as the write target" (L958 /
L500), which differ only by `write.nodeName`
- "an operation replaced after the rewrite is rejected" (L931 / L511), both
new in 22332b3
- "undeclared, differently-cased columns in the RHS and WHERE clause ..."
(L476 / L598)
- the column update schema test (L653 / L47) and "data correctness" (L665 /
L59)
- the assignment outside `requiredDataAttributes` (L1024 / L469)
That is about 110 lines on each side, every fix has to be made twice, and
`DeltaBasedNoMetadataColumnUpdateTableSuite` runs none of them. This suite also
hard-codes `java.util.Map.of("column-update-req-attrs", ...)` at L884, L916 and
L959 and in `createAndInitTableWithReqAttrs` (L289-295) instead of using its
own `reqAttrsTableProps`. Could these move into the trait, with hooks for the
replacement operations and `reqAttrsOperationClass`?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/RowLevelOperationSuiteBase.scala:
##########
@@ -127,6 +128,17 @@ abstract class RowLevelOperationSuiteBase
createTable(columns, transforms)
}
+ // `extraTableProps` as the TBLPROPERTIES clause of a CREATE TABLE statement
+ protected def extraTablePropertiesClause: String = {
Review Comment:
**40.** Nit: this sits between `createTable(columns)` (L126-129) and the
overload it delegates to (L142-149), so the `createTable` overloads, which were
contiguous before this PR, are split. AGENTS.md asks to "put the new member
with the code it belongs with" (as in item 19). Could it move next to
`extraTableProps` (L94-96) and `noMetadata` (L101-102)?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -343,3 +365,60 @@ 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 declaration,
+ * nested fields, duplicate columns, columns that do not exist in the
relation 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) {
+ throw
QueryCompilationErrors.emptyRequiredDataAttributesError(operation.getClass.getName)
+ }
+ val nested = refs.filter(_.fieldNames.length != 1).map(_.fieldNames.toSeq)
Review Comment:
**34.** Nit: a declaration with no name parts, i.e. a `NamedReference` whose
`fieldNames()` is empty, passes this `length != 1` filter, so it is reported as
a nested field, and since `toSQLId(Seq())` is empty, the message reads
"declared nested field(s) [] in `requiredDataAttributes()`. Declare the root
struct column instead of a nested field." This came with item 17's `toSQLId`
change; the same PR prints `<empty>` for this case in
`undeclaredWriteRequirementColumnsError` (`QueryCompilationErrors.scala:4725`)
and `V2ExpressionUtils.unresolvableColumns`. Could empty declarations be
rejected separately, e.g. as an unknown column shown as `<empty>`, before L390?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala:
##########
@@ -53,6 +53,68 @@ abstract class UpdateTableSuiteBase extends
RowLevelOperationSuiteBase {
s"Expected numCopiedRows=$expectedCopied, got
${summary.numCopiedRows()}")
}
+ test("SPARK-58111: updatedColumns: single non-identity assignment") {
Review Comment:
**32.** Minor: 22332b3 moved these six `updatedColumns()` tests from
`DeltaBasedUpdateTableSuiteBase` here, so they now run in all eight UPDATE
suites, i.e. 48 table setups and UPDATE jobs instead of 18. The value they
check is computed from the assignments before the operation type matters
(`RewriteUpdateTable.scala:47-48`), so it does not depend on the table kind,
and "no WHERE clause still reports assigned columns" (L96) checks nothing
beyond L56, because the condition is not involved. Could they become one
table-driven test, e.g. `Seq("id = -1" -> Seq("id"), "id = -1, dep = 'eng'" ->
Seq("id", "dep"), ...)`, or stay in one suite?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -343,3 +365,60 @@ 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 declaration,
+ * nested fields, duplicate columns, columns that do not exist in the
relation 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) {
+ throw
QueryCompilationErrors.emptyRequiredDataAttributesError(operation.getClass.getName)
+ }
+ 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 =>
Review Comment:
**38.** Nit: this is `SchemaUtils.foldName` (`SchemaUtils.scala:551-553`),
whose Scaladoc says that `AttributeSeq` looks attributes up by this key, which
is what the duplicate check relies on. Could this be `refs.map(ref =>
SchemaUtils.foldName(ref.fieldNames.head, conf.caseSensitiveAnalysis))`,
dropping the `java.util.Locale` import?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2Commands.scala:
##########
@@ -356,6 +357,32 @@ trait RowLevelWrite extends V2WriteCommand with
SupportsSubquery {
def condition: Expression
def originalTable: NamedRelation
+ /** The projections that read query columns by ordinal. */
+ protected def queryProjections: Seq[ProjectingInternalRow]
Review Comment:
**35.** Nit: the PR description's compatibility paragraph says that
`RowLevelWrite` has a new abstract `withPrunedQuery`, but this
`queryProjections` is abstract too and drives the new `references`, so an
extension that adds only `withPrunedQuery` still fails to compile. Since the
description becomes the commit message, could it name both?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedColumnUpdateTableSuite.scala:
##########
@@ -0,0 +1,637 @@
+/*
+ * 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 org.apache.spark.{SparkException, SparkUnsupportedOperationException}
+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.InMemoryRowLevelOperationTable
+import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
+import org.apache.spark.sql.connector.write.RowLevelOperationTable
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
V2Writes}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class GroupBasedColumnUpdateTableSuite extends UpdateTableSuiteBase
+ with ColumnUpdateTableSharedTests {
+
+ override protected lazy val extraTableProps: java.util.Map[String, String] =
{
+ val props = new java.util.HashMap[String, String]()
+ props.put("column-update-cow", "true")
+ props
+ }
+
+ override protected def reqAttrsTableProps(reqAttrs: String):
java.util.Map[String, String] = {
+ java.util.Map.of("column-update-cow-req-attrs", reqAttrs)
+ }
+
+ override protected def fullWidthTableProps: java.util.Map[String, String] =
java.util.Map.of()
+
+ test("column-update ReplaceData: column update schema has only the declared
columns") {
+ 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)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "dep", "salary"))
+ }
+
+ test("column-update ReplaceData: data correctness -- bonus preserved, salary
updated") {
+ 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'")
+
+ 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)
+ // CoW: pk=1 and pk=3 match (2 updates); the 'hr' partition has no other
rows so no copies.
+ checkUpdateMetrics(numUpdatedRows = 2, numCopiedRows = 0)
+ }
+
+ test("column-update ReplaceData: subquery WHERE condition data correctness")
{
+ 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)
+
+ withTempView("target_deps") {
+ import testImplicits._
+ val subqueryDF = Seq("hr").toDF()
+ subqueryDF.createOrReplaceTempView("target_deps")
+
+ sql(
+ s"""UPDATE $tableNameAsString
+ |SET salary = -1
+ |WHERE dep IN (SELECT * FROM target_deps)
+ |""".stripMargin)
+ }
+
+ 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 ReplaceData: runtime group filtering data correctness") {
+ Seq(true, false).foreach { dppEnabled =>
+ Seq(true, false).foreach { aqeEnabled =>
+ withSQLConf(
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key ->
dppEnabled.toString,
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqeEnabled.toString) {
+ withTable(tableNameAsString) {
+ withTempView("matched_pk") {
+ createAndInitTable("pk INT, 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)
+
+ import testImplicits._
+ val matchedPkDF = Seq(Some(1), None).toDF()
+ matchedPkDF.createOrReplaceTempView("matched_pk")
+
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = -1 " +
+ s"WHERE pk IN (SELECT * FROM matched_pk)",
+ primaryScanSchema =
+ "pk INT, salary INT, dep STRING, _partition STRING, index
INT",
+ groupFilterScanSchema = Some("pk INT, dep STRING"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") ::
+ Row(2, 200, 20, "software") ::
+ Row(3, 300, 30, "hr") :: Nil)
+
+ checkReplacedPartitions(Seq("hr"))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("column-update ReplaceData: updated and copied rows reach
writeColumnUpdate with " +
+ "metadata") {
+ 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": "hr" }
+ |{ "pk": 3, "salary": 300, "bonus": 30, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateWithMetadataLogEntry(metadata = Row("hr", null), data =
Row(1, "hr", -1)),
+ writeColumnUpdateWithMetadataLogEntry(metadata = Row("hr", 1), data =
Row(2, "hr", 200)))
+ }
+
+ test("column-update ReplaceData: without metadata, rows reach
writeColumnUpdate(record)") {
+ 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": "hr" }
+ |{ "pk": 3, "salary": 300, "bonus": 30, "dep": "software" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true", "no-metadata", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateLogEntry(data = Row(1, "hr", -1)),
+ writeColumnUpdateLogEntry(data = Row(2, "hr", 200)))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") :: Row(2, 200, 20, "hr") :: Row(3, 300, 30,
"software") :: Nil)
+ }
+
+ test("column-update ReplaceData: a writer overriding only
writeColumnUpdate(record) " +
+ "receives rows with metadata") {
+ 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": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true",
"column-update-cow-record-only-writer", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateLogEntry(data = Row(1, "hr", -1)),
+ writeColumnUpdateLogEntry(data = Row(2, "hr", 200)))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") :: Row(2, 200, 20, "hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: without metadata, column update schema is
the declared " +
+ "layout") {
+ 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": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true", "no-metadata", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE pk = 1")
+
+ checkLastWriteInfo(
+ expectedColumnUpdateSchema = Some(StructType(Seq(PK_FIELD, DEP_FIELD,
+ StructField("salary", IntegerType, nullable = true)))))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 110, 10, "hr") :: Row(2, 200, 20, "hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: connector missing writeColumnUpdate
override is rejected") {
+ createAndInitTableReplaceDataNoWriteColumnUpdate("pk INT NOT NULL, salary
INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[SparkUnsupportedOperationException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+ },
+ condition = "DATA_SOURCE_WRITE_COLUMN_UPDATE_NOT_IMPLEMENTED",
+ parameters = Map(
+ "class" ->
"org.apache.spark.sql.connector.catalog.NoWriteColumnUpdateOverrideWriter"))
+ }
+
+ private def createAndInitTableReplaceDataNoWriteColumnUpdate(
+ schemaString: String, jsonData: String): Unit = {
+ createAndInitTable(schemaString, jsonData,
+ java.util.Map.of("column-update-cow-no-write-column-update", "true"))
+ }
+
+ //
---------------------------------------------------------------------------
+ // Write relation tests: verify that only the write relation is narrowed to
the
+ // declared columns, on both copy-on-write plan shapes.
+ //
---------------------------------------------------------------------------
+
+ private val reqAttrsOperationClass =
+
classOf[InMemoryRowLevelOperationTable#PartitionBasedColumnUpdateOperationWithReqAttrs].getName
+
+ private def createAndInitTableWithReqAttrs(reqAttrs: String): Unit = {
+ createAndInitTableWithProps(reqAttrsTableProps(reqAttrs))
+ }
+
+ test("column-update ReplaceData: write relation has the declared columns in
declared order") {
+ createAndInitTableWithReqAttrs("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 ReplaceData: subquery write relation has the declared
columns") {
+ withTempView("deps") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq("hr").toDF("dep").createOrReplaceTempView("deps")
+
+ val write = executeAndKeepAnalyzedWrite {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE dep IN (SELECT dep FROM deps)")
+ }
+
+ assert(write.query.exists(_.isInstanceOf[Union]))
+ checkColumnUpdateWrite(write, "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 ReplaceData: runtime group filtering on an undeclared
condition column") {
+ withTempView("matched_ids") {
+ // `dep` is declared because the scan filters groups at runtime by the
partition column
+ createAndInitTableWithReqAttrs("pk,dep,salary")
+ import testImplicits._
+ Seq(1, 100).toDF().createOrReplaceTempView("matched_ids")
+
+ // `id` is neither declared nor in the write relation; the group filter
must still map it
+ // onto the second read of the table that the subquery rewrite adds
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE id IN (SELECT * FROM matched_ids)",
+ primaryScanSchema = "pk INT, id INT, dep STRING, salary INT, bonus
INT, extra INT, " +
+ "_partition STRING, index INT",
+ groupFilterScanSchema = Some("id INT, dep STRING"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 1, "hr", 110, 10, 1) ::
+ Row(2, 2, "hr", 200, 20, 1) ::
+ Row(3, 3, "software", 300, 30, 1) :: Nil)
+ checkReplacedPartitions(Seq("hr"))
+ }
+ }
+
+ test("column-update ReplaceData: scan reads only the referenced columns") {
+ createAndInitTableWithReqAttrs("pk,salary")
+
+ // `id` is neither declared nor referenced; `dep`, `bonus` and `extra` are
read for the
+ // condition, the assignment and the CHECK constraint
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep =
'hr'",
+ "pk INT, dep STRING, salary INT, bonus INT, extra INT, _partition
STRING, index INT")
+ 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 ReplaceData: subquery scan reads only the referenced
columns") {
+ withTempView("deps") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq("hr").toDF("dep").createOrReplaceTempView("deps")
+
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE dep IN (SELECT dep FROM deps)",
+ "pk INT, dep STRING, salary INT, bonus INT, extra INT, _partition
STRING, index INT")
+ 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 ReplaceData: unread partition column skips runtime group
filtering") {
+ withTempView("matched_ids") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq(1, 100).toDF().createOrReplaceTempView("matched_ids")
+
+ // `dep` is neither declared nor referenced, so the scan doesn't read it
and doesn't
+ // report it from filterAttributes(); every group is read and replaced
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE id IN (SELECT * FROM matched_ids)",
+ primaryScanSchema = "pk INT, id INT, salary INT, bonus INT, extra INT,
" +
+ "_partition STRING, index INT",
+ groupFilterScanSchema = None)
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 1, "hr", 110, 10, 1) ::
+ Row(2, 2, "hr", 200, 20, 1) ::
+ Row(3, 3, "software", 300, 30, 1) :: Nil)
+ checkReplacedPartitions(Seq("hr", "software"))
+ }
+ }
+
+ test("column-update ReplaceData: runtime filter attribute the scan doesn't
read is rejected") {
+ withTempView("matched_ids") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING, salary INT",
+ """{ "pk": 1, "id": 1, "dep": "hr", "salary": 100 }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-cow-req-attrs", "pk,salary",
+ "column-update-cow-unread-filter-attrs", "true"))
+ import testImplicits._
+ Seq(1).toDF().createOrReplaceTempView("matched_ids")
+
+ val ex = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE id IN (SELECT *
FROM matched_ids)")
+ }
+ assert(ex.getCondition ==
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE")
Review Comment:
**31.** Minor: this checks `attribute` and `method`, but not
`relationOutput`, which is the pruned scan output this test is about, nor
`scanClass`, so a change in which other columns the narrow scan reads would go
unnoticed. Similarly, the three CHECK tests
(`ColumnUpdateTableSharedTests.scala:165-169` and `:184-188`, and
`DeltaBasedColumnUpdateTableSuite.scala:1094-1098`) check only the
`CHECK_CONSTRAINT_VIOLATION` condition, so they cannot tell which constraint or
value failed. `DataSourceV2CatalystRuntimeFilterSuite` and
`CheckConstraintSuite` use `checkError` with every parameter for these
conditions. Could these use `checkError`, or `checkErrorMatchPVals` for the
scan class?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -128,12 +153,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
+ if (properties.getOrDefault(COLUMN_UPDATE, "false") == "true") {
+ 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") {
+ // Test-only: returns an empty requiredDataAttributes() so we can verify
Spark rejects it.
+ () => new DeltaBasedColumnUpdateOperationWithReqAttrs(
+ info.command, Array.empty, info.options)
+ } else if (properties.getOrDefault(COLUMN_UPDATE_COW, "false") == "true") {
+ if (info.updatedColumns().exists(_.describe() == "pk")) {
+ () => PartitionBasedOperation(info.command, info.options)
+ } else {
+ () => new PartitionBasedColumnUpdateOperation(
+ info.command, info.updatedColumns().toSeq, info.options)
+ }
+ } else if (properties.containsKey(COLUMN_UPDATE_COW_REQ_ATTRS)) {
+ val reqCols =
properties.get(COLUMN_UPDATE_COW_REQ_ATTRS).split(",").map(_.trim)
+ () => new PartitionBasedColumnUpdateOperationWithReqAttrs(
+ info.command, reqCols, info.options)
+ } else if (
+ properties.getOrDefault(COLUMN_UPDATE_COW_NO_WRITE_COLUMN_UPDATE,
"false") == "true") {
+ () => new PartitionBasedColumnUpdateOperation(
+ info.command, info.updatedColumns().toSeq, info.options)
+ } else if (properties.getOrDefault(COLUMN_UPDATE_SPLIT, "false") ==
"true") {
Review Comment:
**37.** Nit: after item 25, `column-update` and `column-update-req-attrs`
with `split-updates` (L163-179) build the same operations with the same
arguments as `column-update-split` and `column-update-split-req-attrs` here, so
these keys are now synonyms, and the req-attrs plus `split-updates` branch is
never exercised. As a result, "column-update: split-updates sends a narrow
UPDATE as delete and reinsert" (`DeltaBasedColumnUpdateTableSuite.scala:718`)
and "column-update split: delete and reinsert carry the row ID and preserved
metadata" (`:735`) run the same DDL, data, UPDATE and write-log assertions.
Likewise, `column-update-cow-no-write-column-update` is still a dispatch key
(L195-198), although item 18 made it a writer flag. Could the old
`column-update-split` and `column-update-split-req-attrs` keys go (keeping
`column-update-split-row-id`), with the split tests spelled as `column-update`
plus `split-updates`, and one of the two tests dropped?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedColumnUpdateTableSuite.scala:
##########
@@ -0,0 +1,637 @@
+/*
+ * 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 org.apache.spark.{SparkException, SparkUnsupportedOperationException}
+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.InMemoryRowLevelOperationTable
+import org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE
+import org.apache.spark.sql.connector.write.RowLevelOperationTable
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
V2Writes}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class GroupBasedColumnUpdateTableSuite extends UpdateTableSuiteBase
+ with ColumnUpdateTableSharedTests {
+
+ override protected lazy val extraTableProps: java.util.Map[String, String] =
{
+ val props = new java.util.HashMap[String, String]()
+ props.put("column-update-cow", "true")
+ props
+ }
+
+ override protected def reqAttrsTableProps(reqAttrs: String):
java.util.Map[String, String] = {
+ java.util.Map.of("column-update-cow-req-attrs", reqAttrs)
+ }
+
+ override protected def fullWidthTableProps: java.util.Map[String, String] =
java.util.Map.of()
+
+ test("column-update ReplaceData: column update schema has only the declared
columns") {
+ 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)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE dep = 'hr'")
+
+ assert(table.lastWriteInfo.columnUpdateSchema.get.fieldNames.toSeq ==
+ Seq("pk", "dep", "salary"))
+ }
+
+ test("column-update ReplaceData: data correctness -- bonus preserved, salary
updated") {
+ 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'")
+
+ 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)
+ // CoW: pk=1 and pk=3 match (2 updates); the 'hr' partition has no other
rows so no copies.
+ checkUpdateMetrics(numUpdatedRows = 2, numCopiedRows = 0)
+ }
+
+ test("column-update ReplaceData: subquery WHERE condition data correctness")
{
+ 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)
+
+ withTempView("target_deps") {
+ import testImplicits._
+ val subqueryDF = Seq("hr").toDF()
+ subqueryDF.createOrReplaceTempView("target_deps")
+
+ sql(
+ s"""UPDATE $tableNameAsString
+ |SET salary = -1
+ |WHERE dep IN (SELECT * FROM target_deps)
+ |""".stripMargin)
+ }
+
+ 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 ReplaceData: runtime group filtering data correctness") {
+ Seq(true, false).foreach { dppEnabled =>
+ Seq(true, false).foreach { aqeEnabled =>
+ withSQLConf(
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key ->
dppEnabled.toString,
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqeEnabled.toString) {
+ withTable(tableNameAsString) {
+ withTempView("matched_pk") {
+ createAndInitTable("pk INT, 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)
+
+ import testImplicits._
+ val matchedPkDF = Seq(Some(1), None).toDF()
+ matchedPkDF.createOrReplaceTempView("matched_pk")
+
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = -1 " +
+ s"WHERE pk IN (SELECT * FROM matched_pk)",
+ primaryScanSchema =
+ "pk INT, salary INT, dep STRING, _partition STRING, index
INT",
+ groupFilterScanSchema = Some("pk INT, dep STRING"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") ::
+ Row(2, 200, 20, "software") ::
+ Row(3, 300, 30, "hr") :: Nil)
+
+ checkReplacedPartitions(Seq("hr"))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("column-update ReplaceData: updated and copied rows reach
writeColumnUpdate with " +
+ "metadata") {
+ 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": "hr" }
+ |{ "pk": 3, "salary": 300, "bonus": 30, "dep": "software" }
+ |""".stripMargin)
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateWithMetadataLogEntry(metadata = Row("hr", null), data =
Row(1, "hr", -1)),
+ writeColumnUpdateWithMetadataLogEntry(metadata = Row("hr", 1), data =
Row(2, "hr", 200)))
+ }
+
+ test("column-update ReplaceData: without metadata, rows reach
writeColumnUpdate(record)") {
+ 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": "hr" }
+ |{ "pk": 3, "salary": 300, "bonus": 30, "dep": "software" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true", "no-metadata", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateLogEntry(data = Row(1, "hr", -1)),
+ writeColumnUpdateLogEntry(data = Row(2, "hr", 200)))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") :: Row(2, 200, 20, "hr") :: Row(3, 300, 30,
"software") :: Nil)
+ }
+
+ test("column-update ReplaceData: a writer overriding only
writeColumnUpdate(record) " +
+ "receives rows with metadata") {
+ 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": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true",
"column-update-cow-record-only-writer", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+
+ checkLastColumnUpdateWriteLog(
+ writeColumnUpdateLogEntry(data = Row(1, "hr", -1)),
+ writeColumnUpdateLogEntry(data = Row(2, "hr", 200)))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, -1, 10, "hr") :: Row(2, 200, 20, "hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: without metadata, column update schema is
the declared " +
+ "layout") {
+ 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": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow", "true", "no-metadata", "true"))
+
+ sql(s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE pk = 1")
+
+ checkLastWriteInfo(
+ expectedColumnUpdateSchema = Some(StructType(Seq(PK_FIELD, DEP_FIELD,
+ StructField("salary", IntegerType, nullable = true)))))
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 110, 10, "hr") :: Row(2, 200, 20, "hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: connector missing writeColumnUpdate
override is rejected") {
+ createAndInitTableReplaceDataNoWriteColumnUpdate("pk INT NOT NULL, salary
INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |""".stripMargin)
+
+ checkError(
+ exception = intercept[SparkUnsupportedOperationException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1")
+ },
+ condition = "DATA_SOURCE_WRITE_COLUMN_UPDATE_NOT_IMPLEMENTED",
+ parameters = Map(
+ "class" ->
"org.apache.spark.sql.connector.catalog.NoWriteColumnUpdateOverrideWriter"))
+ }
+
+ private def createAndInitTableReplaceDataNoWriteColumnUpdate(
+ schemaString: String, jsonData: String): Unit = {
+ createAndInitTable(schemaString, jsonData,
+ java.util.Map.of("column-update-cow-no-write-column-update", "true"))
+ }
+
+ //
---------------------------------------------------------------------------
+ // Write relation tests: verify that only the write relation is narrowed to
the
+ // declared columns, on both copy-on-write plan shapes.
+ //
---------------------------------------------------------------------------
+
+ private val reqAttrsOperationClass =
+
classOf[InMemoryRowLevelOperationTable#PartitionBasedColumnUpdateOperationWithReqAttrs].getName
+
+ private def createAndInitTableWithReqAttrs(reqAttrs: String): Unit = {
+ createAndInitTableWithProps(reqAttrsTableProps(reqAttrs))
+ }
+
+ test("column-update ReplaceData: write relation has the declared columns in
declared order") {
+ createAndInitTableWithReqAttrs("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 ReplaceData: subquery write relation has the declared
columns") {
+ withTempView("deps") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq("hr").toDF("dep").createOrReplaceTempView("deps")
+
+ val write = executeAndKeepAnalyzedWrite {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE dep IN (SELECT dep FROM deps)")
+ }
+
+ assert(write.query.exists(_.isInstanceOf[Union]))
+ checkColumnUpdateWrite(write, "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 ReplaceData: runtime group filtering on an undeclared
condition column") {
+ withTempView("matched_ids") {
+ // `dep` is declared because the scan filters groups at runtime by the
partition column
+ createAndInitTableWithReqAttrs("pk,dep,salary")
+ import testImplicits._
+ Seq(1, 100).toDF().createOrReplaceTempView("matched_ids")
+
+ // `id` is neither declared nor in the write relation; the group filter
must still map it
+ // onto the second read of the table that the subquery rewrite adds
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE id IN (SELECT * FROM matched_ids)",
+ primaryScanSchema = "pk INT, id INT, dep STRING, salary INT, bonus
INT, extra INT, " +
+ "_partition STRING, index INT",
+ groupFilterScanSchema = Some("id INT, dep STRING"))
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 1, "hr", 110, 10, 1) ::
+ Row(2, 2, "hr", 200, 20, 1) ::
+ Row(3, 3, "software", 300, 30, 1) :: Nil)
+ checkReplacedPartitions(Seq("hr"))
+ }
+ }
+
+ test("column-update ReplaceData: scan reads only the referenced columns") {
+ createAndInitTableWithReqAttrs("pk,salary")
+
+ // `id` is neither declared nor referenced; `dep`, `bonus` and `extra` are
read for the
+ // condition, the assignment and the CHECK constraint
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep =
'hr'",
+ "pk INT, dep STRING, salary INT, bonus INT, extra INT, _partition
STRING, index INT")
+ 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 ReplaceData: subquery scan reads only the referenced
columns") {
+ withTempView("deps") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq("hr").toDF("dep").createOrReplaceTempView("deps")
+
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE dep IN (SELECT dep FROM deps)",
+ "pk INT, dep STRING, salary INT, bonus INT, extra INT, _partition
STRING, index INT")
+ 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 ReplaceData: unread partition column skips runtime group
filtering") {
+ withTempView("matched_ids") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq(1, 100).toDF().createOrReplaceTempView("matched_ids")
+
+ // `dep` is neither declared nor referenced, so the scan doesn't read it
and doesn't
+ // report it from filterAttributes(); every group is read and replaced
+ executeAndCheckScans(
+ s"UPDATE $tableNameAsString SET salary = salary + bonus " +
+ s"WHERE id IN (SELECT * FROM matched_ids)",
+ primaryScanSchema = "pk INT, id INT, salary INT, bonus INT, extra INT,
" +
+ "_partition STRING, index INT",
+ groupFilterScanSchema = None)
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString ORDER BY pk"),
+ Row(1, 1, "hr", 110, 10, 1) ::
+ Row(2, 2, "hr", 200, 20, 1) ::
+ Row(3, 3, "software", 300, 30, 1) :: Nil)
+ checkReplacedPartitions(Seq("hr", "software"))
+ }
+ }
+
+ test("column-update ReplaceData: runtime filter attribute the scan doesn't
read is rejected") {
+ withTempView("matched_ids") {
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING, salary INT",
+ """{ "pk": 1, "id": 1, "dep": "hr", "salary": 100 }
+ |""".stripMargin,
+ java.util.Map.of(
+ "column-update-cow-req-attrs", "pk,salary",
+ "column-update-cow-unread-filter-attrs", "true"))
+ import testImplicits._
+ Seq(1).toDF().createOrReplaceTempView("matched_ids")
+
+ val ex = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE id IN (SELECT *
FROM matched_ids)")
+ }
+ assert(ex.getCondition ==
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.CANNOT_RESOLVE")
+ assert(ex.getMessageParameters.get("attribute") == "`dep`")
+ assert(ex.getMessageParameters.get("method") == "filterAttributes()")
+ }
+ }
+
+ test("column-update ReplaceData: a connector without column updates reads
every column") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of[String, String]())
+
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = 0",
+ "pk INT, salary INT, dep STRING, _partition STRING, index INT")
+ checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(1, 0, "hr") ::
Nil)
+ }
+
+ test("column-update ReplaceData: undeclared distribution column is
rejected") {
+ // without metadata, the write clusters by `dep`
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |""".stripMargin,
+ java.util.Map.of("column-update-cow-req-attrs", "pk,salary",
"no-metadata", "true"))
+
+ val excludeColumnPruning = SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
ColumnPruning.ruleName
+ for {
+ cond <- Seq("pk = 1", "dep = 'hr'")
+ confs <- Seq(Nil, Seq(excludeColumnPruning))
+ } withSQLConf(confs: _*) {
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET salary = -1 WHERE $cond")
+ },
+ condition = "COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS",
+ parameters = Map("connector" -> reqAttrsOperationClass, "columns" ->
"[`dep`]"))
+ }
+ }
+
+ test("column-update ReplaceData: other operations read every column") {
+ 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": "hr" }
+ |""".stripMargin,
+ java.util.Map.of())
+ val fullScanSchema = "pk INT, salary INT, bonus INT, dep STRING,
_partition STRING, index INT"
+
+ executeAndCheckScan(
+ s"UPDATE $tableNameAsString SET salary = -1 WHERE pk = 1",
fullScanSchema)
+
+ executeAndCheckScan(s"DELETE FROM $tableNameAsString WHERE pk > 1",
fullScanSchema)
+
+ withTempView("source") {
+ import testImplicits._
+ Seq((1, 150)).toDF("pk", "salary").createOrReplaceTempView("source")
+ executeAndCheckScan(
+ s"""MERGE INTO $tableNameAsString t
+ |USING source s
+ |ON t.pk = s.pk
+ |WHEN MATCHED THEN UPDATE SET salary = s.salary
+ |""".stripMargin,
+ fullScanSchema)
+ }
+
+ checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(1, 150, 10,
"hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: DELETE and MERGE on a column-update table
read every column") {
+ 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": "hr" }
+ |""".stripMargin)
+ val fullScanSchema = "pk INT, salary INT, bonus INT, dep STRING,
_partition STRING, index INT"
+
+ executeAndCheckScan(s"DELETE FROM $tableNameAsString WHERE pk > 1",
fullScanSchema)
+
+ withTempView("source") {
+ import testImplicits._
+ Seq((1, 150)).toDF("pk", "salary").createOrReplaceTempView("source")
+ executeAndCheckScan(
+ s"""MERGE INTO $tableNameAsString t
+ |USING source s
+ |ON t.pk = s.pk
+ |WHEN MATCHED THEN UPDATE SET salary = s.salary
+ |""".stripMargin,
+ fullScanSchema)
+ }
+
+ checkAnswer(sql(s"SELECT * FROM $tableNameAsString"), Row(1, 150, 10,
"hr") :: Nil)
+ }
+
+ test("column-update ReplaceData: assigned column outside
requiredDataAttributes is rejected") {
+ createAndInitTableWithReqAttrs("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 ReplaceData: assigned column outside
requiredDataAttributes is rejected " +
+ "on the subquery path") {
+ withTempView("deps") {
+ createAndInitTableWithReqAttrs("pk,salary")
+ import testImplicits._
+ Seq("hr").toDF("dep").createOrReplaceTempView("deps")
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"UPDATE $tableNameAsString SET bonus = 0 WHERE dep IN (SELECT
dep FROM deps)")
+ },
+ condition =
"COLUMN_UPDATE_REQUIRED_DATA_ATTRIBUTES_MISSING_UPDATED_COLUMNS",
+ parameters = Map(
+ "connector" -> reqAttrsOperationClass,
+ "missingColumns" -> "[`bonus`]"))
+ }
+ }
+
+ test("column-update ReplaceData: analyzed plan shows the declared columns as
the write target") {
+ createAndInitTableWithReqAttrs("pk,salary")
+
+ val write = executeAndKeepAnalyzedWrite {
+ sql(s"UPDATE $tableNameAsString SET salary = salary + bonus WHERE dep =
'hr'")
+ }
+
+ val header = write.simpleString(Int.MaxValue)
+ assert(header.matches("""ReplaceData RelationV2\[pk#\d+, salary#\d+\]
.*"""), header)
+ }
+
+ test("column-update ReplaceData: 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 = table
+ Seq(
+ t.PartitionBasedOperation(UPDATE, CaseInsensitiveStringMap.empty()) ->
"[pk, salary, dep]",
+ new t.PartitionBasedColumnUpdateOperation(UPDATE) -> "[pk, dep]"
+ ).foreach { case (operation, expectedNames) =>
+ 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 ReplaceData: an operation rebuilt with withTable keeps
the updated columns") {
+ 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 = table
+ t.lastUpdatedColumns = null
+
+ val rebuilt = operationTable.withTable(t)
+
+ assert(t.lastUpdatedColumns.map(_.describe()).toSeq == Seq("salary"))
Review Comment:
**33.** Nit: this brings back a hand-written `lastUpdatedColumns` assertion,
which round 4
([r4074793101](https://github.com/apache/spark/pull/55518#discussion_r4074793101))
asked to replace with `checkLastUpdatedColumns`. L549 resets the field to
`null` precisely to catch `withTable` not calling the builder, and in that case
this line fails with a `NullPointerException`, while the helper fails with "no
row-level operation was built" on the same live table. Could this be
`checkLastUpdatedColumns("salary")`?
--
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]