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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 45999fe254 [spark] Support LEGACY store assignment for format tables 
(#10013)
45999fe254 is described below

commit 45999fe254e5f5467f8598ec103e49c511951896
Author: Zouxxyy <[email protected]>
AuthorDate: Mon Sep 21 10:40:59 2026 +0800

    [spark] Support LEGACY store assignment for format tables (#10013)
---
 .../paimon/spark/catalyst/Compatibility.scala      |  23 +-
 .../paimon/spark/catalyst/Compatibility.scala      |  12 +-
 .../paimon/spark/catalyst/Compatibility.scala      |  12 +-
 .../paimon/spark/catalyst/Compatibility.scala      |  18 +-
 .../spark/catalyst/analysis/PaimonAnalysis.scala   |   3 +
 .../analysis/PaimonFormatOutputResolver.scala      |  59 +++++
 .../paimon/spark/format/PaimonFormatTable.scala    |  21 +-
 .../paimon/spark/sql/FormatTableTestBase.scala     | 272 ++++++++++++++++++++-
 8 files changed, 410 insertions(+), 10 deletions(-)

diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
index 619521c63c..b7c1e31f0f 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
@@ -19,7 +19,8 @@
 package org.apache.paimon.spark.catalyst
 
 import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.catalyst.expressions.{Cast, Expression}
+import org.apache.spark.sql.catalyst.analysis.TableOutputResolver
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression}
 import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
V2WriteCommand}
 import org.apache.spark.sql.catalyst.trees.TreeNodeTag
 import org.apache.spark.sql.execution.ui.SQLPlanMetric
@@ -28,6 +29,26 @@ import org.apache.spark.sql.types.DataType
 
 object Compatibility {
 
+  def resolveTableOutputColumns(
+      tableName: String,
+      expected: Seq[Attribute],
+      query: LogicalPlan,
+      byName: Boolean,
+      conf: SQLConf): LogicalPlan = {
+    // SPARK-38228 fixed this separation in 3.3: LEGACY assignment must use 
non-ANSI casts even
+    // when ANSI expression evaluation is enabled. Scope the override to this 
resolution only.
+    val assignmentConf = if (conf.storeAssignmentPolicy == 
SQLConf.StoreAssignmentPolicy.LEGACY) {
+      val legacyConf = conf.clone()
+      legacyConf.setConf(SQLConf.ANSI_ENABLED, false)
+      legacyConf
+    } else {
+      conf
+    }
+    SQLConf.withExistingConf(assignmentConf) {
+      TableOutputResolver.resolveOutputColumns(tableName, expected, query, 
byName, assignmentConf)
+    }
+  }
+
   def withNewQuery(o: V2WriteCommand, query: LogicalPlan): V2WriteCommand = {
     o.withNewQuery(query)
   }
diff --git 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
index 619521c63c..4703e43663 100644
--- 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
+++ 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
@@ -19,7 +19,8 @@
 package org.apache.paimon.spark.catalyst
 
 import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.catalyst.expressions.{Cast, Expression}
+import org.apache.spark.sql.catalyst.analysis.TableOutputResolver
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression}
 import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
V2WriteCommand}
 import org.apache.spark.sql.catalyst.trees.TreeNodeTag
 import org.apache.spark.sql.execution.ui.SQLPlanMetric
@@ -28,6 +29,15 @@ import org.apache.spark.sql.types.DataType
 
 object Compatibility {
 
+  def resolveTableOutputColumns(
+      tableName: String,
+      expected: Seq[Attribute],
+      query: LogicalPlan,
+      byName: Boolean,
+      conf: SQLConf): LogicalPlan = {
+    TableOutputResolver.resolveOutputColumns(tableName, expected, query, 
byName, conf)
+  }
+
   def withNewQuery(o: V2WriteCommand, query: LogicalPlan): V2WriteCommand = {
     o.withNewQuery(query)
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
similarity index 81%
copy from 
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
copy to 
paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
index 443024058e..2c3dbbf06f 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
+++ 
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
@@ -19,7 +19,8 @@
 package org.apache.paimon.spark.catalyst
 
 import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.catalyst.expressions.{Cast, Expression}
+import org.apache.spark.sql.catalyst.analysis.TableOutputResolver
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression}
 import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
V2WriteCommand}
 import org.apache.spark.sql.catalyst.trees.TreeNodeTag
 import org.apache.spark.sql.execution.ui.SQLPlanMetric
@@ -28,6 +29,15 @@ import org.apache.spark.sql.types.DataType
 
 object Compatibility {
 
+  def resolveTableOutputColumns(
+      tableName: String,
+      expected: Seq[Attribute],
+      query: LogicalPlan,
+      byName: Boolean,
+      conf: SQLConf): LogicalPlan = {
+    TableOutputResolver.resolveOutputColumns(tableName, expected, query, 
byName, conf)
+  }
+
   def withNewQuery(o: V2WriteCommand, query: LogicalPlan): V2WriteCommand = {
     o.withNewQuery(query)
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
index 443024058e..7222e0a27c 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/Compatibility.scala
@@ -19,7 +19,8 @@
 package org.apache.paimon.spark.catalyst
 
 import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.catalyst.expressions.{Cast, Expression}
+import org.apache.spark.sql.catalyst.analysis.TableOutputResolver
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression}
 import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
V2WriteCommand}
 import org.apache.spark.sql.catalyst.trees.TreeNodeTag
 import org.apache.spark.sql.execution.ui.SQLPlanMetric
@@ -28,6 +29,21 @@ import org.apache.spark.sql.types.DataType
 
 object Compatibility {
 
+  def resolveTableOutputColumns(
+      tableName: String,
+      expected: Seq[Attribute],
+      query: LogicalPlan,
+      byName: Boolean,
+      conf: SQLConf): LogicalPlan = {
+    TableOutputResolver.resolveOutputColumns(
+      tableName,
+      expected,
+      query,
+      byName,
+      conf,
+      supportColDefaultValue = false)
+  }
+
   def withNewQuery(o: V2WriteCommand, query: LogicalPlan): V2WriteCommand = {
     o.withNewQuery(query)
   }
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 6353422342..527ca1faad 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
@@ -52,6 +52,9 @@ class PaimonAnalysis(session: SparkSession) extends 
Rule[LogicalPlan] {
         case o @ PaimonDynamicPartitionOverwrite(r, d) if o.resolved =>
           PaimonDynamicPartitionOverwriteCommand(r, d, o.query, 
o.writeOptions, o.isByName)
 
+        case a: V2WriteCommand if 
PaimonFormatOutputResolver.isLegacyFormatWrite(a) =>
+          PaimonFormatOutputResolver.resolve(a)
+
         case a @ PaimonV2WriteCommand(table)
             if a.query.getTagValue(PAIMON_WRITE_RESOLVED).isEmpty =>
           val options = Options.fromMap(writeOptions(a).asJava)
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFormatOutputResolver.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFormatOutputResolver.scala
new file mode 100644
index 0000000000..b640e2917f
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFormatOutputResolver.scala
@@ -0,0 +1,59 @@
+/*
+ * 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.catalyst.analysis
+
+import org.apache.paimon.spark.catalyst.Compatibility
+import org.apache.paimon.spark.format.PaimonFormatTable
+
+import org.apache.spark.sql.catalyst.SQLConfHelper
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
V2WriteCommand}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
+
+/** Align LEGACY format writes without enabling missing-column filling or 
schema evolution. */
+object PaimonFormatOutputResolver extends SQLConfHelper {
+
+  private val resolvedQuery = 
TreeNodeTag[LogicalPlan]("paimon.format.write.resolved-query")
+
+  def isLegacyFormatWrite(write: V2WriteCommand): Boolean = write.table match {
+    case relation: DataSourceV2Relation if 
relation.table.isInstanceOf[PaimonFormatTable] =>
+      write.query.resolved && conf.storeAssignmentPolicy == 
StoreAssignmentPolicy.LEGACY
+    case _ => false
+  }
+
+  def resolve(write: V2WriteCommand): V2WriteCommand = {
+    if (write.getTagValue(resolvedQuery).contains(write.query)) {
+      write
+    } else {
+      val query = Compatibility.resolveTableOutputColumns(
+        write.table.name,
+        write.table.output,
+        write.query,
+        write.isByName,
+        conf)
+      val resolved = Compatibility.withNewQuery(write, query)
+      // LEGACY casts may remain nullable for NOT NULL targets. Keep the 
existing writer-side
+      // nullability checks; do not repeatedly align the query or falsify 
expression nullability.
+      // Tag the command rather than its source query, which can be reused for 
another table.
+      resolved.setTagValue(resolvedQuery, query)
+      resolved
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
index c610b9d031..90fdd7a988 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
@@ -34,14 +34,15 @@ import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.{NoSuchPartitionException, 
NoSuchPartitionsException}
 import org.apache.spark.sql.catalyst.util.CharVarcharUtils
 import org.apache.spark.sql.connector.catalog.{SupportsRead, SupportsWrite, 
TableCapability, TableCatalog, TruncatableTable}
-import org.apache.spark.sql.connector.catalog.TableCapability.{BATCH_READ, 
BATCH_WRITE, OVERWRITE_BY_FILTER, OVERWRITE_DYNAMIC}
+import 
org.apache.spark.sql.connector.catalog.TableCapability.{ACCEPT_ANY_SCHEMA, 
BATCH_READ, BATCH_WRITE, OVERWRITE_BY_FILTER, OVERWRITE_DYNAMIC}
 import org.apache.spark.sql.connector.distributions.Distribution
 import org.apache.spark.sql.connector.expressions.SortOrder
 import org.apache.spark.sql.connector.read.ScanBuilder
 import org.apache.spark.sql.connector.write._
 import org.apache.spark.sql.connector.write.streaming.StreamingWrite
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.paimon.shims.SparkShimLoader
-import org.apache.spark.sql.types.{StringType, StructType}
+import org.apache.spark.sql.types.{DataType, StringType, StructType}
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 
 import java.util
@@ -71,7 +72,14 @@ case class PaimonFormatTable(table: FormatTable)
   def hasCatalogManagedPartitions: Boolean = partitionManager != null
 
   override def capabilities(): util.Set[TableCapability] = {
-    util.EnumSet.of(BATCH_READ, BATCH_WRITE, OVERWRITE_DYNAMIC, 
OVERWRITE_BY_FILTER)
+    val capabilities =
+      util.EnumSet.of(BATCH_READ, BATCH_WRITE, OVERWRITE_DYNAMIC, 
OVERWRITE_BY_FILTER)
+    // Only LEGACY needs our output resolver: Spark's generic V2 analyzer 
rejects it before
+    // aligning columns. Leave ANSI/STRICT on Spark's path, also when our 
extension is not loaded.
+    if (SQLConf.get.storeAssignmentPolicy == 
SQLConf.StoreAssignmentPolicy.LEGACY) {
+      capabilities.add(ACCEPT_ANY_SCHEMA)
+    }
+    capabilities
   }
 
   override def properties: util.Map[String, String] = {
@@ -97,6 +105,13 @@ case class PaimonFormatTable(table: FormatTable)
   }
 
   override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
+    require(
+      DataType.equalsIgnoreNullability(schema, info.schema),
+      "Format table writes must match the table schema. " +
+        s"Expected ${schema.catalogString}, but found 
${info.schema.catalogString}. " +
+        "Configure 
org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions " +
+        "to resolve write columns and types."
+    )
     PaimonFormatTableWriterBuilder(table, info.schema)
   }
 
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
index a7e3bdce5e..bbcd1cc899 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
@@ -18,21 +18,32 @@
 
 package org.apache.paimon.spark.sql
 
-import org.apache.paimon.catalog.{DelegateCatalog, Identifier}
+import org.apache.paimon.catalog.{CatalogContext, DelegateCatalog, Identifier}
 import org.apache.paimon.fs.Path
 import org.apache.paimon.hive.HiveCatalog
+import org.apache.paimon.options.Options
 import org.apache.paimon.spark.{PaimonFormatTableScan, PaimonHiveTestBase, 
PaimonInputPartition}
 import org.apache.paimon.spark.PaimonHiveTestBase.hiveUri
+import org.apache.paimon.spark.format.PaimonFormatTable
 import org.apache.paimon.table.FormatTable
 import org.apache.paimon.table.source.Split
+import org.apache.paimon.types.DataTypes
 import org.apache.paimon.utils.{CompressUtils, PartitionPathUtils}
 
-import org.apache.spark.sql.Row
+import org.apache.spark.sql.{AnalysisException, PaimonUtils, Row}
 import org.apache.spark.sql.catalyst.analysis.NoSuchPartitionException
+import org.apache.spark.sql.catalyst.plans.logical.AppendData
+import org.apache.spark.sql.connector.catalog.{Identifier => SparkIdentifier, 
TableCatalog}
 import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.connector.write.LogicalWriteInfo
 import org.apache.spark.sql.execution.SparkPlan
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
-import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import 
org.apache.spark.sql.execution.datasources.InsertIntoHadoopFsRelationCommand
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2Relation, FileTable}
+import org.apache.spark.sql.types.{LongType, StringType, StructField, 
StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+import java.util.{Collections, Locale}
 
 abstract class FormatTableTestBase extends PaimonHiveTestBase with 
AdaptiveSparkPlanHelper {
 
@@ -43,6 +54,261 @@ abstract class FormatTableTestBase extends 
PaimonHiveTestBase with AdaptiveSpark
     sql(s"USE $hiveDbName")
   }
 
+  test("Format table: LEGACY casts strings without changing the SQL or 
schema") {
+    for (ansiEnabled <- Seq("true", "false")) {
+      withSparkSQLConf(
+        "spark.sql.storeAssignmentPolicy" -> "LEGACY",
+        "spark.sql.ansi.enabled" -> ansiEnabled,
+        "spark.paimon.format-table.implementation" -> "paimon") {
+        withTable("t") {
+          sql("CREATE TABLE t (group_id BIGINT, label STRING) USING parquet")
+          val schema = spark.table("t").schema
+          sql("INSERT INTO t SELECT '123', 'valid'")
+          sql(
+            "INSERT INTO t VALUES ('-456', 'negative'), ('bad', 'invalid'), " +
+              "('9223372036854775808', 'overflow'), (NULL, 'null')")
+          checkAnswer(
+            sql("SELECT * FROM t"),
+            Seq(
+              Row(123L, "valid"),
+              Row(-456L, "negative"),
+              Row(null, "invalid"),
+              Row(null, "overflow"),
+              Row(null, "null")))
+          assert(spark.table("t").schema == schema)
+          assert(spark.conf.get("spark.sql.ansi.enabled") == ansiEnabled)
+        }
+      }
+    }
+  }
+
+  test("Format table: LEGACY aligns by name without filling missing columns or 
evolving schema") {
+    withSparkSQLConf(
+      "spark.sql.storeAssignmentPolicy" -> "LEGACY",
+      "spark.paimon.format-table.implementation" -> "paimon",
+      "spark.paimon.write.merge-schema" -> "true") {
+      withTable("t") {
+        sql("CREATE TABLE t (group_id BIGINT, label STRING) USING parquet")
+        val schema = spark.table("t").schema
+        val source = Seq(("first", "123")).toDF("label", "group_id")
+        source.writeTo("t").append()
+        // Reusing the source for a different table must not reuse that 
table's alignment.
+        withTable("other") {
+          sql("CREATE TABLE other (group_id STRING, label STRING) USING 
parquet")
+          source.writeTo("other").append()
+          checkAnswer(sql("SELECT * FROM other"), Row("123", "first"))
+        }
+        intercept[AnalysisException] {
+          source.drop("label").writeTo("t").append()
+        }
+        intercept[AnalysisException] {
+          Seq(("123", "first", "extra"))
+            .toDF("group_id", "label", "extra")
+            .writeTo("t")
+            .append()
+        }
+        intercept[AnalysisException] {
+          Seq(("123", "first")).toDF("wrong_name", 
"label").writeTo("t").append()
+        }
+        intercept[AnalysisException] {
+          sql("INSERT INTO t SELECT '123'")
+        }
+        intercept[AnalysisException] {
+          sql("INSERT INTO t SELECT '123', 'first', 'extra'")
+        }
+        assert(spark.table("t").schema == schema)
+        checkAnswer(sql("SELECT * FROM t"), Row(123L, "first"))
+      }
+    }
+  }
+
+  test("Format table: LEGACY keeps fixed nested schemas and Spark's field 
matching rules") {
+    withSparkSQLConf(
+      "spark.sql.storeAssignmentPolicy" -> "LEGACY",
+      "spark.paimon.format-table.implementation" -> "paimon") {
+      withTable("t") {
+        sql(
+          "CREATE TABLE t (s STRUCT<id: BIGINT>, a ARRAY<BIGINT>, m 
MAP<STRING, BIGINT>) " +
+            "USING parquet")
+        sql("INSERT INTO t SELECT named_struct('id', '123'), array('4', '5'), 
map('key', '6')")
+        checkAnswer(sql("SELECT * FROM t"), Row(Row(123L), Seq(4L, 5L), 
Map("key" -> 6L)))
+        intercept[AnalysisException] {
+          sql(
+            "INSERT INTO t SELECT named_struct('id', '1', 'extra', '2'), " +
+              "array('4'), map('key', '6')")
+        }
+        val renamedField =
+          sql("SELECT named_struct('other', '1') AS s, array('4') AS a, 
map('key', '6') AS m")
+        if (gteqSpark3_3) {
+          intercept[AnalysisException] {
+            renamedField.writeTo("t").append()
+          }
+        } else {
+          // Spark 3.2's resolver aligns nested structs positionally, even for 
a by-name write.
+          // Preserve its matching semantics while retaining the target's 
field names and types.
+          renamedField.writeTo("t").append()
+          checkAnswer(
+            sql("SELECT * FROM t"),
+            Seq(
+              Row(Row(123L), Seq(4L, 5L), Map("key" -> 6L)),
+              Row(Row(1L), Seq(4L), Map("key" -> 6L))))
+        }
+      }
+    }
+  }
+
+  test("Format table: LEGACY casts append and static or dynamic partition 
overwrites") {
+    withSparkSQLConf(
+      "spark.sql.storeAssignmentPolicy" -> "LEGACY",
+      "spark.paimon.format-table.implementation" -> "paimon") {
+      withTable("t") {
+        sql("CREATE TABLE t (group_id BIGINT, pt INT) USING parquet 
PARTITIONED BY (pt)")
+        sql("INSERT INTO t VALUES ('1', '1'), ('2', '2')")
+        sql("INSERT OVERWRITE t PARTITION (pt=1) SELECT '3'")
+        checkAnswer(sql("SELECT * FROM t"), Seq(Row(3L, 1), Row(2L, 2)))
+        withSparkSQLConf("spark.sql.sources.partitionOverwriteMode" -> 
"dynamic") {
+          sql("INSERT OVERWRITE t PARTITION (pt) SELECT '4', '2'")
+        }
+        checkAnswer(sql("SELECT * FROM t"), Seq(Row(3L, 1), Row(4L, 2)))
+      }
+      withTable("t") {
+        sql("CREATE TABLE t (group_id BIGINT) USING parquet")
+        sql("INSERT INTO t SELECT '1'")
+        sql("INSERT OVERWRITE t SELECT '2'")
+        checkAnswer(sql("SELECT * FROM t"), Row(2L))
+      }
+    }
+  }
+
+  test("Format table: ANSI and STRICT retain their assignment rules for both 
implementations") {
+    for (implementation <- Seq("paimon", "engine"); policy <- Seq("ANSI", 
"STRICT")) {
+      withSparkSQLConf(
+        "spark.paimon.format-table.implementation" -> implementation,
+        "spark.sql.storeAssignmentPolicy" -> policy,
+        "spark.sql.ansi.enabled" -> "false") {
+        withTable("t") {
+          sql("CREATE TABLE t (group_id BIGINT) USING parquet")
+          intercept[AnalysisException] {
+            sql("INSERT INTO t SELECT '123'")
+          }
+          sql("INSERT INTO t SELECT 123")
+          checkAnswer(sql("SELECT * FROM t"), Row(123L))
+        }
+        if (policy == "STRICT") {
+          withTable("t") {
+            sql("CREATE TABLE t (group_id INT) USING parquet")
+            intercept[AnalysisException] {
+              sql("INSERT INTO t SELECT 123L")
+            }
+          }
+        } else {
+          withTable("t") {
+            sql("CREATE TABLE t (group_id INT) USING parquet")
+            val error = intercept[Exception] {
+              sql("INSERT INTO t SELECT 2147483648L")
+            }
+            assert(
+              Iterator
+                .iterate[Throwable](error)(_.getCause)
+                .takeWhile(_ != null)
+                .exists(
+                  e =>
+                    
Option(e.getMessage).exists(_.toLowerCase(Locale.ROOT).contains("overflow"))))
+          }
+        }
+      }
+    }
+  }
+
+  test("Format table: engine retains Spark's V1 write path and LEGACY casts") {
+    withSparkSQLConf(
+      "spark.paimon.format-table.implementation" -> "engine",
+      "spark.sql.storeAssignmentPolicy" -> "LEGACY") {
+      withTable("t") {
+        sql("CREATE TABLE t (group_id BIGINT) USING parquet")
+        val table = spark.sessionState.catalogManager.currentCatalog
+          .asInstanceOf[TableCatalog]
+          .loadTable(SparkIdentifier.of(Array(hiveDbName), "t"))
+        assert(table.isInstanceOf[FileTable], table.getClass.getName)
+        assert(table.schema().fields.head.dataType == LongType)
+        val result = sql("INSERT INTO t SELECT '123'")
+        assert(result.queryExecution.analyzed.collect {
+          case command: InsertIntoHadoopFsRelationCommand => command
+        }.nonEmpty)
+        sql("INSERT INTO t SELECT 'bad'")
+        checkAnswer(sql("SELECT * FROM t"), Seq(Row(123L), Row(null)))
+      }
+    }
+  }
+
+  test("Format table: LEGACY casts preserve NOT NULL enforcement and analyzer 
convergence") {
+    withSparkSQLConf(
+      "spark.sql.storeAssignmentPolicy" -> "LEGACY",
+      "spark.sql.ansi.enabled" -> "true") {
+      withTempDir {
+        dir =>
+          // Build the relation directly so the Hive metastore cannot erase 
field nullability.
+          val options = new Options
+          options.setString("path", dir.toURI.toString)
+          options.setString("file.format", "parquet")
+          val table = FormatTable
+            .builder()
+            .fileIO(fileIO)
+            .identifier(Identifier.create(hiveDbName, "not_null_format"))
+            .rowType(DataTypes.ROW(DataTypes.FIELD(0, "group_id", 
DataTypes.BIGINT().notNull())))
+            .partitionKeys(Collections.emptyList[String]())
+            .location(dir.toURI.toString)
+            .format(FormatTable.Format.PARQUET)
+            .options(options.toMap)
+            .catalogContext(CatalogContext.create(new Options))
+            .build()
+          val relation = DataSourceV2Relation.create(PaimonFormatTable(table), 
None, None)
+
+          def insert(value: String): Unit = {
+            val query = Seq(value).toDF("group_id").queryExecution.analyzed
+            PaimonUtils.createDataset(spark, AppendData.byPosition(relation, 
query)).collect()
+          }
+
+          insert("123")
+          checkAnswer(PaimonUtils.createDataset(spark, relation), Row(123L))
+          val error = intercept[Exception] {
+            insert("bad")
+          }
+          assert(
+            Iterator
+              .iterate[Throwable](error)(_.getCause)
+              .takeWhile(_ != null)
+              .exists(
+                e => Option(e.getMessage).exists(_.contains("Cannot write null 
to non-null"))))
+      }
+    }
+  }
+
+  test("Format table: write builder rejects a schema that bypassed output 
resolution") {
+    withTable("t") {
+      sql("CREATE TABLE t (group_id BIGINT) USING parquet")
+      val table =
+        paimonCatalog.getTable(Identifier.create(hiveDbName, 
"t")).asInstanceOf[FormatTable]
+      val sparkTable = PaimonFormatTable(table)
+      for (
+        inputSchema <- Seq(
+          StructType(Seq(StructField("group_id", StringType))),
+          StructType(Seq(StructField("wrong_name", LongType))),
+          StructType(Nil))
+      ) {
+        val error = intercept[IllegalArgumentException] {
+          sparkTable.newWriteBuilder(new LogicalWriteInfo {
+            override def queryId(): String = "unresolved-format-write"
+            override def schema(): StructType = inputSchema
+            override def options(): CaseInsensitiveStringMap =
+              new CaseInsensitiveStringMap(Collections.emptyMap[String, 
String]())
+          })
+        }
+        assert(error.getMessage.contains("Format table writes must match the 
table schema"))
+      }
+    }
+  }
+
   test("Format table: csv with field-delimiter") {
     withTable("t") {
       sql(s"CREATE TABLE t (f0 INT, f1 INT) USING CSV OPTIONS 
('csv.field-delimiter' ';')")

Reply via email to