This is an automated email from the ASF dual-hosted git repository.
voonhous 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 6fd80965527f fix(spark): make SparkCatalogMetaStoreClient sync-safe
and cover the Spark catalog DDL paths (#19162)
6fd80965527f is described below
commit 6fd80965527f4a5c8476ac188a52d8d293c22807
Author: Y Ethan Guo <[email protected]>
AuthorDate: Sat Aug 29 03:47:31 2026 -0700
fix(spark): make SparkCatalogMetaStoreClient sync-safe and cover the Spark
catalog DDL paths (#19162)
Production (SparkCatalogMetaStoreClient, the IMetaStoreClient used when
hoodie.datasource.hive_sync.use_spark_catalog=true):
- getSchema returned every partition column twice (CatalogTable.schema
already
contains them) and rendered column comments as "Some(x)"/"None";
HoodieHiveSyncClient.updateTableComments then threw "Duplicate key" for
any
partitioned table with sync_comment=true.
- fromCatalogTable dropped CatalogTable.comment, so a "comment" table
property never
round-tripped and updateTableProperties re-altered the table on every
sync.
- close() threw UnsupportedOperationException although
HoodieHiveSyncClient.close
calls it on every sync; it is now a no-op.
- getPartition(db, table, values) threw UnsupportedOperationException
although
HMSDDLExecutor.dropPartitionsToTable reaches it through
HivePartitionUtil.partitionExists, which only handles
NoSuchObjectException; it is
now implemented on ExternalCatalog.getPartitionOption.
- alter_table forwarded to HiveExternalCatalog.alterTable, which keeps the
stored
schema by design, so a column added by
HMSDDLExecutor.updateTableDefinition never
reached the catalog. It now merges the synced columns conservatively and
writes
the result through alterTableDataSchema: new columns are appended and
non-empty
comments applied, while an existing column keeps its type, nullability
and field
metadata (the catalog holds the logical Spark schema, including the
hudi_type
markers for VECTOR/BLOB and the original VariantType, whereas the sync
only speaks
Hive type strings). The merged schema is also handed to alterTable itself
so the
behaviour holds under InMemoryCatalog. toCatalogTable carries column
comments
across so the getTable -> alter_table round trips do not erase them.
- alter_table pinned the identifier to the incoming name, so the rename
step of
HoodieHiveSyncClient.createOrReplaceTable (force-recreate,
recreate-on-error,
base-path change) dropped the real table and left only <table>_temp; a
Table
carrying a different name now goes through ExternalCatalog.renameTable
first.
- HoodieCatalog.alterTable's unsupported-change message printed
"class java.lang.Class" instead of the change class.
Tests:
- TestHoodieCatalogDDL: HoodieCatalog create/load/alter/rename/drop through
the V2
TableCatalog API (external location survives drop, unsupported change
rejected),
SHOW CREATE TABLE through ShowHoodieCreateTableCommand (under
spark.sql.legacy.useV1Command, the only route Spark gives that command),
existing-location config conflict, HoodieInternalV2Table capabilities and
the
V2-to-V1 relation conversion for inserts under schema-on-read.
- TestSparkCatalogMetaStoreClient: pins the merge contract (comment lands,
property-
only alter keeps comment and schema, metadata and catalog type survive a
retype
through the alterTableDataSchema write, comment-less column list keeps
comments,
shorter list drops nothing, rename round trip), getPartition hit and
miss, close
and setMetaConf no-ops, the warehouse fallback, and the
UnsupportedOperationException
contract for the rest of IMetaStoreClient. Helpers register input/output
formats
and a serde like HMSDDLExecutor, which the pre-existing tests needed to
run at all.
- TestSparkCatalogSync: forced table recreation through HiveSyncTool
against the
in-memory catalog.
- TestHoodieSqlCommonUtils: partition-style detection incl. multi-column
tables,
config filtering, meta fields, partition-spec normalization, path
qualification.
- TestCreateTable: the illegal-CTAS cleanup test also covers an explicit
location.
CI: org.apache.spark.sql.hive is added to the scalatest wildcard lists in
bot.yml and
azure-pipelines-20230430.yml; TestSparkCatalogMetaStoreClient had never
executed in
CI and three of its tests had been failing on master unnoticed.
Not covered: HoodieStagedTable. SQL CTAS on the session catalog is
rewritten to the
V1 CreateHoodieTableAsSelectCommand, so the staged path is only reachable
when
HoodieCatalog is registered under a non-session catalog name.
---------
Co-authored-by: voon <[email protected]>
---
.github/workflows/bot.yml | 2 +-
azure-pipelines-20230430.yml | 1 +
.../sql/hive/SparkCatalogMetaStoreClient.scala | 91 ++++++-
.../spark/sql/hudi/catalog/HoodieCatalog.scala | 2 +-
.../sql/hive/TestSparkCatalogMetaStoreClient.scala | 291 ++++++++++++++++++++-
.../sql/hudi/catalog/TestHoodieCatalogDDL.scala | 209 +++++++++++++++
.../sql/hudi/common/TestHoodieSqlCommonUtils.scala | 129 +++++++++
.../spark/sql/hudi/ddl/TestCreateTable.scala | 19 ++
.../spark/sql/hudi/ddl/TestSparkCatalogSync.scala | 47 +++-
9 files changed, 777 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
index 1025838cf8b6..dceae0761bfb 100644
--- a/.github/workflows/bot.yml
+++ b/.github/workflows/bot.yml
@@ -27,7 +27,7 @@ env:
JAVA_UT_FILTER1:
-Dtest=!TestCOWDataSource,!TestMORDataSource,!TestHoodieFileSystemViews
JAVA_UT_FILTER2:
-Dtest=TestCOWDataSource,TestMORDataSource,TestHoodieFileSystemViews
SCALA_TEST_DML_FILTER: -DwildcardSuites=org.apache.spark.sql.hudi.dml
- SCALA_TEST_OTHERS_FILTER:
-DwildcardSuites=org.apache.hudi,org.apache.spark.hudi,org.apache.spark.sql.avro,org.apache.spark.sql.execution,org.apache.spark.sql.hudi.analysis,org.apache.spark.sql.hudi.blob,org.apache.spark.sql.hudi.catalog,org.apache.spark.sql.hudi.command,org.apache.spark.sql.hudi.common,org.apache.spark.sql.hudi.ddl,org.apache.spark.sql.hudi.procedure,org.apache.spark.sql.hudi.feature
+ SCALA_TEST_OTHERS_FILTER:
-DwildcardSuites=org.apache.hudi,org.apache.spark.hudi,org.apache.spark.sql.avro,org.apache.spark.sql.execution,org.apache.spark.sql.hive,org.apache.spark.sql.hudi.analysis,org.apache.spark.sql.hudi.blob,org.apache.spark.sql.hudi.catalog,org.apache.spark.sql.hudi.command,org.apache.spark.sql.hudi.common,org.apache.spark.sql.hudi.ddl,org.apache.spark.sql.hudi.procedure,org.apache.spark.sql.hudi.feature
FLINK_IT_FILTER1: -Dit.test=ITTestHoodieDataSource
FLINK_IT_FILTER2: -Dit.test=!ITTestHoodieDataSource
diff --git a/azure-pipelines-20230430.yml b/azure-pipelines-20230430.yml
index cf9b84638edc..b863f65bb1c5 100644
--- a/azure-pipelines-20230430.yml
+++ b/azure-pipelines-20230430.yml
@@ -74,6 +74,7 @@ parameters:
- 'org.apache.spark.hudi'
- 'org.apache.spark.sql.avro'
- 'org.apache.spark.sql.execution'
+ - 'org.apache.spark.sql.hive'
- 'org.apache.spark.sql.hudi.analysis'
- 'org.apache.spark.sql.hudi.blob'
- 'org.apache.spark.sql.hudi.catalog'
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hive/SparkCatalogMetaStoreClient.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hive/SparkCatalogMetaStoreClient.scala
index 9fa225eeb876..254751b7e7ca 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hive/SparkCatalogMetaStoreClient.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hive/SparkCatalogMetaStoreClient.scala
@@ -20,7 +20,7 @@ package org.apache.spark.sql.hive
import org.apache.hudi.hive.HiveSyncConfig
import org.apache.hadoop.hive.metastore.IMetaStoreClient
-import org.apache.hadoop.hive.metastore.api.{Database, EnvironmentContext,
FieldSchema, Partition, SerDeInfo, StorageDescriptor, Table}
+import org.apache.hadoop.hive.metastore.api.{Database, EnvironmentContext,
FieldSchema, MetaException, NoSuchObjectException, Partition, SerDeInfo,
StorageDescriptor, Table}
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.catalyst.catalog.{CatalogDatabase,
CatalogStorageFormat, CatalogTable, CatalogTablePartition, CatalogTableType}
@@ -65,8 +65,48 @@ class SparkCatalogMetaStoreClient(syncConfig: HiveSyncConfig)
// scalastyle:off method.name
override def alter_table(dbName: String, tableName: String, table: Table):
Unit = {
- val updated = toCatalogTable(table).copy(identifier =
TableIdentifier(tableName, Some(dbName)))
- externalCatalog.alterTable(updated)
+ val current = externalCatalog.getTable(dbName, tableName)
+ // HoodieHiveSyncClient.createOrReplaceTable renames its temp table by
altering it under the
+ // final name, after it has already dropped the real table; Spark's
ExternalCatalog only does
+ // that through renameTable. It runs first so a failed alter still leaves
a usable table under
+ // the final name. Hive lower-cases table names, so a differently-cased
configured name is not
+ // a rename.
+ val targetName =
Option(table.getTableName).filter(_.nonEmpty).getOrElse(tableName)
+ if (!targetName.equalsIgnoreCase(tableName)) {
+ externalCatalog.renameTable(dbName, tableName, targetName)
+ }
+ // HiveExternalCatalog.alterTable deliberately keeps the existing schema
(Spark routes schema
+ // changes through alterTableDataSchema) while InMemoryCatalog replaces it
wholesale, so the
+ // merged schema is handed to both calls. The merge is deliberately
conservative: the catalog
+ // holds the logical Spark schema (nullability, field metadata such as the
VECTOR/BLOB type
+ // markers, the original VariantType) while the sync only speaks Hive type
strings, so an
+ // existing column keeps its type, nullability and metadata and is never
dropped; only new
+ // columns are appended and non-empty comments applied. Property-only
alters therefore leave
+ // the schema untouched.
+ val converted = toCatalogTable(table)
+ val merged = mergeDataSchema(current.dataSchema, converted.dataSchema)
+ val incoming = converted.copy(
+ identifier = TableIdentifier(targetName, Some(dbName)),
+ schema = StructType(merged ++ current.partitionSchema),
+ partitionColumnNames = current.partitionColumnNames)
+ externalCatalog.alterTable(incoming)
+ if (merged != current.dataSchema) {
+ externalCatalog.alterTableDataSchema(dbName, targetName, merged)
+ }
+ }
+
+ private def mergeDataSchema(current: StructType, incoming: StructType):
StructType = {
+ def key(name: String): String = name.toLowerCase(util.Locale.ROOT)
+ val incomingByName = incoming.fields.map(f => key(f.name) -> f).toMap
+ val kept = current.fields.map { existing =>
+ incomingByName.get(key(existing.name))
+ .flatMap(_.getComment().filter(_.nonEmpty))
+ .map(existing.withComment)
+ .getOrElse(existing)
+ }
+ val currentNames = current.fields.map(f => key(f.name)).toSet
+ val added = incoming.fields.filterNot(f =>
currentNames.contains(key(f.name)))
+ StructType(kept ++ added)
}
override def alter_table_with_environmentContext(dbName: String,
@@ -90,6 +130,25 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
listPartitions(dbName, tableName, max)
}
+ /**
+ * Looks up a single partition by its ordered values.
HivePartitionUtil.partitionExists relies on
+ * the Hive contract here: a missing partition surfaces as
NoSuchObjectException, not as null.
+ */
+ override def getPartition(dbName: String, tableName: String, values:
util.List[String]): Partition = {
+ val catalogTable = externalCatalog.getTable(dbName, tableName)
+ val partitionKeys = catalogTable.partitionColumnNames.toList
+ val partitionValues = Option(values).map(_.asScala.toList).getOrElse(Nil)
+ if (partitionValues.size != partitionKeys.size) {
+ val keys = partitionKeys.mkString(",")
+ throw new MetaException(
+ s"Expected ${partitionKeys.size} partition value(s) [$keys] for
$dbName.$tableName but got ${partitionValues.size}")
+ }
+ val spec = partitionKeys.zip(partitionValues).toMap
+ externalCatalog.getPartitionOption(dbName, tableName, spec)
+ .map(fromCatalogPartition(_, dbName, tableName, partitionKeys))
+ .getOrElse(throw new NoSuchObjectException(s"Partition $spec of
$dbName.$tableName does not exist"))
+ }
+
override def add_partitions(parts: util.List[Partition], ifNotExists:
Boolean, needResults: Boolean): util.List[Partition] = {
if (parts == null || parts.isEmpty) {
new util.ArrayList[Partition]()
@@ -128,8 +187,11 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
override def getSchema(dbName: String, tableName: String):
util.List[FieldSchema] = {
val table = externalCatalog.getTable(dbName, tableName)
- val cols = table.schema.fields.map { f =>
- new FieldSchema(f.name, f.dataType.catalogString,
Option(f.getComment()).map(_.toString).getOrElse(""))
+ // CatalogTable.schema already carries the partition columns, so they have
to be filtered out
+ // here or every partition column would be reported twice. Hive's
getSchema returns the data
+ // columns first and the partition columns last, which is the order
reproduced below.
+ val cols = table.schema.fields.filterNot(f =>
table.partitionColumnNames.contains(f.name)).map { f =>
+ new FieldSchema(f.name, f.dataType.catalogString,
f.getComment().getOrElse(""))
}
val partitionCols = table.partitionColumnNames.map { name =>
val dt = table.partitionSchema.fields.find(_.name ==
name).map(_.dataType.catalogString).getOrElse("string")
@@ -151,7 +213,9 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
override def setHiveAddedJars(arg0: String): Unit = unsupported[Unit]()
override def isLocalMetaStore(): Boolean = unsupported[Boolean]()
override def reconnect(): Unit = unsupported[Unit]()
- override def close(): Unit = unsupported[Unit]()
+ // close is a no-op: HoodieHiveSyncClient.close() calls it on every sync and
there is no
+ // connection to release, the Spark session outlives this client.
+ override def close(): Unit = {}
// setMetaConf is no-op: HoodieHiveSyncClient.setMetaConf forwards
// hive.metastore.callerContext.* values to the metastore for audit/tracing.
With Spark's
// external catalog there is no remote HMS to forward to, so accept the call
silently
@@ -176,7 +240,6 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
override def add_partition(arg0:
org.apache.hadoop.hive.metastore.api.Partition):
org.apache.hadoop.hive.metastore.api.Partition =
unsupported[org.apache.hadoop.hive.metastore.api.Partition]()
override def add_partitions(arg0:
java.util.List[org.apache.hadoop.hive.metastore.api.Partition]): Int =
unsupported[Int]()
override def add_partitions_pspec(arg0:
org.apache.hadoop.hive.metastore.partition.spec.PartitionSpecProxy): Int =
unsupported[Int]()
- override def getPartition(arg0: String, arg1: String, arg2:
java.util.List[String]): org.apache.hadoop.hive.metastore.api.Partition =
unsupported[org.apache.hadoop.hive.metastore.api.Partition]()
override def exchange_partition(arg0: java.util.Map[String, String], arg1:
String, arg2: String, arg3: String, arg4: String):
org.apache.hadoop.hive.metastore.api.Partition =
unsupported[org.apache.hadoop.hive.metastore.api.Partition]()
override def exchange_partitions(arg0: java.util.Map[String, String], arg1:
String, arg2: String, arg3: String, arg4: String):
java.util.List[org.apache.hadoop.hive.metastore.api.Partition] =
unsupported[java.util.List[org.apache.hadoop.hive.metastore.api.Partition]]()
override def getPartition(arg0: String, arg1: String, arg2: String):
org.apache.hadoop.hive.metastore.api.Partition =
unsupported[org.apache.hadoop.hive.metastore.api.Partition]()
@@ -303,8 +366,14 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
val cols =
Option(table.getSd).map(_.getCols).map(_.asScala.toList).getOrElse(Nil)
val partCols =
Option(table.getPartitionKeys).map(_.asScala.toList).getOrElse(Nil)
- val dataFields = cols.map(fs => StructField(fs.getName,
CatalystSqlParser.parseDataType(fs.getType), nullable = true, Metadata.empty))
- val partitionFields = partCols.map(fs => StructField(fs.getName,
CatalystSqlParser.parseDataType(fs.getType), nullable = true, Metadata.empty))
+ // Carry the Hive column comment across; fromCatalogTable emits it, and
since alter_table now
+ // writes the data schema back, dropping it here would erase comments on
every sync round trip.
+ def toField(fs: FieldSchema): StructField = {
+ val field = StructField(fs.getName,
CatalystSqlParser.parseDataType(fs.getType), nullable = true, Metadata.empty)
+
Option(fs.getComment).filter(_.nonEmpty).map(field.withComment).getOrElse(field)
+ }
+ val dataFields = cols.map(toField)
+ val partitionFields = partCols.map(toField)
// Strip "spark.sql.*" properties before handing off to Spark's external
catalog.
// HiveExternalCatalog.alterTable / createTable rejects such keys ("Cannot
persist ...
@@ -343,6 +412,10 @@ class SparkCatalogMetaStoreClient(syncConfig:
HiveSyncConfig)
t.setTableName(table.identifier.table)
t.setTableType(if (table.tableType == CatalogTableType.EXTERNAL)
"EXTERNAL_TABLE" else "MANAGED_TABLE")
t.setParameters(new util.HashMap[String, String](table.properties.asJava))
+ // Spark moves the "comment" table property into the dedicated
CatalogTable.comment field when it
+ // reads a table back (HiveClientImpl excludes it from
CatalogTable.properties), so it has to be
+ // put back here or a comment written through createTable/alter_table
would be lost on getTable.
+ table.comment.foreach(c => t.putToParameters("comment", c))
val nonPartitionFields = table.schema.fields.filterNot(f =>
table.partitionColumnNames.contains(f.name))
val cols = nonPartitionFields.map(f => new FieldSchema(f.name,
f.dataType.catalogString, f.getComment().orNull)).toList.asJava
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala
index f5ef66a04b92..2810fcdbcb14 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala
@@ -253,7 +253,7 @@ class HoodieCatalog extends DelegatingCatalogExtension
AlterHoodieTableChangeColumnCommand(tableIdent, colName,
field.withComment(newComment)).run(spark)
}
case (t, _) =>
- throw new UnsupportedOperationException(s"not supported table
change: ${t.getClass}")
+ throw new UnsupportedOperationException(s"not supported table
change: ${t.getName}")
}
loadTable(ident)
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hive/TestSparkCatalogMetaStoreClient.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hive/TestSparkCatalogMetaStoreClient.scala
index 93b2be295e3d..1e6dc3ce21d3 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hive/TestSparkCatalogMetaStoreClient.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hive/TestSparkCatalogMetaStoreClient.scala
@@ -18,11 +18,19 @@
package org.apache.spark.sql.hive
import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.hadoop.HoodieParquetInputFormat
import org.apache.hudi.hive.HiveSyncConfig
import org.apache.hudi.testutils.HoodieClientTestUtils.getSparkConfForTest
-import org.apache.hadoop.hive.metastore.api.{Database, EnvironmentContext,
FieldSchema, Partition, SerDeInfo, StorageDescriptor, Table}
+import org.apache.hadoop.hive.common.ObjectPair
+import org.apache.hadoop.hive.conf.HiveConf
+import org.apache.hadoop.hive.metastore.{IMetaStoreClient,
PartitionDropOptions, TableType}
+import org.apache.hadoop.hive.metastore.api.{ColumnStatistics, CompactionType,
Database, DataOperationType, EnvironmentContext, FieldSchema, FireEventRequest,
ForeignKeysRequest, Function, GetPrincipalsInRoleRequest,
GetRoleGrantsForPrincipalRequest, HiveObjectRef, Index, LockRequest,
NoSuchObjectException, Partition, PartitionEventType, PartitionValuesRequest,
PrimaryKeysRequest, PrincipalType, PrivilegeBag, Role, SerDeInfo,
SetPartitionsStatsRequest, ShowLocksRequest, SQLForeignKey, SQ [...]
+import org.apache.hadoop.hive.metastore.partition.spec.PartitionSpecProxy
+import org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat
+import org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe
import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.types.{MetadataBuilder, StructType}
import org.apache.spark.util.Utils
import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse,
assertNotNull, assertTrue}
import org.scalactic.source
@@ -125,6 +133,74 @@ class TestSparkCatalogMetaStoreClient extends FunSuite
with BeforeAndAfterAll {
client.alter_table_with_environmentContext(databaseName, tableName,
environmentAlteredTable, new EnvironmentContext())
assertEquals("env-context", client.getTable(databaseName,
tableName).getParameters.get("comment"))
+
+ // A column comment set through alter_table (the updateTableComments
path) must land in the
+ // catalog, and a property-only alter afterwards must keep both the
comment and the schema.
+ val commented = client.getTable(databaseName, tableName)
+ commented.getSd.getCols.asScala.find(_.getName ==
"name").get.setComment("the name column")
+ client.alter_table(databaseName, tableName, commented)
+ assertEquals("the name column", client.getSchema(databaseName,
tableName).asScala.find(_.getName == "name").get.getComment)
+
+ val propertyOnly = client.getTable(databaseName, tableName)
+ propertyOnly.putToParameters("comment", "v4")
+ client.alter_table(databaseName, tableName, propertyOnly)
+ assertEquals("v4", client.getTable(databaseName,
tableName).getParameters.get("comment"))
+ assertEquals(Seq("id", "name", "age", "dt"),
client.getSchema(databaseName, tableName).asScala.map(_.getName).toSeq)
+ assertEquals("the name column", client.getSchema(databaseName,
tableName).asScala.find(_.getName == "name").get.getComment)
+
+ // The sync only speaks Hive type strings, so a retyped existing column
must not overwrite the
+ // logical Spark type or the field metadata held by the catalog (the
VECTOR/BLOB type markers
+ // live there); only new columns and comments are written back.
+ val externalCatalog = spark.sessionState.catalog.externalCatalog
+ val markedFields = externalCatalog.getTable(databaseName,
tableName).dataSchema.fields.map {
+ case f if f.name == "name" =>
+ f.copy(metadata = new
MetadataBuilder().withMetadata(f.metadata).putString("hudi_type",
"VECTOR").build())
+ case f => f
+ }
+ externalCatalog.alterTableDataSchema(databaseName, tableName,
StructType(markedFields))
+ // Adding a column at the same time forces the alterTableDataSchema
write, which is the one
+ // call that could erase the marker if the merge did not keep the
existing field.
+ val retyped = client.getTable(databaseName, tableName)
+ retyped.getSd.getCols.asScala.find(_.getName ==
"name").get.setType("binary")
+ val retypedCols = new util.ArrayList[FieldSchema](retyped.getSd.getCols)
+ retypedCols.add(fieldSchema("extra", "int"))
+ retyped.getSd.setCols(retypedCols)
+ client.alter_table(databaseName, tableName, retyped)
+ assertEquals(Seq("id", "name", "age", "extra", "dt"),
client.getSchema(databaseName, tableName).asScala.map(_.getName).toSeq)
+ assertEquals("string", client.getSchema(databaseName,
tableName).asScala.find(_.getName == "name").get.getType)
+ val nameField = externalCatalog.getTable(databaseName,
tableName).dataSchema("name")
+ assertEquals("VECTOR", nameField.metadata.getString("hudi_type"))
+ assertEquals(Some("the name column"), nameField.getComment())
+
+ // updateTableDefinition sends every column with an empty comment
(HiveSchemaUtil), which must
+ // not erase the comments already in the catalog.
+ client.alter_table(databaseName, tableName, newTable(
+ databaseName,
+ tableName,
+ new File(tmp, s"${tableName}_v3").toURI.toString,
+ Seq("id" -> "int", "name" -> "string", "age" -> "int"),
+ Seq("dt" -> "string")))
+ assertEquals("the name column", client.getSchema(databaseName,
tableName).asScala.find(_.getName == "name").get.getComment)
+
+ // A shorter column list must not drop columns from the catalog either.
+ client.alter_table(databaseName, tableName, newTable(
+ databaseName,
+ tableName,
+ new File(tmp, s"${tableName}_v3").toURI.toString,
+ Seq("id" -> "int"),
+ Seq("dt" -> "string")))
+ assertEquals(Seq("id", "name", "age", "extra", "dt"),
client.getSchema(databaseName, tableName).asScala.map(_.getName).toSeq)
+
+ // HoodieHiveSyncClient.createOrReplaceTable renames its temp table by
altering it under the
+ // final name, which needs an explicit ExternalCatalog.renameTable.
+ val renamedName = s"${tableName}_renamed"
+ val renamed = client.getTable(databaseName, tableName)
+ renamed.setTableName(renamedName)
+ client.alter_table(databaseName, tableName, renamed)
+ assertFalse(client.tableExists(databaseName, tableName))
+ assertTrue(client.tableExists(databaseName, renamedName))
+ assertEquals(Seq("id", "name", "age", "extra", "dt"),
client.getSchema(databaseName, renamedName).asScala.map(_.getName).toSeq)
+ assertEquals("VECTOR", externalCatalog.getTable(databaseName,
renamedName).dataSchema("name").metadata.getString("hudi_type"))
}
}
@@ -148,6 +224,11 @@ class TestSparkCatalogMetaStoreClient extends FunSuite
with BeforeAndAfterAll {
val added = client.add_partitions(util.Arrays.asList(partitionOne,
partitionTwo), false, true)
assertEquals(2, added.size())
+ // HivePartitionUtil.partitionExists calls getPartition and treats
NoSuchObjectException as
+ // "absent", so a missing partition must raise that instead of returning
null.
+ assertEquals("2024-01-01", client.getPartition(databaseName, tableName,
util.Collections.singletonList("2024-01-01")).getValues.get(0))
+ assertThrows[NoSuchObjectException](client.getPartition(databaseName,
tableName, util.Collections.singletonList("2099-01-01")))
+
val listedPartitions = client.listPartitions(databaseName, tableName,
(-1).toShort).asScala.toSeq
assertEquals(Set("2024-01-01", "2024-01-02"),
listedPartitions.map(_.getValues.get(0)).toSet)
assertNotNull(listedPartitions.find(_.getValues.get(0) ==
"2024-01-02").orNull)
@@ -203,6 +284,206 @@ class TestSparkCatalogMetaStoreClient extends FunSuite
with BeforeAndAfterAll {
}
}
+ test("supported client edge cases: empty partitions, no-op setMetaConf,
default database location") {
+ val client = newClient()
+
+ // add_partitions with an empty list is a no-op that returns an empty
result.
+ assertTrue(client.add_partitions(new util.ArrayList[Partition](), false,
true).isEmpty)
+
+ // setMetaConf is intentionally a silent no-op; HoodieHiveSyncClient
forwards caller-context
+ // values through it on every sync and there is no remote metastore to
receive them.
+ client.setMetaConf("hive.metastore.callerContext", "hudi")
+
+ // close must not throw either: HoodieHiveSyncClient.close() calls it on
every sync.
+ client.close()
+
+ // createDatabase without an explicit location falls back to the warehouse
path.
+ val databaseName = generateName("db")
+ client.createDatabase(new Database(databaseName, "no-location db", null,
new util.HashMap[String, String]()))
+ val locationUri = client.getDatabase(databaseName).getLocationUri
+ assertTrue(locationUri.contains(warehouseDir.getCanonicalPath),
locationUri)
+ assertFalse(client.tableExists(databaseName, "missing_table"))
+ }
+
+ test("unsupported IMetaStoreClient operations throw
UnsupportedOperationException") {
+ // SparkCatalogMetaStoreClient only implements the subset of
IMetaStoreClient exercised by
+ // HoodieHiveSyncClient/HMSDDLExecutor, plus close and setMetaConf which
those callers invoke
+ // unconditionally and which are deliberate no-ops. Every method outside
that subset must fail
+ // fast rather than return a misleading default. This locks in that
contract across the
+ // delegated surface.
+ val client = newClient()
+
+ // Connection / config lifecycle.
+ assertUnsupported(client.isCompatibleWith(null: HiveConf))
+ assertUnsupported(client.isSameConfObj(null: HiveConf))
+ assertUnsupported(client.setHiveAddedJars(null: String))
+ assertUnsupported(client.isLocalMetaStore())
+ assertUnsupported(client.reconnect())
+ assertUnsupported(client.getMetaConf(null: String))
+ assertUnsupported(client.flushCache())
+
+ // Databases.
+ assertUnsupported(client.getDatabases(null: String))
+ assertUnsupported(client.getAllDatabases())
+ assertUnsupported(client.dropDatabase(null: String))
+ assertUnsupported(client.dropDatabase(null: String, false, false))
+ assertUnsupported(client.dropDatabase(null: String, false, false, false))
+ assertUnsupported(client.alterDatabase(null: String, null: Database))
+
+ // Tables.
+ assertUnsupported(client.getTables(null: String, null: String))
+ assertUnsupported(client.getTables(null: String, null: String, null:
TableType))
+ assertUnsupported(client.getTableMeta(null: String, null: String, null:
util.List[String]))
+ assertUnsupported(client.getAllTables(null: String))
+ assertUnsupported(client.listTableNamesByFilter(null: String, null:
String, 0.toShort))
+ assertUnsupported(client.dropTable(null: String, null: String, false,
false))
+ assertUnsupported(client.dropTable(null: String, null: String, false,
false, false))
+ assertUnsupported(client.dropTable(null: String, false))
+ assertUnsupported(client.tableExists(null: String))
+ assertUnsupported(client.getTable(null: String))
+ assertUnsupported(client.getTableObjectsByName(null: String, null:
util.List[String]))
+ assertUnsupported(client.getFields(null: String, null: String))
+ assertUnsupported(client.insertTable(null: Table, false))
+
+ // Partitions.
+ assertUnsupported(client.appendPartition(null: String, null: String, null:
util.List[String]))
+ assertUnsupported(client.appendPartition(null: String, null: String, null:
String))
+ assertUnsupported(client.add_partition(null: Partition))
+ assertUnsupported(client.add_partitions(null: util.List[Partition]))
+ assertUnsupported(client.add_partitions_pspec(null: PartitionSpecProxy))
+ assertUnsupported(client.getPartition(null: String, null: String, null:
String))
+ assertUnsupported(client.getPartitionWithAuthInfo(null: String, null:
String, null: util.List[String], null: String, null: util.List[String]))
+ assertUnsupported(client.exchange_partition(null: util.Map[String,
String], null: String, null: String, null: String, null: String))
+ assertUnsupported(client.exchange_partitions(null: util.Map[String,
String], null: String, null: String, null: String, null: String))
+ assertUnsupported(client.listPartitionSpecs(null: String, null: String, 0))
+ assertUnsupported(client.listPartitions(null: String, null: String, null:
util.List[String], 0.toShort))
+ assertUnsupported(client.listPartitionNames(null: String, null: String,
0.toShort))
+ assertUnsupported(client.listPartitionNames(null: String, null: String,
null: util.List[String], 0.toShort))
+ assertUnsupported(client.listPartitionValues(null: PartitionValuesRequest))
+ assertUnsupported(client.getNumPartitionsByFilter(null: String, null:
String, null: String))
+ assertUnsupported(client.listPartitionSpecsByFilter(null: String, null:
String, null: String, 0))
+ assertUnsupported(client.listPartitionsByExpr(null: String, null: String,
null: Array[Byte], null: String, 0.toShort, null: util.List[Partition]))
+ assertUnsupported(client.listPartitionsWithAuthInfo(null: String, null:
String, 0.toShort, null: String, null: util.List[String]))
+ assertUnsupported(client.listPartitionsWithAuthInfo(null: String, null:
String, null: util.List[String], 0.toShort, null: String, null:
util.List[String]))
+ assertUnsupported(client.getPartitionsByNames(null: String, null: String,
null: util.List[String]))
+ assertUnsupported(client.markPartitionForEvent(null: String, null: String,
null: util.Map[String, String], null: PartitionEventType))
+ assertUnsupported(client.isPartitionMarkedForEvent(null: String, null:
String, null: util.Map[String, String], null: PartitionEventType))
+ assertUnsupported(client.validatePartitionNameCharacters(null:
util.List[String]))
+ assertUnsupported(client.alter_table(null: String, null: String, null:
Table, false))
+ assertUnsupported(client.dropPartition(null: String, null: String, null:
util.List[String], false))
+ assertUnsupported(client.dropPartition(null: String, null: String, null:
util.List[String], null: PartitionDropOptions))
+ assertUnsupported(client.dropPartitions(null: String, null: String, null:
util.List[ObjectPair[java.lang.Integer, Array[Byte]]], false, false))
+ assertUnsupported(client.dropPartitions(null: String, null: String, null:
util.List[ObjectPair[java.lang.Integer, Array[Byte]]], false, false, false))
+ assertUnsupported(client.dropPartitions(null: String, null: String, null:
util.List[ObjectPair[java.lang.Integer, Array[Byte]]], null:
PartitionDropOptions))
+ assertUnsupported(client.alter_partition(null: String, null: String, null:
Partition))
+ assertUnsupported(client.alter_partition(null: String, null: String, null:
Partition, null: EnvironmentContext))
+ assertUnsupported(client.alter_partitions(null: String, null: String,
null: util.List[Partition]))
+ assertUnsupported(client.renamePartition(null: String, null: String, null:
util.List[String], null: Partition))
+ assertUnsupported(client.partitionNameToVals(null: String))
+ assertUnsupported(client.partitionNameToSpec(null: String))
+ assertUnsupported(client.getConfigValue(null: String, null: String))
+
+ // Indexes.
+ assertUnsupported(client.createIndex(null: Index, null: Table))
+ assertUnsupported(client.alter_index(null: String, null: String, null:
String, null: Index))
+ assertUnsupported(client.getIndex(null: String, null: String, null:
String))
+ assertUnsupported(client.listIndexes(null: String, null: String,
0.toShort))
+ assertUnsupported(client.listIndexNames(null: String, null: String,
0.toShort))
+ assertUnsupported(client.dropIndex(null: String, null: String, null:
String, false))
+
+ // Column statistics.
+ assertUnsupported(client.updateTableColumnStatistics(null:
ColumnStatistics))
+ assertUnsupported(client.updatePartitionColumnStatistics(null:
ColumnStatistics))
+ assertUnsupported(client.getTableColumnStatistics(null: String, null:
String, null: util.List[String]))
+ assertUnsupported(client.getPartitionColumnStatistics(null: String, null:
String, null: util.List[String], null: util.List[String]))
+ assertUnsupported(client.deletePartitionColumnStatistics(null: String,
null: String, null: String, null: String))
+ assertUnsupported(client.deleteTableColumnStatistics(null: String, null:
String, null: String))
+ assertUnsupported(client.getAggrColStatsFor(null: String, null: String,
null: util.List[String], null: util.List[String]))
+ assertUnsupported(client.setPartitionColumnStatistics(null:
SetPartitionsStatsRequest))
+
+ // Roles and privileges.
+ assertUnsupported(client.create_role(null: Role))
+ assertUnsupported(client.drop_role(null: String))
+ assertUnsupported(client.listRoleNames())
+ assertUnsupported(client.grant_role(null: String, null: String, null:
PrincipalType, null: String, null: PrincipalType, false))
+ assertUnsupported(client.revoke_role(null: String, null: String, null:
PrincipalType, false))
+ assertUnsupported(client.list_roles(null: String, null: PrincipalType))
+ assertUnsupported(client.get_privilege_set(null: HiveObjectRef, null:
String, null: util.List[String]))
+ assertUnsupported(client.list_privileges(null: String, null:
PrincipalType, null: HiveObjectRef))
+ assertUnsupported(client.grant_privileges(null: PrivilegeBag))
+ assertUnsupported(client.revoke_privileges(null: PrivilegeBag, false))
+ assertUnsupported(client.get_principals_in_role(null:
GetPrincipalsInRoleRequest))
+ assertUnsupported(client.get_role_grants_for_principal(null:
GetRoleGrantsForPrincipalRequest))
+
+ // Delegation tokens and master keys.
+ assertUnsupported(client.getDelegationToken(null: String, null: String))
+ assertUnsupported(client.renewDelegationToken(null: String))
+ assertUnsupported(client.cancelDelegationToken(null: String))
+ assertUnsupported(client.getTokenStrForm())
+ assertUnsupported(client.addToken(null: String, null: String))
+ assertUnsupported(client.removeToken(null: String))
+ assertUnsupported(client.getToken(null: String))
+ assertUnsupported(client.getAllTokenIdentifiers())
+ assertUnsupported(client.addMasterKey(null: String))
+ assertUnsupported(client.updateMasterKey(null: java.lang.Integer, null:
String))
+ assertUnsupported(client.removeMasterKey(null: java.lang.Integer))
+ assertUnsupported(client.getMasterKeys())
+
+ // Functions.
+ assertUnsupported(client.createFunction(null: Function))
+ assertUnsupported(client.alterFunction(null: String, null: String, null:
Function))
+ assertUnsupported(client.dropFunction(null: String, null: String))
+ assertUnsupported(client.getFunction(null: String, null: String))
+ assertUnsupported(client.getFunctions(null: String, null: String))
+ assertUnsupported(client.getAllFunctions())
+
+ // Transactions and locks.
+ assertUnsupported(client.getValidTxns())
+ assertUnsupported(client.getValidTxns(0L))
+ assertUnsupported(client.openTxn(null: String))
+ assertUnsupported(client.openTxns(null: String, 0))
+ assertUnsupported(client.rollbackTxn(0L))
+ assertUnsupported(client.commitTxn(0L))
+ assertUnsupported(client.abortTxns(null: util.List[java.lang.Long]))
+ assertUnsupported(client.showTxns())
+ assertUnsupported(client.lock(null: LockRequest))
+ assertUnsupported(client.checkLock(0L))
+ assertUnsupported(client.unlock(0L))
+ assertUnsupported(client.showLocks())
+ assertUnsupported(client.showLocks(null: ShowLocksRequest))
+ assertUnsupported(client.heartbeat(0L, 0L))
+ assertUnsupported(client.heartbeatTxnRange(0L, 0L))
+ assertUnsupported(client.compact(null: String, null: String, null: String,
null: CompactionType))
+ assertUnsupported(client.compact(null: String, null: String, null: String,
null: CompactionType, null: util.Map[String, String]))
+ assertUnsupported(client.compact2(null: String, null: String, null:
String, null: CompactionType, null: util.Map[String, String]))
+ assertUnsupported(client.showCompactions())
+ assertUnsupported(client.addDynamicPartitions(0L, null: String, null:
String, null: util.List[String]))
+ assertUnsupported(client.addDynamicPartitions(0L, null: String, null:
String, null: util.List[String], null: DataOperationType))
+
+ // Notifications and file metadata.
+ assertUnsupported(client.getNextNotification(0L, 0, null:
IMetaStoreClient.NotificationFilter))
+ assertUnsupported(client.getCurrentNotificationEventId())
+ assertUnsupported(client.fireListenerEvent(null: FireEventRequest))
+ assertUnsupported(client.getFileMetadata(null: util.List[java.lang.Long]))
+ assertUnsupported(client.getFileMetadataBySarg(null:
util.List[java.lang.Long], null: java.nio.ByteBuffer, false))
+ assertUnsupported(client.clearFileMetadata(null:
util.List[java.lang.Long]))
+ assertUnsupported(client.putFileMetadata(null: util.List[java.lang.Long],
null: util.List[java.nio.ByteBuffer]))
+ assertUnsupported(client.cacheFileMetadata(null: String, null: String,
null: String, false))
+
+ // Constraints.
+ assertUnsupported(client.getPrimaryKeys(null: PrimaryKeysRequest))
+ assertUnsupported(client.getForeignKeys(null: ForeignKeysRequest))
+ assertUnsupported(client.createTableWithConstraints(null: Table, null:
util.List[SQLPrimaryKey], null: util.List[SQLForeignKey]))
+ assertUnsupported(client.dropConstraint(null: String, null: String, null:
String))
+ assertUnsupported(client.addPrimaryKey(null: util.List[SQLPrimaryKey]))
+ assertUnsupported(client.addForeignKey(null: util.List[SQLForeignKey]))
+ }
+
+ private def assertUnsupported(fn: => Any): Unit = {
+ assertThrows[UnsupportedOperationException](fn)
+ ()
+ }
+
private def newClient(): SparkCatalogMetaStoreClient = {
SparkSession.setActiveSession(spark)
SparkSession.setDefaultSession(spark)
@@ -223,10 +504,15 @@ class TestSparkCatalogMetaStoreClient extends FunSuite
with BeforeAndAfterAll {
table.setPartitionKeys(partitionColumns.map { case (name, dataType) =>
fieldSchema(name, dataType) }.asJava)
val serdeInfo = new SerDeInfo()
+ serdeInfo.setSerializationLib(classOf[ParquetHiveSerDe].getName)
serdeInfo.setParameters(new util.HashMap[String, String]())
+ // Mirror the storage format HMSDDLExecutor registers; Spark's
HiveClientImpl loads the
+ // input and output format classes by name when it converts the table, so
they must be set.
val storageDescriptor = new StorageDescriptor()
storageDescriptor.setCols(columns.map { case (name, dataType) =>
fieldSchema(name, dataType) }.asJava)
+ storageDescriptor.setInputFormat(classOf[HoodieParquetInputFormat].getName)
+
storageDescriptor.setOutputFormat(classOf[MapredParquetOutputFormat].getName)
storageDescriptor.setLocation(location)
storageDescriptor.setSerdeInfo(serdeInfo)
table.setSd(storageDescriptor)
@@ -244,9 +530,12 @@ class TestSparkCatalogMetaStoreClient extends FunSuite
with BeforeAndAfterAll {
partition.setParameters(new util.HashMap[String, String]())
val serdeInfo = new SerDeInfo()
+ serdeInfo.setSerializationLib(classOf[ParquetHiveSerDe].getName)
serdeInfo.setParameters(new util.HashMap[String, String]())
val storageDescriptor = new StorageDescriptor()
+ storageDescriptor.setInputFormat(classOf[HoodieParquetInputFormat].getName)
+
storageDescriptor.setOutputFormat(classOf[MapredParquetOutputFormat].getName)
storageDescriptor.setLocation(location)
storageDescriptor.setSerdeInfo(serdeInfo)
partition.setSd(storageDescriptor)
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogDDL.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogDDL.scala
new file mode 100644
index 000000000000..f509a26078a9
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogDDL.scala
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.catalog
+
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException
+import org.apache.spark.sql.connector.catalog.{Identifier, TableChange}
+import
org.apache.spark.sql.connector.catalog.TableCapability.{ACCEPT_ANY_SCHEMA,
BATCH_READ, OVERWRITE_BY_FILTER, TRUNCATE, V1_BATCH_WRITE}
+import org.apache.spark.sql.connector.expressions.Transform
+import org.apache.spark.sql.hudi.HoodieSqlCommonUtils
+import org.apache.spark.sql.hudi.command.ShowHoodieCreateTableCommand
+import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+import org.apache.spark.sql.types.{IntegerType, LongType, StringType,
StructField, StructType}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+
+import java.io.File
+
+import scala.collection.JavaConverters._
+
+/**
+ * DDL-level coverage for [[HoodieCatalog]], [[HoodieInternalV2Table]] and the
+ * create/show-create table commands, exercised end-to-end through the V2
session
+ * catalog wired up by [[HoodieSparkSqlTestBase]] (spark_catalog =
HoodieCatalog).
+ */
+class TestHoodieCatalogDDL extends HoodieSparkSqlTestBase {
+
+ private def hoodieCatalog: HoodieCatalog =
+
spark.sessionState.catalogManager.v2SessionCatalog.asInstanceOf[HoodieCatalog]
+
+ test("HoodieCatalog create, load, alter, rename and drop via the V2 catalog
API") {
+ withTempDir { tmp =>
+ val catalog = hoodieCatalog
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ val ident = Identifier.of(Array("default"), tableName)
+ val schema = StructType(Seq(
+ StructField("id", IntegerType),
+ StructField("name", StringType),
+ StructField("ts", LongType)))
+ val props = Map(
+ "provider" -> "hudi",
+ "primaryKey" -> "id",
+ "preCombineField" -> "ts",
+ "location" -> tablePath).asJava
+
+ // createTable routes to createHoodieTable(CREATE) and initializes the
table on disk.
+ catalog.createTable(ident, schema, Array.empty[Transform], props)
+ assertTrue(catalog.tableExists(ident))
+ assertTrue(new File(s"$tablePath/.hoodie/hoodie.properties").exists())
+
+ // loadTable returns a Hudi-backed table exposing the user schema.
+ val loaded = catalog.loadTable(ident)
+ assertEquals(Seq("id", "name", "ts"),
+
HoodieSqlCommonUtils.removeMetaFields(loaded.schema()).fieldNames.toSeq)
+
+ // alterTable: add a column.
+ catalog.alterTable(ident, TableChange.addColumn(Array("age"),
IntegerType, true))
+ assertTrue(catalog.loadTable(ident).schema().fieldNames.contains("age"))
+
+ // alterTable: update a column comment.
+ catalog.alterTable(ident, TableChange.updateColumnComment(Array("name"),
"the name column"))
+ val commented = catalog.loadTable(ident).schema().fields.find(_.name ==
"name").get
+ assertEquals("the name column", commented.getComment().getOrElse(""))
+
+ // alterTable: changing a column type is rejected by the V2 alter path,
which routes to
+ // AlterHoodieTableChangeColumnCommand and does not support column type
changes.
+ val typeChange = intercept[HoodieAnalysisException] {
+ catalog.alterTable(ident, TableChange.updateColumnType(Array("age"),
LongType))
+ }
+ assertTrue(typeChange.getMessage.contains(
+ "ALTER TABLE CHANGE COLUMN is not supported for changing column
'age'"),
+ typeChange.getMessage)
+ assertEquals(IntegerType,
+ catalog.loadTable(ident).schema().fields.find(_.name ==
"age").get.dataType)
+
+ // alterTable: any change that is neither AddColumn nor a ColumnChange
falls through to the
+ // default arm of HoodieCatalog.alterTable and is reported as
unsupported.
+ val unsupportedChange = intercept[UnsupportedOperationException] {
+ catalog.alterTable(ident, TableChange.setProperty("some.key", "v"))
+ }
+ assertTrue(unsupportedChange.getMessage.contains("SetProperty"),
unsupportedChange.getMessage)
+
+ // renameTable moves the catalog entry.
+ val renamed = Identifier.of(Array("default"), s"${tableName}_renamed")
+ catalog.renameTable(ident, renamed)
+ assertFalse(catalog.tableExists(ident))
+ assertTrue(catalog.tableExists(renamed))
+
+ // dropTable removes the Hudi table from the catalog.
+ assertTrue(catalog.dropTable(renamed))
+ assertFalse(catalog.tableExists(renamed))
+ // HoodieCatalog.dropTable passes purge = false, so the external
location survives the drop.
+ assertTrue(existsPath(tablePath))
+ }
+ }
+
+ test("SHOW CREATE TABLE regenerates Hudi DDL") {
+ val tableName = generateTableName
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | ts long,
+ | dt string
+ |) using hudi
+ |partitioned by (dt)
+ |comment 'a hudi table'
+ |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+ |""".stripMargin)
+
+ // Spark's ResolveSessionCatalog only emits the V1 ShowCreateTableCommand
under
+ // `spark.sql.legacy.useV1Command`; HoodieAnalysis then rewrites it to
ShowHoodieCreateTableCommand.
+ withSQLConf("spark.sql.legacy.useV1Command" -> "true") {
+ val ddl = spark.sql(s"show create table $tableName").head().getString(0)
+ assertTrue(ddl.contains("CREATE TABLE IF NOT EXISTS"), ddl)
+ assertTrue(ddl.contains(s"`default`.`$tableName`"), ddl)
+ assertTrue(ddl.contains("USING hudi"), ddl)
+ assertTrue(ddl.contains("PARTITIONED BY (dt)"), ddl)
+ assertTrue(ddl.contains("COMMENT 'a hudi table'"), ddl)
+ assertTrue(ddl.contains("TBLPROPERTIES"), ddl)
+ assertTrue(ddl.contains("primaryKey='id'"), ddl)
+ }
+
+ intercept[NoSuchTableException] {
+
ShowHoodieCreateTableCommand(TableIdentifier("does_not_exist_tbl")).run(spark)
+ }
+ }
+
+ test("CREATE over an existing location rejects conflicting table
properties") {
+ withTempDir { tmp =>
+ val basePath = s"${tmp.getCanonicalPath}/shared"
+ val first = generateTableName
+ spark.sql(
+ s"""
+ |create table $first (id int, name string, ts long) using hudi
+ |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+ |location '$basePath'
+ |""".stripMargin)
+
+ // A conflicting primaryKey against the on-disk table config is rejected
with a config-conflict
+ // error surfaced from the write-path validation (HoodieWriterUtils).
+ val conflicting = generateTableName
+ checkExceptionContain(
+ s"""
+ |create table $conflicting (id int, name string, ts long) using hudi
+ |tblproperties (primaryKey = 'name', preCombineField = 'ts')
+ |location '$basePath'
+ |""".stripMargin)("hoodie.table.recordkey.fields")
+ }
+ }
+
+ test("HoodieInternalV2Table exposes v2 capabilities and handles reads and
writes") {
+ withTempDir { tmp =>
+ withSQLConf("hoodie.schema.on.read.enable" -> "true") {
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (id int, name string, ts long) using hudi
+ |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+ |location '$tablePath'
+ |""".stripMargin)
+
+ // With schema evolution enabled, loadTable returns the V2 table
directly.
+ val ident = Identifier.of(Array("default"), tableName)
+ val loaded = hoodieCatalog.loadTable(ident)
+ assertTrue(loaded.isInstanceOf[HoodieInternalV2Table])
+ val v2 = loaded.asInstanceOf[HoodieInternalV2Table]
+ assertEquals(
+ Set(BATCH_READ, V1_BATCH_WRITE, OVERWRITE_BY_FILTER, TRUNCATE,
ACCEPT_ANY_SCHEMA).asJava,
+ v2.capabilities())
+ assertTrue(v2.schema().fieldNames.contains("id"))
+ assertTrue(v2.partitioning().isEmpty)
+ assertFalse(v2.properties().isEmpty)
+ // v2.name() is catalog-qualified on Spark 3.4+
(spark_catalog.default.<t>) but only
+ // db-qualified on Spark 3.3 (TableIdentifier has no catalog field
there), so match either.
+ assertTrue(
+ v2.name() == s"spark_catalog.default.$tableName" || v2.name() ==
s"default.$tableName",
+ v2.name())
+
+ // HoodieSpark35Analysis (and its per-version HoodieSpark3xAnalysis
siblings) rewrites the
+ // V2 relation behind an InsertIntoStatement into the V1
LogicalRelation before any write
+ // builder is created, so these inserts cover the V2-to-V1 relation
conversion rather than
+ // HoodieV1WriteBuilder.
+ spark.sql(s"insert into $tableName values (1, 'a1', 1000), (2, 'a2',
2000)")
+ checkAnswer(s"select id, name from $tableName")(Seq(1, "a1"), Seq(2,
"a2"))
+ spark.sql(s"insert overwrite table $tableName values (3, 'a3', 3000)")
+ checkAnswer(s"select id, name from $tableName")(Seq(3, "a3"))
+ }
+ }
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieSqlCommonUtils.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieSqlCommonUtils.scala
new file mode 100644
index 000000000000..6289cc78faaf
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieSqlCommonUtils.scala
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.common
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.catalyst.analysis.caseInsensitiveResolution
+import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat,
CatalogTable, CatalogTableType}
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.hudi.HoodieSqlCommonUtils
+import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
+import org.apache.spark.sql.types.{IntegerType, StringType, StructField,
StructType}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.net.URI
+
+/**
+ * Unit coverage for the pure helper methods in [[HoodieSqlCommonUtils]] that
are otherwise
+ * only reached through heavier read/write code paths.
+ */
+class TestHoodieSqlCommonUtils extends AnyFunSuite {
+
+ private def partitionedTable(partitionCols: Seq[String]): CatalogTable = {
+ val fields = StructField("id", IntegerType) +:
partitionCols.map(StructField(_, StringType))
+ CatalogTable(
+ identifier = TableIdentifier("t", Some("default")),
+ tableType = CatalogTableType.MANAGED,
+ storage = CatalogStorageFormat.empty,
+ schema = StructType(fields),
+ provider = Some("hudi"),
+ partitionColumnNames = partitionCols)
+ }
+
+ private val nonPartitionedTable: CatalogTable = CatalogTable(
+ identifier = TableIdentifier("t0", Some("default")),
+ tableType = CatalogTableType.MANAGED,
+ storage = CatalogStorageFormat.empty,
+ schema = StructType(Seq(StructField("id", IntegerType))),
+ provider = Some("hudi"))
+
+ test("partition style detectors classify partition paths") {
+ val t = partitionedTable(Seq("dt"))
+
+
assertTrue(HoodieSqlCommonUtils.isHiveStyledPartitioning(Seq("dt=2021-04-01"),
t))
+
assertFalse(HoodieSqlCommonUtils.isHiveStyledPartitioning(Seq("2021-04-01"), t))
+ assertTrue(HoodieSqlCommonUtils.isHiveStyledPartitioning(Seq("anything"),
nonPartitionedTable))
+
+ assertTrue(HoodieSqlCommonUtils.isUrlEncodeEnabled(Seq("dt=2021-04-01"),
t))
+ assertFalse(HoodieSqlCommonUtils.isUrlEncodeEnabled(Seq("a/b"), t))
+ assertFalse(HoodieSqlCommonUtils.isUrlEncodeEnabled(Seq("x"),
nonPartitionedTable))
+
+ // Both detectors compare the slash-separated fragment count against the
partition columns,
+ // so a multi-column table only matches when every fragment is present.
+ val t2 = partitionedTable(Seq("dt", "hh"))
+
assertTrue(HoodieSqlCommonUtils.isHiveStyledPartitioning(Seq("dt=2021-04-01/hh=12"),
t2))
+
assertFalse(HoodieSqlCommonUtils.isHiveStyledPartitioning(Seq("dt=2021-04-01/12"),
t2))
+
assertTrue(HoodieSqlCommonUtils.isUrlEncodeEnabled(Seq("2021%2F04%2F01/12"),
t2))
+ assertFalse(HoodieSqlCommonUtils.isUrlEncodeEnabled(Seq("2021/04/01/12"),
t2))
+ }
+
+ test("config helpers and meta field utilities") {
+ val opts = Map("hoodie.a" -> "1", "spark.hoodie.b" -> "2", "other" -> "3")
+ assertEquals(Map("hoodie.a" -> "1"),
HoodieSqlCommonUtils.filterHoodieConfigs(opts))
+ assertEquals(Map("hoodie.b" -> "2"),
HoodieSqlCommonUtils.extractSparkPrefixedHoodieConfigs(opts))
+
+ val base = StructType(Seq(StructField("id", IntegerType),
StructField("name", StringType)))
+ val withMeta = HoodieSqlCommonUtils.addMetaFields(base)
+ assertTrue(withMeta.fieldNames.contains("_hoodie_commit_time"))
+ assertEquals(base.fields.length + 5, withMeta.fields.length)
+ assertEquals(base, HoodieSqlCommonUtils.removeMetaFields(withMeta))
+ assertTrue(HoodieSqlCommonUtils.isMetaField("_hoodie_commit_time"))
+ assertFalse(HoodieSqlCommonUtils.isMetaField("id"))
+
+ val attrs = Seq(
+ AttributeReference("_hoodie_commit_time", StringType)(),
+ AttributeReference("id", IntegerType)())
+ assertEquals(Seq("id"),
HoodieSqlCommonUtils.removeMetaFields(attrs).map(_.name))
+ }
+
+ test("normalizePartitionSpec normalizes keys and rejects invalid specs") {
+ val resolver = caseInsensitiveResolution
+ assertEquals(Map("dt" -> "2021"),
+ HoodieSqlCommonUtils.normalizePartitionSpec(Map("DT" -> "2021"),
Seq("dt"), "t", resolver))
+
+ // Unknown partition column.
+ intercept[HoodieAnalysisException] {
+ HoodieSqlCommonUtils.normalizePartitionSpec(Map("bad" -> "x"),
Seq("dt"), "t", resolver)
+ }
+ // Not all partition columns specified.
+ intercept[HoodieAnalysisException] {
+ HoodieSqlCommonUtils.normalizePartitionSpec(Map.empty[String, String],
Seq("dt"), "t", resolver)
+ }
+ // Duplicate partition columns.
+ intercept[HoodieAnalysisException] {
+ HoodieSqlCommonUtils.normalizePartitionSpec(
+ Map("dt" -> "a", "DT" -> "b"), Seq("dt", "DT"), "t", resolver)
+ }
+ }
+
+ test("path qualification and column helpers") {
+ val qualified = HoodieSqlCommonUtils.makePathQualified(new
URI("/tmp/hudi_test_path"), new Configuration())
+ assertTrue(qualified.startsWith("file:"))
+
+ val resolver = caseInsensitiveResolution
+ val schema = StructType(Seq(StructField("id", IntegerType),
StructField("name", StringType)))
+ assertEquals("id", HoodieSqlCommonUtils.findColumnByName(schema, "ID",
resolver).get.name)
+ assertTrue(HoodieSqlCommonUtils.findColumnByName(schema, "missing",
resolver).isEmpty)
+ assertTrue(HoodieSqlCommonUtils.columnEqual(
+ StructField("a", IntegerType), StructField("A", IntegerType), resolver))
+ assertFalse(HoodieSqlCommonUtils.columnEqual(
+ StructField("a", IntegerType), StructField("a", StringType), resolver))
+ }
+}
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 7c87f5c034a6..240982465f35 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
@@ -1443,6 +1443,25 @@ class TestCreateTable extends HoodieSparkSqlTestBase
with ExtendedParserTestHelp
val dbPath =
spark.sessionState.catalog.getDatabaseMetadata("default").locationUri.getPath
val tablePath = s"${dbPath}/${tableName}"
assertResult(false)(existsPath(tablePath))
+
+ // The same illegal CTAS against an explicit location must clean the
external path up too.
+ withTempDir { tmp =>
+ val externalTableName = generateTableName
+ val externalTablePath = s"${tmp.getCanonicalPath}/$externalTableName"
+ checkExceptionContain(
+ s"""
+ | create table $externalTableName using hudi
+ | tblproperties(
+ | primaryKey = 'id',
+ | type = 'cow',
+ | hoodie.compact.inline='true'
+ | )
+ | location '$externalTablePath'
+ | AS
+ | select 1 as id, 'a1' as name, 10 as price, 1000 as ts
+ |""".stripMargin)("Compaction is not supported on a CopyOnWrite
table")
+ assertResult(false)(existsPath(externalTablePath))
+ }
}
test("Test Create Non-Hudi Table(Parquet Table)") {
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSparkCatalogSync.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSparkCatalogSync.scala
index 8f644d263a39..970adc4bde44 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSparkCatalogSync.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestSparkCatalogSync.scala
@@ -22,12 +22,13 @@ import org.apache.hudi.common.config.TypedProperties
import org.apache.hudi.config.HoodieWriteConfig
import org.apache.hudi.hive.{HiveStylePartitionValueExtractor,
HiveSyncConfigHolder, HiveSyncTool}
import org.apache.hudi.hive.ddl.HiveSyncMode
-import org.apache.hudi.sync.common.HoodieSyncConfig.{META_SYNC_BASE_PATH,
META_SYNC_DATABASE_NAME, META_SYNC_PARTITION_EXTRACTOR_CLASS,
META_SYNC_PARTITION_FIELDS, META_SYNC_TABLE_NAME}
+import org.apache.hudi.sync.common.HoodieSyncConfig.{META_SYNC_BASE_PATH,
META_SYNC_DATABASE_NAME, META_SYNC_FORCE_RECREATE_TABLE,
META_SYNC_PARTITION_EXTRACTOR_CLASS, META_SYNC_PARTITION_FIELDS,
META_SYNC_TABLE_NAME}
import org.apache.hadoop.hive.conf.HiveConf
import org.apache.spark.sql.{DataFrame, SaveMode}
+import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
-import org.junit.jupiter.api.Assertions.{assertFalse, assertTrue}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
class TestSparkCatalogSync extends HoodieSparkSqlTestBase {
@@ -113,6 +114,48 @@ class TestSparkCatalogSync extends HoodieSparkSqlTestBase {
}
}
+ test("Test Spark catalog sync with forced table recreation") {
+ withTempDir { tmp =>
+ import spark.implicits._
+
+ val tableName = generateTableName
+ val databaseName = "testdb"
+ val basePath = s"${tmp.getCanonicalPath}/$tableName"
+ val syncProps = buildSyncProps(databaseName, tableName, basePath)
+
+ try {
+ spark.sql(s"create database if not exists $databaseName")
+ writeToHudi(
+ Seq((1, "a1", 1000L, "2024-01-01")).toDF("id", "name", "ts", "dt"),
+ tableName,
+ basePath,
+ SaveMode.Overwrite)
+ syncOnce(syncProps)
+ assertTrue(spark.catalog.tableExists(databaseName, tableName), "Table
should exist after the first sync")
+
+ // A property set between the syncs must not survive, which proves the
table was really
+ // recreated rather than left as it was.
+ val identifier = TableIdentifier(tableName, Some(databaseName))
+ val before = spark.sessionState.catalog.getTableMetadata(identifier)
+ spark.sessionState.catalog.alterTable(before.copy(properties =
before.properties + ("drift_marker" -> "true")))
+
assertTrue(spark.sessionState.catalog.getTableMetadata(identifier).properties.contains("drift_marker"))
+
+ // Recreation syncs into a temp table, drops the real one and renames
the temp table over
+ // it through alter_table, which needs SparkCatalogMetaStoreClient to
honor the new name.
+ syncProps.setProperty(META_SYNC_FORCE_RECREATE_TABLE.key, "true")
+ syncOnce(syncProps)
+ val remaining = spark.catalog.listTables(databaseName).collect()
+
.map(_.name.toLowerCase).filter(_.startsWith(tableName.toLowerCase)).toSeq
+ assertEquals(Seq(tableName.toLowerCase), remaining, "Only the
recreated table should remain after a forced recreation")
+
assertFalse(spark.sessionState.catalog.getTableMetadata(identifier).properties.contains("drift_marker"),
+ "The recreated table must not carry the pre-recreation property")
+ assertEquals(1L, spark.table(s"$databaseName.$tableName").count())
+ } finally {
+ spark.sql(s"drop table if exists $databaseName.$tableName")
+ }
+ }
+ }
+
private def syncOnce(syncProps: TypedProperties): Unit = {
val syncTool = new HiveSyncTool(syncProps, new HiveConf())
try {