szehon-ho commented on code in PR #58632:
URL: https://github.com/apache/spark/pull/58632#discussion_r4169923440


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/DataTypeUtils.scala:
##########
@@ -213,13 +217,27 @@ object DataTypeUtils {
       // If write-side data type is a user-defined type, check with its 
underlying data type.
       case (w, r) if w.isInstanceOf[UserDefinedType[_]] && 
!r.isInstanceOf[UserDefinedType[_]] =>
         canWrite(tableName, w.asInstanceOf[UserDefinedType[_]].sqlType, r, 
byName, resolver,
-          context, storeAssignmentPolicy, addError)
+          context, storeAssignmentPolicy, addError, 
ansiStoreAssignmentCastCheck)
 
       // If read-side data type is a user-defined type, check with its 
underlying data type.
       case (w, r) if r.isInstanceOf[UserDefinedType[_]] && 
!w.isInstanceOf[UserDefinedType[_]] =>
         canWrite(tableName, w, r.asInstanceOf[UserDefinedType[_]].sqlType, 
byName, resolver,
-          context, storeAssignmentPolicy, addError)
-
+          context, storeAssignmentPolicy, addError, 
ansiStoreAssignmentCastCheck)
+
+      // RUNTIME defers to the inserted ANSI cast, which fails on malformed or 
overflowing
+      // values at runtime. Clearly invalid conversion are still rejected 
either in an earlier
+      // branch of this match clause, or in checkAnalysis.
+      case (w, r) if storeAssignmentPolicy == ANSI && 
ansiStoreAssignmentCastCheck == RUNTIME =>
+        (w, r) match {
+          // Long/decimal -> timestamp can silently overflow, and variant -> 
complex types doesn't
+          // enforce nested field non-nullability. Keep rejecting them for now.
+          case (LongType | _: DecimalType, TimestampType) |
+               (VariantType, _: StructType | _: ArrayType | _: MapType) |
+               (_: TimeType | _: AnyTimestampNanoType, VariantType) =>

Review Comment:
   **[P2] Apply the datetime-to-variant exclusion recursively**
   
   This guard only checks the top-level source type. With TIME support enabled, 
`ARRAY<TIME> -> VARIANT` still reaches `case _ => true`; arrays containing 
nanosecond timestamps have the same problem when that feature is enabled. 
`Cast.canAnsiCast` accepts these arrays through the recursive 
`VariantGet.checkDataType`, but `VariantExpressionEvalUtils.buildVariant` 
recurses into their elements and has no TIME or nanosecond timestamp cases. A 
non-null element therefore causes a `MatchError` instead of rejecting the 
unsupported assignment during analysis. Please make the exclusion inspect array 
element types recursively and add tests for arrays and nested arrays.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/SchemaAlignmentConfigSuite.scala:
##########
@@ -0,0 +1,294 @@
+/*
+ * 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.connector.catalog
+
+import scala.util.{Failure, Success, Try}
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{AnalysisException, DataFrame, QueryTest, Row}
+import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException
+import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{ArrayType, IntegerType, MapType, 
StringType, StructType}
+
+/**
+ * A catalog that creates [[InMemoryRowLevelOperationTable]]s carrying a fixed
+ * [[SchemaAlignmentConfig]] supplied by the concrete subclass. It returns the 
live table instance
+ * on load (rather than a copy) so the config is preserved for the analyzer.
+ */
+abstract class SchemaAlignmentTestCatalog extends 
InMemoryRowLevelOperationTableCatalog {
+
+  protected def tableConfig: SchemaAlignmentConfig
+
+  override def loadTable(ident: Identifier): Table = liveTable(ident)
+
+  override def createTable(ident: Identifier, tableInfo: TableInfo): Table = {
+    if (tables.containsKey(ident)) {
+      throw new TableAlreadyExistsException(ident.asMultipartIdentifier)
+    }
+    val name = s"${this.name}.${ident.quoted}"
+    val schema = CatalogV2Util.v2ColumnsToStructType(tableInfo.columns)
+    val table = new InMemoryRowLevelOperationTable(
+      name, schema, tableInfo.partitions, tableInfo.properties, 
tableInfo.constraints(),
+      schemaAlignmentConfig = tableConfig)
+    tables.put(ident, table)
+    namespaces.putIfAbsent(ident.namespace.toList, Map())
+    table
+  }
+}
+
+/** A catalog whose tables opt into every [[SchemaAlignmentConfig]] 
relaxation. */
+class RelaxedSchemaAlignmentCatalog extends SchemaAlignmentTestCatalog {
+  override protected def tableConfig: SchemaAlignmentConfig = new 
SchemaAlignmentConfig {
+    override def allowLegacyStoreAssignmentPolicy(): Boolean = true
+    override def deferCastValidationToRuntime(): Boolean = true
+  }
+}
+
+/** A catalog whose tables keep the strict data source v2 defaults. */
+class StrictSchemaAlignmentCatalog extends SchemaAlignmentTestCatalog {
+  override protected def tableConfig: SchemaAlignmentConfig = 
SchemaAlignmentConfig.DEFAULT
+}
+
+/**
+ * End-to-end coverage for [[SchemaAlignmentConfig]]: a table that opts into a 
relaxation gets the
+ * more permissive analyzer behavior, while an otherwise identical table using 
the default (strict)
+ * config keeps the data source v2 behavior. Exercised on both the INSERT path
+ * ([[org.apache.spark.sql.catalyst.analysis.Analyzer.ResolveOutputRelation]]) 
and the row-level
+ * path 
([[org.apache.spark.sql.catalyst.analysis.ResolveRowLevelCommandAssignments]]).
+ */
+class SchemaAlignmentConfigSuite extends QueryTest with SharedSparkSession {
+
+  private val relaxed = "relaxed"
+  private val strict = "strict"
+
+  override def sparkConf: SparkConf =
+    super.sparkConf
+      .set(s"spark.sql.catalog.$relaxed", 
classOf[RelaxedSchemaAlignmentCatalog].getName)
+      .set(s"spark.sql.catalog.$strict", 
classOf[StrictSchemaAlignmentCatalog].getName)
+
+  private def withLegacyPolicy(f: => Unit): Unit =
+    withSQLConf(
+      SQLConf.STORE_ASSIGNMENT_POLICY.key -> 
StoreAssignmentPolicy.LEGACY.toString)(f)
+
+  private def withAnsiPolicy(f: => Unit): Unit =
+    withSQLConf(
+      SQLConf.STORE_ASSIGNMENT_POLICY.key -> 
StoreAssignmentPolicy.ANSI.toString)(f)
+
+  private def legacyRejected(f: => Unit): Unit =
+    checkError(
+      exception = intercept[AnalysisException](f),
+      condition = "_LEGACY_ERROR_TEMP_1000",
+      parameters = Map("configKey" -> SQLConf.STORE_ASSIGNMENT_POLICY.key))
+
+  test("allowLegacyStoreAssignmentPolicy: INSERT under LEGACY policy") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT) USING foo")
+      withLegacyPolicy {
+        sql(s"INSERT INTO $relaxed.t VALUES (1)")
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1))
+        legacyRejected(sql(s"INSERT INTO $strict.t VALUES (1)"))
+      }
+    }
+  }
+
+  test("allowLegacyStoreAssignmentPolicy: UPDATE under LEGACY policy") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT, data STRING) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT, data STRING) USING foo")
+      sql(s"INSERT INTO $relaxed.t VALUES (1, 'a')")
+      sql(s"INSERT INTO $strict.t VALUES (1, 'a')")
+      withLegacyPolicy {
+        sql(s"UPDATE $relaxed.t SET data = 'b' WHERE id = 1")
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, "b"))
+        legacyRejected(sql(s"UPDATE $strict.t SET data = 'b' WHERE id = 1"))
+      }
+    }
+  }
+
+  test("deferCastValidationToRuntime: INSERT of an ANSI-incompatible cast") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT) USING foo")
+      withAnsiPolicy {
+        sql(s"INSERT INTO $relaxed.t VALUES ('1')")
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1))
+        checkError(
+          exception = intercept[AnalysisException] {
+            sql(s"INSERT INTO $strict.t VALUES ('1')")
+          },
+          condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+          parameters = Map(
+            "tableName" -> s"`$strict`.`t`",
+            "colName" -> "`id`",
+            "srcType" -> "\"STRING\"",
+            "targetType" -> "\"INT\""))
+      }
+    }
+  }
+
+  test("deferCastValidationToRuntime: UPDATE with an ANSI-incompatible cast") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT, data INT) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT, data INT) USING foo")
+      sql(s"INSERT INTO $relaxed.t VALUES (1, 0)")
+      sql(s"INSERT INTO $strict.t VALUES (1, 0)")
+      withAnsiPolicy {
+        sql(s"UPDATE $relaxed.t SET data = '5' WHERE id = 1")
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, 5))
+        checkError(
+          exception = intercept[AnalysisException] {
+            sql(s"UPDATE $strict.t SET data = '5' WHERE id = 1")
+          },
+          condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+          parameters = Map(
+            "tableName" -> "``",
+            "colName" -> "`data`",
+            "srcType" -> "\"STRING\"",
+            "targetType" -> "\"INT\""))
+      }
+    }
+  }
+
+  test("allowLegacyStoreAssignmentPolicy: MERGE under LEGACY policy") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT, data STRING) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT, data STRING) USING foo")
+      sql(s"INSERT INTO $relaxed.t VALUES (1, 'a')")
+      sql(s"INSERT INTO $strict.t VALUES (1, 'a')")
+      def merge(target: String): String =
+        s"""MERGE INTO $target t
+           |USING (SELECT 1 AS id, 'b' AS data) s
+           |ON t.id = s.id
+           |WHEN MATCHED THEN UPDATE SET t.data = s.data""".stripMargin
+      withLegacyPolicy {
+        sql(merge(s"$relaxed.t"))
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, "b"))
+        legacyRejected(sql(merge(s"$strict.t")))
+      }
+    }
+  }
+
+  test("deferCastValidationToRuntime: MERGE with an ANSI-incompatible cast") {
+    withTable(s"$relaxed.t", s"$strict.t") {
+      sql(s"CREATE TABLE $relaxed.t (id INT, data INT) USING foo")
+      sql(s"CREATE TABLE $strict.t (id INT, data INT) USING foo")
+      sql(s"INSERT INTO $relaxed.t VALUES (1, 0)")
+      sql(s"INSERT INTO $strict.t VALUES (1, 0)")
+      def merge(target: String): String =
+        s"""MERGE INTO $target t
+           |USING (SELECT 1 AS id, '5' AS data) s
+           |ON t.id = s.id
+           |WHEN MATCHED THEN UPDATE SET t.data = s.data""".stripMargin
+      withAnsiPolicy {
+        sql(merge(s"$relaxed.t"))
+        checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, 5))
+        checkError(
+          exception = intercept[AnalysisException](sql(merge(s"$strict.t"))),
+          condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+          parameters = Map(
+            "tableName" -> "``",
+            "colName" -> "`data`",
+            "srcType" -> "\"STRING\"",
+            "targetType" -> "\"INT\""))
+      }
+    }
+  }
+
+  test("deferCastValidationToRuntime: structurally impossible casts are still 
rejected") {
+    withTable(s"$relaxed.t") {
+      sql(s"CREATE TABLE $relaxed.t (d DATE) USING foo")
+      withAnsiPolicy {
+        // BOOLEAN cannot be cast to DATE at all, so the write is rejected 
even though the table
+        // defers store-assignment cast validation to runtime.
+        intercept[AnalysisException] {
+          sql(s"INSERT INTO $relaxed.t VALUES (true)")
+        }
+      }
+    }
+  }
+
+  private def appendByName(
+      catalog: String, targetSchema: StructType, source: DataFrame): 
Try[Seq[Row]] = {
+    var result: Try[Seq[Row]] = Try(Seq.empty[Row])
+    withTable(s"$catalog.t") {
+      spark.createDataFrame(new java.util.ArrayList[Row](), targetSchema)
+        .writeTo(s"$catalog.t").create()
+      result = Try {
+        source.writeTo(s"$catalog.t").append()
+        spark.table(s"$catalog.t").collect().toSeq
+      }
+    }
+    result
+  }
+
+  private def assertRelaxedMatchesStrict(targetSchema: StructType, source: 
DataFrame): Unit =
+    withAnsiPolicy {
+      val fromRelaxed = appendByName(relaxed, targetSchema, source)
+      val fromStrict = appendByName(strict, targetSchema, source)
+      (fromRelaxed, fromStrict) match {
+        case (Success(relaxedRows), Success(strictRows)) =>
+          assert(relaxedRows.map(_.toString).sorted == 
strictRows.map(_.toString).sorted,
+            s"relaxed=$relaxedRows strict=$strictRows")
+        case (Failure(relaxedError: AnalysisException), Failure(strictError: 
AnalysisException)) =>
+          assert(relaxedError.getCondition == strictError.getCondition,
+            s"relaxed=${relaxedError.getCondition} 
strict=${strictError.getCondition}")
+        case (Failure(_), Failure(_)) =>

Review Comment:
   The stricter rejection assertions are in place now. The runtime-failure 
coverage requested here is still missing in the latest revision: the successful 
relaxed conversions use valid literals, while the error cases check 
analysis-time rejection.
   
   Please also add non-foldable source values such as malformed strings and 
out-of-range numeric strings assigned to INT, and assert the specific Spark 
errors during execution. Cover INSERT and the row-level assignment paths, 
including `spark.sql.ansi.enabled=false`, to verify that the inserted casts 
still enforce ANSI behavior independently of the session flag. Null-bearing 
inputs assigned to NOT NULL columns would also cover the remaining null-check 
request.



-- 
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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to