This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new b4e556950a [spark] Support V1 UPDATE for data evolution tables (#8389)
b4e556950a is described below
commit b4e556950a5746000c0204657cd578aa16ad4704
Author: Kerwin Zhang <[email protected]>
AuthorDate: Tue Jun 30 17:24:06 2026 +0800
[spark] Support V1 UPDATE for data evolution tables (#8389)
---
.../catalyst/analysis/PaimonUpdateTable.scala | 44 ++++++++---
.../UpdatePaimonDataEvolutionTableCommand.scala | 92 ++++++++++++++++++++++
.../paimon/spark/sql/RowTrackingTestBase.scala | 61 +++++++++++---
3 files changed, 176 insertions(+), 21 deletions(-)
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonUpdateTable.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonUpdateTable.scala
index e03e658141..ab1761bbff 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonUpdateTable.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonUpdateTable.scala
@@ -19,7 +19,7 @@
package org.apache.paimon.spark.catalyst.analysis
import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
-import org.apache.paimon.spark.commands.UpdatePaimonTableCommand
+import
org.apache.paimon.spark.commands.{UpdatePaimonDataEvolutionTableCommand,
UpdatePaimonTableCommand}
import org.apache.paimon.table.FileStoreTable
import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
@@ -49,11 +49,6 @@ object PaimonUpdateTable extends Rule[LogicalPlan] with
RowLevelHelper with Expr
throw new RuntimeException("Can't update the primary key
column.")
}
- if (paimonTable.coreOptions().dataEvolutionEnabled()) {
- throw new RuntimeException(
- "Update operation is not supported when data evolution is
enabled yet.")
- }
-
// Align against `u.table.output`: for CHAR/VARCHAR columns the
analyzer adds a
// `readSidePadding` Project whose output has different exprIds
than `relation`, and
// the parsed assignment keys reference the Project's
attributes. Order matches
@@ -66,15 +61,42 @@ object PaimonUpdateTable extends Rule[LogicalPlan] with
RowLevelHelper with Expr
val alignedExpressions =
alignedAssignments.map(_.value).zip(relation.output)
val alignedUpdateTable = u.copy(assignments = alignedAssignments)
+ val dataEvolutionEnabled =
paimonTable.coreOptions().dataEvolutionEnabled()
+
+ if (dataEvolutionEnabled) {
+ // The rewritten files keep the original row ids, which are
derived from the
+ // file's firstRowId per partition; moving a row to another
partition would need
+ // re-assigned row ids (delete + insert semantics).
+ val partitionKeys = paimonTable.partitionKeys().asScala.toSeq
+ if (!validUpdateAssignment(u.table.outputSet, partitionKeys,
assignments)) {
+ throw new RuntimeException(
+ "Update to partition columns is not supported for data
evolution tables.")
+ }
+ }
if (!shouldFallbackToV1Update(table, alignedUpdateTable)) {
+ if (dataEvolutionEnabled) {
+ // Data-evolution tables do not currently expose Spark V2
row-level operations.
+ // Keep this guard in case capability rules change; this
branch intentionally
+ // implements only V1 data-evolution UPDATE.
+ throw new RuntimeException(
+ "Update operation is not supported when data evolution is
enabled yet.")
+ }
alignedUpdateTable
} else {
- UpdatePaimonTableCommand(
- relation,
- paimonTable,
- condition.getOrElse(TrueLiteral),
- alignedExpressions)
+ if (dataEvolutionEnabled) {
+ UpdatePaimonDataEvolutionTableCommand(
+ relation,
+ table,
+ condition.getOrElse(TrueLiteral),
+ alignedExpressions)
+ } else {
+ UpdatePaimonTableCommand(
+ relation,
+ paimonTable,
+ condition.getOrElse(TrueLiteral),
+ alignedExpressions)
+ }
}
case _ =>
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
new file mode 100644
index 0000000000..9e2c35e3e4
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
@@ -0,0 +1,92 @@
+/*
+ * 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.paimon.spark.commands
+
+import org.apache.paimon.spark.SparkTable
+import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.ROW_ID_COLUMN
+
+import org.apache.spark.sql.{Row, SparkSession}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
AttributeReference, EqualTo, Expression}
+import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
+import org.apache.spark.sql.catalyst.plans.logical.{Assignment, Filter,
Project, SupportsSubquery}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.paimon.shims.SparkShimLoader
+
+/** V1 UPDATE command for data-evolution tables, implemented through
partial-column MERGE. */
+case class UpdatePaimonDataEvolutionTableCommand(
+ relation: DataSourceV2Relation,
+ v2Table: SparkTable,
+ condition: Expression,
+ alignedExpressions: Seq[(Expression, Attribute)])
+ extends PaimonLeafRunnableCommand
+ with SupportsSubquery {
+
+ override def run(sparkSession: SparkSession): Seq[Row] = {
+ val targetRowId = rowIdAttribute(relation)
+ val sourceTable = updatedRowIdSource(targetRowId)
+ val sourceRowId = sourceTable.output.head.asInstanceOf[AttributeReference]
+
+ val matchedCondition = EqualTo(targetRowId, sourceRowId)
+ val updateAction = SparkShimLoader.shim.createUpdateAction(
+ None,
+ alignedExpressions.map { case (expression, attribute) =>
Assignment(attribute, expression) })
+
+ MergeIntoPaimonDataEvolutionTable(
+ v2Table,
+ relation,
+ sourceTable,
+ matchedCondition,
+ Seq(updateAction),
+ Nil,
+ Nil).run(sparkSession)
+ }
+
+ private def updatedRowIdSource(targetRowId: AttributeReference): Project = {
+ val conditionReferences = condition.references.toSeq.collect {
+ case attr: AttributeReference => attr
+ }
+ val readOutput = deduplicateByExprId(conditionReferences :+ targetRowId)
+ val sourceScan =
+ SparkShimLoader.shim.copyDataSourceV2Relation(relation, v2Table,
readOutput)
+ // Keep the Filter visible for conditional UPDATEs. The data-evolution
MERGE command uses a
+ // self-merge shortcut for Project(PaimonRelation); if a WHERE update were
shaped that way, the
+ // shortcut would bypass the source join path and update every row.
+ val filteredSource = if (condition == TrueLiteral) sourceScan else
Filter(condition, sourceScan)
+
+ Project(Seq(Alias(targetRowId, ROW_ID_COLUMN)()), filteredSource)
+ }
+
+ private def rowIdAttribute(relation: DataSourceV2Relation):
AttributeReference = {
+ (relation.output ++ relation.metadataOutput)
+ .collectFirst {
+ case attr: AttributeReference if attr.name == ROW_ID_COLUMN => attr
+ }
+ .getOrElse(throw new RuntimeException(
+ s"Cannot find $ROW_ID_COLUMN metadata column for data-evolution
UPDATE."))
+ }
+
+ private def deduplicateByExprId(attributes: Seq[AttributeReference]):
Seq[AttributeReference] = {
+ attributes
+ .foldLeft(Seq.empty[AttributeReference]) {
+ case (deduplicated, attr) if deduplicated.exists(_.exprId ==
attr.exprId) => deduplicated
+ case (deduplicated, attr) => deduplicated :+ attr
+ }
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
index f53089470d..c7f6ea86e3 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
@@ -1011,16 +1011,57 @@ abstract class RowTrackingTestBase extends
PaimonSparkTestBase with AdaptiveSpar
}
}
- test("Data Evolution: update table throws exception") {
- withTable("t") {
- sql(
- "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
- sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c
FROM range(2, 4)")
- assert(
- intercept[RuntimeException] {
- sql("UPDATE t SET b = 22")
- }.getMessage
- .contains("Update operation is not supported when data evolution is
enabled yet."))
+ test("Data Evolution: V1 update table with data-evolution") {
+ withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+ withTable("t") {
+ sql(
+ "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
+ sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c
FROM range(2, 4)")
+
+ sql("UPDATE t SET b = 22 WHERE id = 2")
+ checkAnswer(
+ sql("SELECT *, _ROW_ID, _SEQUENCE_NUMBER FROM t ORDER BY id"),
+ Seq(Row(2, 22, 2, 0, 2), Row(3, 3, 3, 1, 2))
+ )
+ }
+ }
+ }
+
+ test("Data Evolution: V1 update table with data-evolution without
condition") {
+ withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+ withTable("t") {
+ sql(
+ "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
+ sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c
FROM range(2, 4)")
+
+ sql("UPDATE t SET b = 22")
+ checkAnswer(
+ sql("SELECT *, _ROW_ID, _SEQUENCE_NUMBER FROM t ORDER BY id"),
+ Seq(Row(2, 22, 2, 0, 2), Row(3, 22, 3, 1, 2))
+ )
+ }
+ }
+ }
+
+ test("Data Evolution: V1 update partition column throws exception") {
+ withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+ withTable("t") {
+ sql("""
+ |CREATE TABLE t (id INT, b INT, dt STRING)
+ |PARTITIONED BY (dt)
+ |TBLPROPERTIES ('row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true')
+ |""".stripMargin)
+ sql("INSERT INTO t VALUES (1, 1, 'p1'), (2, 2, 'p2')")
+
+ assert(
+ intercept[RuntimeException] {
+ sql("UPDATE t SET dt = 'p3' WHERE id = 1")
+ }.getMessage
+ .contains("Update to partition columns is not supported for data
evolution tables."))
+
+ sql("UPDATE t SET b = 10 WHERE id = 1")
+ checkAnswer(sql("SELECT * FROM t ORDER BY id"), Seq(Row(1, 10, "p1"),
Row(2, 2, "p2")))
+ }
}
}