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 1367e46bfe [spark] Support partition pruning for data evolution 
self-merge (#9489)
1367e46bfe is described below

commit 1367e46bfe05931d1530fa312c70d9ed7375e4b1
Author: zhoulii <[email protected]>
AuthorDate: Mon Aug 31 18:11:29 2026 +0800

    [spark] Support partition pruning for data evolution self-merge (#9489)
---
 .../MergeIntoPaimonDataEvolutionTable.scala        | 128 +++++++++--
 .../MergeIntoPaimonDataEvolutionTable.scala        | 128 +++++++++--
 .../paimon/spark/sql/RowTrackingTestBase.scala     | 243 +++++++++++++++++++--
 3 files changed, 449 insertions(+), 50 deletions(-)

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 fd70124ab5..1e59af4b57 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
@@ -27,9 +27,9 @@ import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
 import org.apache.paimon.io.{CompactIncrement, DataIncrement}
 import org.apache.paimon.manifest.IndexManifestEntry
+import org.apache.paimon.options.Options
 import org.apache.paimon.spark.SparkTable
 import org.apache.paimon.spark.catalyst.analysis.PaimonRelation
-import org.apache.paimon.spark.catalyst.analysis.PaimonRelation.isPaimonTable
 import org.apache.paimon.spark.catalyst.analysis.PaimonUpdateTable.toColumn
 import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
 import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
@@ -45,6 +45,7 @@ import org.apache.paimon.types.VectorType.isVectorStoreFile
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{Dataset, Row, SparkSession}
 import org.apache.spark.sql.PaimonUtils._
+import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases
 import org.apache.spark.sql.catalyst.analysis.SimpleAnalyzer.resolver
 import org.apache.spark.sql.catalyst.expressions.{Alias, And, 
AttributeReference, EqualTo, Expression, ExprId, Literal, Or, PythonUDF, 
SubqueryExpression}
 import org.apache.spark.sql.catalyst.expressions.Literal.{FalseLiteral, 
TrueLiteral}
@@ -61,6 +62,7 @@ import scala.collection.{immutable, mutable}
 import scala.collection.JavaConverters._
 import scala.collection.Searching.{search, Found, InsertionPoint}
 import scala.collection.mutable.{ArrayBuffer, ListBuffer}
+import scala.util.control.NonFatal
 
 /** Command for Merge Into for Data Evolution paimon table. */
 case class MergeIntoPaimonDataEvolutionTable(
@@ -150,25 +152,97 @@ case class MergeIntoPaimonDataEvolutionTable(
    *
    * without any extra shuffle, join, or sort.
    */
-  private lazy val isSelfMergeOnRowId: Boolean = {
-    if (!isPaimonTable(sourceTable)) {
-      false
-    } else if (
-      
!originalTargetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name)
-    ) {
-      false
+  private case class SelfMergeSpec(residualCondition: Option[Expression])
+
+  private def passthroughSourceRelation(plan: LogicalPlan): 
Option[DataSourceV2Relation] = {
+    EliminateSubqueryAliases(plan) match {
+      case relation: DataSourceV2Relation if 
isPaimonRelationWithoutTimeTravel(relation) =>
+        Some(relation)
+      case Project(projectList, child) if isPassthroughProject(projectList, 
child) =>
+        passthroughSourceRelation(child)
+      case _ =>
+        None
+    }
+  }
+
+  private def isPaimonRelationWithoutTimeTravel(relation: 
DataSourceV2Relation): Boolean =
+    relation.table match {
+      case sparkTable: SparkTable =>
+        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
+      case _ => false
+    }
+
+  private def isPassthroughProject(projectList: Seq[Expression], child: 
LogicalPlan): Boolean = {
+    val childAttributes = child.output ++ child.metadataOutput
+
+    def isChildAttribute(attr: AttributeReference): Boolean =
+      childAttributes.exists(_.exprId == attr.exprId)
+
+    projectList.forall {
+      case attr: AttributeReference => isChildAttribute(attr)
+      case alias: Alias =>
+        alias.child match {
+          case attr: AttributeReference =>
+            resolver(alias.name, attr.name) && isChildAttribute(attr)
+          case _ => false
+        }
+      case _ => false
+    }
+  }
+
+  private lazy val sameSourceAndTargetTable: Boolean =
+    passthroughSourceRelation(sourceTable)
+      .exists(sourceRelation => 
originalTargetRelation.name.equals(sourceRelation.name))
+
+  private def isTargetRowId(attr: AttributeReference): Boolean = {
+    attr.name == ROW_ID_NAME && (originalTargetRelation.output ++
+      originalTargetRelation.metadataOutput).exists(_.exprId == attr.exprId)
+  }
+
+  private def isSourceRowId(attr: AttributeReference): Boolean = {
+    attr.name == ROW_ID_NAME && (sourceTable.output ++ 
sourceTable.metadataOutput)
+      .exists(_.exprId == attr.exprId)
+  }
+
+  private def isRowIdEquality(expression: Expression): Boolean = expression 
match {
+    case EqualTo(left: AttributeReference, right: AttributeReference) =>
+      (isTargetRowId(left) && isSourceRowId(right)) ||
+      (isSourceRowId(left) && isTargetRowId(right))
+    case _ => false
+  }
+
+  private lazy val isExactSelfMergeOnRowId: Boolean =
+    sameSourceAndTargetTable && isRowIdEquality(matchedCondition)
+
+  private lazy val selfMergeSpec: Option[SelfMergeSpec] = {
+    if (!sameSourceAndTargetTable) {
+      None
     } else {
-      matchedCondition match {
-        case EqualTo(left: AttributeReference, right: AttributeReference)
-            if left.name == ROW_ID_NAME && right.name == ROW_ID_NAME =>
-          true
-        case _ => false
+      val conjuncts = splitConjunctivePredicates(matchedCondition)
+      val rowIdEqualities = conjuncts.filter(isRowIdEquality)
+      val residualConditions = conjuncts.filterNot(isRowIdEquality)
+      val partitionRowType = table.schema().logicalPartitionType()
+      val targetOnlyResidualConditions = residualConditions.filter {
+        condition => canEvaluate(condition, targetTable) && 
canEvaluateWithinJoin(condition)
+      }
+      val allResidualConditionsArePartitionPredicates =
+        extractMergePartitionFilters(targetOnlyResidualConditions, 
partitionRowType).size ==
+          residualConditions.size && 
residualConditions.forall(canConvertToPaimonPredicate)
+
+      if (rowIdEqualities.size != 1 || 
!allResidualConditionsArePartitionPredicates) {
+        None
+      } else {
+        Some(SelfMergeSpec(residualConditions.reduceOption(And)))
       }
     }
   }
 
+  private lazy val useSelfMergeShortcut: Boolean =
+    selfMergeSpec.isDefined && notMatchedActions.isEmpty && 
notMatchedBySourceActions.isEmpty
+
   assert(
-    !(isSelfMergeOnRowId && (notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)),
+    !(isExactSelfMergeOnRowId &&
+      (notMatchedActions.nonEmpty || notMatchedBySourceActions.nonEmpty)),
     "Self-Merge on _ROW_ID only supports WHEN MATCHED actions. WHEN NOT 
MATCHED and " +
       "WHEN NOT MATCHED BY SOURCE are not supported."
   )
@@ -340,6 +414,18 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
+  private def canConvertToPaimonPredicate(expression: Expression): Boolean = {
+    try {
+      convertConditionToPaimonPredicate(
+        expression,
+        targetRelation.output,
+        rowType,
+        ignorePartialFailure = false).isDefined
+    } catch {
+      case NonFatal(_) => false
+    }
+  }
+
   private def targetRelatedSplits(
       sparkSession: SparkSession,
       tableSplits: Seq[DataSplit],
@@ -347,8 +433,9 @@ case class MergeIntoPaimonDataEvolutionTable(
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       persistSourceDss: Option[Dataset[Row]]): Seq[DataSplit] = {
     // Self-Merge shortcut:
-    // In Self-Merge mode, every row in the table may be updated, so we scan 
all splits.
-    if (isSelfMergeOnRowId) {
+    // The snapshot-level partition filter has already removed unrelated 
partitions. Every row in
+    // the remaining splits may be updated, so the shortcut scans all of them.
+    if (useSelfMergeShortcut) {
       return tableSplits
     }
 
@@ -583,7 +670,7 @@ case class MergeIntoPaimonDataEvolutionTable(
     def matchedActionInstructions(actions: Seq[MergeAction]): 
Seq[MergeRows.Instruction] =
       actions.map(matchedActionInstruction) ++ keepCopyInstructions
 
-    val targetActionOutput: Dataset[Row] = if (isSelfMergeOnRowId) {
+    val targetActionOutput: Dataset[Row] = if (useSelfMergeShortcut) {
       // Self-Merge shortcut:
       // - Scan the target table only (no source scan, no join), and read all 
columns required by
       //   merge condition and update expressions.
@@ -632,8 +719,13 @@ case class MergeIntoPaimonDataEvolutionTable(
           throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
 
+      // The shortcut removes the source scan and join. A target row has a 
matching source only
+      // when the residual ON condition holds.
+      val sourceRowPresentCondition =
+        selfMergeSpec.flatMap(_.residualCondition).getOrElse(TrueLiteral)
+
       val mergeRows = MergeRows(
-        isSourceRowPresent = TrueLiteral,
+        isSourceRowPresent = sourceRowPresentCondition,
         isTargetRowPresent = TrueLiteral,
         matchedInstructions = 
matchedActionInstructions(rewrittenMatchedActions),
         notMatchedInstructions = Nil,
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 42ffd2ad3c..9d24d1fcf8 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
@@ -27,9 +27,9 @@ import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
 import org.apache.paimon.io.{CompactIncrement, DataIncrement}
 import org.apache.paimon.manifest.IndexManifestEntry
+import org.apache.paimon.options.Options
 import org.apache.paimon.spark.SparkTable
 import org.apache.paimon.spark.catalyst.analysis.PaimonRelation
-import org.apache.paimon.spark.catalyst.analysis.PaimonRelation.isPaimonTable
 import org.apache.paimon.spark.catalyst.analysis.PaimonUpdateTable.toColumn
 import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
 import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
@@ -45,6 +45,7 @@ import org.apache.paimon.types.VectorType.isVectorStoreFile
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{Dataset, Row, SparkSession}
 import org.apache.spark.sql.PaimonUtils._
+import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases
 import org.apache.spark.sql.catalyst.analysis.SimpleAnalyzer.resolver
 import org.apache.spark.sql.catalyst.expressions.{Alias, And, 
AttributeReference, EqualTo, Expression, ExprId, Literal, Or, PythonUDF, 
SubqueryExpression}
 import org.apache.spark.sql.catalyst.expressions.Literal.{FalseLiteral, 
TrueLiteral}
@@ -61,6 +62,7 @@ import scala.collection.{immutable, mutable}
 import scala.collection.JavaConverters._
 import scala.collection.Searching.{search, Found, InsertionPoint}
 import scala.collection.mutable.{ArrayBuffer, ListBuffer}
+import scala.util.control.NonFatal
 
 /** Command for Merge Into for Data Evolution paimon table. */
 case class MergeIntoPaimonDataEvolutionTable(
@@ -150,25 +152,97 @@ case class MergeIntoPaimonDataEvolutionTable(
    *
    * without any extra shuffle, join, or sort.
    */
-  private lazy val isSelfMergeOnRowId: Boolean = {
-    if (!isPaimonTable(sourceTable)) {
-      false
-    } else if (
-      
!originalTargetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name)
-    ) {
-      false
+  private case class SelfMergeSpec(residualCondition: Option[Expression])
+
+  private def passthroughSourceRelation(plan: LogicalPlan): 
Option[DataSourceV2Relation] = {
+    EliminateSubqueryAliases(plan) match {
+      case relation: DataSourceV2Relation if 
isPaimonRelationWithoutTimeTravel(relation) =>
+        Some(relation)
+      case Project(projectList, child) if isPassthroughProject(projectList, 
child) =>
+        passthroughSourceRelation(child)
+      case _ =>
+        None
+    }
+  }
+
+  private def isPaimonRelationWithoutTimeTravel(relation: 
DataSourceV2Relation): Boolean =
+    relation.table match {
+      case sparkTable: SparkTable =>
+        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
+      case _ => false
+    }
+
+  private def isPassthroughProject(projectList: Seq[Expression], child: 
LogicalPlan): Boolean = {
+    val childAttributes = child.output ++ child.metadataOutput
+
+    def isChildAttribute(attr: AttributeReference): Boolean =
+      childAttributes.exists(_.exprId == attr.exprId)
+
+    projectList.forall {
+      case attr: AttributeReference => isChildAttribute(attr)
+      case alias: Alias =>
+        alias.child match {
+          case attr: AttributeReference =>
+            resolver(alias.name, attr.name) && isChildAttribute(attr)
+          case _ => false
+        }
+      case _ => false
+    }
+  }
+
+  private lazy val sameSourceAndTargetTable: Boolean =
+    passthroughSourceRelation(sourceTable)
+      .exists(sourceRelation => 
originalTargetRelation.name.equals(sourceRelation.name))
+
+  private def isTargetRowId(attr: AttributeReference): Boolean = {
+    attr.name == ROW_ID_NAME && (originalTargetRelation.output ++
+      originalTargetRelation.metadataOutput).exists(_.exprId == attr.exprId)
+  }
+
+  private def isSourceRowId(attr: AttributeReference): Boolean = {
+    attr.name == ROW_ID_NAME && (sourceTable.output ++ 
sourceTable.metadataOutput)
+      .exists(_.exprId == attr.exprId)
+  }
+
+  private def isRowIdEquality(expression: Expression): Boolean = expression 
match {
+    case EqualTo(left: AttributeReference, right: AttributeReference) =>
+      (isTargetRowId(left) && isSourceRowId(right)) ||
+      (isSourceRowId(left) && isTargetRowId(right))
+    case _ => false
+  }
+
+  private lazy val isExactSelfMergeOnRowId: Boolean =
+    sameSourceAndTargetTable && isRowIdEquality(matchedCondition)
+
+  private lazy val selfMergeSpec: Option[SelfMergeSpec] = {
+    if (!sameSourceAndTargetTable) {
+      None
     } else {
-      matchedCondition match {
-        case EqualTo(left: AttributeReference, right: AttributeReference)
-            if left.name == ROW_ID_NAME && right.name == ROW_ID_NAME =>
-          true
-        case _ => false
+      val conjuncts = splitConjunctivePredicates(matchedCondition)
+      val rowIdEqualities = conjuncts.filter(isRowIdEquality)
+      val residualConditions = conjuncts.filterNot(isRowIdEquality)
+      val partitionRowType = table.schema().logicalPartitionType()
+      val targetOnlyResidualConditions = residualConditions.filter {
+        condition => canEvaluate(condition, targetTable) && 
canEvaluateWithinJoin(condition)
+      }
+      val allResidualConditionsArePartitionPredicates =
+        extractMergePartitionFilters(targetOnlyResidualConditions, 
partitionRowType).size ==
+          residualConditions.size && 
residualConditions.forall(canConvertToPaimonPredicate)
+
+      if (rowIdEqualities.size != 1 || 
!allResidualConditionsArePartitionPredicates) {
+        None
+      } else {
+        Some(SelfMergeSpec(residualConditions.reduceOption(And)))
       }
     }
   }
 
+  private lazy val useSelfMergeShortcut: Boolean =
+    selfMergeSpec.isDefined && notMatchedActions.isEmpty && 
notMatchedBySourceActions.isEmpty
+
   assert(
-    !(isSelfMergeOnRowId && (notMatchedActions.nonEmpty || 
notMatchedBySourceActions.nonEmpty)),
+    !(isExactSelfMergeOnRowId &&
+      (notMatchedActions.nonEmpty || notMatchedBySourceActions.nonEmpty)),
     "Self-Merge on _ROW_ID only supports WHEN MATCHED actions. WHEN NOT 
MATCHED and " +
       "WHEN NOT MATCHED BY SOURCE are not supported."
   )
@@ -340,6 +414,18 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
+  private def canConvertToPaimonPredicate(expression: Expression): Boolean = {
+    try {
+      convertConditionToPaimonPredicate(
+        expression,
+        targetRelation.output,
+        rowType,
+        ignorePartialFailure = false).isDefined
+    } catch {
+      case NonFatal(_) => false
+    }
+  }
+
   private def targetRelatedSplits(
       sparkSession: SparkSession,
       tableSplits: Seq[DataSplit],
@@ -347,8 +433,9 @@ case class MergeIntoPaimonDataEvolutionTable(
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       persistSourceDss: Option[Dataset[Row]]): Seq[DataSplit] = {
     // Self-Merge shortcut:
-    // In Self-Merge mode, every row in the table may be updated, so we scan 
all splits.
-    if (isSelfMergeOnRowId) {
+    // The snapshot-level partition filter has already removed unrelated 
partitions. Every row in
+    // the remaining splits may be updated, so the shortcut scans all of them.
+    if (useSelfMergeShortcut) {
       return tableSplits
     }
 
@@ -582,7 +669,7 @@ case class MergeIntoPaimonDataEvolutionTable(
     def matchedActionInstructions(actions: Seq[MergeAction]): 
Seq[MergeRows.Instruction] =
       actions.map(matchedActionInstruction) ++ keepCopyInstructions
 
-    val targetActionOutput: Dataset[Row] = if (isSelfMergeOnRowId) {
+    val targetActionOutput: Dataset[Row] = if (useSelfMergeShortcut) {
       // Self-Merge shortcut:
       // - Scan the target table only (no source scan, no join), and read all 
columns required by
       //   merge condition and update expressions.
@@ -631,8 +718,13 @@ case class MergeIntoPaimonDataEvolutionTable(
           throw new UnsupportedOperationException(s"Unsupported matched 
action: $other.")
       }
 
+      // The shortcut removes the source scan and join. A target row has a 
matching source only
+      // when the residual ON condition holds.
+      val sourceRowPresentCondition =
+        selfMergeSpec.flatMap(_.residualCondition).getOrElse(TrueLiteral)
+
       val mergeRows = MergeRows(
-        isSourceRowPresent = TrueLiteral,
+        isSourceRowPresent = sourceRowPresentCondition,
         isTargetRowPresent = TrueLiteral,
         matchedInstructions = 
matchedActionInstructions(rewrittenMatchedActions),
         notMatchedInstructions = Nil,
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 1560bbca92..6ae50630f8 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
@@ -940,43 +940,54 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
   }
 
   private def executeMergeIntoAndAssertPartitionPruned(mergeSql: String): Unit 
= {
+    val (_, resultedTableFiles) = executeMergeIntoAndCollectPlans(mergeSql)
+    assert(
+      resultedTableFiles.nonEmpty,
+      "Expected target PaimonSplitScan in merge into executed plans.")
+    assert(
+      resultedTableFiles.contains(1),
+      s"Expected target scan to read only one partition file, but got resulted 
table files: " +
+        resultedTableFiles.mkString(", ")
+    )
+  }
+
+  private def executeMergeIntoAndCollectPlans(mergeSql: String): 
(Seq[LogicalPlan], Seq[Long]) = {
+    val mergeRowsPlans = new 
java.util.concurrent.CopyOnWriteArrayList[LogicalPlan]()
     val resultedTableFiles = new 
java.util.concurrent.CopyOnWriteArrayList[Long]()
 
     val listener = new QueryExecutionListener {
       override def onSuccess(funcName: String, qe: QueryExecution, durationNs: 
Long): Unit = {
-        checkPlan(qe)
+        collectPlans(qe)
       }
 
       override def onFailure(funcName: String, qe: QueryExecution, exception: 
Exception): Unit = {
-        checkPlan(qe)
+        collectPlans(qe)
       }
 
-      private def checkPlan(qe: QueryExecution): Unit = {
+      private def collectPlans(qe: QueryExecution): Unit = {
+        if (qe.analyzed.collectFirst { case _: MergeRows => true }.nonEmpty) {
+          mergeRowsPlans.add(qe.analyzed)
+        }
         collect(qe.executedPlan) {
           case scanExec: BatchScanExec
               if scanExec.scan.isInstanceOf[PaimonSplitScan] &&
                 scanExec.scan.description().startsWith("PaimonSplitScan: 
[target]") =>
-            val scan = scanExec.scan.asInstanceOf[PaimonSplitScan]
-            metric(scan.reportDriverMetrics(), RESULTED_TABLE_FILES)
-        }.foreach(resultedTableFile => 
resultedTableFiles.add(resultedTableFile))
+            metric(
+              
scanExec.scan.asInstanceOf[PaimonSplitScan].reportDriverMetrics(),
+              RESULTED_TABLE_FILES)
+        }.foreach(resultedTableFiles.add)
       }
     }
 
     spark.listenerManager.register(listener)
     try {
-      sql(mergeSql)
+      sql(mergeSql).collect()
       Utils.waitUntilEventEmpty(spark)
     } finally {
       spark.listenerManager.unregister(listener)
     }
 
-    val metrics = resultedTableFiles.asScala
-    assert(metrics.nonEmpty, "Expected target PaimonSplitScan in merge into 
executed plans.")
-    assert(
-      metrics.contains(1),
-      s"Expected target scan to read only one partition file, but got resulted 
table files: " +
-        metrics.mkString(", ")
-    )
+    (mergeRowsPlans.asScala.toSeq, 
resultedTableFiles.asScala.map(_.toLong).toSeq)
   }
 
   private def metric(metrics: Array[CustomTaskMetric], name: String): Long = {
@@ -1131,6 +1142,210 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: self-merge on _ROW_ID with partition pruning") {
+    withSparkSQLConf("spark.paimon.data-evolution.merge-into.file-pruning" -> 
"false") {
+      withTable("target") {
+        sql("""
+              |CREATE TABLE target (id INT, b INT, dt STRING)
+              |TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |PARTITIONED BY (dt)
+              |""".stripMargin)
+        sql("""
+              |INSERT INTO target VALUES
+              |  (1, 10, '2026-08-30'),
+              |  (2, 20, '2026-08-30'),
+              |  (3, 30, '2026-08-31'),
+              |  (4, 40, '2026-09-01')
+              |""".stripMargin)
+
+        val (mergeRowsPlans, resultedTableFiles) = 
executeMergeIntoAndCollectPlans(
+          """
+            |MERGE INTO target
+            |USING target AS source
+            |ON source._ROW_ID = target._ROW_ID AND target.dt = '2026-08-30'
+            |WHEN MATCHED AND target.id = 1 THEN UPDATE SET target.b = 
source.b + 100
+            |""".stripMargin)
+
+        assert(mergeRowsPlans.nonEmpty, "Expected a MergeRows plan for 
self-merge.")
+        assert(
+          mergeRowsPlans.forall(_.collectFirst {
+            case p: Join => p
+            case p: Sort => p
+            case p: RepartitionByExpression => p
+          }.isEmpty),
+          s"Found unexpected Join/Sort/Exchange in plans: 
${mergeRowsPlans.mkString("\n")}"
+        )
+        assert(
+          resultedTableFiles.contains(1L),
+          s"Expected target scan to read one partition file, but got: " +
+            resultedTableFiles.mkString(", "))
+
+        checkAnswer(
+          sql("SELECT id, b, dt FROM target ORDER BY id"),
+          Seq(
+            Row(1, 110, "2026-08-30"),
+            Row(2, 20, "2026-08-30"),
+            Row(3, 30, "2026-08-31"),
+            Row(4, 40, "2026-09-01")))
+      }
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for computed source projection") 
{
+    withTable("target") {
+      sql("""
+            |CREATE TABLE target (id INT, b INT, dt STRING)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |PARTITIONED BY (dt)
+            |""".stripMargin)
+      sql("INSERT INTO target VALUES (1, 10, 'p1'), (2, 20, 'p2')")
+
+      val (mergeRowsPlans, _) =
+        executeMergeIntoAndCollectPlans("""
+                                          |MERGE INTO target
+                                          |USING (
+                                          |  SELECT _ROW_ID, b + 1 AS b FROM 
target
+                                          |) source
+                                          |ON target._ROW_ID = source._ROW_ID
+                                          |  AND target.dt = 'p1'
+                                          |WHEN MATCHED THEN UPDATE SET 
target.b = source.b
+                                          |""".stripMargin)
+
+      assert(
+        mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+        s"Expected general MERGE plan with Join, but got: 
${mergeRowsPlans.mkString("\n")}"
+      )
+      checkAnswer(
+        sql("SELECT id, b, dt FROM target ORDER BY id"),
+        Seq(Row(1, 11, "p1"), Row(2, 20, "p2")))
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for time-travel source") {
+    withTable("target") {
+      sql("""
+            |CREATE TABLE target (id INT, b INT, dt STRING)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |PARTITIONED BY (dt)
+            |""".stripMargin)
+      sql("INSERT INTO target VALUES (1, 10, 'p1'), (2, 30, 'p2')")
+      val oldSnapshotId = 
loadTable("target").snapshotManager().latestSnapshotId()
+      sql("UPDATE target SET b = 20 WHERE id = 1")
+
+      val (mergeRowsPlans, _) =
+        executeMergeIntoAndCollectPlans(s"""
+                                           |MERGE INTO target
+                                           |USING (
+                                           |  SELECT _ROW_ID, b
+                                           |  FROM target VERSION AS OF 
$oldSnapshotId
+                                           |) source
+                                           |ON target._ROW_ID = source._ROW_ID
+                                           |  AND target.dt = 'p1'
+                                           |WHEN MATCHED THEN UPDATE SET 
target.b = source.b
+                                           |""".stripMargin)
+
+      assert(
+        mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+        s"Expected general MERGE plan with Join, but got: 
${mergeRowsPlans.mkString("\n")}"
+      )
+      checkAnswer(
+        sql("SELECT id, b, dt FROM target ORDER BY id"),
+        Seq(Row(1, 10, "p1"), Row(2, 30, "p2")))
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for non-partition residual 
condition") {
+    withTable("target") {
+      sql(
+        "CREATE TABLE target (id INT, b INT) TBLPROPERTIES " +
+          "('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 
'true')")
+      sql("INSERT INTO target VALUES (1, 10), (2, 20)")
+
+      val (mergeRowsPlans, _) =
+        executeMergeIntoAndCollectPlans("""
+                                          |MERGE INTO target
+                                          |USING target AS source
+                                          |ON target._ROW_ID = source._ROW_ID 
AND target.id = 1
+                                          |WHEN MATCHED THEN UPDATE SET 
target.b = source.b + 100
+                                          |""".stripMargin)
+
+      assert(
+        mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+        s"Expected general MERGE plan with Join, but got: 
${mergeRowsPlans.mkString("\n")}"
+      )
+      checkAnswer(sql("SELECT id, b FROM target ORDER BY id"), Seq(Row(1, 
110), Row(2, 20)))
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for dynamic partition 
condition") {
+    withTable("target") {
+      sql("""
+            |CREATE TABLE target (id INT, b INT, dt STRING)
+            |TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |PARTITIONED BY (dt)
+            |""".stripMargin)
+      sql("INSERT INTO target VALUES (1, 10, 'p1'), (2, 20, 'p2')")
+
+      val (mergeRowsPlans, _) =
+        executeMergeIntoAndCollectPlans(
+          """
+            |MERGE INTO target
+            |USING target AS source
+            |ON target._ROW_ID = source._ROW_ID AND target.dt = source.dt
+            |WHEN MATCHED THEN UPDATE SET target.b = source.b + 100
+            |""".stripMargin)
+
+      assert(
+        mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+        s"Expected general MERGE plan with Join, but got: 
${mergeRowsPlans.mkString("\n")}"
+      )
+      checkAnswer(
+        sql("SELECT id, b, dt FROM target ORDER BY id"),
+        Seq(Row(1, 110, "p1"), Row(2, 120, "p2")))
+    }
+  }
+
+  test("Data Evolution: self-merge with not matched action uses general MERGE 
path") {
+    withSparkSQLConf("spark.paimon.data-evolution.merge-into.file-pruning" -> 
"false") {
+      withTable("target") {
+        sql("""
+              |CREATE TABLE target (id INT, b INT, dt STRING)
+              |TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |PARTITIONED BY (dt)
+              |""".stripMargin)
+        sql("INSERT INTO target VALUES (1, 10, 'p1'), (2, 20, 'p2')")
+
+        val (mergeRowsPlans, _) =
+          executeMergeIntoAndCollectPlans("""
+                                            |MERGE INTO target
+                                            |USING target AS source
+                                            |ON target._ROW_ID = 
source._ROW_ID AND target.dt = 'p1'
+                                            |WHEN MATCHED THEN UPDATE SET 
target.b = source.b + 100
+                                            |WHEN NOT MATCHED THEN INSERT (id, 
b, dt)
+                                            |  VALUES (source.id + 10, 
source.b, source.dt)
+                                            |""".stripMargin)
+
+        assert(
+          mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+          s"Expected general MERGE plan with Join, but got: 
${mergeRowsPlans.mkString("\n")}"
+        )
+        checkAnswer(
+          sql("SELECT id, b, dt FROM target ORDER BY id"),
+          Seq(Row(1, 110, "p1"), Row(2, 20, "p2"), Row(12, 20, "p2")))
+      }
+    }
+  }
+
   test("Data Evolution: V1 update table with data-evolution") {
     withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
       withTable("t") {

Reply via email to