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 85dd958b6b [spark] supports deletion actions in data evolution merge 
into (#8446)
85dd958b6b is described below

commit 85dd958b6ba4d3b054dfb64d9d87c9a81318eb73
Author: Faiz <[email protected]>
AuthorDate: Fri Jul 3 21:47:40 2026 +0800

    [spark] supports deletion actions in data evolution merge into (#8446)
---
 docs/docs/multimodal-table/data-evolution.mdx      |  54 ++-
 .../MergeIntoPaimonDataEvolutionTable.scala        | 416 +++++++++++++++------
 .../MergeIntoPaimonDataEvolutionTable.scala        | 407 ++++++++++++++------
 .../spark/commands/PaimonRowLevelCommand.scala     |  11 +
 .../spark/sql/DataEvolutionDeletionTestBase.scala  | 381 +++++++++++++++++++
 5 files changed, 1042 insertions(+), 227 deletions(-)

diff --git a/docs/docs/multimodal-table/data-evolution.mdx 
b/docs/docs/multimodal-table/data-evolution.mdx
index ad8c03064c..4ca866ff4a 100644
--- a/docs/docs/multimodal-table/data-evolution.mdx
+++ b/docs/docs/multimodal-table/data-evolution.mdx
@@ -40,6 +40,8 @@ Data Evolution mode offers the following advantages:
   I/O cost of rewriting untouched columns.
 - **Reduced file rewrites**: append new column data to dedicated files when
   backfilling or evolving data.
+- **Delete support**: record row deletions with deletion vectors without
+  rewriting existing column files.
 - **Optimized reads**: merge original and updated column files at read time.
 
 To enable Data Evolution, create an append table with both
@@ -204,18 +206,62 @@ commit.close()
 Notes:
 
 - Spark SQL supports standalone `UPDATE` statements for Data Evolution tables.
-  SQL `DELETE` statements are not supported for Data Evolution tables yet. Use
-  Spark `UPDATE` or `MERGE INTO`, the Flink procedure, or PyPaimon APIs.
 - Concurrent Spark SQL `UPDATE` statements that update the same data file and
   columns may be retried automatically. Configure retry attempts with
   `spark.paimon.write.data-evolution.update-conflict-retry.max-attempts` and
   retry wait time with
   `spark.paimon.write.data-evolution.update-conflict-retry.wait-ms`.
-- `MERGE INTO` for Data Evolution tables does not support the
-  `WHEN NOT MATCHED BY SOURCE` clause.
+- In Spark SQL, `MERGE INTO` supports `WHEN NOT MATCHED BY SOURCE` for delete
+  actions on Data Evolution tables.
 - The Flink `data_evolution_merge_into` procedure currently supports updating
   or inserting columns, but not inserting new rows.
 
+## Deletes
+
+Data Evolution tables can use deletion vectors to record deleted rows without
+rewriting existing column files. To write deletes, enable deletion vectors
+together with row tracking and Data Evolution:
+
+:::warning
+
+Deleting rows with deletion vectors does not physically remove the original
+data immediately. If a table has many deleted rows, run compaction to
+materialize the deletions; otherwise, the deleted data still consumes storage
+space and may reduce read efficiency.
+
+:::
+
+```sql
+CREATE TABLE target_table (id INT, b INT, c INT) TBLPROPERTIES (
+    'row-tracking.enabled' = 'true',
+    'data-evolution.enabled' = 'true',
+    'deletion-vectors.enabled' = 'true'
+);
+```
+
+Spark SQL supports `DELETE FROM` for Data Evolution tables:
+
+```sql
+DELETE FROM target_table WHERE id = 1;
+```
+
+Spark SQL also supports delete actions in `MERGE INTO`:
+
+```sql
+CREATE TABLE source_table (id INT, op STRING);
+INSERT INTO source_table VALUES (2, 'delete'), (3, 'update');
+
+MERGE INTO target_table AS t
+USING source_table AS s
+ON t.id = s.id
+WHEN MATCHED AND s.op = 'delete' THEN DELETE
+WHEN MATCHED AND s.op = 'update' THEN UPDATE SET t.b = t.b + 10
+WHEN NOT MATCHED BY SOURCE AND t.id > 10 THEN DELETE;
+```
+
+The `WHEN NOT MATCHED BY SOURCE` clause requires Spark 3.4 or later. Currently,
+only Spark supports writing deletes for Data Evolution tables.
+
 ## Self Updates
 
 Self updates transform existing column values in place. In Flink SQL, create a
diff --git 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index e4e2d9b1e1..5e28c056f2 100644
--- 
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++ 
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions
 import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
 import org.apache.paimon.Snapshot
 import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.deletionvectors.DeletionVector
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
 import org.apache.paimon.io.{CompactIncrement, DataIncrement}
@@ -71,23 +72,26 @@ case class MergeIntoPaimonDataEvolutionTable(
     matchedActions: Seq[MergeAction],
     notMatchedActions: Seq[MergeAction],
     notMatchedBySourceActions: Seq[MergeAction])
-  extends PaimonLeafRunnableCommand
-  with WithFileStoreTable
-  with ExpressionHelper
+  extends PaimonRowLevelCommand
   with Logging {
 
-  private lazy val writer = PaimonSparkWriter(table)
-
-  assert(
-    notMatchedBySourceActions.isEmpty,
-    "notMatchedBySourceActions is not supported in 
MergeIntoPaimonDataEvolutionTable.")
   assert(
-    matchedActions.forall(x => x.isInstanceOf[UpdateAction]),
-    "Only SET clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN MATCHED.")
+    matchedActions.forall {
+      case _: UpdateAction | _: DeleteAction => true
+      case _ => false
+    },
+    "Only SET and DELETE clauses are supported in 
MergeIntoPaimonDataEvolutionTable for SQL: " +
+      "WHEN MATCHED."
+  )
   assert(
     notMatchedActions.forall(x => x.isInstanceOf[InsertAction]),
     "Only INSERT clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN NOT MATCHED."
   )
+  assert(
+    notMatchedBySourceActions.forall(x => x.isInstanceOf[DeleteAction]),
+    "Only DELETE clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN NOT " +
+      "MATCHED BY SOURCE."
+  )
 
   import MergeIntoPaimonDataEvolutionTable._
 
@@ -105,6 +109,18 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   override lazy val table: FileStoreTable = 
targetSparkTable.getTable.asInstanceOf[FileStoreTable]
 
+  private lazy val hasTargetSideDeleteActions: Boolean =
+    matchedActions.exists(_.isInstanceOf[DeleteAction]) ||
+      notMatchedBySourceActions.exists(_.isInstanceOf[DeleteAction])
+
+  private lazy val hasMatchedDeleteActions: Boolean =
+    matchedActions.exists(_.isInstanceOf[DeleteAction])
+
+  assert(
+    !hasTargetSideDeleteActions || 
table.coreOptions().deletionVectorsEnabled(),
+    "MERGE INTO DELETE on DataEvolution table requires 
deletion-vectors.enabled=true."
+  )
+
   private val updateColumns: Set[AttributeReference] = {
     val columns = mutable.Set[AttributeReference]()
     for (action <- matchedActions) {
@@ -115,6 +131,7 @@ case class MergeIntoPaimonDataEvolutionTable(
               columns += assignmentKeyAttribute(assignment)
             }
           }
+        case _ =>
       }
     }
     columns.toSet
@@ -153,8 +170,8 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   assert(
     !(isSelfMergeOnRowId && (notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)),
-    "Self-Merge on _ROW_ID only supports WHEN MATCHED THEN UPDATE. WHEN NOT 
MATCHED and WHEN " +
-      "NOT MATCHED BY SOURCE are not supported."
+    "Self-Merge on _ROW_ID only supports WHEN MATCHED actions. WHEN NOT 
MATCHED and " +
+      "WHEN NOT MATCHED BY SOURCE are not supported."
   )
 
   private lazy val targetRelation: DataSourceV2Relation = 
matchedUpdateScanTarget._2
@@ -169,7 +186,11 @@ case class MergeIntoPaimonDataEvolutionTable(
   }
 
   private def invokeMergeInto(sparkSession: SparkSession): Unit = {
+    val readSnapshot = table.snapshotManager().latestSnapshot()
     val snapshotReader = table.newSnapshotReader()
+    if (readSnapshot != null) {
+      snapshotReader.withSnapshot(readSnapshot)
+    }
     pushDownMergePartitionFilter(snapshotReader)
     val plan = snapshotReader.read()
     val tableSplits: Seq[DataSplit] = plan
@@ -212,7 +233,7 @@ case class MergeIntoPaimonDataEvolutionTable(
     val persistSourceDss: Option[Dataset[Row]] =
       if (
         table.coreOptions().dataEvolutionMergeIntoSourcePersist()
-        && (matchedActions.nonEmpty || notMatchedActions.nonEmpty)
+        && (matchedActions.nonEmpty || notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)
       ) {
         val dss = createDataset(sparkSession, sourceTable)
         dss.persist()
@@ -221,29 +242,42 @@ case class MergeIntoPaimonDataEvolutionTable(
         None
       }
 
+    var targetActionCleanup = () => ()
     try {
-      // step 1: find the related data splits, make it target file plan
-      val dataSplits: Seq[DataSplit] = targetRelatedSplits(
+      // step 1: find the related data splits for matched / insert actions.
+      lazy val matchedOrInsertDataSplits: Seq[DataSplit] = targetRelatedSplits(
         sparkSession,
         tableSplits,
         firstRowIds,
         firstRowIdToBlobFirstRowIds,
         persistSourceDss)
-      val touchedFileTargetRelation =
-        createNewScanPlan(dataSplits, targetRelation)
+      lazy val touchedFileTargetRelation =
+        createNewScanPlan(matchedOrInsertDataSplits, targetRelation)
 
-      // step 2: invoke update action
-      val updateCommit =
+      // step 2: invoke target-side action
+      val matchedResult =
         if (matchedActions.nonEmpty) {
-          val updateResult =
-            updateActionInvoke(
-              dataSplits,
-              sparkSession,
-              touchedFileTargetRelation,
-              firstRowIds,
-              persistSourceDss)
-          checkUpdateResult(updateResult)
-        } else Nil
+          val result = targetActionInvoke(
+            matchedOrInsertDataSplits,
+            sparkSession,
+            touchedFileTargetRelation,
+            firstRowIds,
+            persistSourceDss)
+          targetActionCleanup = result.cleanup
+          result
+        } else {
+          TargetActionResult(Nil, None, () => ())
+        }
+      val notMatchedBySourceDvs =
+        if (notMatchedBySourceActions.nonEmpty) {
+          notMatchedBySourceDeletionVectors(sparkSession, readSnapshot, 
persistSourceDss)
+        } else None
+
+      // Merge deletion vectors generated from NMBS and matched conditions
+      // These two statement branch could generate deletion vectors for same 
files!
+      val deleteCommit = persistMergedDeletionVectors(
+        Seq(matchedResult.deletionVectors, notMatchedBySourceDvs).flatten,
+        readSnapshot)
 
       // step 3: invoke insert action
       val insertCommit =
@@ -251,11 +285,14 @@ case class MergeIntoPaimonDataEvolutionTable(
           insertActionInvoke(sparkSession, touchedFileTargetRelation, 
persistSourceDss)
         else Nil
 
-      if (plan.snapshotId() != null) {
-        writer.rowIdCheckConflict(plan.snapshotId())
+      if (readSnapshot != null) {
+        writer.rowIdCheckConflict(readSnapshot.id())
       }
-      writer.commit(updateCommit ++ insertCommit, Snapshot.Operation.MERGE)
+      writer.commit(
+        matchedResult.commitMessages ++ deleteCommit ++ insertCommit,
+        Snapshot.Operation.MERGE)
     } finally {
+      targetActionCleanup()
       if (persistSourceDss.isDefined) {
         persistSourceDss.get.unpersist(blocking = false)
       }
@@ -349,12 +386,21 @@ case class MergeIntoPaimonDataEvolutionTable(
       .map(_.get())
   }
 
-  private def updateActionInvoke(
+  private case class TargetActionResult(
+      commitMessages: Seq[CommitMessage],
+      deletionVectors: Option[Dataset[SparkDeletionVector]],
+      cleanup: () => Unit)
+
+  private def targetActionInvoke(
       dataSplits: Seq[DataSplit],
       sparkSession: SparkSession,
       touchedFileTargetRelation: DataSourceV2Relation,
       firstRowIds: immutable.IndexedSeq[Long],
-      persistSourceDss: Option[Dataset[Row]]): Seq[CommitMessage] = {
+      persistSourceDss: Option[Dataset[Row]]): TargetActionResult = {
+    if (dataSplits.isEmpty) {
+      return TargetActionResult(Nil, None, () => ())
+    }
+
     val conditionFields = extractFields(matchedCondition)
     val allFields = mutable.SortedSet.empty[AttributeReference](
       (o1, o2) => {
@@ -403,27 +449,37 @@ case class MergeIntoPaimonDataEvolutionTable(
     val rawBlobMarkerAttributes = rawBlobUpdateColumns.map(
       attr =>
         AttributeReference(rawBlobMarkerNamesByColumn(attr.name), BooleanType, 
nullable = false)())
-    val mergeOutput = updateColumnsSorted ++ metadataColumns ++ 
rawBlobMarkerAttributes
-
-    val realUpdateActions = matchedActions
-      .map(s => s.asInstanceOf[UpdateAction])
-      .map(
-        update =>
-          UpdateAction.apply(
-            update.condition,
-            update.assignments.filter(
-              a => updateColumnsSorted.contains(assignmentKeyAttribute(a)))))
+    val deletedAttribute =
+      AttributeReference(MERGE_DELETED_NAME, BooleanType, nullable = false)()
+    // Row metadata, _FIRST_ROW_ID added by addFirstRowId, and the delete 
marker.
+    val fixedMergeOutputColumnCount = metadataColumns.size + 2
+    val mergeOutput = (updateColumnsSorted ++ metadataColumns ++ 
rawBlobMarkerAttributes) :+
+      deletedAttribute
+
+    val targetMatchedActions = matchedActions.map {
+      case update: UpdateAction =>
+        UpdateAction.apply(
+          update.condition,
+          update.assignments.filter(a => 
updateColumnsSorted.contains(assignmentKeyAttribute(a))))
+      case delete: DeleteAction => delete
+      case other =>
+        throw new UnsupportedOperationException(s"Unsupported matched action: 
$other.")
+    }
 
     // All fields are composed by:
     // 1. Match condition fields
-    // 2. For each update action, the condition fields and the assignment 
value fields
+    // 2. For each target-side action, the condition fields and the assignment 
value fields
     // 3. All updated fields exclude raw blob fields
-    for (action <- realUpdateActions) {
+    for (action <- targetMatchedActions) {
       action.condition.foreach(condition => allFields ++= 
extractFields(condition))
-      for (assignment <- action.assignments) {
-        if (isModifiedAssignment(assignment)) {
-          allFields ++= extractFields(assignment.value)
-        }
+      action match {
+        case update: UpdateAction =>
+          for (assignment <- update.assignments) {
+            if (isModifiedAssignment(assignment)) {
+              allFields ++= extractFields(assignment.value)
+            }
+          }
+        case _ =>
       }
     }
     allFields ++= updateColumnsSorted.filterNot(isRawBlobUpdateColumn)
@@ -468,11 +524,11 @@ case class MergeIntoPaimonDataEvolutionTable(
             TrueLiteral
           }
       }
-      updatedColumns ++ metadata ++ markers
+      updatedColumns ++ metadata ++ markers :+ FalseLiteral
     }
 
     // the output projection for target table copy
-    def copyOutput: Seq[Expression] = {
+    def copyOutput(deleted: Boolean): Seq[Expression] = {
       val copiedColumns = updateColumnsSorted.map {
         attr =>
           if (rawBlobUpdateColumns.exists(_.sameRef(attr))) {
@@ -481,22 +537,45 @@ case class MergeIntoPaimonDataEvolutionTable(
             attr
           }
       }
-      copiedColumns ++ metadataColumns ++ rawBlobUpdateColumns.map(_ => 
TrueLiteral)
+      val deletedLiteral = if (deleted) TrueLiteral else FalseLiteral
+      copiedColumns ++ metadataColumns ++ rawBlobUpdateColumns.map(_ => 
TrueLiteral) :+
+        deletedLiteral
     }
 
     def reorderPartialWriteColumns(dataset: Dataset[Row]): Dataset[Row] = {
-      if (rawBlobMarkerAttributes.isEmpty) {
-        dataset
-      } else {
-        val columns =
-          updateColumnsSorted.map(attr => quotedColumn(attr.name)) ++
-            Seq(quotedColumn(ROW_ID_NAME), quotedColumn(FIRST_ROW_ID_NAME)) ++
-            rawBlobMarkerAttributes.map(attr => quotedColumn(attr.name))
-        dataset.select(columns: _*)
+      val columns =
+        updateColumnsSorted.map(attr => quotedColumn(attr.name)) ++
+          Seq(quotedColumn(ROW_ID_NAME), quotedColumn(FIRST_ROW_ID_NAME)) ++
+          rawBlobMarkerAttributes.map(attr => quotedColumn(attr.name))
+      dataset.select(columns: _*)
+    }
+
+    val keepCopyInstructions = Seq(
+      SparkShimLoader.shim
+        .mergeRowsKeepCopy(TrueLiteral, copyOutput(deleted = false))
+        .asInstanceOf[MergeRows.Instruction])
+
+    def matchedActionInstruction(action: MergeAction): MergeRows.Instruction = 
{
+      action match {
+        case update: UpdateAction =>
+          SparkShimLoader.shim
+            .mergeRowsKeepUpdate(
+              update.condition.getOrElse(TrueLiteral),
+              updateOutput(update, modifiedRawBlobNames(update)))
+            .asInstanceOf[MergeRows.Instruction]
+        case DeleteAction(condition) =>
+          SparkShimLoader.shim
+            .mergeRowsKeepUpdate(condition.getOrElse(TrueLiteral), 
copyOutput(deleted = true))
+            .asInstanceOf[MergeRows.Instruction]
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
     }
 
-    val toWrite = if (isSelfMergeOnRowId) {
+    def matchedActionInstructions(actions: Seq[MergeAction]): 
Seq[MergeRows.Instruction] =
+      actions.map(matchedActionInstruction) ++ keepCopyInstructions
+
+    val targetActionOutput: Dataset[Row] = if (isSelfMergeOnRowId) {
       // Self-Merge shortcut:
       // - Scan the target table only (no source scan, no join), and read all 
columns required by
       //   merge condition and update expressions.
@@ -532,47 +611,34 @@ case class MergeIntoPaimonDataEvolutionTable(
         }
       }
 
-      val rawBlobModifiedByAction = realUpdateActions.map(modifiedRawBlobNames)
-
-      val rewrittenUpdateActions: Seq[UpdateAction] = realUpdateActions.map {
-        ua =>
-          val newCond = ua.condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget))
-          val newAssignments = ua.assignments.map {
+      val rewrittenMatchedActions: Seq[MergeAction] = targetMatchedActions.map 
{
+        case action: UpdateAction =>
+          val newCond = action.condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget))
+          val newAssignments = action.assignments.map {
             a => Assignment(a.key, rewriteSourceToTarget(a.value, 
sourceToTarget))
           }
-          ua.copy(condition = newCond, assignments = newAssignments)
+          action.copy(condition = newCond, assignments = newAssignments)
+        case DeleteAction(condition) =>
+          DeleteAction(condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget)))
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
 
       val mergeRows = MergeRows(
         isSourceRowPresent = TrueLiteral,
         isTargetRowPresent = TrueLiteral,
-        matchedInstructions = rewrittenUpdateActions
-          .zip(rawBlobModifiedByAction)
-          .map {
-            case (action, rawBlobModified) =>
-              SparkShimLoader.shim
-                .mergeRowsKeepUpdate(
-                  action.condition.getOrElse(TrueLiteral),
-                  updateOutput(action, rawBlobModified))
-                .asInstanceOf[MergeRows.Instruction]
-          } ++ Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        matchedInstructions = 
matchedActionInstructions(rewrittenMatchedActions),
         notMatchedInstructions = Nil,
-        notMatchedBySourceInstructions = Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        notMatchedBySourceInstructions = keepCopyInstructions,
         checkCardinality = false,
         output = mergeOutput,
         child = readPlan
       )
 
-      val withFirstRowId = reorderPartialWriteColumns(
-        addFirstRowId(sparkSession, mergeRows, firstRowIds))
-      assert(
-        withFirstRowId.schema.fields.length == updateColumnsSorted.size + 2 + 
rawBlobUpdateColumns.size)
+      val withFirstRowId = addFirstRowId(sparkSession, mergeRows, firstRowIds)
+      val expectedOutputColumnCount =
+        updateColumnsSorted.size + fixedMergeOutputColumnCount + 
rawBlobUpdateColumns.size
+      assert(withFirstRowId.schema.fields.length == expectedOutputColumnCount)
       withFirstRowId
     } else {
       val allReadFieldsOnTarget = allFields.filter(
@@ -596,44 +662,163 @@ case class MergeIntoPaimonDataEvolutionTable(
         Join(targetTableProj, sourceTableProj, LeftOuter, 
Some(matchedCondition), JoinHint.NONE)
       val rowFromSourceAttr = attribute(ROW_FROM_SOURCE, joinPlan)
       val rowFromTargetAttr = attribute(ROW_FROM_TARGET, joinPlan)
+
       val mergeRows = MergeRows(
         isSourceRowPresent = rowFromSourceAttr,
         isTargetRowPresent = rowFromTargetAttr,
-        matchedInstructions = realUpdateActions
-          .map(
-            action => {
-              SparkShimLoader.shim
-                .mergeRowsKeepUpdate(
-                  action.condition.getOrElse(TrueLiteral),
-                  updateOutput(action, modifiedRawBlobNames(action)))
-                .asInstanceOf[MergeRows.Instruction]
-            }) ++ Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        matchedInstructions = matchedActionInstructions(targetMatchedActions),
         notMatchedInstructions = Nil,
-        notMatchedBySourceInstructions = Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]).toSeq,
+        notMatchedBySourceInstructions = keepCopyInstructions,
         checkCardinality = false,
         output = mergeOutput,
         child = joinPlan
       )
-      val withFirstRowId = reorderPartialWriteColumns(
-        addFirstRowId(sparkSession, mergeRows, firstRowIds))
-      assert(
-        withFirstRowId.schema.fields.length == updateColumnsSorted.size + 2 + 
rawBlobUpdateColumns.size)
+      val withFirstRowId = addFirstRowId(sparkSession, mergeRows, firstRowIds)
+      val expectedOutputColumnCount =
+        updateColumnsSorted.size + fixedMergeOutputColumnCount + 
rawBlobUpdateColumns.size
+      assert(withFirstRowId.schema.fields.length == expectedOutputColumnCount)
       withFirstRowId
         .repartition(col(FIRST_ROW_ID_NAME))
         .sortWithinPartitions(FIRST_ROW_ID_NAME, ROW_ID_NAME)
     }
 
-    val writer = DataEvolutionPaimonWriter(table, dataSplits)
-    writer.writePartialFields(
-      toWrite,
-      updateColumnsSorted.map(_.name),
-      rawBlobUpdateColumns.map(attr => attr.name -> 
rawBlobMarkerNamesByColumn(attr.name)).toMap)
+    val writePartialFields = updateColumnsSorted.nonEmpty
+    val shouldPersistOutput = writePartialFields && hasMatchedDeleteActions
+    val output: Dataset[Row] =
+      if (shouldPersistOutput) targetActionOutput.persist()
+      else targetActionOutput
+    var success = false
+    try {
+      val updateCommit =
+        if (writePartialFields) {
+          val dataEvolutionWriter = DataEvolutionPaimonWriter(table, 
dataSplits)
+          val partialCommit = dataEvolutionWriter.writePartialFields(
+            reorderPartialWriteColumns(output),
+            updateColumnsSorted.map(_.name),
+            rawBlobUpdateColumns
+              .map(attr => attr.name -> rawBlobMarkerNamesByColumn(attr.name))
+              .toMap
+          )
+          checkUpdateResult(partialCommit)
+        } else {
+          Nil
+        }
+
+      val deletionVectors =
+        if (hasMatchedDeleteActions) {
+          val dataFilePathToMeta = candidateFileMap(dataSplits)
+          Some(
+            collectDataEvolutionDeletionVectors(
+              dataSplits,
+              dataFilePathToMeta,
+              
output.filter(quotedColumn(MERGE_DELETED_NAME)).select(quotedColumn(ROW_ID_NAME)),
+              sparkSession))
+        } else {
+          None
+        }
+
+      success = true
+      TargetActionResult(
+        updateCommit,
+        deletionVectors,
+        if (shouldPersistOutput) () => output.unpersist(blocking = false) else 
() => ())
+    } finally {
+      if (!success && shouldPersistOutput) {
+        output.unpersist(blocking = false)
+      }
+    }
+  }
+
+  private def notMatchedBySourceDeletionVectors(
+      sparkSession: SparkSession,
+      readSnapshot: Snapshot,
+      persistSourceDss: Option[Dataset[Row]]): 
Option[Dataset[SparkDeletionVector]] = {
+    // Do not reuse tableSplits here because they may have been pruned by the 
MERGE ON
+    // partition filter. NOT MATCHED BY SOURCE must scan target rows 
independently.
+    val snapshotReader = table.newSnapshotReader()
+    if (readSnapshot != null) {
+      snapshotReader.withSnapshot(readSnapshot)
+    }
+    if (table.coreOptions().manifestDeleteFileDropStats()) {
+      snapshotReader.dropStats()
+    }
+    val dataSplits =
+      snapshotReader.read().splits().asScala.collect { case split: DataSplit 
=> split }.toSeq
+    if (dataSplits.isEmpty) {
+      return None
+    }
+
+    val rowIdMetadataColumn = (targetRelation.output ++ 
targetRelation.metadataOutput)
+      .filter(attr => attr.name.equals(ROW_ID_NAME))
+      .groupBy(_.exprId)
+      .map { case (_, attrs) => attrs.head }
+      .head
+
+    // No need to project source/target, spark optimizer will take care of it
+    val deleteCondition = notMatchedBySourceDeleteCondition
+    val targetReadPlan = createNewScanPlan(dataSplits, targetRelation)
+      .copy(output = targetRelation.output :+ rowIdMetadataColumn)
+    val targetFilteredPlan =
+      if (deleteCondition == TrueLiteral) targetReadPlan
+      else Filter(deleteCondition, targetReadPlan)
+    val sourceChild = 
persistSourceDss.map(_.queryExecution.logical).getOrElse(sourceTable)
+    val antiJoinPlan =
+      Join(targetFilteredPlan, sourceChild, LeftAnti, Some(matchedCondition), 
JoinHint.NONE)
+    val deletePlan = Project(Seq(rowIdMetadataColumn), antiJoinPlan)
+
+    val dataFilePathToMeta = candidateFileMap(dataSplits)
+    val deletionVectors = collectDataEvolutionDeletionVectors(
+      dataSplits,
+      dataFilePathToMeta,
+      createDataset(sparkSession, 
deletePlan).select(quotedColumn(ROW_ID_NAME)),
+      sparkSession)
+    Some(deletionVectors)
+  }
+
+  private def persistMergedDeletionVectors(
+      deletionVectorDatasets: Seq[Dataset[SparkDeletionVector]],
+      readSnapshot: Snapshot): Seq[CommitMessage] = {
+    if (deletionVectorDatasets.isEmpty) {
+      return Nil
+    }
+
+    val sparkSession = deletionVectorDatasets.head.sparkSession
+    import sparkSession.implicits._
+    val deletionVectors = deletionVectorDatasets
+      .reduce(_.union(_))
+      .groupByKey(_.dataFilePath)
+      .mapGroups {
+        (_, iter) =>
+          var first: SparkDeletionVector = null
+          var merged: DeletionVector = null
+          while (iter.hasNext) {
+            val current = iter.next()
+            val deletionVector = 
DeletionVector.deserializeFromBytes(current.deletionVector)
+            if (first == null) {
+              first = current
+              merged = deletionVector
+            } else {
+              merged.merge(deletionVector)
+            }
+          }
+
+          SparkDeletionVector(
+            first.bucketPath,
+            first.partition,
+            first.bucket,
+            first.dataFilePath,
+            DeletionVector.serializeToBytes(merged))
+      }
+
+    writer.persistDeletionVectors(deletionVectors, readSnapshot)
+  }
+
+  private def notMatchedBySourceDeleteCondition: Expression = {
+    if (notMatchedBySourceActions.exists(_.condition.isEmpty)) {
+      TrueLiteral
+    } else {
+      notMatchedBySourceActions.flatMap(_.condition).reduce(Or)
+    }
   }
 
   private def insertActionInvoke(
@@ -670,6 +855,8 @@ case class MergeIntoPaimonDataEvolutionTable(
                   else Literal(null))
             )
             .asInstanceOf[MergeRows.Instruction]
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported not matched 
action: $other.")
       }.toSeq,
       notMatchedBySourceInstructions = Nil,
       checkCardinality = false,
@@ -836,6 +1023,7 @@ object MergeIntoPaimonDataEvolutionTable {
   final private val ROW_FROM_TARGET = "__row_from_target"
   final private val ROW_ID_NAME = "_ROW_ID"
   final private val FIRST_ROW_ID_NAME = "_FIRST_ROW_ID";
+  final private val MERGE_DELETED_NAME = "_PAIMON_MERGE_INTO_DELETED"
   final private val RAW_BLOB_PLACEHOLDER_MARKER_PREFIX = 
"__paimon_raw_blob_placeholder_"
 
   private[commands] def withMatchedUpdateScanOptions(
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index 15379a1147..5e28c056f2 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions
 import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
 import org.apache.paimon.Snapshot
 import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.deletionvectors.DeletionVector
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
 import org.apache.paimon.io.{CompactIncrement, DataIncrement}
@@ -71,23 +72,26 @@ case class MergeIntoPaimonDataEvolutionTable(
     matchedActions: Seq[MergeAction],
     notMatchedActions: Seq[MergeAction],
     notMatchedBySourceActions: Seq[MergeAction])
-  extends PaimonLeafRunnableCommand
-  with WithFileStoreTable
-  with ExpressionHelper
+  extends PaimonRowLevelCommand
   with Logging {
 
-  private lazy val writer = PaimonSparkWriter(table)
-
-  assert(
-    notMatchedBySourceActions.isEmpty,
-    "notMatchedBySourceActions is not supported in 
MergeIntoPaimonDataEvolutionTable.")
   assert(
-    matchedActions.forall(x => x.isInstanceOf[UpdateAction]),
-    "Only SET clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN MATCHED.")
+    matchedActions.forall {
+      case _: UpdateAction | _: DeleteAction => true
+      case _ => false
+    },
+    "Only SET and DELETE clauses are supported in 
MergeIntoPaimonDataEvolutionTable for SQL: " +
+      "WHEN MATCHED."
+  )
   assert(
     notMatchedActions.forall(x => x.isInstanceOf[InsertAction]),
     "Only INSERT clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN NOT MATCHED."
   )
+  assert(
+    notMatchedBySourceActions.forall(x => x.isInstanceOf[DeleteAction]),
+    "Only DELETE clause is supported in MergeIntoPaimonDataEvolutionTable for 
SQL: WHEN NOT " +
+      "MATCHED BY SOURCE."
+  )
 
   import MergeIntoPaimonDataEvolutionTable._
 
@@ -105,6 +109,18 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   override lazy val table: FileStoreTable = 
targetSparkTable.getTable.asInstanceOf[FileStoreTable]
 
+  private lazy val hasTargetSideDeleteActions: Boolean =
+    matchedActions.exists(_.isInstanceOf[DeleteAction]) ||
+      notMatchedBySourceActions.exists(_.isInstanceOf[DeleteAction])
+
+  private lazy val hasMatchedDeleteActions: Boolean =
+    matchedActions.exists(_.isInstanceOf[DeleteAction])
+
+  assert(
+    !hasTargetSideDeleteActions || 
table.coreOptions().deletionVectorsEnabled(),
+    "MERGE INTO DELETE on DataEvolution table requires 
deletion-vectors.enabled=true."
+  )
+
   private val updateColumns: Set[AttributeReference] = {
     val columns = mutable.Set[AttributeReference]()
     for (action <- matchedActions) {
@@ -115,6 +131,7 @@ case class MergeIntoPaimonDataEvolutionTable(
               columns += assignmentKeyAttribute(assignment)
             }
           }
+        case _ =>
       }
     }
     columns.toSet
@@ -153,8 +170,8 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   assert(
     !(isSelfMergeOnRowId && (notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)),
-    "Self-Merge on _ROW_ID only supports WHEN MATCHED THEN UPDATE. WHEN NOT 
MATCHED and WHEN " +
-      "NOT MATCHED BY SOURCE are not supported."
+    "Self-Merge on _ROW_ID only supports WHEN MATCHED actions. WHEN NOT 
MATCHED and " +
+      "WHEN NOT MATCHED BY SOURCE are not supported."
   )
 
   private lazy val targetRelation: DataSourceV2Relation = 
matchedUpdateScanTarget._2
@@ -169,7 +186,11 @@ case class MergeIntoPaimonDataEvolutionTable(
   }
 
   private def invokeMergeInto(sparkSession: SparkSession): Unit = {
+    val readSnapshot = table.snapshotManager().latestSnapshot()
     val snapshotReader = table.newSnapshotReader()
+    if (readSnapshot != null) {
+      snapshotReader.withSnapshot(readSnapshot)
+    }
     pushDownMergePartitionFilter(snapshotReader)
     val plan = snapshotReader.read()
     val tableSplits: Seq[DataSplit] = plan
@@ -212,7 +233,7 @@ case class MergeIntoPaimonDataEvolutionTable(
     val persistSourceDss: Option[Dataset[Row]] =
       if (
         table.coreOptions().dataEvolutionMergeIntoSourcePersist()
-        && (matchedActions.nonEmpty || notMatchedActions.nonEmpty)
+        && (matchedActions.nonEmpty || notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)
       ) {
         val dss = createDataset(sparkSession, sourceTable)
         dss.persist()
@@ -221,28 +242,42 @@ case class MergeIntoPaimonDataEvolutionTable(
         None
       }
 
+    var targetActionCleanup = () => ()
     try {
-      // step 1: find the related data splits, make it target file plan
-      val dataSplits: Seq[DataSplit] = targetRelatedSplits(
+      // step 1: find the related data splits for matched / insert actions.
+      lazy val matchedOrInsertDataSplits: Seq[DataSplit] = targetRelatedSplits(
         sparkSession,
         tableSplits,
         firstRowIds,
         firstRowIdToBlobFirstRowIds,
         persistSourceDss)
-      val touchedFileTargetRelation =
-        createNewScanPlan(dataSplits, targetRelation)
+      lazy val touchedFileTargetRelation =
+        createNewScanPlan(matchedOrInsertDataSplits, targetRelation)
 
-      // step 2: invoke update action
-      val updateCommit =
+      // step 2: invoke target-side action
+      val matchedResult =
         if (matchedActions.nonEmpty) {
-          val updateResult = updateActionInvoke(
-            dataSplits,
+          val result = targetActionInvoke(
+            matchedOrInsertDataSplits,
             sparkSession,
             touchedFileTargetRelation,
             firstRowIds,
             persistSourceDss)
-          checkUpdateResult(updateResult)
-        } else Nil
+          targetActionCleanup = result.cleanup
+          result
+        } else {
+          TargetActionResult(Nil, None, () => ())
+        }
+      val notMatchedBySourceDvs =
+        if (notMatchedBySourceActions.nonEmpty) {
+          notMatchedBySourceDeletionVectors(sparkSession, readSnapshot, 
persistSourceDss)
+        } else None
+
+      // Merge deletion vectors generated from NMBS and matched conditions
+      // These two statement branch could generate deletion vectors for same 
files!
+      val deleteCommit = persistMergedDeletionVectors(
+        Seq(matchedResult.deletionVectors, notMatchedBySourceDvs).flatten,
+        readSnapshot)
 
       // step 3: invoke insert action
       val insertCommit =
@@ -250,11 +285,14 @@ case class MergeIntoPaimonDataEvolutionTable(
           insertActionInvoke(sparkSession, touchedFileTargetRelation, 
persistSourceDss)
         else Nil
 
-      if (plan.snapshotId() != null) {
-        writer.rowIdCheckConflict(plan.snapshotId())
+      if (readSnapshot != null) {
+        writer.rowIdCheckConflict(readSnapshot.id())
       }
-      writer.commit(updateCommit ++ insertCommit, Snapshot.Operation.MERGE)
+      writer.commit(
+        matchedResult.commitMessages ++ deleteCommit ++ insertCommit,
+        Snapshot.Operation.MERGE)
     } finally {
+      targetActionCleanup()
       if (persistSourceDss.isDefined) {
         persistSourceDss.get.unpersist(blocking = false)
       }
@@ -348,12 +386,21 @@ case class MergeIntoPaimonDataEvolutionTable(
       .map(_.get())
   }
 
-  private def updateActionInvoke(
+  private case class TargetActionResult(
+      commitMessages: Seq[CommitMessage],
+      deletionVectors: Option[Dataset[SparkDeletionVector]],
+      cleanup: () => Unit)
+
+  private def targetActionInvoke(
       dataSplits: Seq[DataSplit],
       sparkSession: SparkSession,
       touchedFileTargetRelation: DataSourceV2Relation,
       firstRowIds: immutable.IndexedSeq[Long],
-      persistSourceDss: Option[Dataset[Row]]): Seq[CommitMessage] = {
+      persistSourceDss: Option[Dataset[Row]]): TargetActionResult = {
+    if (dataSplits.isEmpty) {
+      return TargetActionResult(Nil, None, () => ())
+    }
+
     val conditionFields = extractFields(matchedCondition)
     val allFields = mutable.SortedSet.empty[AttributeReference](
       (o1, o2) => {
@@ -402,27 +449,37 @@ case class MergeIntoPaimonDataEvolutionTable(
     val rawBlobMarkerAttributes = rawBlobUpdateColumns.map(
       attr =>
         AttributeReference(rawBlobMarkerNamesByColumn(attr.name), BooleanType, 
nullable = false)())
-    val mergeOutput = updateColumnsSorted ++ metadataColumns ++ 
rawBlobMarkerAttributes
-
-    val realUpdateActions = matchedActions
-      .map(s => s.asInstanceOf[UpdateAction])
-      .map(
-        update =>
-          UpdateAction.apply(
-            update.condition,
-            update.assignments.filter(
-              a => updateColumnsSorted.contains(assignmentKeyAttribute(a)))))
+    val deletedAttribute =
+      AttributeReference(MERGE_DELETED_NAME, BooleanType, nullable = false)()
+    // Row metadata, _FIRST_ROW_ID added by addFirstRowId, and the delete 
marker.
+    val fixedMergeOutputColumnCount = metadataColumns.size + 2
+    val mergeOutput = (updateColumnsSorted ++ metadataColumns ++ 
rawBlobMarkerAttributes) :+
+      deletedAttribute
+
+    val targetMatchedActions = matchedActions.map {
+      case update: UpdateAction =>
+        UpdateAction.apply(
+          update.condition,
+          update.assignments.filter(a => 
updateColumnsSorted.contains(assignmentKeyAttribute(a))))
+      case delete: DeleteAction => delete
+      case other =>
+        throw new UnsupportedOperationException(s"Unsupported matched action: 
$other.")
+    }
 
     // All fields are composed by:
     // 1. Match condition fields
-    // 2. For each update action, the condition fields and the assignment 
value fields
+    // 2. For each target-side action, the condition fields and the assignment 
value fields
     // 3. All updated fields exclude raw blob fields
-    for (action <- realUpdateActions) {
+    for (action <- targetMatchedActions) {
       action.condition.foreach(condition => allFields ++= 
extractFields(condition))
-      for (assignment <- action.assignments) {
-        if (isModifiedAssignment(assignment)) {
-          allFields ++= extractFields(assignment.value)
-        }
+      action match {
+        case update: UpdateAction =>
+          for (assignment <- update.assignments) {
+            if (isModifiedAssignment(assignment)) {
+              allFields ++= extractFields(assignment.value)
+            }
+          }
+        case _ =>
       }
     }
     allFields ++= updateColumnsSorted.filterNot(isRawBlobUpdateColumn)
@@ -467,11 +524,11 @@ case class MergeIntoPaimonDataEvolutionTable(
             TrueLiteral
           }
       }
-      updatedColumns ++ metadata ++ markers
+      updatedColumns ++ metadata ++ markers :+ FalseLiteral
     }
 
     // the output projection for target table copy
-    def copyOutput: Seq[Expression] = {
+    def copyOutput(deleted: Boolean): Seq[Expression] = {
       val copiedColumns = updateColumnsSorted.map {
         attr =>
           if (rawBlobUpdateColumns.exists(_.sameRef(attr))) {
@@ -480,22 +537,45 @@ case class MergeIntoPaimonDataEvolutionTable(
             attr
           }
       }
-      copiedColumns ++ metadataColumns ++ rawBlobUpdateColumns.map(_ => 
TrueLiteral)
+      val deletedLiteral = if (deleted) TrueLiteral else FalseLiteral
+      copiedColumns ++ metadataColumns ++ rawBlobUpdateColumns.map(_ => 
TrueLiteral) :+
+        deletedLiteral
     }
 
     def reorderPartialWriteColumns(dataset: Dataset[Row]): Dataset[Row] = {
-      if (rawBlobMarkerAttributes.isEmpty) {
-        dataset
-      } else {
-        val columns =
-          updateColumnsSorted.map(attr => quotedColumn(attr.name)) ++
-            Seq(quotedColumn(ROW_ID_NAME), quotedColumn(FIRST_ROW_ID_NAME)) ++
-            rawBlobMarkerAttributes.map(attr => quotedColumn(attr.name))
-        dataset.select(columns: _*)
+      val columns =
+        updateColumnsSorted.map(attr => quotedColumn(attr.name)) ++
+          Seq(quotedColumn(ROW_ID_NAME), quotedColumn(FIRST_ROW_ID_NAME)) ++
+          rawBlobMarkerAttributes.map(attr => quotedColumn(attr.name))
+      dataset.select(columns: _*)
+    }
+
+    val keepCopyInstructions = Seq(
+      SparkShimLoader.shim
+        .mergeRowsKeepCopy(TrueLiteral, copyOutput(deleted = false))
+        .asInstanceOf[MergeRows.Instruction])
+
+    def matchedActionInstruction(action: MergeAction): MergeRows.Instruction = 
{
+      action match {
+        case update: UpdateAction =>
+          SparkShimLoader.shim
+            .mergeRowsKeepUpdate(
+              update.condition.getOrElse(TrueLiteral),
+              updateOutput(update, modifiedRawBlobNames(update)))
+            .asInstanceOf[MergeRows.Instruction]
+        case DeleteAction(condition) =>
+          SparkShimLoader.shim
+            .mergeRowsKeepUpdate(condition.getOrElse(TrueLiteral), 
copyOutput(deleted = true))
+            .asInstanceOf[MergeRows.Instruction]
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
     }
 
-    val toWrite = if (isSelfMergeOnRowId) {
+    def matchedActionInstructions(actions: Seq[MergeAction]): 
Seq[MergeRows.Instruction] =
+      actions.map(matchedActionInstruction) ++ keepCopyInstructions
+
+    val targetActionOutput: Dataset[Row] = if (isSelfMergeOnRowId) {
       // Self-Merge shortcut:
       // - Scan the target table only (no source scan, no join), and read all 
columns required by
       //   merge condition and update expressions.
@@ -531,47 +611,34 @@ case class MergeIntoPaimonDataEvolutionTable(
         }
       }
 
-      val rawBlobModifiedByAction = realUpdateActions.map(modifiedRawBlobNames)
-
-      val rewrittenUpdateActions: Seq[UpdateAction] = realUpdateActions.map {
-        ua =>
-          val newCond = ua.condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget))
-          val newAssignments = ua.assignments.map {
+      val rewrittenMatchedActions: Seq[MergeAction] = targetMatchedActions.map 
{
+        case action: UpdateAction =>
+          val newCond = action.condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget))
+          val newAssignments = action.assignments.map {
             a => Assignment(a.key, rewriteSourceToTarget(a.value, 
sourceToTarget))
           }
-          ua.copy(condition = newCond, assignments = newAssignments)
+          action.copy(condition = newCond, assignments = newAssignments)
+        case DeleteAction(condition) =>
+          DeleteAction(condition.map(c => rewriteSourceToTarget(c, 
sourceToTarget)))
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
 
       val mergeRows = MergeRows(
         isSourceRowPresent = TrueLiteral,
         isTargetRowPresent = TrueLiteral,
-        matchedInstructions = rewrittenUpdateActions
-          .zip(rawBlobModifiedByAction)
-          .map {
-            case (action, rawBlobModified) =>
-              SparkShimLoader.shim
-                .mergeRowsKeepUpdate(
-                  action.condition.getOrElse(TrueLiteral),
-                  updateOutput(action, rawBlobModified))
-                .asInstanceOf[MergeRows.Instruction]
-          } ++ Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        matchedInstructions = 
matchedActionInstructions(rewrittenMatchedActions),
         notMatchedInstructions = Nil,
-        notMatchedBySourceInstructions = Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        notMatchedBySourceInstructions = keepCopyInstructions,
         checkCardinality = false,
         output = mergeOutput,
         child = readPlan
       )
 
-      val withFirstRowId = reorderPartialWriteColumns(
-        addFirstRowId(sparkSession, mergeRows, firstRowIds))
-      assert(
-        withFirstRowId.schema.fields.length == updateColumnsSorted.size + 2 + 
rawBlobUpdateColumns.size)
+      val withFirstRowId = addFirstRowId(sparkSession, mergeRows, firstRowIds)
+      val expectedOutputColumnCount =
+        updateColumnsSorted.size + fixedMergeOutputColumnCount + 
rawBlobUpdateColumns.size
+      assert(withFirstRowId.schema.fields.length == expectedOutputColumnCount)
       withFirstRowId
     } else {
       val allReadFieldsOnTarget = allFields.filter(
@@ -595,44 +662,163 @@ case class MergeIntoPaimonDataEvolutionTable(
         Join(targetTableProj, sourceTableProj, LeftOuter, 
Some(matchedCondition), JoinHint.NONE)
       val rowFromSourceAttr = attribute(ROW_FROM_SOURCE, joinPlan)
       val rowFromTargetAttr = attribute(ROW_FROM_TARGET, joinPlan)
+
       val mergeRows = MergeRows(
         isSourceRowPresent = rowFromSourceAttr,
         isTargetRowPresent = rowFromTargetAttr,
-        matchedInstructions = realUpdateActions
-          .map(
-            action => {
-              SparkShimLoader.shim
-                .mergeRowsKeepUpdate(
-                  action.condition.getOrElse(TrueLiteral),
-                  updateOutput(action, modifiedRawBlobNames(action)))
-                .asInstanceOf[MergeRows.Instruction]
-            }) ++ Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]),
+        matchedInstructions = matchedActionInstructions(targetMatchedActions),
         notMatchedInstructions = Nil,
-        notMatchedBySourceInstructions = Seq(
-          SparkShimLoader.shim
-            .mergeRowsKeepCopy(TrueLiteral, copyOutput)
-            .asInstanceOf[MergeRows.Instruction]).toSeq,
+        notMatchedBySourceInstructions = keepCopyInstructions,
         checkCardinality = false,
         output = mergeOutput,
         child = joinPlan
       )
-      val withFirstRowId = reorderPartialWriteColumns(
-        addFirstRowId(sparkSession, mergeRows, firstRowIds))
-      assert(
-        withFirstRowId.schema.fields.length == updateColumnsSorted.size + 2 + 
rawBlobUpdateColumns.size)
+      val withFirstRowId = addFirstRowId(sparkSession, mergeRows, firstRowIds)
+      val expectedOutputColumnCount =
+        updateColumnsSorted.size + fixedMergeOutputColumnCount + 
rawBlobUpdateColumns.size
+      assert(withFirstRowId.schema.fields.length == expectedOutputColumnCount)
       withFirstRowId
         .repartition(col(FIRST_ROW_ID_NAME))
         .sortWithinPartitions(FIRST_ROW_ID_NAME, ROW_ID_NAME)
     }
 
-    val writer = DataEvolutionPaimonWriter(table, dataSplits)
-    writer.writePartialFields(
-      toWrite,
-      updateColumnsSorted.map(_.name),
-      rawBlobUpdateColumns.map(attr => attr.name -> 
rawBlobMarkerNamesByColumn(attr.name)).toMap)
+    val writePartialFields = updateColumnsSorted.nonEmpty
+    val shouldPersistOutput = writePartialFields && hasMatchedDeleteActions
+    val output: Dataset[Row] =
+      if (shouldPersistOutput) targetActionOutput.persist()
+      else targetActionOutput
+    var success = false
+    try {
+      val updateCommit =
+        if (writePartialFields) {
+          val dataEvolutionWriter = DataEvolutionPaimonWriter(table, 
dataSplits)
+          val partialCommit = dataEvolutionWriter.writePartialFields(
+            reorderPartialWriteColumns(output),
+            updateColumnsSorted.map(_.name),
+            rawBlobUpdateColumns
+              .map(attr => attr.name -> rawBlobMarkerNamesByColumn(attr.name))
+              .toMap
+          )
+          checkUpdateResult(partialCommit)
+        } else {
+          Nil
+        }
+
+      val deletionVectors =
+        if (hasMatchedDeleteActions) {
+          val dataFilePathToMeta = candidateFileMap(dataSplits)
+          Some(
+            collectDataEvolutionDeletionVectors(
+              dataSplits,
+              dataFilePathToMeta,
+              
output.filter(quotedColumn(MERGE_DELETED_NAME)).select(quotedColumn(ROW_ID_NAME)),
+              sparkSession))
+        } else {
+          None
+        }
+
+      success = true
+      TargetActionResult(
+        updateCommit,
+        deletionVectors,
+        if (shouldPersistOutput) () => output.unpersist(blocking = false) else 
() => ())
+    } finally {
+      if (!success && shouldPersistOutput) {
+        output.unpersist(blocking = false)
+      }
+    }
+  }
+
+  private def notMatchedBySourceDeletionVectors(
+      sparkSession: SparkSession,
+      readSnapshot: Snapshot,
+      persistSourceDss: Option[Dataset[Row]]): 
Option[Dataset[SparkDeletionVector]] = {
+    // Do not reuse tableSplits here because they may have been pruned by the 
MERGE ON
+    // partition filter. NOT MATCHED BY SOURCE must scan target rows 
independently.
+    val snapshotReader = table.newSnapshotReader()
+    if (readSnapshot != null) {
+      snapshotReader.withSnapshot(readSnapshot)
+    }
+    if (table.coreOptions().manifestDeleteFileDropStats()) {
+      snapshotReader.dropStats()
+    }
+    val dataSplits =
+      snapshotReader.read().splits().asScala.collect { case split: DataSplit 
=> split }.toSeq
+    if (dataSplits.isEmpty) {
+      return None
+    }
+
+    val rowIdMetadataColumn = (targetRelation.output ++ 
targetRelation.metadataOutput)
+      .filter(attr => attr.name.equals(ROW_ID_NAME))
+      .groupBy(_.exprId)
+      .map { case (_, attrs) => attrs.head }
+      .head
+
+    // No need to project source/target, spark optimizer will take care of it
+    val deleteCondition = notMatchedBySourceDeleteCondition
+    val targetReadPlan = createNewScanPlan(dataSplits, targetRelation)
+      .copy(output = targetRelation.output :+ rowIdMetadataColumn)
+    val targetFilteredPlan =
+      if (deleteCondition == TrueLiteral) targetReadPlan
+      else Filter(deleteCondition, targetReadPlan)
+    val sourceChild = 
persistSourceDss.map(_.queryExecution.logical).getOrElse(sourceTable)
+    val antiJoinPlan =
+      Join(targetFilteredPlan, sourceChild, LeftAnti, Some(matchedCondition), 
JoinHint.NONE)
+    val deletePlan = Project(Seq(rowIdMetadataColumn), antiJoinPlan)
+
+    val dataFilePathToMeta = candidateFileMap(dataSplits)
+    val deletionVectors = collectDataEvolutionDeletionVectors(
+      dataSplits,
+      dataFilePathToMeta,
+      createDataset(sparkSession, 
deletePlan).select(quotedColumn(ROW_ID_NAME)),
+      sparkSession)
+    Some(deletionVectors)
+  }
+
+  private def persistMergedDeletionVectors(
+      deletionVectorDatasets: Seq[Dataset[SparkDeletionVector]],
+      readSnapshot: Snapshot): Seq[CommitMessage] = {
+    if (deletionVectorDatasets.isEmpty) {
+      return Nil
+    }
+
+    val sparkSession = deletionVectorDatasets.head.sparkSession
+    import sparkSession.implicits._
+    val deletionVectors = deletionVectorDatasets
+      .reduce(_.union(_))
+      .groupByKey(_.dataFilePath)
+      .mapGroups {
+        (_, iter) =>
+          var first: SparkDeletionVector = null
+          var merged: DeletionVector = null
+          while (iter.hasNext) {
+            val current = iter.next()
+            val deletionVector = 
DeletionVector.deserializeFromBytes(current.deletionVector)
+            if (first == null) {
+              first = current
+              merged = deletionVector
+            } else {
+              merged.merge(deletionVector)
+            }
+          }
+
+          SparkDeletionVector(
+            first.bucketPath,
+            first.partition,
+            first.bucket,
+            first.dataFilePath,
+            DeletionVector.serializeToBytes(merged))
+      }
+
+    writer.persistDeletionVectors(deletionVectors, readSnapshot)
+  }
+
+  private def notMatchedBySourceDeleteCondition: Expression = {
+    if (notMatchedBySourceActions.exists(_.condition.isEmpty)) {
+      TrueLiteral
+    } else {
+      notMatchedBySourceActions.flatMap(_.condition).reduce(Or)
+    }
   }
 
   private def insertActionInvoke(
@@ -669,6 +855,8 @@ case class MergeIntoPaimonDataEvolutionTable(
                   else Literal(null))
             )
             .asInstanceOf[MergeRows.Instruction]
+        case other =>
+          throw new UnsupportedOperationException(s"Unsupported not matched 
action: $other.")
       }.toSeq,
       notMatchedBySourceInstructions = Nil,
       checkCardinality = false,
@@ -835,6 +1023,7 @@ object MergeIntoPaimonDataEvolutionTable {
   final private val ROW_FROM_TARGET = "__row_from_target"
   final private val ROW_ID_NAME = "_ROW_ID"
   final private val FIRST_ROW_ID_NAME = "_FIRST_ROW_ID";
+  final private val MERGE_DELETED_NAME = "_PAIMON_MERGE_INTO_DELETED"
   final private val RAW_BLOB_PLACEHOLDER_MARKER_PREFIX = 
"__paimon_raw_blob_placeholder_"
 
   private[commands] def withMatchedUpdateScanOptions(
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonRowLevelCommand.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonRowLevelCommand.scala
index 3fad7fb4df..a99c7b36ab 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonRowLevelCommand.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonRowLevelCommand.scala
@@ -196,6 +196,17 @@ trait PaimonRowLevelCommand
       sparkSession)
   }
 
+  protected def collectDataEvolutionDeletionVectors(
+      candidateDataSplits: Seq[DataSplit],
+      dataFilePathToMeta: Map[String, SparkDataFileMeta],
+      dataset: Dataset[Row],
+      sparkSession: SparkSession): Dataset[SparkDeletionVector] = {
+    buildDeletionVectors(
+      dataFilePathToMeta,
+      dataEvolutionDeletionTargets(candidateDataSplits, dataset, sparkSession),
+      sparkSession)
+  }
+
   private def pathAndIndexDeletionTargets(
       dataset: Dataset[Row],
       sparkSession: SparkSession): 
Dataset[PaimonRowLevelCommand.DeletionTarget] = {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataEvolutionDeletionTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataEvolutionDeletionTestBase.scala
index 5d2ba1d697..24f44a7527 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataEvolutionDeletionTestBase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataEvolutionDeletionTestBase.scala
@@ -199,4 +199,385 @@ abstract class DataEvolutionDeletionTestBase extends 
PaimonSparkTestBase {
         Seq(Row(5, 5, 5, 5L), Row(7, 107, 7, 7L), Row(8, 8, 8, 8L)))
     }
   }
+
+  test("Data Evolution deletion: self merge delete by row id") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 5)")
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(5, 10)")
+
+      sql("""
+            |MERGE INTO t
+            |USING t AS source
+            |ON t._ROW_ID = source._ROW_ID
+            |WHEN MATCHED AND source.id IN (2, 6, 9) THEN DELETE
+            |""".stripMargin)
+
+      checkAnswer(
+        sql("SELECT id, b, c, _ROW_ID FROM t ORDER BY id"),
+        Seq(
+          Row(0, 0, 0, 0L),
+          Row(1, 1, 1, 1L),
+          Row(3, 3, 3, 3L),
+          Row(4, 4, 4, 4L),
+          Row(5, 5, 5, 5L),
+          Row(7, 7, 7, 7L),
+          Row(8, 8, 8, 8L))
+      )
+    }
+  }
+
+  test("Data Evolution deletion: merge matched delete writes deletion vectors 
only") {
+    withTable("s", "t") {
+      sql("CREATE TABLE s (id INT)")
+      sql("INSERT INTO s VALUES (1), (6), (8), (99)")
+
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 5)")
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(5, 10)")
+
+      val fileCountBefore = sql("SELECT COUNT(*) FROM `t$files` WHERE 
file_path NOT LIKE '%.blob'")
+        .collect()
+        .head
+        .getLong(0)
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN MATCHED THEN DELETE
+            |""".stripMargin)
+
+      val fileCountAfter = sql("SELECT COUNT(*) FROM `t$files` WHERE file_path 
NOT LIKE '%.blob'")
+        .collect()
+        .head
+        .getLong(0)
+      assert(fileCountAfter == fileCountBefore)
+
+      checkAnswer(
+        sql("SELECT id, b, c, _ROW_ID FROM t ORDER BY id"),
+        Seq(
+          Row(0, 0, 0, 0L),
+          Row(2, 2, 2, 2L),
+          Row(3, 3, 3, 3L),
+          Row(4, 4, 4, 4L),
+          Row(5, 5, 5, 5L),
+          Row(7, 7, 7, 7L),
+          Row(9, 9, 9, 9L))
+      )
+    }
+  }
+
+  test("Data Evolution deletion: merge matched update and delete keeps 
first-match semantics") {
+    withTable("s", "t") {
+      sql("CREATE TABLE s (id INT, new_b INT, op STRING)")
+      sql("""
+            |INSERT INTO s VALUES
+            |  (1, 100, 'update'),
+            |  (2, 200, 'delete'),
+            |  (6, 600, 'update'),
+            |  (11, 1100, 'delete')
+            |""".stripMargin)
+
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 5)")
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(5, 10)")
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN MATCHED AND s.op = 'delete' THEN DELETE
+            |WHEN MATCHED AND s.id IN (1, 2, 6) THEN UPDATE SET t.b = s.new_b
+            |""".stripMargin)
+
+      checkAnswer(
+        sql("SELECT id, b, c, _ROW_ID FROM t ORDER BY id"),
+        Seq(
+          Row(0, 0, 0, 0L),
+          Row(1, 100, 1, 1L),
+          Row(3, 3, 3, 3L),
+          Row(4, 4, 4, 4L),
+          Row(5, 5, 5, 5L),
+          Row(6, 600, 6, 6L),
+          Row(7, 7, 7, 7L),
+          Row(8, 8, 8, 8L),
+          Row(9, 9, 9, 9L))
+      )
+    }
+  }
+
+  test("Data Evolution deletion: merge not matched by source delete") {
+    assume(gteqSpark3_4)
+
+    withTable("s", "t") {
+      sql("CREATE TABLE s (id INT)")
+      sql("INSERT INTO s VALUES (1), (3)")
+
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 6)")
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN NOT MATCHED BY SOURCE THEN DELETE
+            |""".stripMargin)
+
+      checkAnswer(
+        sql("SELECT id, b, c, _ROW_ID FROM t ORDER BY id"),
+        Seq(Row(1, 1, 1, 1L), Row(3, 3, 3, 3L)))
+    }
+  }
+
+  test("Data Evolution deletion: merge update insert and not matched by source 
delete") {
+    assume(gteqSpark3_4)
+
+    withTable("s", "t") {
+      sql("CREATE TABLE s (id INT, new_b INT)")
+      sql("INSERT INTO s VALUES (1, 100), (4, 400)")
+
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 4)")
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN MATCHED THEN UPDATE SET t.b = s.new_b
+            |WHEN NOT MATCHED THEN INSERT (id, b, c) VALUES (s.id, s.new_b, 
s.new_b)
+            |WHEN NOT MATCHED BY SOURCE THEN DELETE
+            |""".stripMargin)
+
+      checkAnswer(sql("SELECT id, b, c FROM t ORDER BY id"), Seq(Row(1, 100, 
1), Row(4, 400, 400)))
+    }
+  }
+
+  test("Data Evolution deletion: repeated complex merge on table with deletion 
vectors") {
+    assume(gteqSpark3_4)
+
+    withTempView("s") {
+      withTable("t") {
+        sql("""
+              |CREATE TABLE t (id INT, b INT, c INT)
+              |TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true',
+              |  'deletion-vectors.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 5)")
+        sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(5, 10)")
+        sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(10, 12)")
+
+        sql("""
+              |CREATE OR REPLACE TEMP VIEW s AS
+              |SELECT * FROM VALUES
+              |  (1, 100, 'update'),
+              |  (2, 200, 'delete'),
+              |  (12, 1200, 'insert')
+              |AS s(id, new_b, op)
+              |""".stripMargin)
+        sql("""
+              |MERGE INTO t
+              |USING s
+              |ON t.id = s.id
+              |WHEN MATCHED AND s.op = 'delete' THEN DELETE
+              |WHEN MATCHED AND s.op = 'update' THEN UPDATE SET t.b = s.new_b
+              |WHEN NOT MATCHED THEN INSERT (id, b, c) VALUES (s.id, s.new_b, 
s.new_b + 1000)
+              |WHEN NOT MATCHED BY SOURCE AND t.id IN (3, 8) THEN DELETE
+              |""".stripMargin)
+
+        checkAnswer(
+          sql("SELECT id, b, c FROM t ORDER BY id"),
+          Seq(
+            Row(0, 0, 0),
+            Row(1, 100, 1),
+            Row(4, 4, 4),
+            Row(5, 5, 5),
+            Row(6, 6, 6),
+            Row(7, 7, 7),
+            Row(9, 9, 9),
+            Row(10, 10, 10),
+            Row(11, 11, 11),
+            Row(12, 1200, 2200))
+        )
+
+        sql("""
+              |CREATE OR REPLACE TEMP VIEW s AS
+              |SELECT * FROM VALUES
+              |  (1, 101, 'update'),
+              |  (2, 202, 'update'),
+              |  (4, 400, 'delete'),
+              |  (13, 1300, 'insert')
+              |AS s(id, new_b, op)
+              |""".stripMargin)
+        sql("""
+              |MERGE INTO t
+              |USING s
+              |ON t.id = s.id
+              |WHEN MATCHED AND s.op = 'delete' THEN DELETE
+              |WHEN MATCHED AND s.op = 'update' THEN UPDATE SET t.b = s.new_b
+              |WHEN NOT MATCHED THEN INSERT (id, b, c) VALUES (s.id, s.new_b, 
s.new_b + 1000)
+              |WHEN NOT MATCHED BY SOURCE AND t.id IN (5, 10) THEN DELETE
+              |""".stripMargin)
+
+        checkAnswer(
+          sql("SELECT id, b, c FROM t ORDER BY id, b, c"),
+          Seq(
+            Row(0, 0, 0),
+            Row(1, 101, 1),
+            Row(2, 202, 1202),
+            Row(6, 6, 6),
+            Row(7, 7, 7),
+            Row(9, 9, 9),
+            Row(11, 11, 11),
+            Row(12, 1200, 2200),
+            Row(13, 1300, 2300))
+        )
+      }
+    }
+  }
+
+  test("Data Evolution deletion: not matched by source delete does not trigger 
partial copy") {
+    assume(gteqSpark3_4)
+
+    withTable("s", "t") {
+      sql("CREATE TABLE s (id INT, new_b INT)")
+      sql("INSERT INTO s VALUES (1, 100)")
+
+      sql("""
+            |CREATE TABLE t (id INT, b INT, c INT)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(0, 5)")
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(5, 10)")
+      sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(10, 15)")
+
+      val thirdRangeFileCountBefore =
+        sql(
+          "SELECT COUNT(*) FROM `t$files` WHERE first_row_id = 10 AND 
file_path NOT LIKE '%.blob'")
+          .collect()
+          .head
+          .getLong(0)
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN MATCHED THEN UPDATE SET t.b = s.new_b
+            |WHEN NOT MATCHED BY SOURCE AND t.id >= 10 THEN DELETE
+            |""".stripMargin)
+
+      val thirdRangeFileCountAfter =
+        sql(
+          "SELECT COUNT(*) FROM `t$files` WHERE first_row_id = 10 AND 
file_path NOT LIKE '%.blob'")
+          .collect()
+          .head
+          .getLong(0)
+      assert(thirdRangeFileCountAfter == thirdRangeFileCountBefore)
+
+      checkAnswer(
+        sql("SELECT id, b, c, _ROW_ID FROM t ORDER BY id"),
+        Seq(
+          Row(0, 0, 0, 0L),
+          Row(1, 100, 1, 1L),
+          Row(2, 2, 2, 2L),
+          Row(3, 3, 3, 3L),
+          Row(4, 4, 4, 4L),
+          Row(5, 5, 5, 5L),
+          Row(6, 6, 6, 6L),
+          Row(7, 7, 7, 7L),
+          Row(8, 8, 8, 8L),
+          Row(9, 9, 9, 9L))
+      )
+    }
+  }
+
+  test("Data Evolution deletion: merge blob update and delete") {
+    withTable("s", "t") {
+      sql("""
+            |CREATE TABLE t (id INT, b INT, picture BINARY)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true',
+            |  'deletion-vectors.enabled' = 'true',
+            |  'blob-field' = 'picture',
+            |  'blob.target-file-size' = '1 b')
+            |""".stripMargin)
+      sql("""
+            |INSERT INTO t SELECT /*+ REPARTITION(1) */ id, b, picture FROM 
VALUES
+            |  (0, 0, X'00'), (1, 1, X'01'), (2, 2, X'02'), (3, 3, X'03'), (4, 
4, X'04')
+            |  AS v(id, b, picture)
+            |""".stripMargin)
+      sql("""
+            |INSERT INTO t SELECT /*+ REPARTITION(1) */ id, b, picture FROM 
VALUES
+            |  (5, 5, X'05'), (6, 6, X'06'), (7, 7, X'07'), (8, 8, X'08'), (9, 
9, X'09')
+            |  AS v(id, b, picture)
+            |""".stripMargin)
+
+      sql("CREATE TABLE s (id INT, picture BINARY, op STRING)")
+      sql("""
+            |INSERT INTO s VALUES
+            |  (2, X'22', 'delete'),
+            |  (6, X'66', 'update'),
+            |  (7, X'4D', 'update'),
+            |  (9, X'79', 'delete')
+            |""".stripMargin)
+
+      sql("""
+            |MERGE INTO t
+            |USING s
+            |ON t.id = s.id
+            |WHEN MATCHED AND s.op = 'delete' THEN DELETE
+            |WHEN MATCHED THEN UPDATE SET t.picture = s.picture
+            |""".stripMargin)
+
+      checkAnswer(
+        sql("SELECT id, b, picture, _ROW_ID FROM t ORDER BY id"),
+        Seq(
+          Row(0, 0, Array[Byte](0), 0L),
+          Row(1, 1, Array[Byte](1), 1L),
+          Row(3, 3, Array[Byte](3), 3L),
+          Row(4, 4, Array[Byte](4), 4L),
+          Row(5, 5, Array[Byte](5), 5L),
+          Row(6, 6, Array[Byte](102), 6L),
+          Row(7, 7, Array[Byte](77), 7L),
+          Row(8, 8, Array[Byte](8), 8L)
+        )
+      )
+    }
+  }
 }

Reply via email to