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 f39f99713f9f fix(spark): create Hudi tables via the DSv2 Column API 
and accept unchanged properties in HoodieCatalog (#20426)
f39f99713f9f is described below

commit f39f99713f9f8327d403947f4e38aad4259eb55a
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Mon Oct 26 22:39:30 2026 -0700

    fix(spark): create Hudi tables via the DSv2 Column API and accept unchanged 
properties in HoodieCatalog (#20426)
    
    * fix(spark): create Hudi tables via the DSv2 Column API and accept 
unchanged properties in HoodieCatalog
    
    Spark 4.1's TableCatalog.createTable(Identifier, TableInfo), used by Spark
    Declarative Pipelines, defaults to the Column[]-based createTable overload,
    which DelegatingCatalogExtension forwards to the session catalog: the table 
is
    registered without hoodie.properties. Route that overload to HoodieCatalog's
    StructType-based createTable for Hudi tables. The Column API exists from 
Spark
    3.4 on, so the override lives in a trait with a Spark 3.4+ and an empty 
Spark
    3.3 version, added from a source directory chosen by the Spark profile.
    
    Declarative Pipelines also re-sends every table property as SetProperty on
    each run, which HoodieCatalog.alterTable rejected. Accept SetProperty and
    RemoveProperty: changes that keep the current value are no-ops, changes to
    properties persisted in hoodie.properties are rejected, and other properties
    are applied to the catalog table.
    
    * fix(spark): validate and preserve catalog property changes
    
    ---------
    
    Co-authored-by: danny0405 <[email protected]>
---
 hudi-spark-datasource/hudi-spark-common/pom.xml    |  20 ++
 .../hudi/catalog/HoodieCatalogColumnsSupport.scala |  27 +++
 .../hudi/catalog/HoodieCatalogColumnsSupport.scala |  49 ++++
 .../apache/spark/sql/hudi/HoodieOptionConfig.scala |  60 ++++-
 .../spark/sql/hudi/catalog/HoodieCatalog.scala     |  67 +++++-
 .../sql/hudi/catalog/TestHoodieCatalogDDL.scala    | 246 ++++++++++++++++++++-
 .../sql/hudi/common/TestHoodieOptionConfig.scala   |  42 ++++
 pom.xml                                            |   3 +
 8 files changed, 490 insertions(+), 24 deletions(-)

diff --git a/hudi-spark-datasource/hudi-spark-common/pom.xml 
b/hudi-spark-datasource/hudi-spark-common/pom.xml
index 1dca8ee367a8..20f98fe3dab3 100644
--- a/hudi-spark-datasource/hudi-spark-common/pom.xml
+++ b/hudi-spark-datasource/hudi-spark-common/pom.xml
@@ -39,6 +39,26 @@
     </resources>
 
     <plugins>
+      <plugin>
+        <groupId>org.codehaus.mojo</groupId>
+        <artifactId>build-helper-maven-plugin</artifactId>
+        <version>${build-helper-maven-plugin.version}</version>
+        <executions>
+          <execution>
+            <id>add-spark-version-source</id>
+            <phase>generate-sources</phase>
+            <goals>
+              <goal>add-source</goal>
+            </goals>
+            <configuration>
+              <!-- Code that only compiles against some Spark versions, e.g. 
the DSv2 Column API added in Spark 3.4 -->
+              <sources>
+                <source>${hudi.spark.common.version.source}</source>
+              </sources>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-dependency-plugin</artifactId>
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.3/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.3/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
new file mode 100644
index 000000000000..d0e43e111f8e
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.3/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
@@ -0,0 +1,27 @@
+/*
+ * 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.connector.catalog.DelegatingCatalogExtension
+
+/**
+ * Spark 3.3 has no `Column`-based `createTable` overload, so there is nothing 
to route; see the Spark 3.4+ version.
+ */
+trait HoodieCatalogColumnsSupport extends DelegatingCatalogExtension
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.4plus/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.4plus/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
new file mode 100644
index 000000000000..169637e40427
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala-spark3.4plus/org/apache/spark/sql/hudi/catalog/HoodieCatalogColumnsSupport.scala
@@ -0,0 +1,49 @@
+/*
+ * 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.hudi.SparkAdapterSupport
+
+import org.apache.spark.sql.connector.catalog.{CatalogV2Util, Column, 
DelegatingCatalogExtension, Identifier, Table}
+import org.apache.spark.sql.connector.expressions.Transform
+
+import java.util
+
+/**
+ * Routes the [[Column]]-based `createTable` overload (Spark 3.4+) to the 
`StructType`-based one that
+ * [[HoodieCatalog]] implements, for Hudi tables.
+ *
+ * [[DelegatingCatalogExtension]] forwards this overload straight to the 
session catalog, which registers the
+ * table without initializing it as a Hudi table (no `hoodie.properties`). 
Spark 4.1's
+ * `createTable(Identifier, TableInfo)`, used by Spark Declarative Pipelines, 
ends up in this overload as well.
+ */
+trait HoodieCatalogColumnsSupport extends DelegatingCatalogExtension with 
SparkAdapterSupport {
+
+  override def createTable(ident: Identifier,
+                           columns: Array[Column],
+                           partitions: Array[Transform],
+                           properties: util.Map[String, String]): Table = {
+    if (sparkAdapter.isHoodieTable(properties)) {
+      createTable(ident, CatalogV2Util.v2ColumnsToStructType(columns), 
partitions, properties)
+    } else {
+      super.createTable(ident, columns, partitions, properties)
+    }
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/HoodieOptionConfig.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/HoodieOptionConfig.scala
index ce95da64bb8e..d22bf261e507 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/HoodieOptionConfig.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/HoodieOptionConfig.scala
@@ -19,14 +19,18 @@ package org.apache.spark.sql.hudi
 
 import org.apache.hudi.DataSourceWriteOptions
 import org.apache.hudi.common.avro.HoodieAvroUtils.getRootLevelFieldName
+import org.apache.hudi.common.config.ConfigProperty
 import org.apache.hudi.common.model.HoodieTableType
 import org.apache.hudi.common.table.HoodieTableConfig
-import org.apache.hudi.common.util.ValidationUtils
+import org.apache.hudi.common.util.{ConfigUtils, ValidationUtils}
 import org.apache.hudi.config.{HoodieIndexConfig, HoodieWriteConfig}
 
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.types.StructType
 
+import java.util.Locale
+
+import scala.collection.JavaConverters._
 
 /**
  * The HoodieOptionConfig defines some short name for the hoodie
@@ -133,6 +137,25 @@ object HoodieOptionConfig {
   private lazy val tableConfigKeyToSqlOptionKey: Map[String, String] =
     (sqlOptionKeyToTableConfigKey -- legacySqlOptionKeys).map(f => f._2 -> 
f._1)
 
+  private lazy val tableConfigProperties: Map[String, ConfigProperty[_]] =
+    classOf[HoodieTableConfig].getFields
+      .filter(_.getType == classOf[ConfigProperty[_]])
+      .map(_.get(null).asInstanceOf[ConfigProperty[_]])
+      .map(config => config.key -> config).toMap
+
+  private lazy val tableConfigKeys: Map[String, String] = {
+    // Alternatives take precedence over deprecated declarations, e.g. 
PRECOMBINE_FIELD -> ORDERING_FIELDS.
+    tableConfigProperties.keys.map(key => key -> key).toMap ++ 
tableConfigProperties.values.flatMap { config =>
+      config.getAlternatives.asScala.map(_ -> config.key)
+    }.toMap
+  }
+
+  private lazy val caseInsensitiveSqlOptionKeys: Map[String, String] =
+    sqlOptionKeyToWriteConfigKey.keys.map(key => key.toLowerCase(Locale.ROOT) 
-> key).toMap
+
+  private[hudi] def canonicalizeSqlOptionKey(key: String): String =
+    caseInsensitiveSqlOptionKeys.getOrElse(key.toLowerCase(Locale.ROOT), key)
+
   /**
    * Mapping of the short sql value to the hoodie's config value
    */
@@ -182,6 +205,29 @@ object HoodieOptionConfig {
     }
   }
 
+  /**
+   * Map a SQL option, data source write config or table config, given by key 
and value, to the table config
+   * it is persisted as in hoodie.properties. Returns None if it is not 
persisted as a table config.
+   */
+  def mapToTableConfig(key: String, value: String): Option[(String, String)] = 
{
+    val sqlKey = writeConfigKeyToSqlOptionKey.getOrElse(key, 
canonicalizeSqlOptionKey(key))
+    
sqlOptionKeyToTableConfigKey.get(sqlKey).orElse(tableConfigKeys.get(key)).map { 
tableKey =>
+      val tableValue = if (tableKey == HoodieTableConfig.TYPE.key) {
+        
sqlOptionValueToHoodieConfigValue.getOrElse(value.toLowerCase(Locale.ROOT), 
value.toUpperCase(Locale.ROOT))
+      } else {
+        value
+      }
+      tableKey -> tableValue
+    }
+  }
+
+  /** Read a persisted value using its declared alternative keys and the same 
conversion as incoming options. */
+  private[hudi] def getTableConfigValue(tableConfig: HoodieTableConfig, key: 
String): Option[String] = {
+    tableConfigProperties.get(key)
+      .flatMap(config => 
Option(ConfigUtils.getStringWithAltKeys(tableConfig.getProps, config, null: 
String)))
+      .flatMap(value => mapToTableConfig(key, value).map(_._2))
+  }
+
   val defaultSqlOptions: Map[String, String] = {
     HoodieOptionConfig.getClass.getDeclaredFields
       .filter(f => f.getType == classOf[HoodieSQLOption[_]])
@@ -261,17 +307,7 @@ object HoodieOptionConfig {
   }
 
   def makeOptionsCaseInsensitive(sqlOptions: Map[String, String]): Map[String, 
String] = {
-    // Make Keys Case Insensitive
-    val standardOptions = Seq(SQL_KEY_TABLE_PRIMARY_KEY, 
SQL_KEY_ORDERING_FIELDS,
-      SQL_KEY_TABLE_TYPE, SQL_PAYLOAD_CLASS, SQL_RECORD_MERGE_STRATEGY_ID, 
SQL_RECORD_MERGE_MODE)
-      .flatMap(key => (List(key.sqlKeyName) ++ key.alternatives).toStream)
-
-    sqlOptions.map(option => {
-      standardOptions.find(_.equalsIgnoreCase(option._1)) match {
-        case Some(standardKey) => (standardKey, option._2)
-        case None => (option._1, option._2)
-      }
-    })
+    sqlOptions.map { case (key, value) => canonicalizeSqlOptionKey(key) -> 
value }
   }
 
   private def getSqlOptionWithAlternatives(sqlOption: HoodieSQLOption[String], 
options: Map[String, String]): Option[String] = {
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 2810fcdbcb14..70a5853c4e73 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
@@ -39,10 +39,10 @@ import org.apache.spark.sql.catalyst.catalog._
 import org.apache.spark.sql.catalyst.catalog.HoodieCatalogTable.needFilterProps
 import org.apache.spark.sql.connector.catalog._
 import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.IdentifierHelper
-import org.apache.spark.sql.connector.catalog.TableChange.{AddColumn, 
ColumnChange, UpdateColumnComment, UpdateColumnType}
+import org.apache.spark.sql.connector.catalog.TableChange.{AddColumn, 
ColumnChange, RemoveProperty, SetProperty, UpdateColumnComment, 
UpdateColumnType}
 import org.apache.spark.sql.connector.expressions.{FieldReference, 
IdentityTransform, Transform}
 import org.apache.spark.sql.execution.datasources.DataSource
-import org.apache.spark.sql.hudi.{HoodieSqlCommonUtils, ProvidesHoodieConfig}
+import org.apache.spark.sql.hudi.{HoodieOptionConfig, HoodieSqlCommonUtils, 
ProvidesHoodieConfig}
 import 
org.apache.spark.sql.hudi.analysis.HoodieSparkBaseAnalysis.HoodieV1OrV2Table
 import 
org.apache.spark.sql.hudi.catalog.HoodieCatalog.{buildPartitionTransforms, 
isTablePartitioned}
 import org.apache.spark.sql.hudi.command._
@@ -56,6 +56,7 @@ import scala.collection.JavaConverters._
 import scala.collection.mutable
 
 class HoodieCatalog extends DelegatingCatalogExtension
+  with HoodieCatalogColumnsSupport
   with StagingTableCatalog
   with SparkAdapterSupport
   with ProvidesHoodieConfig {
@@ -223,7 +224,12 @@ class HoodieCatalog extends DelegatingCatalogExtension
     loadTable(ident) match {
       case HoodieV1OrV2Table(table) => {
         val tableIdent = TableIdentifier(ident.name(), 
ident.namespace().lastOption)
-        changes.groupBy(c => c.getClass).foreach {
+        val (propertyChanges, otherChanges) = changes.partition {
+          case _: SetProperty | _: RemoveProperty => true
+          case _ => false
+        }
+        val catalogPropertyChanges = if (propertyChanges.isEmpty) Seq.empty 
else catalogPropertyChangesToApply(table, propertyChanges)
+        otherChanges.groupBy(c => c.getClass).foreach {
           case (t, newColumns) if t == classOf[AddColumn] =>
             AlterHoodieTableAddColumnsCommand(
               tableIdent,
@@ -255,6 +261,9 @@ class HoodieCatalog extends DelegatingCatalogExtension
           case (t, _) =>
             throw new UnsupportedOperationException(s"not supported table 
change: ${t.getName}")
         }
+        if (catalogPropertyChanges.nonEmpty) {
+          super.alterTable(ident, catalogPropertyChanges: _*)
+        }
 
         loadTable(ident)
       }
@@ -262,6 +271,58 @@ class HoodieCatalog extends DelegatingCatalogExtension
     }
   }
 
+  /**
+   * Returns the property changes that still have to be applied to the catalog 
table.
+   *
+   * Callers such as Spark Declarative Pipelines re-send every table property 
on each run, so changes that keep the
+   * current value are dropped. Properties persisted in hoodie.properties 
(record key, ordering fields, table type,
+   * ...) can't be changed through the catalog, since the catalog would no 
longer match the table.
+   */
+  private def catalogPropertyChangesToApply(table: CatalogTable, changes: 
Seq[TableChange]): Seq[TableChange] = {
+    lazy val tableConfig = HoodieCatalogTable(spark, table).tableConfig
+    val properties = table.properties
+    lazy val sqlProperties = 
HoodieOptionConfig.makeOptionsCaseInsensitive(properties)
+    def catalogValue(key: String): Option[String] =
+      sqlProperties.get(HoodieOptionConfig.canonicalizeSqlOptionKey(key))
+
+    // Only filter immutable properties here. Spark applies mutable changes in 
order, including repeated keys.
+    val catalogChanges = changes.filter {
+      case set: SetProperty =>
+        HoodieOptionConfig.mapToTableConfig(set.property, set.value) match {
+          case Some(_) if catalogValue(set.property).contains(set.value) => 
false
+          case Some((tableConfigKey, value)) =>
+            val persisted = 
HoodieOptionConfig.getTableConfigValue(tableConfig, tableConfigKey)
+            if (!persisted.contains(value)) {
+              throw new HoodieAnalysisException(s"Can't set ${set.property} to 
'${set.value}' on Hudi table " +
+                s"${table.identifier}: it is persisted in hoodie.properties as 
$tableConfigKey " +
+                s"(current value: ${persisted.getOrElse("unset")}) and can't 
be changed by altering the table.")
+            }
+            false
+          case None => true
+        }
+      case remove: RemoveProperty =>
+        val persisted = HoodieOptionConfig.mapToTableConfig(remove.property, 
"").isDefined
+        if (persisted && catalogValue(remove.property).isDefined) {
+          throw new HoodieAnalysisException(s"Can't remove ${remove.property} 
from Hudi table ${table.identifier}: " +
+            "it is persisted in hoodie.properties and can't be changed by 
altering the table.")
+        }
+        !persisted
+    }
+
+    val updated = CatalogV2Util.applyPropertiesChanges(properties, 
catalogChanges)
+    if (updated == properties) {
+      Seq.empty
+    } else {
+      val removed = (properties.keySet -- 
updated.keySet).toSeq.map(TableChange.removeProperty)
+      val set = updated.toSeq.collect {
+        // The session catalog reconstructs the comment from properties, even 
though Hive stores it separately.
+        case (key, value) if key == TableCatalog.PROP_COMMENT || 
!properties.get(key).contains(value) =>
+          TableChange.setProperty(key, value)
+      }
+      set ++ removed
+    }
+  }
+
   private def deduceTableLocationURIAndTableType(
       ident: Identifier, properties: util.Map[String, String]): (URI, 
CatalogTableType) = {
     val locOpt = if (isPathIdentifier(ident)) {
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
index f509a26078a9..68adad112b72 100644
--- 
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
@@ -17,19 +17,24 @@
 
 package org.apache.spark.sql.hudi.catalog
 
+import org.apache.hudi.HoodieSparkUtils
+
 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.catalyst.catalog.HoodieCatalogTable
+import org.apache.spark.sql.connector.catalog.{Identifier, Table, 
TableCatalog, 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.execution.datasources.v2.V2SessionCatalog
 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.apache.spark.sql.types.{DataType, IntegerType, LongType, 
StringType, StructField, StructType}
 import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
 
 import java.io.File
+import java.util.Locale
 
 import scala.collection.JavaConverters._
 
@@ -89,13 +94,6 @@ class TestHoodieCatalogDDL extends HoodieSparkSqlTestBase {
       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)
@@ -110,6 +108,236 @@ class TestHoodieCatalogDDL extends HoodieSparkSqlTestBase 
{
     }
   }
 
+  test("alterTable ignores re-sent table properties and rejects changes to 
persisted table configs") {
+    withTempDir { tmp =>
+      val catalog = hoodieCatalog
+      val tableName = generateTableName
+      val ident = Identifier.of(Array("default"), tableName)
+      val tableIdent = TableIdentifier(tableName, Some("default"))
+      val schema = StructType(Seq(
+        StructField("id", IntegerType),
+        StructField("name", StringType),
+        StructField("ts", LongType)))
+      // The properties Spark Declarative Pipelines passes on create and 
re-sends as SetProperty on every run.
+      val props = Map(
+        "provider" -> "hudi",
+        "comment" -> "a hudi table",
+        "hoodie.datasource.write.recordkey.field" -> "id",
+        "hoodie.datasource.write.precombine.field" -> "ts",
+        "hoodie.table.type" -> "COPY_ON_WRITE")
+      catalog.createTable(ident, schema, Array.empty[Transform],
+        (props + ("location" -> s"${tmp.getCanonicalPath}/$tableName")).asJava)
+      val propertiesBefore = 
spark.sessionState.catalog.getTableMetadata(tableIdent).properties
+
+      // Re-sending unchanged values, including SQL aliases and differently 
cased values, is a no-op.
+      catalog.alterTable(ident, props.map { case (k, v) => 
TableChange.setProperty(k, v) }.toSeq: _*)
+      catalog.alterTable(ident,
+        TableChange.setProperty("PRIMARYKEY", "id"),
+        TableChange.setProperty("TYPE", "cow"),
+        TableChange.setProperty("hoodie.table.type", "copy_on_write"),
+        TableChange.setProperty("PRECOMBINEFIELD", "ts"),
+        TableChange.setProperty("hoodie.table.precombine.field", "ts"),
+        TableChange.setProperty("hoodie.table.ordering.fields", "ts"))
+      assertEquals(propertiesBefore, 
spark.sessionState.catalog.getTableMetadata(tableIdent).properties)
+
+      // Changing or removing a property persisted in hoodie.properties is 
rejected.
+      val recordKeyChange = intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, 
TableChange.setProperty("hoodie.datasource.write.recordkey.field", "name"))
+      }
+      
assertTrue(recordKeyChange.getMessage.contains("hoodie.table.recordkey.fields"),
 recordKeyChange.getMessage)
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, TableChange.setProperty("type", "mor"))
+      }
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, 
TableChange.setProperty("hoodie.datasource.write.precombine.field", "name"))
+      }
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, TableChange.removeProperty("primaryKey"))
+      }
+      Seq("PRIMARYKEY", "PrimaryKey").foreach { key =>
+        intercept[HoodieAnalysisException] {
+          catalog.alterTable(ident, TableChange.setProperty(key, "name"))
+        }
+        intercept[HoodieAnalysisException] {
+          catalog.alterTable(ident, TableChange.removeProperty(key))
+        }
+      }
+      // Field names and class names are case-sensitive, unlike table-type 
enum values.
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, TableChange.setProperty("primaryKey", "ID"))
+      }
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, TableChange.setProperty("primaryKey", " id 
"))
+      }
+      val tableConfig = HoodieCatalogTable(spark, 
spark.sessionState.catalog.getTableMetadata(tableIdent)).tableConfig
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident, 
TableChange.setProperty("hoodie.table.keygenerator.class",
+          tableConfig.getKeyGeneratorClassName.toUpperCase(Locale.ROOT)))
+      }
+      // A rejected change does not apply the other changes of the same call.
+      intercept[HoodieAnalysisException] {
+        catalog.alterTable(ident,
+          TableChange.setProperty("some.key", "v"), 
TableChange.setProperty("primaryKey", "name"))
+      }
+      assertEquals(propertiesBefore, 
spark.sessionState.catalog.getTableMetadata(tableIdent).properties)
+
+      // Other properties and the comment are applied to the catalog table.
+      catalog.alterTable(ident,
+        TableChange.setProperty("some.key", "v"), 
TableChange.setProperty("comment", "new comment"))
+      val altered = spark.sessionState.catalog.getTableMetadata(tableIdent)
+      assertEquals((propertiesBefore - "comment") + ("some.key" -> "v"), 
altered.properties - "comment")
+      assertEquals(Some("v"), altered.properties.get("some.key"))
+      assertEquals(Some("new comment"), altered.comment)
+      catalog.alterTable(ident, TableChange.removeProperty("some.key"))
+      
assertFalse(spark.sessionState.catalog.getTableMetadata(tableIdent).properties.contains("some.key"))
+
+      // The table is still writable with its original configuration.
+      spark.sql(s"insert into $tableName values (1, 'a1', 1000)")
+      checkAnswer(s"select id, name, ts from $tableName")(Seq(1, "a1", 1000))
+    }
+  }
+
+  test("alterTable applies mutable property changes in order, including 
table-service write configs") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val ident = Identifier.of(Array("default"), tableName)
+      val tableIdent = TableIdentifier(tableName, Some("default"))
+      val catalog = hoodieCatalog
+      catalog.createTable(ident, StructType(Seq(StructField("id", 
IntegerType))), Array.empty[Transform],
+        Map("provider" -> "hudi", "primaryKey" -> "id", "location" -> 
s"${tmp.getCanonicalPath}/$tableName").asJava)
+      def properties: Map[String, String] = 
spark.sessionState.catalog.getTableMetadata(tableIdent).properties
+      val before = properties
+
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "v"), 
TableChange.removeProperty("some.key"))
+      assertEquals(before, properties)
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "old"))
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "new"), 
TableChange.setProperty("some.key", "old"))
+      assertEquals(Some("old"), properties.get("some.key"))
+      catalog.alterTable(ident, TableChange.removeProperty("some.key"), 
TableChange.setProperty("some.key", "old"))
+      assertEquals(Some("old"), properties.get("some.key"))
+      // Net no-ops on one key must also remain no-ops when another key is 
changed in the same call.
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "new"), 
TableChange.setProperty("some.key", "old"),
+        TableChange.setProperty("another.key", "v"), 
TableChange.removeProperty("absent.key"))
+      assertEquals(Some("old"), properties.get("some.key"))
+      assertEquals(Some("v"), properties.get("another.key"))
+
+      val writeConfigs = Seq("hoodie.table.services.enabled", 
"hoodie.table.services.incremental.enabled",
+        "hoodie.table.service.manager.enabled")
+      catalog.alterTable(ident, writeConfigs.map(TableChange.setProperty(_, 
"false")): _*)
+      writeConfigs.foreach(key => assertEquals(Some("false"), 
properties.get(key)))
+      catalog.alterTable(ident, writeConfigs.map(TableChange.removeProperty): 
_*)
+      writeConfigs.foreach(key => assertFalse(properties.contains(key)))
+    }
+  }
+
+  test("alterTable preserves unrelated properties changed after the initial 
catalog read") {
+    withTempDir { tmp =>
+      val session = spark.sessionState.catalog
+      val tableName = generateTableName
+      val ident = Identifier.of(Array("default"), tableName)
+      val tableIdent = TableIdentifier(tableName, Some("default"))
+      hoodieCatalog.createTable(ident, StructType(Seq(StructField("id", 
IntegerType))), Array.empty[Transform],
+        Map("provider" -> "hudi", "primaryKey" -> "id", "a" -> "old", 
"location" -> s"${tmp.getCanonicalPath}/$tableName").asJava)
+      val delegate = new V2SessionCatalog(session)
+      def setA(value: String): Unit = {
+        val current = session.getTableMetadata(tableIdent)
+        session.alterTable(current.copy(properties = current.properties + ("a" 
-> value)))
+      }
+      // The delegate preserves an unrelated update committed before its own 
read.
+      setA("concurrent")
+      delegate.alterTable(ident, TableChange.setProperty("b", "control"))
+      assertEquals("concurrent", 
session.getTableMetadata(tableIdent).properties("a"))
+      setA("old")
+
+      val interleaved = new HoodieCatalog {
+        private var inject = true
+        override def loadTable(id: Identifier): Table = {
+          val snapshot = super.loadTable(id)
+          if (inject) {
+            inject = false
+            setA("concurrent")
+          }
+          snapshot
+        }
+      }
+      interleaved.setDelegateCatalog(delegate)
+      interleaved.alterTable(ident, TableChange.setProperty("b", "requested"))
+      val properties = session.getTableMetadata(tableIdent).properties
+      assertEquals("concurrent", properties("a"))
+      assertEquals("requested", properties("b"))
+    }
+  }
+
+  test("alterTable preserves separately stored comments unless explicitly 
removed") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val ident = Identifier.of(Array("default"), tableName)
+      val tableIdent = TableIdentifier(tableName, Some("default"))
+      val catalog = hoodieCatalog
+      catalog.createTable(ident, StructType(Seq(StructField("id", 
IntegerType))), Array.empty[Transform],
+        Map("provider" -> "hudi", "primaryKey" -> "id", "comment" -> "keep me",
+          "location" -> s"${tmp.getCanonicalPath}/$tableName").asJava)
+      def metadata = spark.sessionState.catalog.getTableMetadata(tableIdent)
+      def storeCommentSeparately(): Unit = {
+        // HiveClientImpl excludes "comment" from properties and returns it 
only in CatalogTable.comment.
+        spark.sessionState.catalog.alterTable(metadata.copy(properties = 
metadata.properties - "comment"))
+      }
+
+      storeCommentSeparately()
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "v"))
+      assertEquals(Some("keep me"), metadata.comment)
+      storeCommentSeparately()
+      catalog.alterTable(ident, TableChange.setProperty("comment", "keep me"), 
TableChange.removeProperty("some.key"))
+      assertEquals(Some("keep me"), metadata.comment)
+      storeCommentSeparately()
+      catalog.alterTable(ident, TableChange.removeProperty("comment"), 
TableChange.setProperty("comment", "keep me"),
+        TableChange.setProperty("some.key", "v2"))
+      assertEquals(Some("keep me"), metadata.comment)
+      storeCommentSeparately()
+      catalog.alterTable(ident, TableChange.removeProperty("comment"))
+      assertEquals(None, metadata.comment)
+      catalog.alterTable(ident, TableChange.setProperty("some.key", "v3"))
+      assertEquals(None, metadata.comment)
+    }
+  }
+
+  test("createTable with the Column-based overload initializes Hudi tables") {
+    // The Column-based overload exists from Spark 3.4 on; it is invoked 
reflectively so that this suite also
+    // compiles against Spark 3.3. Spark 4.1's createTable(Identifier, 
TableInfo) delegates to it.
+    if (HoodieSparkUtils.gteqSpark3_4) {
+      withTempDir { tmp =>
+        val catalog = hoodieCatalog
+        val columnClass = 
Class.forName("org.apache.spark.sql.connector.catalog.Column")
+        val createColumn = columnClass.getMethod("create", classOf[String], 
classOf[DataType])
+        def columns(fields: (String, DataType)*): AnyRef = {
+          val array = java.lang.reflect.Array.newInstance(columnClass, 
fields.size)
+          fields.zipWithIndex.foreach { case ((name, dataType), i) =>
+            java.lang.reflect.Array.set(array, i, createColumn.invoke(null, 
name, dataType))
+          }
+          array
+        }
+        val createTable = classOf[TableCatalog].getMethod("createTable", 
classOf[Identifier],
+          java.lang.reflect.Array.newInstance(columnClass, 0).getClass, 
classOf[Array[Transform]], classOf[java.util.Map[_, _]])
+        val cols = columns("id" -> IntegerType, "name" -> StringType, "ts" -> 
LongType)
+
+        val hudiTable = generateTableName
+        val tablePath = s"${tmp.getCanonicalPath}/$hudiTable"
+        createTable.invoke(catalog, Identifier.of(Array("default"), 
hudiTable), cols, Array.empty[Transform],
+          Map("provider" -> "hudi", "primaryKey" -> "id", "preCombineField" -> 
"ts", "location" -> tablePath).asJava)
+        assertTrue(new File(s"$tablePath/.hoodie/hoodie.properties").exists())
+        spark.sql(s"insert into $hudiTable values (1, 'a1', 1000)")
+        checkAnswer(s"select id, name, ts from $hudiTable")(Seq(1, "a1", 1000))
+
+        // Non-Hudi tables still go to the session catalog.
+        val parquetTable = generateTableName
+        createTable.invoke(catalog, Identifier.of(Array("default"), 
parquetTable), cols, Array.empty[Transform],
+          Map("provider" -> "parquet", "location" -> 
s"${tmp.getCanonicalPath}/$parquetTable").asJava)
+        assertEquals(Some("parquet"),
+          
spark.sessionState.catalog.getTableMetadata(TableIdentifier(parquetTable, 
Some("default"))).provider)
+      }
+    }
+  }
+
   test("SHOW CREATE TABLE regenerates Hudi DDL") {
     val tableName = generateTableName
     spark.sql(
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieOptionConfig.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieOptionConfig.scala
index 078ef5b35276..d9a2f7954925 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieOptionConfig.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestHoodieOptionConfig.scala
@@ -78,6 +78,48 @@ class TestHoodieOptionConfig extends 
SparkClientFunctionalTestHarness {
     assertEquals(expected, 
HoodieOptionConfig.makeOptionsCaseInsensitive(options))
   }
 
+  @Test
+  def testMapToTableConfigRecognizesAliasesAndDeclaredConfigs(): Unit = {
+    Seq("primaryKey", "PRIMARYKEY", "hoodie.datasource.write.recordkey.field", 
"hoodie.table.recordkey.fields").foreach { key =>
+      assertEquals(Some(HoodieTableConfig.RECORDKEY_FIELDS.key -> "Id"), 
HoodieOptionConfig.mapToTableConfig(key, "Id"))
+    }
+    Seq("orderingFields", "PRECOMBINEFIELD", 
"hoodie.datasource.write.precombine.field", 
"hoodie.table.precombine.field").foreach { key =>
+      assertEquals(Some(HoodieTableConfig.ORDERING_FIELDS.key -> "ts"), 
HoodieOptionConfig.mapToTableConfig(key, "ts"))
+    }
+    Seq("TYPE", "hoodie.datasource.write.table.type", 
"hoodie.table.type").foreach { key =>
+      assertEquals(Some(HoodieTableConfig.TYPE.key -> "MERGE_ON_READ"), 
HoodieOptionConfig.mapToTableConfig(key, "MoR"))
+      assertEquals(Some(HoodieTableConfig.TYPE.key -> "COPY_ON_WRITE"), 
HoodieOptionConfig.mapToTableConfig(key, "copy_on_write"))
+    }
+    assertEquals(Some(HoodieTableConfig.HIVE_STYLE_PARTITIONING_ENABLE.key -> 
"true"),
+      
HoodieOptionConfig.mapToTableConfig(HoodieTableConfig.HIVE_STYLE_PARTITIONING_ENABLE.key,
 "true"))
+    assertEquals(Some(HoodieTableConfig.BASE_FILE_FORMAT.key -> "PARQUET"),
+      HoodieOptionConfig.mapToTableConfig("hoodie.table.ro.file.format", 
"PARQUET"))
+    // Only table-type values are aliases: a field named "cow" must stay a 
field name.
+    assertEquals(Some(HoodieTableConfig.RECORDKEY_FIELDS.key -> "cow"), 
HoodieOptionConfig.mapToTableConfig("primaryKey", "cow"))
+  }
+
+  @Test
+  def testMapToTableConfigLeavesWriteAndCustomPropertiesMutable(): Unit = {
+    Seq("hoodie.table.services.enabled", 
"hoodie.table.services.incremental.enabled",
+      "hoodie.table.service.manager.enabled", "hoodie.table.custom.setting", 
"some.key").foreach { key =>
+      assertEquals(None, HoodieOptionConfig.mapToTableConfig(key, "false"), 
key)
+    }
+  }
+
+  @Test
+  def testGetTableConfigValueUsesDeclaredAlternativesWithoutDefaults(): Unit = 
{
+    val config = new HoodieTableConfig()
+    assertEquals(None, HoodieOptionConfig.getTableConfigValue(config, 
HoodieTableConfig.TYPE.key))
+    config.setValue("hoodie.table.precombine.field", "ts")
+    config.setValue("hoodie.table.ro.file.format", "PARQUET")
+    assertEquals(Some("ts"), HoodieOptionConfig.getTableConfigValue(config, 
HoodieTableConfig.ORDERING_FIELDS.key))
+    assertEquals(Some("PARQUET"), 
HoodieOptionConfig.getTableConfigValue(config, 
HoodieTableConfig.BASE_FILE_FORMAT.key))
+    config.setValue(HoodieTableConfig.ORDERING_FIELDS.key, "new_ts")
+    assertEquals(Some("new_ts"), 
HoodieOptionConfig.getTableConfigValue(config, 
HoodieTableConfig.ORDERING_FIELDS.key))
+    config.setValue(HoodieTableConfig.TYPE.key, "copy_on_write")
+    assertEquals(Some("COPY_ON_WRITE"), 
HoodieOptionConfig.getTableConfigValue(config, HoodieTableConfig.TYPE.key))
+  }
+
   @Test
   def testWithDefaultSqlOptions(): Unit = {
     val ops1 = Map("primaryKey" -> "id")
diff --git a/pom.xml b/pom.xml
index 91fba5ec295f..2080f34e42ec 100644
--- a/pom.xml
+++ b/pom.xml
@@ -273,6 +273,8 @@
     <vortex.version>0.76.0</vortex.version>
     
<vortex.spark.artifact>vortex-spark_${scala.binary.version}</vortex.spark.artifact>
     <vortex.skip.tests>false</vortex.skip.tests>
+    <!-- Spark-version specific sources of hudi-spark-common: Spark 3.4+ by 
default, overridden by the spark3.3 profile -->
+    
<hudi.spark.common.version.source>src/main/scala-spark3.4plus</hudi.spark.common.version.source>
     <!-- The following properties are only used for Jacoco coverage report 
aggregation -->
     <copy.files>false</copy.files>
     
<copy.files.target.dir>${maven.multiModuleProjectDirectory}</copy.files.target.dir>
@@ -2731,6 +2733,7 @@
         <lance.skip.tests>true</lance.skip.tests>
         <!-- Vortex: Skip tests for Spark 3.3 due to lack of support -->
         <vortex.skip.tests>true</vortex.skip.tests>
+        
<hudi.spark.common.version.source>src/main/scala-spark3.3</hudi.spark.common.version.source>
         <!-- NOTE: Some Hudi modules require standalone Parquet/Orc/etc 
file-format dependency (hudi-hive-sync,
                    hudi-hadoop-mr, for ex). Since these Hudi modules might be 
used from w/in the execution engine(s)
                    bringing these file-formats as dependencies as well, we 
need to make sure that versions are

Reply via email to