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 bd9274da9d [spark] Make saveAsTable+overwrite behave as INSERT
OVERWRITE (#8225)
bd9274da9d is described below
commit bd9274da9d52c6a8549bedf867fa462451dbd5f7
Author: Zouxxyy <[email protected]>
AuthorDate: Sun Jun 14 10:14:06 2026 +0800
[spark] Make saveAsTable+overwrite behave as INSERT OVERWRITE (#8225)
Previously, `df.write.mode("overwrite").saveAsTable("t")` produced a
`ReplaceTableAsSelect` plan, which could drop + recreate the table when
the user did not re-specify `partitionBy()` and primary-key options —
silently losing the partition spec, primary keys, and table properties.
This PR makes `saveAsTable` + `overwrite` on an existing table (Spark
3.4+) be rewritten to `OverwriteByExpression` (or
`OverwritePartitionsDynamic` when `partitionOverwriteMode=dynamic`),
preserving the existing table definition. This aligns with the behavior
of `INSERT OVERWRITE` and is consistent with Delta Lake.
SQL `CREATE OR REPLACE TABLE AS SELECT` and V2 `writeTo().replace()` are
not affected.
---
docs/docs/spark/dataframe.md | 27 ++--
.../shim/PaimonReplaceTableAsSelectStrategy.scala | 50 ++++++-
.../shim/PaimonReplaceTableAsSelectStrategy.scala | 52 ++++++-
.../paimon/spark/sql/DataFrameWriteTestBase.scala | 156 +++++++++++++++++++--
4 files changed, 255 insertions(+), 30 deletions(-)
diff --git a/docs/docs/spark/dataframe.md b/docs/docs/spark/dataframe.md
index 14cc60a028..d69b629497 100644
--- a/docs/docs/spark/dataframe.md
+++ b/docs/docs/spark/dataframe.md
@@ -56,7 +56,7 @@ Note: `insertInto` ignores the column names and just uses
position-based write,
if you need to write by column name, use `saveAsTable` or `save` instead.
### Insert Overwrite
-You can achieve INSERT OVERWRITE semantics by setting the mode to `overwrite`
with `insertInto`.
+You can achieve INSERT OVERWRITE semantics by setting the mode to `overwrite`.
It supports dynamic partition overwritten for partitioned table.
To enable dynamic overwritten you need to set the Spark session configuration
`spark.sql.sources.partitionOverwriteMode` to `dynamic`.
@@ -66,25 +66,18 @@ val data: DataFrame = ...
data.write.format("paimon")
.mode("overwrite")
- .insertInto("test_tbl")
+ .insertInto("test_tbl") // or .saveAsTable("test_tbl")
```
-## Replace Table
-You can achieve REPLACE TABLE semantics by setting the mode to `overwrite`
with `saveAsTable` or `save`.
+{{< hint info >}}
+Since Spark 3.4, `saveAsTable` with `overwrite` mode only overwrites data and
preserves
+the existing table definition (partitions, primary keys, and properties).
+If you need to replace the table definition, use SQL `CREATE OR REPLACE TABLE
... AS SELECT`.
-It first drops the existing table and then create a new one,
-so you need to specify the table's properties or partition columns if needed.
-
-```scala
-val data: DataFrame = ...
-
-data.write.format("paimon")
- .option("primary-key", "a,pt")
- .option("k1", "v1")
- .partitionBy("pt")
- .mode("overwrite")
- .saveAsTable("test_tbl") // or .save("/path/to/default.db/test_tbl")
-```
+Before Spark 3.4, `saveAsTable` with `overwrite` mode drops and recreates the
table, so the
+table definition is reset to the DataFrame's schema and only the partitions
and options
+explicitly re-specified via `partitionBy()` / write options are kept.
+{{< /hint >}}
## Query
diff --git
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
index b741627cdb..12ec5ee1d0 100644
---
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
+++
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
@@ -25,11 +25,13 @@ import org.apache.paimon.spark.catalog.SparkBaseCatalog
import org.apache.spark.sql.{SparkSession, Strategy}
import org.apache.spark.sql.catalyst.analysis.{NoSuchTableException,
ResolvedIdentifier}
-import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, ReplaceTable,
ReplaceTableAsSelect, TableSpec}
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan,
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceTable,
ReplaceTableAsSelect, TableSpec}
import org.apache.spark.sql.connector.catalog.{Identifier,
StagingTableCatalog, Table, TableCatalog}
import org.apache.spark.sql.connector.expressions.Transform
import org.apache.spark.sql.execution.{PaimonStrategyHelper, SparkPlan}
-import
org.apache.spark.sql.execution.datasources.v2.{AtomicReplaceTableAsSelectExec,
ReplaceTableAsSelectExec}
+import
org.apache.spark.sql.execution.datasources.v2.{AtomicReplaceTableAsSelectExec,
DataSourceV2Relation, ReplaceTableAsSelectExec}
+import org.apache.spark.sql.internal.SQLConf.PartitionOverwriteMode
import org.apache.spark.sql.paimon.shims.SparkShimLoader
import org.apache.spark.sql.util.CaseInsensitiveStringMap
@@ -51,6 +53,17 @@ case class PaimonReplaceTableAsSelectStrategy(spark:
SparkSession)
orCreate,
analyzedQuery) if
PaimonReplaceTableStrategyHelper.supportsCatalog(catalog, tableSpec) =>
assert(analyzedQuery.isDefined)
+ // For V1 saveAsTable + overwrite on an existing table, rewrite to
+ // OverwriteByExpression to preserve table definition.
+ if (PaimonReplaceTableStrategyHelper.isV1SaveAsTableOverwrite) {
+ val overwrite = PaimonReplaceTableStrategyHelper
+ .rewriteToOverwrite(spark, catalog, ident, analyzedQuery.get,
options)
+ if (overwrite.isDefined) {
+ val qe = spark.sessionState.executePlan(overwrite.get)
+ return qe.sparkPlan :: Nil
+ }
+ }
+
val (tableOptions, writeOptions) =
PaimonStrategyHelper.splitTableAndWriteOptions(options)
val qualifiedSpec = qualifyTableSpec(tableSpec, tableOptions)
val writeOpts = new CaseInsensitiveStringMap(writeOptions.asJava)
@@ -137,6 +150,39 @@ private[shim] object PaimonReplaceTableStrategyHelper {
case _ => false
}
+ /** @see PaimonReplaceTableStrategyHelper in paimon-spark-common for full
documentation. */
+ def isV1SaveAsTableOverwrite: Boolean = {
+ Thread.currentThread().getStackTrace.exists {
+ e =>
+ val cls = e.getClassName
+ cls.contains("DataFrameWriter") && !cls.contains("DataFrameWriterV2")
+ }
+ }
+
+ /** @see PaimonReplaceTableStrategyHelper in paimon-spark-common for full
documentation. */
+ def rewriteToOverwrite(
+ spark: SparkSession,
+ catalog: SparkBaseCatalog,
+ ident: Identifier,
+ query: LogicalPlan,
+ writeOptions: Map[String, String]): Option[LogicalPlan] = {
+ try {
+ val existing = catalog.loadTable(ident)
+ if (!existing.isInstanceOf[SparkTable]) return None
+ val relation =
+ DataSourceV2Relation.create(existing,
Some(catalog.asInstanceOf[TableCatalog]), Some(ident))
+ val dynamicOverwrite = existing.partitioning().nonEmpty &&
+ spark.sessionState.conf.partitionOverwriteMode ==
PartitionOverwriteMode.DYNAMIC
+ if (dynamicOverwrite) {
+ Some(OverwritePartitionsDynamic.byName(relation, query, writeOptions))
+ } else {
+ Some(OverwriteByExpression.byName(relation, query, Literal(true),
writeOptions))
+ }
+ } catch {
+ case _: NoSuchTableException => None
+ }
+ }
+
/**
* Whether replace can use Spark's staged replace path. Paimon's
replaceTable is not a
* rollbackable atomic replace; it swaps the current schema and truncates
current data while
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
index 60410b631e..9e156c3e26 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/execution/shim/PaimonReplaceTableAsSelectStrategy.scala
@@ -25,10 +25,13 @@ import org.apache.paimon.spark.catalog.SparkBaseCatalog
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.analysis.{NoSuchTableException,
ResolvedIdentifier}
-import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, ReplaceTable,
ReplaceTableAsSelect, TableSpec}
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan,
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceTable,
ReplaceTableAsSelect, TableSpec}
import org.apache.spark.sql.connector.catalog.{Identifier,
StagingTableCatalog, TableCatalog}
import org.apache.spark.sql.connector.expressions.Transform
import org.apache.spark.sql.execution.{PaimonStrategyHelper, SparkPlan,
SparkStrategy}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.internal.SQLConf.PartitionOverwriteMode
import org.apache.spark.sql.paimon.shims.SparkShimLoader
import scala.collection.JavaConverters._
@@ -46,6 +49,17 @@ case class PaimonReplaceTableAsSelectStrategy(spark:
SparkSession)
options,
orCreate,
true) if PaimonReplaceTableStrategyHelper.supportsCatalog(catalog,
tableSpec) =>
+ // For V1 saveAsTable + overwrite on an existing table, rewrite to
+ // OverwriteByExpression to preserve table definition.
+ if (PaimonReplaceTableStrategyHelper.isV1SaveAsTableOverwrite) {
+ val overwrite = PaimonReplaceTableStrategyHelper
+ .rewriteToOverwrite(spark, catalog, ident, query, options)
+ if (overwrite.isDefined) {
+ val qe = spark.sessionState.executePlan(overwrite.get)
+ return qe.sparkPlan :: Nil
+ }
+ }
+
val (tableOptions, writeOptions) =
PaimonStrategyHelper.splitTableAndWriteOptions(options)
val qualifiedSpec = qualifyTableSpec(tableSpec, tableOptions)
if (PaimonReplaceTableStrategyHelper.canAtomicReplace(catalog, ident,
qualifiedSpec, parts)) {
@@ -119,6 +133,42 @@ private[shim] object PaimonReplaceTableStrategyHelper {
case _ => false
}
+ /** Whether the current call originates from V1
DataFrameWriter.saveAsTable(). */
+ def isV1SaveAsTableOverwrite: Boolean = {
+ Thread.currentThread().getStackTrace.exists {
+ e =>
+ val cls = e.getClassName
+ cls.contains("DataFrameWriter") && !cls.contains("DataFrameWriterV2")
+ }
+ }
+
+ /**
+ * Rewrite to OverwriteByExpression or OverwritePartitionsDynamic for an
existing table,
+ * preserving table definition. Returns None if the table does not exist.
+ */
+ def rewriteToOverwrite(
+ spark: SparkSession,
+ catalog: SparkBaseCatalog,
+ ident: Identifier,
+ query: LogicalPlan,
+ writeOptions: Map[String, String]): Option[LogicalPlan] = {
+ try {
+ val existing = catalog.loadTable(ident)
+ if (!existing.isInstanceOf[SparkTable]) return None
+ val relation =
+ DataSourceV2Relation.create(existing,
Some(catalog.asInstanceOf[TableCatalog]), Some(ident))
+ val dynamicOverwrite = existing.partitioning().nonEmpty &&
+ spark.sessionState.conf.partitionOverwriteMode ==
PartitionOverwriteMode.DYNAMIC
+ if (dynamicOverwrite) {
+ Some(OverwritePartitionsDynamic.byName(relation, query, writeOptions))
+ } else {
+ Some(OverwriteByExpression.byName(relation, query, Literal(true),
writeOptions))
+ }
+ } catch {
+ case _: NoSuchTableException => None
+ }
+ }
+
/**
* Whether replace can use Spark's staged replace path. Paimon's
replaceTable is not a
* rollbackable atomic replace; it swaps the current schema and truncates
current data while
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
index de31d8ccc4..e4b91730ed 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
@@ -27,6 +27,8 @@ import org.junit.jupiter.api.Assertions
import java.sql.{Date, Timestamp}
+import scala.collection.JavaConverters._
+
abstract class DataFrameWriteTestBase extends PaimonSparkTestBase {
override protected def sparkConf: SparkConf = {
@@ -132,16 +134,33 @@ abstract class DataFrameWriteTestBase extends
PaimonSparkTestBase {
Seq(Row("pt=p1"), Row("pt=p2"), Row("pt=p3"))
)
- // saveAsTable with overwrite mode will call replace table internal,
- // so here we set the props and partitions again.
- Seq((5, "x5", "p1"))
- .toDF("a", "b", "pt")
- .write
- .format("paimon")
- .option("primary-key", "a,pt")
- .partitionBy("pt")
- .mode("overwrite")
- .saveAsTable("t")
+ if (gteqSpark3_4) {
+ // On Spark 3.4+, saveAsTable with overwrite mode behaves as
INSERT OVERWRITE,
+ // preserving the table definition (partitions, primary keys,
properties).
+ // No need to re-specify partitionBy or primary-key.
+ Seq((5, "x5", "p1"))
+ .toDF("a", "b", "pt")
+ .write
+ .format("paimon")
+ .mode("overwrite")
+ .saveAsTable("t")
+
+ // Verify table definition is preserved
+ val table = loadTable("t")
+ Assertions.assertEquals(Seq("pt"),
table.partitionKeys().asScala.toSeq)
+ Assertions.assertEquals(Seq("a", "pt"),
table.primaryKeys().asScala.toSeq)
+ } else {
+ // On Spark 3.2/3.3, saveAsTable with overwrite still requires
re-specifying
+ // partitionBy and primary-key as the insert-overwrite
optimization is not available.
+ Seq((5, "x5", "p1"))
+ .toDF("a", "b", "pt")
+ .write
+ .format("paimon")
+ .option("primary-key", "a,pt")
+ .partitionBy("pt")
+ .mode("overwrite")
+ .saveAsTable("t")
+ }
checkAnswer(
spark.read.format("paimon").table("t").orderBy("a"),
Seq(Row(5, "x5", "p1"))
@@ -155,6 +174,123 @@ abstract class DataFrameWriteTestBase extends
PaimonSparkTestBase {
}
}
+ test("Paimon dataframe: saveAsTable overwrite preserves table definition and
snapshots") {
+ assume(gteqSpark3_4)
+ withTable("t") {
+ // Create a partitioned table with primary key and custom properties
+ spark.sql("""CREATE TABLE t (a INT, b STRING, pt STRING)
+ |USING paimon
+ |PARTITIONED BY (pt)
+ |TBLPROPERTIES ('primary-key' = 'a,pt', 'bucket' = '2')
+ |""".stripMargin)
+ spark.sql("INSERT INTO t VALUES (1, 'old1', 'p1'), (2, 'old2', 'p2')")
+
+ val oldLocation = loadTable("t").location().toString
+ val oldSnapshotId = loadTable("t").snapshotManager().latestSnapshotId()
+
+ // Overwrite without re-specifying partitionBy or primary-key
+ Seq((3, "new3", "p3"))
+ .toDF("a", "b", "pt")
+ .write
+ .format("paimon")
+ .mode("overwrite")
+ .saveAsTable("t")
+
+ // Verify table definition is fully preserved
+ val table = loadTable("t")
+ Assertions.assertEquals(Seq("pt"), table.partitionKeys().asScala.toSeq)
+ Assertions.assertEquals(Seq("a", "pt"),
table.primaryKeys().asScala.toSeq)
+ Assertions.assertEquals("2", table.options().get("bucket"))
+ Assertions.assertEquals(oldLocation, table.location().toString)
+
+ // Verify data is replaced
+ checkAnswer(sql("SELECT * FROM t ORDER BY a"), Row(3, "new3", "p3") ::
Nil)
+
+ // Verify old snapshots are still accessible via time travel
+ checkAnswer(
+ sql(s"SELECT * FROM t VERSION AS OF $oldSnapshotId ORDER BY a"),
+ Row(1, "old1", "p1") :: Row(2, "old2", "p2") :: Nil)
+ }
+ }
+
+ test("Paimon dataframe: saveAsTable overwrite on non-partitioned table") {
+ assume(gteqSpark3_4)
+ withTable("t") {
+ Seq((1, "x1"), (2, "x2"))
+ .toDF("a", "b")
+ .write
+ .format("paimon")
+ .mode("append")
+ .saveAsTable("t")
+
+ // Overwrite
+ Seq((3, "x3"))
+ .toDF("a", "b")
+ .write
+ .format("paimon")
+ .mode("overwrite")
+ .saveAsTable("t")
+
+ checkAnswer(sql("SELECT * FROM t ORDER BY a"), Row(3, "x3") :: Nil)
+ // Verify still non-partitioned
+ Assertions.assertTrue(loadTable("t").partitionKeys().isEmpty)
+ }
+ }
+
+ test("Paimon dataframe: saveAsTable overwrite creates table when not
exists") {
+ assume(gteqSpark3_4)
+ withTable("new_tbl") {
+ Seq((1, "x1"))
+ .toDF("a", "b")
+ .write
+ .format("paimon")
+ .option("primary-key", "a")
+ .mode("overwrite")
+ .saveAsTable("new_tbl")
+
+ checkAnswer(sql("SELECT * FROM new_tbl"), Row(1, "x1") :: Nil)
+ Assertions.assertEquals(Seq("a"),
loadTable("new_tbl").primaryKeys().asScala.toSeq)
+ }
+ }
+
+ test("Paimon dataframe: saveAsTable overwrite respects dynamic partition
overwrite mode") {
+ assume(gteqSpark3_4)
+ withTable("t") {
+ spark.sql("""CREATE TABLE t (a INT, b STRING, pt STRING)
+ |USING paimon
+ |PARTITIONED BY (pt)
+ |TBLPROPERTIES ('primary-key' = 'a,pt')
+ |""".stripMargin)
+ spark.sql("INSERT INTO t VALUES (1, 'x1', 'p1'), (2, 'x2', 'p2'), (3,
'x3', 'p1')")
+
+ // Dynamic partition overwrite: only p1 should be replaced, p2 untouched
+ withSparkSQLConf("spark.sql.sources.partitionOverwriteMode" ->
"dynamic") {
+ Seq((4, "x4", "p1"))
+ .toDF("a", "b", "pt")
+ .write
+ .format("paimon")
+ .mode("overwrite")
+ .saveAsTable("t")
+ }
+ checkAnswer(
+ sql("SELECT * FROM t ORDER BY a"),
+ Row(2, "x2", "p2") :: Row(4, "x4", "p1") :: Nil)
+
+ // Static overwrite (default): all partitions replaced
+ Seq((5, "x5", "p3"))
+ .toDF("a", "b", "pt")
+ .write
+ .format("paimon")
+ .mode("overwrite")
+ .saveAsTable("t")
+ checkAnswer(sql("SELECT * FROM t ORDER BY a"), Row(5, "x5", "p3") :: Nil)
+
+ // Verify table definition is preserved in both cases
+ Assertions.assertEquals(Seq("pt"),
loadTable("t").partitionKeys().asScala.toSeq)
+ Assertions.assertEquals(Seq("a", "pt"),
loadTable("t").primaryKeys().asScala.toSeq)
+ }
+ }
+
test("Paimon: DataFrameWrite.saveAsTable") {
withTable("test_ctas") {
Seq((1L, "x1"), (2L, "x2"))