This is an automated email from the ASF dual-hosted git repository.
danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new ba4615b864f0 fix(spark): support saveAsTable append to existing Hudi
tables (#20423)
ba4615b864f0 is described below
commit ba4615b864f07c71016b043af84fe37dba8b0b69
Author: Danny Chan <[email protected]>
AuthorDate: Mon Nov 2 11:31:35 2026 +0800
fix(spark): support saveAsTable append to existing Hudi tables (#20423)
---
.../spark/sql/hudi/analysis/HoodieAnalysis.scala | 45 +++-
.../spark/sql/hudi/ddl/TestCreateTable.scala | 248 ++++++++++++++++++++-
.../command/CreateHoodieTableAsSelectCommand.scala | 24 +-
.../command/InsertIntoHoodieTableCommand.scala | 11 +-
.../command/CreateHoodieTableAsSelectCommand.scala | 24 +-
.../command/InsertIntoHoodieTableCommand.scala | 11 +-
6 files changed, 344 insertions(+), 19 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala
index f9d99cbc0602..ad240a8e7876 100644
---
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala
+++
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala
@@ -18,10 +18,11 @@
package org.apache.spark.sql.hudi.analysis
import org.apache.hudi.{HoodieSchemaUtils, HoodieSparkUtils,
SparkAdapterSupport}
+import
org.apache.hudi.HoodieWriterUtils.{mappingSparkDatasourceConfigsToTableConfigs,
validateTableConfig}
import org.apache.hudi.common.util.ReflectionUtils.loadClass
import org.apache.hudi.common.util.ValidationUtils
-import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.{SaveMode, SparkSession}
import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute
import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat,
CatalogTable, HoodieCatalogTable}
@@ -31,7 +32,8 @@ import org.apache.spark.sql.catalyst.plans.logical._
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.execution.command._
import org.apache.spark.sql.execution.datasources.{CreateTable,
LogicalRelation}
-import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.{isMetaField,
removeMetaFields}
+import
org.apache.spark.sql.hudi.HoodieOptionConfig.mapSqlOptionsToDataSourceWriteConfigs
+import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.{isMetaField,
makePathQualified, removeMetaFields}
import org.apache.spark.sql.hudi.analysis.HoodieAnalysis.{sparkAdapter,
MatchCreateIndex, MatchCreateTableLike, MatchDropIndex,
MatchInsertIntoStatement, MatchMergeIntoTable, MatchRefreshIndex,
MatchShowIndexes, ResolvesToHudiTable}
import org.apache.spark.sql.hudi.blob.ReadBlobRule
import org.apache.spark.sql.hudi.command._
@@ -40,6 +42,7 @@ import
org.apache.spark.sql.hudi.command.InsertIntoHoodieTableCommand.alignQuery
import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
import org.apache.spark.sql.hudi.command.procedures.{HoodieProcedures,
Procedure, ProcedureArgs}
+import java.net.URI
import java.util
import scala.collection.mutable.ListBuffer
@@ -410,9 +413,13 @@ case class ResolveImplementationsEarly(spark:
SparkSession) extends Rule[Logical
// Convert to CreateHoodieTableAsSelectCommand
case ct @ CreateTable(table, mode, Some(query))
if sparkAdapter.isHoodieTable(table) && ct.query.forall(_.resolved) =>
- val alignedQuery = alignCtasQueryByPartitionOrder(
- stripMetaFieldAttributes(query), table.partitionColumnNames)
- CreateHoodieTableAsSelectCommand(table, mode, alignedQuery)
+ val appendToExistingTable = mode == SaveMode.Append &&
spark.sessionState.catalog.tableExists(table.identifier)
+ val alignedQuery = if (appendToExistingTable) {
+ alignAppendQuery(table, query)
+ } else {
+ alignCtasQueryByPartitionOrder(stripMetaFieldAttributes(query),
table.partitionColumnNames)
+ }
+ CreateHoodieTableAsSelectCommand(table, mode, alignedQuery,
appendToExistingTable)
case ct: CreateTable =>
try {
@@ -434,6 +441,34 @@ case class ResolveImplementationsEarly(spark:
SparkSession) extends Rule[Logical
}
}
+ private def alignAppendQuery(table: CatalogTable, query: LogicalPlan):
LogicalPlan = {
+ val existingTable =
spark.sessionState.catalog.getTableMetadata(table.identifier)
+ if (!sparkAdapter.isHoodieTable(existingTable)) {
+ throw new HoodieAnalysisException(s"Cannot append using hudi to table
${table.identifier.unquotedString}: " +
+ s"the existing table uses ${existingTable.provider.getOrElse("an
unspecified provider")}")
+ }
+ table.storage.locationUri.foreach { location =>
+ val hadoopConf = spark.sessionState.newHadoopConf()
+ val specifiedLocation = new URI(makePathQualified(location,
hadoopConf)).normalize()
+ val existingLocation = new URI(makePathQualified(existingTable.location,
hadoopConf)).normalize()
+ if (specifiedLocation != existingLocation) {
+ throw new HoodieAnalysisException(s"The location of the existing table
${existingTable.identifier.unquotedString} " +
+ s"($existingLocation) doesn't match the specified location
($specifiedLocation)")
+ }
+ }
+ val conf = spark.sessionState.conf
+ val partitionColumns = table.partitionColumnNames
+ if (partitionColumns.nonEmpty && (partitionColumns.size !=
existingTable.partitionColumnNames.size ||
+ !partitionColumns.zip(existingTable.partitionColumnNames).forall { case
(specified, existing) => conf.resolver(specified, existing) })) {
+ throw new HoodieAnalysisException(s"Specified partitioning
${partitionColumns.mkString("[", ", ", "]")} " +
+ s"does not match that of the existing table
${existingTable.partitionColumnNames.mkString("[", ", ", "]")}")
+ }
+ val catalogTable = new HoodieCatalogTable(spark, existingTable)
+ val options =
mapSqlOptionsToDataSourceWriteConfigs(table.storage.properties --
HoodieCatalogTable.needFilterProps - "path")
+ validateTableConfig(spark, options ++
mappingSparkDatasourceConfigsToTableConfigs(options), catalogTable.tableConfig)
+ alignQueryOutput(query, catalogTable, Map.empty, conf,
allowPositionalFallback = false)
+ }
+
private def alignCtasQueryByPartitionOrder(query: LogicalPlan,
partitionColumns: Seq[String]): LogicalPlan = {
if (partitionColumns.isEmpty) {
query
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala
index 3e3f3cf774ec..67c89050c1f3 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala
@@ -35,9 +35,10 @@ import
org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
import org.apache.hudi.testutils.Assertions
import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
-import org.apache.spark.sql.{SaveMode, SparkSession}
+import org.apache.spark.sql.{AnalysisException, SaveMode, SparkSession}
import org.apache.spark.sql.catalyst.TableIdentifier
-import org.apache.spark.sql.catalyst.catalog.{CatalogTableType,
HoodieCatalogTable}
+import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat,
CatalogTable, CatalogTableType, HoodieCatalogTable}
+import org.apache.spark.sql.execution.datasources.CreateTable
import org.apache.spark.sql.functions.{col, concat, expr, lit}
import org.apache.spark.sql.hudi.HoodieSqlCommonUtils
import org.apache.spark.sql.hudi.command.CreateHoodieTableCommand
@@ -1654,6 +1655,249 @@ class TestCreateTable extends HoodieSparkSqlTestBase
with ExtendedParserTestHelp
spark.sql(s"drop table $tableName")
}
+ for (tableType <- Seq("cow", "mor"); external <- Seq(false, true);
partitioned <- Seq(false, true)) {
+ test(s"saveAsTable append preserves existing $tableType table
(external=$external, partitioned=$partitioned)") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val location = if (external) s"LOCATION
'${tmp.getCanonicalPath}/$tableName'" else ""
+ val partition = if (partitioned) "PARTITIONED BY (dt)" else ""
+ spark.sql(
+ s"""CREATE TABLE $tableName (id INT, name STRING, ts BIGINT, dt
STRING)
+ |USING hudi $partition $location
+ |TBLPROPERTIES (type = '$tableType', primaryKey = 'id',
orderingFields = 'ts')
+ |""".stripMargin)
+ try {
+ spark.sql(s"INSERT INTO $tableName VALUES (1, 'first', 1, 'p1')")
+ val identifier = TableIdentifier(tableName)
+ val before = spark.sessionState.catalog.getTableMetadata(identifier)
+ val tablePath = HoodieCatalogTable(spark, before).tableLocation
+ val configBefore = createMetaClient(spark,
tablePath).getTableConfig.getProps.asScala.toMap
+
+ // Omit table options and partitionBy: these must come from the
existing table.
+ spark.sql("SELECT 2 AS id, 'second' AS name, CAST(2 AS BIGINT) AS
ts, 'p2' AS dt")
+ .write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ checkAnswer(s"SELECT id, name, ts, dt FROM $tableName")(
+ Seq(1, "first", 1L, "p1"), Seq(2, "second", 2L, "p2"))
+ // saveAsTable appends by column name even when the input order
differs.
+ spark.sql("SELECT 'p3' AS dt, CAST(3 AS BIGINT) AS ts, 'third' AS
name, 3 AS id")
+ .write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ checkAnswer(s"SELECT id, name, ts, dt FROM $tableName")(
+ Seq(1, "first", 1L, "p1"), Seq(2, "second", 2L, "p2"), Seq(3,
"third", 3L, "p3"))
+ spark.sql("SELECT 2 AS id, 'updated' AS name, CAST(4 AS BIGINT) AS
ts, 'p2' AS dt")
+ .write.format("hudi").mode(SaveMode.Append)
+ .option("hoodie.spark.sql.insert.into.operation",
"upsert").saveAsTable(tableName)
+ // The snapshot view must contain the updated row exactly once,
including for MOR tables.
+ checkAnswer(s"SELECT id, name, ts, dt FROM $tableName")(
+ Seq(1, "first", 1L, "p1"), Seq(2, "updated", 4L, "p2"), Seq(3,
"third", 3L, "p3"))
+ val after = spark.sessionState.catalog.getTableMetadata(identifier)
+ assertEquals(before.storage.locationUri, after.storage.locationUri)
+ assertEquals(before.partitionColumnNames, after.partitionColumnNames)
+ assertEquals(before.schema, after.schema)
+ assertEquals(configBefore, createMetaClient(spark,
tablePath).getTableConfig.getProps.asScala.toMap)
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+ }
+
+ for (tableType <- Seq("cow", "mor")) {
+ test(s"saveAsTable append aligns types and rejects extra columns for
$tableType") {
+ val tableName = generateTableName
+ spark.sql(s"CREATE TABLE $tableName (id INT, name STRING) USING hudi " +
+ s"TBLPROPERTIES (type = '$tableType', primaryKey = 'id')")
+ try {
+ spark.sql("SELECT 'first' AS name, CAST(1 AS BIGINT) AS id")
+ .write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ checkAnswer(s"SELECT id, name FROM $tableName")(Seq(1, "first"))
+ assertEquals(IntegerType, spark.table(tableName).schema("id").dataType)
+ val path = HoodieCatalogTable(spark,
spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName))).tableLocation
+ assertEquals(IntegerType,
spark.read.format("hudi").load(path).schema("id").dataType)
+ val metaClient = createMetaClient(spark, path)
+ val commitsBefore =
metaClient.getActiveTimeline.getWriteTimeline.countInstants()
+ val error = intercept[AnalysisException] {
+ spark.sql("SELECT 2 AS id, 'second' AS name, 'unexpected' AS extra")
+ .write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.toLowerCase.contains("too many data
columns"))
+ assertEquals(commitsBefore,
metaClient.reloadActiveTimeline().getWriteTimeline.countInstants())
+ // A rejected append must leave the persisted and catalog schemas
usable for later inserts.
+ spark.sql(s"INSERT INTO $tableName VALUES (2, 'second')")
+ checkAnswer(s"SELECT id, name FROM $tableName")(Seq(1, "first"),
Seq(2, "second"))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+
+ for (partitionColumns <- Seq(Seq.empty[String], Seq("dt"), Seq("name",
"dt"))) {
+ test(s"saveAsTable append validates specified partitioning
${partitionColumns.mkString(",")}") {
+ val tableName = generateTableName
+ val partition = if (partitionColumns.isEmpty) "" else s"PARTITIONED BY
(${partitionColumns.mkString(",")})"
+ spark.sql(s"CREATE TABLE $tableName (id INT, name STRING, dt STRING)
USING hudi $partition " +
+ "TBLPROPERTIES (primaryKey = 'id')")
+ try {
+ val df = spark.sql("SELECT 1 AS id, 'first' AS name, 'p1' AS dt")
+ val error = intercept[AnalysisException] {
+
df.write.format("hudi").mode(SaveMode.Append).partitionBy("name").saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.contains("does not match that of the
existing table"))
+ if (partitionColumns.size > 1) {
+ intercept[AnalysisException] {
+
df.write.format("hudi").mode(SaveMode.Append).partitionBy(partitionColumns.reverse:
_*).saveAsTable(tableName)
+ }
+ }
+ withSQLConf("spark.sql.caseSensitive" -> "false") {
+ df.write.format("hudi").mode(SaveMode.Append)
+
.partitionBy(partitionColumns.map(_.toUpperCase(java.util.Locale.ROOT)):
_*).saveAsTable(tableName)
+ }
+ checkAnswer(s"SELECT id, name, dt FROM $tableName")(Seq(1, "first",
"p1"))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+
+ test("saveAsTable append rejects a non-Hudi table") {
+ val tableName = generateTableName
+ spark.sql(s"CREATE TABLE $tableName (id INT) USING parquet")
+ try {
+ val error = intercept[AnalysisException] {
+ spark.sql("SELECT 1 AS
id").write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.contains("existing table uses parquet"))
+ spark.sql(s"INSERT INTO $tableName VALUES (2)")
+ checkAnswer(s"SELECT * FROM $tableName")(Seq(2))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+
+ test("saveAsTable append rejects conflicting table options") {
+ val tableName = generateTableName
+ spark.sql(s"CREATE TABLE $tableName (id INT, name STRING, ts BIGINT, dt
STRING) USING hudi " +
+ "PARTITIONED BY (dt) TBLPROPERTIES (primaryKey = 'id', orderingFields =
'ts')")
+ try {
+ val conflicts = Seq(
+ "hoodie.datasource.write.recordkey.field" -> "name",
+ "hoodie.datasource.write.precombine.field" -> "id",
+ "hoodie.datasource.write.table.type" -> "MERGE_ON_READ",
+ "hoodie.datasource.write.keygenerator.class" ->
"org.apache.hudi.keygen.ComplexKeyGenerator",
+ "primaryKey" -> "name",
+ "type" -> "mor")
+ val df = spark.sql("SELECT 1 AS id, 'first' AS name, CAST(1 AS BIGINT)
AS ts, 'p1' AS dt")
+ conflicts.foreach { case (key, value) =>
+ withClue(s"Conflicting option $key=$value: ") {
+ val error = intercept[org.apache.hudi.exception.HoodieException] {
+ df.write.format("hudi").mode(SaveMode.Append).option(key,
value).saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.contains("Config conflict"))
+ }
+ }
+ df.write.format("hudi").mode(SaveMode.Append)
+ .option("primaryKey", "id").option("type",
"cow").saveAsTable(tableName)
+ checkAnswer(s"SELECT id, name, ts, dt FROM $tableName")(Seq(1, "first",
1L, "p1"))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+
+ for (caseSensitive <- Seq(false, true)) {
+ test(s"saveAsTable append matches columns only by name
(caseSensitive=$caseSensitive)") {
+ withSQLConf("spark.sql.caseSensitive" -> caseSensitive.toString) {
+ val tableName = generateTableName
+ spark.sql(s"CREATE TABLE $tableName (id INT, first STRING, last
STRING) USING hudi " +
+ "TBLPROPERTIES (primaryKey = 'id')")
+ try {
+ val error = intercept[AnalysisException] {
+ spark.sql("SELECT 1 AS id, 'Doe' AS last, 'John' AS first_name")
+
.write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.contains("first"))
+ assertEquals(0L, spark.table(tableName).count())
+ val upperCase = spark.sql("SELECT 'Doe' AS LAST, 1 AS ID, 'John' AS
FIRST")
+ if (caseSensitive) {
+ intercept[AnalysisException] {
+
upperCase.write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ }
+ spark.sql("SELECT 'Doe' AS last, 1 AS id, 'John' AS first")
+
.write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ } else {
+
upperCase.write.format("hudi").mode(SaveMode.Append).saveAsTable(tableName)
+ }
+ // SQL INSERT INTO retains its positional fallback for differently
named input columns.
+ spark.sql(s"INSERT INTO $tableName SELECT 2 AS other_id, 'Jane' AS
other_first, 'Roe' AS other_last")
+ checkAnswer(s"SELECT id, first, last FROM $tableName")(
+ Seq(1, "John", "Doe"), Seq(2, "Jane", "Roe"))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+ }
+
+ for (external <- Seq(false, true)) {
+ test(s"saveAsTable append validates the qualified location
(external=$external)") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val locationClause = if (external) s"LOCATION
'${tmp.getCanonicalPath}/$tableName'" else ""
+ spark.sql(s"CREATE TABLE $tableName (id INT) USING hudi
$locationClause TBLPROPERTIES (primaryKey = 'id')")
+ try {
+ spark.sql(s"INSERT INTO $tableName VALUES (1)")
+ val location =
spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)).location
+ val other = new java.io.File(tmp, "other")
+ val error = intercept[AnalysisException] {
+ spark.sql("SELECT 2 AS
id").write.format("hudi").mode(SaveMode.Append)
+ .option("path", other.getCanonicalPath).saveAsTable(tableName)
+ }
+ assertTrue(error.getMessage.contains("doesn't match the specified
location"))
+ assertFalse(other.exists())
+ checkAnswer(s"SELECT id FROM $tableName")(Seq(1))
+ // Qualified URIs and unqualified paths referring to the same
location are both valid.
+ spark.sql("SELECT 2 AS
id").write.format("hudi").mode(SaveMode.Append)
+ .option("path", location.getPath).saveAsTable(tableName)
+ spark.sql("SELECT 3 AS
id").write.format("hudi").mode(SaveMode.Append)
+ .option("path", location.toString + "/").saveAsTable(tableName)
+ checkAnswer(s"SELECT id FROM $tableName")(Seq(1), Seq(2), Seq(3))
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+ }
+
+ for (existsAtAnalysis <- Seq(false, true)) {
+ test(s"saveAsTable append rejects table existence changes after analysis
(existsAtAnalysis=$existsAtAnalysis)") {
+ val tableName = generateTableName
+ val createTable = s"CREATE TABLE $tableName (id INT) USING hudi
TBLPROPERTIES (primaryKey = 'id')"
+ if (existsAtAnalysis) {
+ spark.sql(createTable)
+ }
+ try {
+ val table = CatalogTable(TableIdentifier(tableName),
CatalogTableType.MANAGED,
+ CatalogStorageFormat.empty, new StructType(), provider =
Some("hudi"))
+ val query = spark.sql("SELECT 1 AS id").queryExecution.analyzed
+ val analyzed = spark.sessionState.executePlan(CreateTable(table,
SaveMode.Append, Some(query))).analyzed
+ if (existsAtAnalysis) {
+ spark.sql(s"DROP TABLE $tableName")
+ } else {
+ spark.sql(createTable)
+ }
+ val error = intercept[AnalysisException] {
+
spark.sessionState.executePlan(analyzed).executedPlan.executeCollect()
+ }
+ assertTrue(error.getMessage.contains("existence changed after
analysis"))
+ if (existsAtAnalysis) {
+ assertFalse(spark.catalog.tableExists(tableName))
+ } else {
+ assertEquals(0L, spark.table(tableName).count())
+ }
+ } finally {
+ spark.sql(s"DROP TABLE IF EXISTS $tableName")
+ }
+ }
+ }
+
test("Test CTAS not de-duplicating (by default)") {
withRecordType() {
withTempDir { tmp =>
diff --git
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
index ffb45bd80746..d1f70b6740a7 100644
---
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
+++
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
@@ -34,6 +34,7 @@ import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.command.DataWritingCommand
import org.apache.spark.sql.execution.metric.SQLMetric
+import
org.apache.spark.sql.hudi.HoodieOptionConfig.mapSqlOptionsToDataSourceWriteConfigs
import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
import scala.collection.JavaConverters._
@@ -44,7 +45,8 @@ import scala.collection.JavaConverters._
case class CreateHoodieTableAsSelectCommand(
table: CatalogTable,
mode: SaveMode,
- query: LogicalPlan) extends DataWritingCommand {
+ query: LogicalPlan,
+ appendToExistingTable: Boolean = false) extends DataWritingCommand {
override def innerChildren: Seq[QueryPlan[_]] = Seq(query)
override lazy val metrics: Map[String, SQLMetric] =
HoodieCommandMetrics.metrics
@@ -63,7 +65,12 @@ case class CreateHoodieTableAsSelectCommand(
val qualifiedTableIdentifier = table.identifier.copy(database = Some(db))
val tableName = qualifiedTableIdentifier.unquotedString
- if (sessionState.catalog.tableExists(qualifiedTableIdentifier)) {
+ val tableExists =
sessionState.catalog.tableExists(qualifiedTableIdentifier)
+ if (mode == SaveMode.Append && tableExists != appendToExistingTable) {
+ throw new HoodieAnalysisException(s"Table $tableName existence changed
after analysis. Retry the append.")
+ }
+
+ if (tableExists) {
checkState(mode != SaveMode.Overwrite,
s"Expect the table $tableName has been dropped when the save mode is
Overwrite")
@@ -77,6 +84,19 @@ case class CreateHoodieTableAsSelectCommand(
return Seq.empty
// scalastyle:on
}
+
+ if (mode == SaveMode.Append) {
+ val existingTable =
sessionState.catalog.getTableMetadata(qualifiedTableIdentifier)
+ val partitionSpec = existingTable.partitionColumnNames.map((_,
None)).toMap
+ InsertIntoHoodieTableCommand.run(sparkSession, existingTable, plan,
partitionSpec,
+ overwrite = false,
+ extraOptions =
mapSqlOptionsToDataSourceWriteConfigs(table.storage.properties --
needFilterProps - "path"),
+ metrics = metrics)
+ DataWritingCommand.propogateMetrics(sparkSession.sparkContext, this,
metrics)
+ // scalastyle:off
+ return Seq.empty
+ // scalastyle:on
+ }
}
// Remove some properties should not be used
diff --git
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
index 3fc336167e89..155391717af8 100644
---
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
+++
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
@@ -149,11 +149,13 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
* @param catalogTable catalog table
* @param partitionsSpec partition spec specifying static/dynamic partition
values
* @param conf Spark's [[SQLConf]]
+ * @param allowPositionalFallback whether to retry positional matching when
column names do not match
*/
def alignQueryOutput(query: LogicalPlan,
catalogTable: HoodieCatalogTable,
partitionsSpec: Map[String, Option[String]],
- conf: SQLConf): LogicalPlan = {
+ conf: SQLConf,
+ allowPositionalFallback: Boolean = true):
LogicalPlan = {
val targetPartitionSchema = catalogTable.partitionSchema
val staticPartitionValues = filterStaticPartitionValues(partitionsSpec)
@@ -168,7 +170,7 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
// since such columns wouldn't be otherwise specified w/in the query
itself and therefore couldn't be matched
// positionally for example
val expectedQueryColumns =
catalogTable.tableSchemaWithoutMetaFields.filterNot(f =>
staticPartitionValues.contains(f.name))
- val coercedQueryOutput =
coerceQueryOutputColumns(StructType(expectedQueryColumns), cleanedQuery,
catalogTable, conf)
+ val coercedQueryOutput =
coerceQueryOutputColumns(StructType(expectedQueryColumns), cleanedQuery,
catalogTable, conf, allowPositionalFallback)
// After potential reshaping validate that the output of the query
conforms to the table's schema
validate(removeMetaFields(coercedQueryOutput.schema), partitionsSpec,
catalogTable)
@@ -180,7 +182,8 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
private def coerceQueryOutputColumns(expectedSchema: StructType,
query: LogicalPlan,
catalogTable: HoodieCatalogTable,
- conf: SQLConf): LogicalPlan = {
+ conf: SQLConf,
+ allowPositionalFallback: Boolean):
LogicalPlan = {
val planUtils = sparkAdapter.getCatalystPlanUtils
try {
planUtils.resolveOutputColumns(
@@ -188,7 +191,7 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
} catch {
// NOTE: In case matching by name didn't match the query output, we will
attempt positional matching
// SPARK-42309 Error message changed in Spark 3.5.0 so we need to match
two strings here
- case ae: AnalysisException if
(ae.getMessage().startsWith("[INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA]
Cannot write incompatible data for the table")
+ case ae: AnalysisException if allowPositionalFallback &&
(ae.getMessage().startsWith("[INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA]
Cannot write incompatible data for the table")
|| ae.getMessage().startsWith("Cannot write incompatible data to
table")) =>
planUtils.resolveOutputColumns(
catalogTable.catalogTableName,
sparkAdapter.getSchemaUtils.toAttributes(expectedSchema), query, byName =
false, conf)
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
index 932cd900580c..459225db2656 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
+++
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala
@@ -35,6 +35,7 @@ import org.apache.spark.sql.classic.SparkSession
import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.command.DataWritingCommand
import org.apache.spark.sql.execution.metric.SQLMetric
+import
org.apache.spark.sql.hudi.HoodieOptionConfig.mapSqlOptionsToDataSourceWriteConfigs
import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
import scala.collection.JavaConverters._
@@ -45,7 +46,8 @@ import scala.collection.JavaConverters._
case class CreateHoodieTableAsSelectCommand(
table: CatalogTable,
mode: SaveMode,
- query: LogicalPlan) extends DataWritingCommand {
+ query: LogicalPlan,
+ appendToExistingTable: Boolean = false) extends DataWritingCommand {
override def innerChildren: Seq[QueryPlan[_]] = Seq(query)
override lazy val metrics: Map[String, SQLMetric] =
HoodieCommandMetrics.metrics
@@ -64,7 +66,12 @@ case class CreateHoodieTableAsSelectCommand(
val qualifiedTableIdentifier = table.identifier.copy(database = Some(db))
val tableName = qualifiedTableIdentifier.unquotedString
- if (sessionState.catalog.tableExists(qualifiedTableIdentifier)) {
+ val tableExists =
sessionState.catalog.tableExists(qualifiedTableIdentifier)
+ if (mode == SaveMode.Append && tableExists != appendToExistingTable) {
+ throw new HoodieAnalysisException(s"Table $tableName existence changed
after analysis. Retry the append.")
+ }
+
+ if (tableExists) {
checkState(mode != SaveMode.Overwrite,
s"Expect the table $tableName has been dropped when the save mode is
Overwrite")
@@ -78,6 +85,19 @@ case class CreateHoodieTableAsSelectCommand(
return Seq.empty
// scalastyle:on
}
+
+ if (mode == SaveMode.Append) {
+ val existingTable =
sessionState.catalog.getTableMetadata(qualifiedTableIdentifier)
+ val partitionSpec = existingTable.partitionColumnNames.map((_,
None)).toMap
+ InsertIntoHoodieTableCommand.run(sparkSession, existingTable, plan,
partitionSpec,
+ overwrite = false,
+ extraOptions =
mapSqlOptionsToDataSourceWriteConfigs(table.storage.properties --
needFilterProps - "path"),
+ metrics = metrics)
+ DataWritingCommand.propogateMetrics(sparkSession.sparkContext, this,
metrics)
+ // scalastyle:off
+ return Seq.empty
+ // scalastyle:on
+ }
}
// Remove some properties should not be used
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
index b1a2a3a2c6e7..5ed8ec54980f 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
+++
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala
@@ -149,11 +149,13 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
* @param catalogTable catalog table
* @param partitionsSpec partition spec specifying static/dynamic partition
values
* @param conf Spark's [[SQLConf]]
+ * @param allowPositionalFallback whether to retry positional matching when
column names do not match
*/
def alignQueryOutput(query: LogicalPlan,
catalogTable: HoodieCatalogTable,
partitionsSpec: Map[String, Option[String]],
- conf: SQLConf): LogicalPlan = {
+ conf: SQLConf,
+ allowPositionalFallback: Boolean = true):
LogicalPlan = {
val targetPartitionSchema = catalogTable.partitionSchema
val staticPartitionValues = filterStaticPartitionValues(partitionsSpec)
@@ -168,7 +170,7 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
// since such columns wouldn't be otherwise specified w/in the query
itself and therefore couldn't be matched
// positionally for example
val expectedQueryColumns =
catalogTable.tableSchemaWithoutMetaFields.filterNot(f =>
staticPartitionValues.contains(f.name))
- val coercedQueryOutput =
coerceQueryOutputColumns(StructType(expectedQueryColumns), cleanedQuery,
catalogTable, conf)
+ val coercedQueryOutput =
coerceQueryOutputColumns(StructType(expectedQueryColumns), cleanedQuery,
catalogTable, conf, allowPositionalFallback)
// After potential reshaping validate that the output of the query
conforms to the table's schema
validate(removeMetaFields(coercedQueryOutput.schema), partitionsSpec,
catalogTable)
@@ -180,7 +182,8 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
private def coerceQueryOutputColumns(expectedSchema: StructType,
query: LogicalPlan,
catalogTable: HoodieCatalogTable,
- conf: SQLConf): LogicalPlan = {
+ conf: SQLConf,
+ allowPositionalFallback: Boolean):
LogicalPlan = {
val planUtils = sparkAdapter.getCatalystPlanUtils
try {
planUtils.resolveOutputColumns(
@@ -188,7 +191,7 @@ object InsertIntoHoodieTableCommand extends Logging with
ProvidesHoodieConfig wi
} catch {
// NOTE: In case matching by name didn't match the query output, we will
attempt positional matching
// SPARK-42309 Error message changed in Spark 3.5.0 so we need to match
two strings here
- case ae: AnalysisException if
(ae.getMessage().startsWith("[INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA]
Cannot write incompatible data for the table")
+ case ae: AnalysisException if allowPositionalFallback &&
(ae.getMessage().startsWith("[INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA]
Cannot write incompatible data for the table")
|| ae.getMessage().startsWith("Cannot write incompatible data to
table")) =>
planUtils.resolveOutputColumns(
catalogTable.catalogTableName,
sparkAdapter.getSchemaUtils.toAttributes(expectedSchema), query, byName =
false, conf)