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 2ad6fe9823 [spark] Identify the self-merge source by table location 
and branch, not by name (#10071)
2ad6fe9823 is described below

commit 2ad6fe9823cff7a99a2ea903997418f19b7deab1
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Tue Sep 22 10:42:25 2026 +0800

    [spark] Identify the self-merge source by table location and branch, not by 
name (#10071)
---
 .../MergeIntoPaimonDataEvolutionTable.scala        |  31 +++-
 .../MergeIntoPaimonDataEvolutionTable.scala        |  30 +++-
 .../paimon/spark/sql/RowTrackingTestBase.scala     | 157 ++++++++++++++++++++-
 3 files changed, 210 insertions(+), 8 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 c6db56419b..5cf8f812d0 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
@@ -42,6 +42,7 @@ import org.apache.paimon.table.source.snapshot.SnapshotReader
 import org.apache.paimon.table.source.snapshot.TimeTravelUtil
 import org.apache.paimon.types.{BlobType, RowType}
 import org.apache.paimon.types.VectorType.isVectorStoreFile
+import org.apache.paimon.utils.BranchManager
 
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{Dataset, Row, SparkSession}
@@ -195,9 +196,32 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
-  private lazy val sameSourceAndTargetTable: Boolean =
-    passthroughSourceRelation(sourceTable)
-      .exists(sourceRelation => 
originalTargetRelation.name.equals(sourceRelation.name))
+  /**
+   * Identity of the Paimon table behind a relation: its storage location and 
branch. The relation
+   * name is not enough: two catalogs can expose different tables under the 
same `database.table`
+   * name, and a self-merge that only compares names would drop the source 
scan and silently rewrite
+   * the source columns to the target's own.
+   */
+  private def paimonTableIdentity(relation: DataSourceV2Relation): 
Option[(String, String)] =
+    relation.table match {
+      case sparkTable: SparkTable =>
+        sparkTable.getTable match {
+          case fileStoreTable: FileStoreTable =>
+            Some(
+              (
+                fileStoreTable.location().toString,
+                
BranchManager.normalizeBranch(CoreOptions.branch(fileStoreTable.options()))))
+          case _ => None
+        }
+      case _ => None
+    }
+
+  private lazy val sameSourceAndTargetTable: Boolean = {
+    val targetIdentity = paimonTableIdentity(originalTargetRelation)
+    targetIdentity.isDefined && passthroughSourceRelation(sourceTable).exists {
+      sourceRelation => paimonTableIdentity(sourceRelation) == targetIdentity
+    }
+  }
 
   private def isTargetRowId(attr: AttributeReference): Boolean = {
     attr.name == ROW_ID_NAME && (originalTargetRelation.output ++
@@ -592,7 +616,6 @@ case class MergeIntoPaimonDataEvolutionTable(
     val rawBlobFieldNames = rawBlobFields
       .map(_.name())
       .toSet
-
     def isRawBlobUpdateColumn(attr: AttributeReference): Boolean = {
       rawBlobFieldNames.exists(rawBlobFieldName => resolver(rawBlobFieldName, 
attr.name))
     }
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 f40db70fc1..5cf8f812d0 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
@@ -42,6 +42,7 @@ import org.apache.paimon.table.source.snapshot.SnapshotReader
 import org.apache.paimon.table.source.snapshot.TimeTravelUtil
 import org.apache.paimon.types.{BlobType, RowType}
 import org.apache.paimon.types.VectorType.isVectorStoreFile
+import org.apache.paimon.utils.BranchManager
 
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{Dataset, Row, SparkSession}
@@ -195,9 +196,32 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
-  private lazy val sameSourceAndTargetTable: Boolean =
-    passthroughSourceRelation(sourceTable)
-      .exists(sourceRelation => 
originalTargetRelation.name.equals(sourceRelation.name))
+  /**
+   * Identity of the Paimon table behind a relation: its storage location and 
branch. The relation
+   * name is not enough: two catalogs can expose different tables under the 
same `database.table`
+   * name, and a self-merge that only compares names would drop the source 
scan and silently rewrite
+   * the source columns to the target's own.
+   */
+  private def paimonTableIdentity(relation: DataSourceV2Relation): 
Option[(String, String)] =
+    relation.table match {
+      case sparkTable: SparkTable =>
+        sparkTable.getTable match {
+          case fileStoreTable: FileStoreTable =>
+            Some(
+              (
+                fileStoreTable.location().toString,
+                
BranchManager.normalizeBranch(CoreOptions.branch(fileStoreTable.options()))))
+          case _ => None
+        }
+      case _ => None
+    }
+
+  private lazy val sameSourceAndTargetTable: Boolean = {
+    val targetIdentity = paimonTableIdentity(originalTargetRelation)
+    targetIdentity.isDefined && passthroughSourceRelation(sourceTable).exists {
+      sourceRelation => paimonTableIdentity(sourceRelation) == targetIdentity
+    }
+  }
 
   private def isTargetRowId(attr: AttributeReference): Boolean = {
     attr.name == ROW_ID_NAME && (originalTargetRelation.output ++
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 0a5d30e231..dc31d88092 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
@@ -21,8 +21,8 @@ package org.apache.paimon.spark.sql
 import org.apache.paimon.Snapshot.{CommitKind, Operation}
 import org.apache.paimon.errors.ErrorMessages
 import org.apache.paimon.globalindex.IndexedSplit
+import org.apache.paimon.spark.{PaimonSparkTestBase, SparkCatalog}
 import org.apache.paimon.spark.PaimonMetrics.RESULTED_TABLE_FILES
-import org.apache.paimon.spark.PaimonSparkTestBase
 import org.apache.paimon.spark.catalyst.analysis.PaimonRelation
 import org.apache.paimon.spark.commands.{DataEvolutionPaimonWriter, 
DataEvolutionRowIdConflictCommitter, PaimonSparkWriter}
 import org.apache.paimon.spark.read.PaimonSplitScan
@@ -1561,6 +1561,161 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: self-merge falls back for a same-named table in 
another catalog") {
+    withSecondCatalog("paimon2", Utils.createTempDir.getCanonicalPath) {
+      withTable("t") {
+        sql("""
+              |CREATE TABLE paimon.test.t (id INT, v INT, w INT) TBLPROPERTIES 
(
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO paimon.test.t VALUES (1, 10, 1), (2, 20, 2), (3, 30, 
3)")
+        sql("""
+              |CREATE TABLE paimon2.test.t (id INT, v INT, w INT) 
TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO paimon2.test.t VALUES (1, 100, 9), (2, 200, 9), (3, 
300, 9)")
+
+        // Same `database.table` name, different table: the source must be 
scanned and joined,
+        // its columns must not be rewritten to the target's own.
+        val (mergeRowsPlans, _) =
+          executeMergeIntoAndCollectPlans("""
+                                            |MERGE INTO paimon.test.t AS t
+                                            |USING paimon2.test.t AS s
+                                            |ON t._ROW_ID = s._ROW_ID
+                                            |WHEN MATCHED AND s.v > 100 THEN 
UPDATE SET v = s.v
+                                            |""".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, v, w FROM paimon.test.t ORDER BY id"),
+          Seq(Row(1, 10, 1), Row(2, 200, 2), Row(3, 300, 3)))
+        checkAnswer(
+          sql("SELECT id, v, w FROM paimon2.test.t ORDER BY id"),
+          Seq(Row(1, 100, 9), Row(2, 200, 9), Row(3, 300, 9)))
+
+        // NOT MATCHED against the other catalog's table inserts, it does not 
hit the self-merge
+        // assertion that rejects NOT MATCHED actions.
+        sql("INSERT INTO paimon2.test.t VALUES (4, 400, 9)")
+        sql("""
+              |MERGE INTO paimon.test.t AS t
+              |USING paimon2.test.t AS s
+              |ON t._ROW_ID = s._ROW_ID
+              |WHEN MATCHED THEN UPDATE SET w = s.w
+              |WHEN NOT MATCHED THEN INSERT (id, v, w) VALUES (s.id, s.v, s.w)
+              |""".stripMargin)
+        checkAnswer(
+          sql("SELECT id, v, w FROM paimon.test.t ORDER BY id"),
+          Seq(Row(1, 10, 9), Row(2, 200, 9), Row(3, 300, 9), Row(4, 400, 9)))
+      }
+    }
+  }
+
+  test("Data Evolution: self-merge shortcut applies to the same table through 
another catalog") {
+    withSecondCatalog("paimon_alias", tempDBDir.getCanonicalPath) {
+      withTable("t") {
+        sql("""
+              |CREATE TABLE paimon.test.t (id INT, v INT) TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO paimon.test.t VALUES (1, 10), (2, 20)")
+
+        // Both names resolve to the same storage location and branch, so this 
is a self-merge.
+        val (mergeRowsPlans, _) =
+          executeMergeIntoAndCollectPlans("""
+                                            |MERGE INTO paimon.test.t AS t
+                                            |USING paimon_alias.test.t AS s
+                                            |ON t._ROW_ID = s._ROW_ID
+                                            |WHEN MATCHED THEN UPDATE SET v = 
s.v + 1
+                                            |""".stripMargin)
+        assertSelfMergeShortcut(mergeRowsPlans)
+        checkAnswer(sql("SELECT id, v FROM paimon.test.t ORDER BY id"), 
Seq(Row(1, 11), Row(2, 21)))
+      }
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for another branch of the same 
table") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (id INT, v INT) TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t VALUES (1, 10), (2, 20)")
+      sql("CALL sys.create_tag(table => 'test.t', tag => 'base')")
+      sql("CALL sys.create_branch(table => 'test.t', branch => 'b1', tag => 
'base')")
+      // The branch shares the row ids of the tag but carries its own values.
+      sql("UPDATE `t$branch_b1` SET v = v + 100")
+      checkAnswer(sql("SELECT id, v FROM `t$branch_b1` ORDER BY id"), 
Seq(Row(1, 110), Row(2, 120)))
+
+      val (mergeRowsPlans, _) =
+        executeMergeIntoAndCollectPlans("""
+                                          |MERGE INTO t
+                                          |USING `t$branch_b1` AS s
+                                          |ON t._ROW_ID = s._ROW_ID
+                                          |WHEN MATCHED THEN UPDATE SET v = s.v
+                                          |""".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, v FROM t ORDER BY id"), Seq(Row(1, 110), 
Row(2, 120)))
+    }
+  }
+
+  test("Data Evolution: self-merge falls back for a same-named table in 
another database") {
+    withDatabase("test2") {
+      sql("CREATE DATABASE test2")
+      withTable("t", "test2.t") {
+        sql("""
+              |CREATE TABLE t (id INT, v INT) TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO t VALUES (1, 10), (2, 20)")
+        sql("""
+              |CREATE TABLE test2.t (id INT, v INT) TBLPROPERTIES (
+              |  'row-tracking.enabled' = 'true',
+              |  'data-evolution.enabled' = 'true')
+              |""".stripMargin)
+        sql("INSERT INTO test2.t VALUES (1, 100), (2, 200)")
+
+        val (mergeRowsPlans, _) =
+          executeMergeIntoAndCollectPlans("""
+                                            |MERGE INTO t
+                                            |USING test2.t AS s
+                                            |ON t._ROW_ID = s._ROW_ID
+                                            |WHEN MATCHED THEN UPDATE SET v = 
s.v
+                                            |""".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, v FROM t ORDER BY id"), Seq(Row(1, 100), 
Row(2, 200)))
+      }
+    }
+  }
+
+  private def withSecondCatalog(name: String, warehouse: String)(f: => Unit): 
Unit = {
+    spark.conf.set(s"spark.sql.catalog.$name", classOf[SparkCatalog].getName)
+    spark.conf.set(s"spark.sql.catalog.$name.warehouse", warehouse)
+    sql(s"CREATE DATABASE IF NOT EXISTS $name.test")
+    try {
+      f
+    } finally {
+      sql(s"DROP TABLE IF EXISTS $name.test.t")
+      if (warehouse != tempDBDir.getCanonicalPath) {
+        sql(s"DROP DATABASE IF EXISTS $name.test CASCADE")
+      }
+      spark.conf.unset(s"spark.sql.catalog.$name.warehouse")
+      spark.conf.unset(s"spark.sql.catalog.$name")
+    }
+  }
+
   test("Data Evolution: self-merge falls back for time-travel source") {
     assume(gteqSpark3_3)
 

Reply via email to