sundapeng commented on code in PR #9119:
URL: https://github.com/apache/paimon/pull/9119#discussion_r3758291155


##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonPartitionManagement.scala:
##########
@@ -58,7 +58,13 @@ trait PaimonPartitionManagement extends 
SupportsAtomicPartitionManagement with L
             toPaimonPartition(r, partitionKeys.take(r.numFields))
         }
       case _ =>
-        throw new UnsupportedOperationException("Only FileStoreTable supports 
partitions.")
+        // Reached by the partition operations this trait still serves 
directly, such as TRUNCATE
+        // PARTITION. Saying that only a FileStoreTable has partitions would 
be wrong for a Format
+        // Table with catalog-managed partitions, which has them and lists 
them here.
+        throw new UnsupportedOperationException(
+          s"This partition operation is supported only for a Paimon table, and 
${table.name()} is " +

Review Comment:
   Good point, changed to the "native Paimon table" wording as you suggested. I 
also split the fallback into two cases: a FormatTable gets the message pointing 
to ADD PARTITION / DROP PARTITION / MSCK REPAIR TABLE, and any other 
non-FileStoreTable type (a system table can also reach this trait) gets a 
generic message, so the error never mislabels a table as a Format Table.



##########
paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionEdgeParityTest.scala:
##########
@@ -0,0 +1,292 @@
+/*
+ * 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.paimon.spark.sql
+
+import org.apache.paimon.catalog.Identifier
+import org.apache.paimon.fs.Path
+import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
+import org.apache.paimon.table.FormatTable
+
+import org.apache.spark.sql.Row
+
+import scala.collection.JavaConverters._
+
+/**
+ * The partition operations left over once ADD, DROP, SHOW and ANALYZE are 
covered: `TRUNCATE`,
+ * `RENAME PARTITION`, and a null or empty string as a partition value. Spark 
pins all three for a
+ * metastore table in `TruncateTableSuiteBase`, 
`AlterTableRenamePartitionSuiteBase` and the
+ * `SPARK-33591` / `SPARK-33904` cases of the add, drop and show partition 
suites.
+ *
+ * An operation this table cannot support has to be refused outright. The 
failure worth catching is
+ * a half-done one: the registration moved and the directory did not, or the 
data went away and the
+ * registration stayed behind claiming it is still there.
+ */
+class CatalogManagedPartitionEdgeParityTest extends 
PaimonSparkTestWithRestCatalogBase {
+
+  private val defaultPartitionName = "__DEFAULT_PARTITION__"
+
+  // ------------------------------------------------------------------ 
TRUNCATE
+
+  test("TRUNCATE TABLE either clears the data and keeps the partitions, or 
refuses outright") {
+    val tableName = "edge_truncate_table"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', '20260101', 
'00')")
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (2, 'b', '20260102', 
'00')")
+      val before = registered(tableName)
+
+      val outcome = attempt(s"TRUNCATE TABLE ${qualified(tableName)}")
+
+      outcome match {
+        case Refused(_) =>
+          // Refusing is a defensible answer; leaving the table half-truncated 
is not.
+          assert(registered(tableName) == before)
+          assert(rowIds(tableName) == Seq(1, 2))
+        case Accepted =>
+          // Spark keeps the partitions of a truncated table (SPARK-34418): 
truncation empties a
+          // table, it does not redefine which partitions it has.
+          assert(registered(tableName) == before, "TRUNCATE dropped partition 
registrations")
+          assert(rowIds(tableName).isEmpty, "TRUNCATE left rows behind")
+      }
+    }
+  }
+
+  test("TRUNCATE TABLE PARTITION stays inside the partition it names, or 
refuses outright") {
+    val tableName = "edge_truncate_partition"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', '20260101', 
'00')")
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (2, 'b', '20260102', 
'00')")
+      val before = registered(tableName)
+
+      val outcome =
+        attempt(s"TRUNCATE TABLE ${qualified(tableName)} PARTITION (dt = 
'20260101', hour = '00')")
+
+      outcome match {
+        case Refused(message) =>
+          assert(registered(tableName) == before)
+          assert(rowIds(tableName) == Seq(1, 2))
+          // The refusal has to name what is unsupported. This table has 
partitions and lists them,
+          // so a message claiming otherwise sends the reader looking for the 
wrong problem.
+          assert(
+            !message.contains("Only FileStoreTable supports partitions"),
+            s"refusal misdescribes the table: $message")
+          assert(message.contains("MSCK REPAIR TABLE"), message)
+        case Accepted =>
+          assert(registered(tableName) == before, "TRUNCATE PARTITION dropped 
registrations")
+          assert(rowIds(tableName) == Seq(2), "TRUNCATE PARTITION touched the 
wrong partitions")
+      }
+    }
+  }
+
+  // ------------------------------------------------------------------ RENAME 
PARTITION
+
+  test("RENAME PARTITION moves both the registration and the data, or refuses 
outright") {
+    val tableName = "edge_rename_partition"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', '20260101', 
'00')")
+      val before = registered(tableName)
+
+      val outcome = attempt(
+        s"ALTER TABLE ${qualified(tableName)} PARTITION (dt = '20260101', hour 
= '00') " +
+          s"RENAME TO PARTITION (dt = '20260201', hour = '00')")
+
+      outcome match {
+        case Refused(_) =>
+          // Nothing may have moved: a registration pointing at a directory 
that is no longer
+          // there, or data under a spec nobody registered, are both worse 
than a refusal.
+          assert(registered(tableName) == before)
+          assert(rowIds(tableName) == Seq(1))
+          assert(directoryExists(tableName, "dt=20260101/hour=00"))
+        case Accepted =>
+          assert(registered(tableName) == Set("20260201/00"))
+          assert(rowIds(tableName) == Seq(1), "renamed partition lost its 
rows")
+          assert(directoryExists(tableName, "dt=20260201/hour=00"))
+          assert(!directoryExists(tableName, "dt=20260101/hour=00"))
+      }
+    }
+  }
+
+  // ------------------------------------------------------------------ null / 
empty partition values
+
+  test("a null partition value registers under the default partition name and 
reads back as null") {
+    val tableName = "edge_null_partition"
+    withTable(tableName) {
+      createTable(tableName)
+
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', NULL, '00')")
+
+      assert(registered(tableName) == Set(s"$defaultPartitionName/00"))
+      assert(directoryExists(tableName, s"dt=$defaultPartitionName/hour=00"))
+      checkAnswer(
+        sql(s"SELECT id, payload, dt, hour FROM ${qualified(tableName)}"),
+        Seq(Row(1, "a", null, "00")))
+    }
+  }
+
+  test("SHOW PARTITIONS spells a null partition value the way Spark's v2 path 
spells it") {
+    val tableName = "edge_null_show"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', NULL, '00')")
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (2, 'b', '20260101', 
'00')")
+
+      // Not what Hive prints. Spark's own ShowPartitionsExec renders a null 
partition value as the
+      // literal "null" (`if (partValueUTF8String == null) "null"`), while its 
v1 path over a Hive
+      // table prints __HIVE_DEFAULT_PARTITION__ because the metastore spec 
already holds that
+      // string. Both the registration and the directory here use the default 
partition name, so
+      // what SHOW prints is not a spec that can be pasted back into DROP 
PARTITION — see the
+      // drop-by-name case below for the spelling that does work.
+      val shown =
+        sql(s"SHOW PARTITIONS 
${qualified(tableName)}").collect().map(_.getString(0)).toSet
+      assert(shown == Set("dt=null/hour=00", "dt=20260101/hour=00"), 
shown.mkString(", "))
+      assert(registered(tableName).contains(s"$defaultPartitionName/00"))
+    }
+  }
+
+  test("the null rendering is Spark's, not this table's: a native Paimon table 
prints it too") {
+    val tableName = "edge_null_show_paimon"
+    withTable(tableName) {
+      sql(s"""CREATE TABLE $tableName (id INT, dt STRING)
+             |PARTITIONED BY (dt)
+             |""".stripMargin)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, NULL)")
+
+      val shown =
+        sql(s"SHOW PARTITIONS 
${qualified(tableName)}").collect().map(_.getString(0)).toSet
+      // Same rendering on a table that has nothing to do with Format Tables, 
which puts the
+      // divergence in Spark's v2 command rather than in the catalog-managed 
partition work.
+      assert(shown == Set("dt=null"), shown.mkString(", "))
+    }
+  }
+
+  test("MSCK discovers a default partition directory an outside writer left") {
+    val tableName = "edge_null_msck"
+    withTable(tableName) {
+      createTable(tableName)
+      writeCsvPartition(tableName, defaultPartitionName, "00", 7)
+
+      sql(s"MSCK REPAIR TABLE ${qualified(tableName)}").collect()
+
+      assert(registered(tableName) == Set(s"$defaultPartitionName/00"))
+      // The registration is only worth something if the rows behind it are 
readable.
+      assert(rowIds(tableName) == Seq(7))
+    }
+  }
+
+  test("the default partition can be dropped by name and takes its data with 
it") {
+    val tableName = "edge_null_drop"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', NULL, '00')")
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (2, 'b', '20260101', 
'00')")
+
+      sql(
+        s"ALTER TABLE ${qualified(tableName)} DROP PARTITION " +
+          s"(dt = '$defaultPartitionName', hour = '00')")
+
+      assert(registered(tableName) == Set("20260101/00"))
+      assert(!directoryExists(tableName, s"dt=$defaultPartitionName/hour=00"))
+      assert(rowIds(tableName) == Seq(2))
+    }
+  }
+
+  test("an empty string partition value uses the default partition encoding") {
+    val tableName = "edge_empty_string"
+    withTable(tableName) {
+      createTable(tableName)
+
+      sql(s"INSERT INTO ${qualified(tableName)} VALUES (1, 'a', '', '00')")
+
+      // Paimon's partition encoding normalizes a null or empty dynamic 
partition value to the
+      // configured default partition name, and the reader decodes it as null.
+      assert(registered(tableName) == Set(s"$defaultPartitionName/00"))
+      assert(directoryExists(tableName, s"dt=$defaultPartitionName/hour=00"))
+      checkAnswer(
+        sql(s"SELECT id, payload, dt, hour FROM ${qualified(tableName)}"),
+        Seq(Row(1, "a", null, "00")))
+    }
+  }
+
+  // ------------------------------------------------------------------ helpers
+
+  sealed private trait Outcome
+  private case object Accepted extends Outcome
+  private case class Refused(message: String) extends Outcome
+
+  /** Runs a statement, reporting whether it was accepted rather than failing 
the test outright. */
+  private def attempt(statement: String): Outcome =
+    try {
+      sql(statement).collect()
+      Accepted
+    } catch {
+      case error: Throwable =>

Review Comment:
   Fixed. The catch is now `case NonFatal(error) if isExpectedRefusal(error)`, 
where the guard requires an `UnsupportedOperationException` in the cause chain 
or a Spark "not supported" / "does not support" message. Anything else, 
including a parse or resolution error, propagates and fails the test. Verified 
the three refusal paths still hit the intended errors: TRUNCATE TABLE gets 
Spark's "Table does not support truncates", TRUNCATE PARTITION gets the new 
message from PaimonPartitionManagement, and RENAME PARTITION gets "Partition 
renaming is not supported". Both suites pass, 16/16.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to