From 0e0c238079de2cdb19fb2c757d216050a9fdc245 Mon Sep 17 00:00:00 2001 From: sablejade <315442805+sablejade@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:59:19 +0000 Subject: [PATCH] [spark] Support configurable dynamic partition column order --- .../spark_connector_configuration.html | 6 + .../paimon/spark/SparkConnectorOptions.java | 16 + .../catalyst/analysis/PaimonAnalysis.scala | 74 ++++- .../paimon/spark/util/OptionUtils.scala | 16 +- .../sql/InsertOverwriteTableTestBase.scala | 32 ++ ...aimonDynamicPartitionColumnOrderTest.scala | 303 ++++++++++++++++++ 6 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonDynamicPartitionColumnOrderTest.scala diff --git a/docs/generated/spark_connector_configuration.html b/docs/generated/spark_connector_configuration.html index cd95fd5fd41a..7bb20d791b03 100644 --- a/docs/generated/spark_connector_configuration.html +++ b/docs/generated/spark_connector_configuration.html @@ -86,6 +86,12 @@ Boolean Whether to adjust the target split size based on pruned (projected) columns. If enabled, split size estimation uses only the columns actually being read. + +
sql.dynamic-partition-column-order
+ AUTO +

Enum

+ Controls how non-BY-NAME Spark SQL dynamic partition writes interpret partition columns. TABLE uses table schema order, HIVE expects dynamic partition columns at the end when they are declared in a PARTITION clause or when a dynamic overwrite query matches that Hive-style output, and AUTO preserves compatible table and Hive order detection.

Possible values: +
vector-search.lateral-join.parallelism
16 diff --git a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java index 2f315b8df0f5..d43a80da72cd 100644 --- a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java +++ b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java @@ -82,6 +82,15 @@ public class SparkConnectorOptions { .withDescription( "If true, v2 write will be used. Currently, only HASH_FIXED and BUCKET_UNAWARE bucket modes are supported. Will fall back to v1 write for other bucket modes. Currently, Spark V2 write does not support TableCapability.STREAMING_WRITE."); + public static final ConfigOption DYNAMIC_PARTITION_COLUMN_ORDER = + key("sql.dynamic-partition-column-order") + .enumType(DynamicPartitionColumnOrder.class) + .defaultValue(DynamicPartitionColumnOrder.AUTO) + .withDescription( + "Controls how non-BY-NAME Spark SQL dynamic partition writes interpret partition columns. " + + "TABLE uses table schema order, HIVE expects dynamic partition columns at the end when they are declared in a PARTITION clause or when a dynamic overwrite query matches that Hive-style output, " + + "and AUTO preserves compatible table and Hive order detection."); + public static final ConfigOption DATA_EVOLUTION_UPDATE_CONFLICT_RETRY_MAX_ATTEMPTS = key("write.data-evolution.update-conflict-retry.max-attempts") .intType() @@ -152,4 +161,11 @@ public class SparkConnectorOptions { .withDescription( "Whether to adjust the target split size based on pruned (projected) columns. " + "If enabled, split size estimation uses only the columns actually being read."); + + /** Column order policy for non-BY-NAME dynamic partition writes. */ + public enum DynamicPartitionColumnOrder { + AUTO, + TABLE, + HIVE + } } diff --git a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonAnalysis.scala b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonAnalysis.scala index d888401c25d0..97a21d0ef238 100644 --- a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonAnalysis.scala +++ b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonAnalysis.scala @@ -19,11 +19,13 @@ package org.apache.paimon.spark.catalyst.analysis import org.apache.paimon.options.Options +import org.apache.paimon.spark.SparkConnectorOptions.DynamicPartitionColumnOrder import org.apache.paimon.spark.SparkTable import org.apache.paimon.spark.catalyst.Compatibility import org.apache.paimon.spark.catalyst.analysis.PaimonRelation.isPaimonTable import org.apache.paimon.spark.catalyst.plans.logical.{PaimonDropPartitions, PaimonHiveDynamicPartitionQuery} import org.apache.paimon.spark.commands.{PaimonAnalyzeTableColumnCommand, PaimonDynamicPartitionOverwriteCommand, PaimonShowColumnsCommand, SchemaEvolutionHelper} +import org.apache.paimon.spark.util.OptionUtils import org.apache.paimon.table.FileStoreTable import org.apache.spark.sql.{PaimonUtils, SparkSession} @@ -111,19 +113,40 @@ class PaimonAnalysis(session: SparkSession) extends Rule[LogicalPlan] { val query = stripHiveDynamicPartitionMarker(v2WriteCommand.query) hiveDynamicPartitionColumns(v2WriteCommand.query) match { case Some(dynamicPartitionColumns) if !v2WriteCommand.isByName => + val configuredColumnOrder = OptionUtils.dynamicPartitionColumnOrder() + val columnOrder = configuredColumnOrder match { + case DynamicPartitionColumnOrder.AUTO + if dynamicPartitionColumnsUseTableOrder(query, table, dynamicPartitionColumns) => + DynamicPartitionColumnOrder.TABLE + case order => order + } resolveDynamicPartitionWrite( query, table, - hiveStyleDynamicPartitionOutput(table, dynamicPartitionColumns), + columnOrder, + hiveStyleDynamicPartitionOutput(query, table, dynamicPartitionColumns), options, mergeSchemaEnabled) case _ => v2WriteCommand match { case o: OverwritePartitionsDynamic if !o.isByName => + val hiveStyleCandidate = hiveStyleDynamicPartitionOutput(query, table) + val configuredColumnOrder = + if (hiveStyleCandidate.isDefined) { + OptionUtils.dynamicPartitionColumnOrder() + } else { + DynamicPartitionColumnOrder.AUTO + } + val hiveStyleOutput = configuredColumnOrder match { + case DynamicPartitionColumnOrder.TABLE => None + case DynamicPartitionColumnOrder.HIVE | DynamicPartitionColumnOrder.AUTO => + hiveStyleCandidate + } resolveDynamicPartitionWrite( query, table, - hiveStyleDynamicPartitionOutput(query, table), + configuredColumnOrder, + hiveStyleOutput, options, mergeSchemaEnabled) case _ => @@ -153,13 +176,17 @@ class PaimonAnalysis(session: SparkSession) extends Rule[LogicalPlan] { private def resolveDynamicPartitionWrite( query: LogicalPlan, table: DataSourceV2Relation, + columnOrder: DynamicPartitionColumnOrder, hiveStyleOutput: Option[Seq[Attribute]], options: Options, mergeSchemaEnabled: Boolean): LogicalPlan = { hiveStyleOutput match { case Some(hiveStyleOutput) - if !sameOutputNames(query.output, table.output) && - !sameOutputNames(hiveStyleOutput, table.output) => + if (columnOrder == DynamicPartitionColumnOrder.HIVE && + !sameOutputNames(query.output, table.output)) || + (columnOrder == DynamicPartitionColumnOrder.AUTO && + !sameOutputNames(query.output, table.output) && + !sameOutputNames(hiveStyleOutput, table.output)) => val hiveStyleQuery = resolveWriteOutput(query, table.name, hiveStyleOutput, byName = false, mergeSchemaEnabled) resolveWriteOutput( @@ -214,12 +241,31 @@ class PaimonAnalysis(session: SparkSession) extends Rule[LogicalPlan] { table: DataSourceV2Relation): Option[Seq[Attribute]] = { val dynamicPartitionColumns = table.table.asInstanceOf[SparkTable].getTable.partitionKeys().asScala.toSeq - hiveStyleDynamicPartitionOutput(table, dynamicPartitionColumns).filter { + hiveStyleDynamicPartitionOutput(query, table, dynamicPartitionColumns).filter { hiveStyleOutput => sameOutputNames(query.output, hiveStyleOutput) } } + private def dynamicPartitionColumnsUseTableOrder( + query: LogicalPlan, + table: DataSourceV2Relation, + dynamicPartitionColumns: Seq[String]): Boolean = { + if (query.output.size != table.output.size) { + false + } else { + val dynamicPartitionAttrs = table.output.zipWithIndex.filter { + case (attr, _) => + dynamicPartitionColumns.exists(partition => conf.resolver(attr.name, partition)) + } + dynamicPartitionAttrs.size == dynamicPartitionColumns.size && + dynamicPartitionAttrs.forall { + case (attr, index) => conf.resolver(query.output(index).name, attr.name) + } + } + } + private def hiveStyleDynamicPartitionOutput( + query: LogicalPlan, table: DataSourceV2Relation, dynamicPartitionColumns: Seq[String]): Option[Seq[Attribute]] = { val partitionKeys = table.table.asInstanceOf[SparkTable].getTable.partitionKeys().asScala.toSeq @@ -238,7 +284,23 @@ class PaimonAnalysis(session: SparkSession) extends Rule[LogicalPlan] { } val hiveStyleOutput = dataAttrs ++ dynamicPartitionAttrs if (dynamicPartitionAttrs.size == dynamicPartitionColumns.size) { - Some(hiveStyleOutput) + val staticPartitionAttrsByIndex = table.output.zipWithIndex.collect { + case (attr, index) + if partitionKeys.exists(partition => conf.resolver(attr.name, partition)) && + !dynamicPartitionColumns.exists(partition => conf.resolver(attr.name, partition)) && + query.output + .lift(index) + .exists(queryAttr => conf.resolver(queryAttr.name, attr.name)) => + index -> attr + }.toMap + val staticPartitionAttrs = staticPartitionAttrsByIndex.values.toSeq + val remainingAttrs = hiveStyleOutput.filterNot { + attr => + staticPartitionAttrs.exists(staticAttr => conf.resolver(attr.name, staticAttr.name)) + }.iterator + Some(table.output.indices.map { + index => staticPartitionAttrsByIndex.getOrElse(index, remainingAttrs.next()) + }) } else { None } diff --git a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala index 37521ceda653..a0657418eb31 100644 --- a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala +++ b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala @@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions import org.apache.paimon.catalog.Identifier import org.apache.paimon.options.ConfigOption import org.apache.paimon.spark.{SparkCatalogOptions, SparkConnectorOptions} +import org.apache.paimon.spark.SparkConnectorOptions.DynamicPartitionColumnOrder import org.apache.paimon.table.Table import org.apache.spark.internal.Logging @@ -29,7 +30,7 @@ import org.apache.spark.sql.SparkSession import org.apache.spark.sql.catalyst.SQLConfHelper import org.apache.spark.sql.internal.StaticSQLConf -import java.util.{HashMap => JHashMap, Map => JMap} +import java.util.{HashMap => JHashMap, Locale, Map => JMap} import java.util.regex.Pattern import scala.collection.JavaConverters._ @@ -106,6 +107,19 @@ object OptionUtils extends SQLConfHelper with Logging { configuredValue && isVersionSupported } + def dynamicPartitionColumnOrder(): DynamicPartitionColumnOrder = { + val configuredValue = getOptionString(SparkConnectorOptions.DYNAMIC_PARTITION_COLUMN_ORDER) + try { + DynamicPartitionColumnOrder.valueOf(configuredValue.trim.toUpperCase(Locale.ROOT)) + } catch { + case _: IllegalArgumentException => + throw new IllegalArgumentException( + s"Invalid value '$configuredValue' for " + + s"spark.paimon.${SparkConnectorOptions.DYNAMIC_PARTITION_COLUMN_ORDER.key()}. " + + "Supported values are AUTO, TABLE, and HIVE.") + } + } + def writeMergeSchemaEnabled(): Boolean = { getOptionString(SparkConnectorOptions.MERGE_SCHEMA).toBoolean } diff --git a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/InsertOverwriteTableTestBase.scala b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/InsertOverwriteTableTestBase.scala index ad6836011411..d8d034d8bfe5 100644 --- a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/InsertOverwriteTableTestBase.scala +++ b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/InsertOverwriteTableTestBase.scala @@ -811,6 +811,38 @@ abstract class InsertOverwriteTableTestBase extends PaimonSparkTestBase { } } + test("Paimon Insert: table dynamic partition order survives UNION output aliases") { + if (gteqSpark3_4) { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "table" + ) { + withTable("dynamic_union") { + sql(""" + |CREATE TABLE dynamic_union ( + | ds STRING, + | part STRING, + | uid STRING, + | value STRING + |) PARTITIONED BY (ds, part) + |""".stripMargin) + + sql(""" + |INSERT OVERWRITE dynamic_union PARTITION (ds, part) + |SELECT '2026-08-10' AS ds, 'p1' AS part, 'u1' AS uid, 'v1' AS detail_ratio + |UNION ALL + |SELECT '2026-08-10' AS ds, 'p2' AS part, 'u2' AS uid, 'v2' AS value + |""".stripMargin) + + checkAnswer( + sql("SELECT ds, part, uid, value FROM dynamic_union ORDER BY part"), + Seq(Row("2026-08-10", "p1", "u1", "v1"), Row("2026-08-10", "p2", "u2", "v2"))) + } + } + } + } + test("Paimon Insert: dynamic insert into table with partition columns contain primary key") { withSparkSQLConf("spark.sql.shuffle.partitions" -> "10") { withTable("pk_pt") { diff --git a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonDynamicPartitionColumnOrderTest.scala b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonDynamicPartitionColumnOrderTest.scala new file mode 100644 index 000000000000..4de0620d24de --- /dev/null +++ b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonDynamicPartitionColumnOrderTest.scala @@ -0,0 +1,303 @@ +/* + * 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.spark.PaimonSparkTestBase +import org.apache.paimon.spark.util.OptionUtils + +import org.apache.spark.sql.PaimonUtils.createDataset +import org.apache.spark.sql.Row +import org.apache.spark.sql.catalyst.QueryPlanningTracker +import org.apache.spark.sql.catalyst.plans.logical.V2WriteCommand + +class PaimonDynamicPartitionColumnOrderTest extends PaimonSparkTestBase { + + private val targetTableName = "dynamic_partition_order" + + private def withDynamicPartitionTable(f: => Unit): Unit = { + withTable(targetTableName) { + sql(s""" + |CREATE TABLE $targetTableName ( + | ds STRING, + | part STRING, + | uid STRING, + | value STRING + |) PARTITIONED BY (ds, part) + |""".stripMargin) + f + } + } + + private def analyzedWriteQuery(insert: String) = { + val parsed = spark.sessionState.sqlParser.parsePlan(insert) + spark.sessionState.analyzer + .executeAndCheck(parsed, new QueryPlanningTracker) + .asInstanceOf[V2WriteCommand] + .query + } + + private def tableOrderUnion: String = + s""" + |INSERT OVERWRITE $targetTableName PARTITION (ds, part) + |SELECT '2026-08-10' AS ds, 'p1' AS part, 'u1' AS uid, 'v1' AS detail_ratio + |UNION ALL + |SELECT '2026-08-10' AS ds, 'p2' AS part, 'u2' AS uid, 'v2' AS value + |""".stripMargin + + test("table order preserves UNION output aliases") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "table" + ) { + withDynamicPartitionTable { + assert( + createDataset(spark, analyzedWriteQuery(tableOrderUnion)).collect().toSeq == Seq( + Row("2026-08-10", "p1", "u1", "v1"), + Row("2026-08-10", "p2", "u2", "v2"))) + } + } + } + + test("community defaults dynamic partition writes to auto order") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true") { + withDynamicPartitionTable { + assert( + createDataset(spark, analyzedWriteQuery(tableOrderUnion)).collect().toSeq == Seq( + Row("2026-08-10", "p1", "u1", "v1"), + Row("2026-08-10", "p2", "u2", "v2"))) + } + } + } + + test("auto detects table order despite UNION output aliases") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "auto" + ) { + withDynamicPartitionTable { + assert( + createDataset(spark, analyzedWriteQuery(tableOrderUnion)).collect().toSeq == Seq( + Row("2026-08-10", "p1", "u1", "v1"), + Row("2026-08-10", "p2", "u2", "v2"))) + } + } + } + + test("auto maps Hive order dynamic partition columns to table order") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "auto" + ) { + withDynamicPartitionTable { + val hiveOrderInsert = + s""" + |INSERT OVERWRITE $targetTableName PARTITION (ds, part) + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds, 'p1' AS part + |""".stripMargin + + assert( + createDataset(spark, analyzedWriteQuery(hiveOrderInsert)).collect().toSeq == Seq( + Row("2026-08-10", "p1", "u1", "v1"))) + } + } + } + + test("hive order maps dynamic partition columns at the end to table order") { + Seq("true", "false").foreach { + useV2Write => + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> useV2Write, + "spark.paimon.sql.dynamic-partition-column-order" -> "hive" + ) { + withDynamicPartitionTable { + sql(s""" + |INSERT OVERWRITE $targetTableName PARTITION (ds, part) + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds, 'p1' AS part + |""".stripMargin) + + checkAnswer( + sql(s"SELECT ds, part, uid, value FROM $targetTableName"), + Row("2026-08-10", "p1", "u1", "v1")) + } + } + } + } + + test("hive order preserves input already matching table schema") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "hive" + ) { + withDynamicPartitionTable { + val tableOrderInsert = + s""" + |INSERT OVERWRITE $targetTableName PARTITION (ds, part) + |SELECT '2026-08-10' AS ds, 'p1' AS part, 'u1' AS uid, 'v1' AS value + |""".stripMargin + + assert( + createDataset(spark, analyzedWriteQuery(tableOrderInsert)).collect().toSeq == Seq( + Row("2026-08-10", "p1", "u1", "v1"))) + } + } + } + + test("hive order keeps VALUES positional without partition clause") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "hive" + ) { + withDynamicPartitionTable { + sql(s""" + |INSERT OVERWRITE $targetTableName VALUES + | ('2026-08-10', 'p1', 'u1', 'v1') + |""".stripMargin) + + checkAnswer( + sql(s"SELECT ds, part, uid, value FROM $targetTableName"), + Row("2026-08-10", "p1", "u1", "v1")) + } + } + } + + test("auto and hive orders detect named Hive-style output without partition clause") { + Seq("auto", "hive").foreach { + columnOrder => + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> columnOrder + ) { + withDynamicPartitionTable { + sql(s""" + |INSERT OVERWRITE $targetTableName + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds, 'p1' AS part + |""".stripMargin) + + checkAnswer( + sql(s"SELECT ds, part, uid, value FROM $targetTableName"), + Row("2026-08-10", "p1", "u1", "v1")) + } + } + } + } + + test("table order remains positional without partition clause") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "table" + ) { + withDynamicPartitionTable { + sql(s""" + |INSERT OVERWRITE $targetTableName + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds, 'p1' AS part + |""".stripMargin) + + checkAnswer( + sql(s"SELECT ds, part, uid, value FROM $targetTableName"), + Row("u1", "v1", "2026-08-10", "p1")) + } + } + } + + test("auto and hive orders preserve mixed static and dynamic partitions") { + Seq("auto", "hive").foreach { + columnOrder => + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> columnOrder + ) { + withTable("mixed_partition_order") { + sql(""" + |CREATE TABLE mixed_partition_order ( + | uid STRING, + | ds STRING, + | value STRING, + | region STRING + |) PARTITIONED BY (region, ds) + |""".stripMargin) + + sql(""" + |INSERT OVERWRITE mixed_partition_order PARTITION (region = 'cn', ds) + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds + |""".stripMargin) + + checkAnswer( + sql("SELECT uid, ds, value, region FROM mixed_partition_order"), + Row("u1", "2026-08-10", "v1", "cn")) + } + } + } + } + + test("table order remains positional for Hive-style input") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "table" + ) { + withDynamicPartitionTable { + val hiveOrderInsert = + s""" + |INSERT OVERWRITE $targetTableName PARTITION (ds, part) + |SELECT 'u1' AS uid, 'v1' AS value, '2026-08-10' AS ds, 'p1' AS part + |""".stripMargin + + assert( + createDataset(spark, analyzedWriteQuery(hiveOrderInsert)).collect().toSeq == Seq( + Row("u1", "v1", "2026-08-10", "p1"))) + } + } + } + + test("invalid dynamic partition column order fails clearly") { + withSparkSQLConf("spark.paimon.sql.dynamic-partition-column-order" -> "unknown") { + val error = intercept[IllegalArgumentException] { + OptionUtils.dynamicPartitionColumnOrder() + } + assert(error.getMessage.contains("Supported values are AUTO, TABLE, and HIVE")) + } + } + + test("invalid dynamic partition column order does not affect non-partitioned writes") { + withSparkSQLConf( + "spark.sql.sources.partitionOverwriteMode" -> "dynamic", + "spark.paimon.write.use-v2-write" -> "true", + "spark.paimon.sql.dynamic-partition-column-order" -> "unknown" + ) { + withTable("non_partitioned_order") { + sql("CREATE TABLE non_partitioned_order (id INT, value STRING)") + sql("INSERT INTO non_partitioned_order VALUES (1, 'v1')") + checkAnswer(sql("SELECT id, value FROM non_partitioned_order"), Row(1, "v1")) + sql("INSERT OVERWRITE non_partitioned_order VALUES (2, 'v2')") + checkAnswer(sql("SELECT id, value FROM non_partitioned_order"), Row(2, "v2")) + } + } + } +}