This is an automated email from the ASF dual-hosted git repository.

wombatu-kun 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 770e8aabf367 fix(spark): match the staged table, not LogicalWriteInfo, 
in BasicStagedTable.newWriteBuilder (#19251)
770e8aabf367 is described below

commit 770e8aabf367b51b56912e4f9120fe0500334198
Author: Vova Kolmakov <[email protected]>
AuthorDate: Mon Jul 13 21:18:35 2026 +0700

    fix(spark): match the staged table, not LogicalWriteInfo, in 
BasicStagedTable.newWriteBuilder (#19251)
---
 .../spark/sql/hudi/catalog/BasicStagedTable.scala  |   2 +-
 .../spark/sql/hudi/catalog/HoodieCatalog.scala     |  20 +++-
 .../sql/hudi/catalog/TestBasicStagedTable.scala    |  52 ++++++++++
 .../catalog/TestHoodieCatalogStagedTable.scala     | 112 +++++++++++++++++++++
 4 files changed, 182 insertions(+), 4 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala
index 2cc94c3e1479..ae2ac079187d 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala
@@ -38,7 +38,7 @@ case class BasicStagedTable(ident: Identifier,
                             table: Table,
                             catalog: TableCatalog) extends SupportsWrite with 
StagedTable {
   override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
-    info match {
+    table match {
       case supportsWrite: SupportsWrite => supportsWrite.newWriteBuilder(info)
       case _ => throw new HoodieException(s"Table `${ident.name}` does not 
support writes.")
     }
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 252760265302..f5ef66a04b92 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
@@ -74,7 +74,7 @@ class HoodieCatalog extends DelegatingCatalogExtension
     } else {
       BasicStagedTable(
         ident,
-        super.createTable(ident, schema, partitions, properties),
+        createOrLoadTable(ident, schema, partitions, properties),
         this)
     }
   }
@@ -92,7 +92,7 @@ class HoodieCatalog extends DelegatingCatalogExtension
       super.dropTable(ident)
       BasicStagedTable(
         ident,
-        super.createTable(ident, schema, partitions, properties),
+        createOrLoadTable(ident, schema, partitions, properties),
         this)
     }
   }
@@ -115,11 +115,25 @@ class HoodieCatalog extends DelegatingCatalogExtension
       }
       BasicStagedTable(
         ident,
-        super.createTable(ident, schema, partitions, properties),
+        createOrLoadTable(ident, schema, partitions, properties),
         this)
     }
   }
 
+  /**
+   * Creates the table in the delegate catalog and returns it, never null.
+   *
+   * A delegate is allowed to return null from `createTable`: 
`V2SessionCatalog` does so deliberately, to save the
+   * `loadTable` round-trip for a `CREATE TABLE` without `AS SELECT`. Load the 
table that was just created in that
+   * case, so that the staged table is never backed by a null table. Spark 
guards its own staging the same way.
+   */
+  private def createOrLoadTable(ident: Identifier,
+                                schema: StructType,
+                                partitions: Array[Transform],
+                                properties: util.Map[String, String]): Table = 
{
+    Option(super.createTable(ident, schema, partitions, 
properties)).getOrElse(loadTable(ident))
+  }
+
   override def loadTable(ident: Identifier): Table = {
     super.loadTable(ident) match {
       case V1Table(catalogTable0) if sparkAdapter.isHoodieTable(catalogTable0) 
=>
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.scala
new file mode 100644
index 000000000000..7efc70a82abf
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.scala
@@ -0,0 +1,52 @@
+/*
+ * 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.exception.HoodieException
+
+import org.apache.spark.sql.connector.catalog.{Identifier, SupportsWrite, 
Table, TableCatalog}
+import org.apache.spark.sql.connector.write.{LogicalWriteInfo, WriteBuilder}
+import org.junit.jupiter.api.Assertions.{assertSame, assertThrows, assertTrue}
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.{mock, when}
+
+class TestBasicStagedTable {
+
+  private val ident = Identifier.of(Array("db"), "tbl")
+
+  @Test
+  def testNewWriteBuilderDelegatesToWritableTable(): Unit = {
+    val table = mock(classOf[SupportsWrite])
+    val info = mock(classOf[LogicalWriteInfo])
+    val writeBuilder = mock(classOf[WriteBuilder])
+    when(table.newWriteBuilder(info)).thenReturn(writeBuilder)
+
+    val staged = BasicStagedTable(ident, table, mock(classOf[TableCatalog]))
+
+    assertSame(writeBuilder, staged.newWriteBuilder(info))
+  }
+
+  @Test
+  def testNewWriteBuilderThrowsWhenTableIsNotWritable(): Unit = {
+    val staged = BasicStagedTable(ident, mock(classOf[Table]), 
mock(classOf[TableCatalog]))
+
+    val ex = assertThrows(classOf[HoodieException],
+      () => staged.newWriteBuilder(mock(classOf[LogicalWriteInfo])))
+    assertTrue(ex.getMessage.contains("`tbl` does not support writes"))
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala
new file mode 100644
index 000000000000..c607b434580c
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala
@@ -0,0 +1,112 @@
+/*
+ * 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.exception.HoodieException
+import org.apache.hudi.testutils.HoodieClientTestUtils
+
+import org.apache.spark.api.java.JavaSparkContext
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.connector.catalog.{Identifier, StagedTable, 
SupportsWrite, Table, TableCatalog}
+import org.apache.spark.sql.connector.expressions.Transform
+import org.apache.spark.sql.connector.write.{LogicalWriteInfo, WriteBuilder}
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+import org.junit.jupiter.api.{AfterAll, BeforeAll, TestInstance}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertSame, 
assertThrows, assertTrue}
+import org.junit.jupiter.api.TestInstance.Lifecycle
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import org.mockito.Mockito.{mock, when}
+
+import java.util
+
+import scala.collection.JavaConverters._
+
+/**
+ * Tests the staging methods of {@link HoodieCatalog} for a non-Hudi table, 
which stage through
+ * {@link BasicStagedTable} and hand the write off to the delegate catalog's 
table.
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+class TestHoodieCatalogStagedTable {
+
+  private val ident = Identifier.of(Array("default"), "tbl")
+  private val schema = StructType(Seq(StructField("id", IntegerType)))
+  private val partitions = Array.empty[Transform]
+  // Not a Hudi provider, so the staging methods take the BasicStagedTable 
branch
+  private val properties: util.Map[String, String] = Map("provider" -> 
"parquet").asJava
+
+  private var sparkSession: SparkSession = _
+
+  @BeforeAll
+  def setUp(): Unit = {
+    val jsc = new JavaSparkContext(
+      
HoodieClientTestUtils.getSparkConfForTest(classOf[TestHoodieCatalogStagedTable].getName))
+    jsc.setLogLevel("ERROR")
+    // HoodieCatalog resolves SparkSession.active in its constructor
+    sparkSession = SparkSession.builder.config(jsc.getConf).getOrCreate
+  }
+
+  @AfterAll
+  def tearDown(): Unit = {
+    sparkSession.close()
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = Array("stageCreate", "stageReplace", 
"stageCreateOrReplace"))
+  def testStagedWriteIsDelegatedToWritableTable(stagingMethod: String): Unit = 
{
+    val delegateTable = mock(classOf[SupportsWrite])
+    val info = mock(classOf[LogicalWriteInfo])
+    val writeBuilder = mock(classOf[WriteBuilder])
+    when(delegateTable.newWriteBuilder(info)).thenReturn(writeBuilder)
+    val delegate = mock(classOf[TableCatalog])
+    when(delegate.createTable(ident, schema, partitions, 
properties)).thenReturn(delegateTable)
+
+    val staged = stage(stagingMethod, delegate)
+
+    assertSame(writeBuilder, 
staged.asInstanceOf[SupportsWrite].newWriteBuilder(info))
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = Array("stageCreate", "stageReplace", 
"stageCreateOrReplace"))
+  def testStagedTableIsLoadedWhenDelegateCreateTableReturnsNull(stagingMethod: 
String): Unit = {
+    // V2SessionCatalog, the default delegate, returns null from createTable 
by design, to save the loadTable call
+    val delegate = mock(classOf[TableCatalog])
+    val loadedTable = mock(classOf[Table])
+    when(loadedTable.schema()).thenReturn(schema)
+    when(delegate.loadTable(ident)).thenReturn(loadedTable)
+
+    val staged = stage(stagingMethod, delegate)
+
+    // The staged table is backed by the table that was just created, rather 
than by null
+    assertEquals(schema, staged.schema())
+    // It is not writable, so the write is rejected instead of being delegated
+    val ex = assertThrows(classOf[HoodieException],
+      () => 
staged.asInstanceOf[SupportsWrite].newWriteBuilder(mock(classOf[LogicalWriteInfo])))
+    assertTrue(ex.getMessage.contains("`tbl` does not support writes"))
+  }
+
+  private def stage(stagingMethod: String, delegate: TableCatalog): 
StagedTable = {
+    val catalog = new HoodieCatalog()
+    catalog.setDelegateCatalog(delegate)
+    stagingMethod match {
+      case "stageCreate" => catalog.stageCreate(ident, schema, partitions, 
properties)
+      case "stageReplace" => catalog.stageReplace(ident, schema, partitions, 
properties)
+      case "stageCreateOrReplace" => catalog.stageCreateOrReplace(ident, 
schema, partitions, properties)
+    }
+  }
+}

Reply via email to