This is an automated email from the ASF dual-hosted git repository.
gengliangwang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new d3f4a73abebf [SPARK-59342][SQL] Allow configuring schema alignment
behavior for DSv2 writes
d3f4a73abebf is described below
commit d3f4a73abebf70c8dd2d61f94bbcc3972bbf76f6
Author: Johan Lasperas <[email protected]>
AuthorDate: Wed Oct 7 15:57:36 2026 -0700
[SPARK-59342][SQL] Allow configuring schema alignment behavior for DSv2
writes
### What changes were proposed in this pull request?
This change extends the DSv2 connector interface to allow connectors to
configure schema alignment behavior. For DSv2 batch / row-level writes, under
`spark.sql.storeAssignmentPolicy=ANSI`, implicit cast are validated at analysis
time, rejecting casts that are valid but may fail, e.g. `STRING`->`INT`.
This change allows connectors to opt into runtime checks instead of
analysis check: allow more casts during analysis (`STRING`-> any, complex type
-> STRING, ..) and fail at runtime on malformed / overflowing values.
New internal DSV2 interface:
```
public interface ConfigurableSchemaAlignment extends Table {
SchemaAlignmentConfig schemaAlignmentConfig();
}
public interface SchemaAlignmentConfig {
default AnsiStoreAssignmentCastCheck ansiStoreAssignmentCastCheck() {
// AT_ANALYSIS or AT_RUNTIME.
return AnsiStoreAssignmentCastCheck.AT_ANALYSIS;
}
}
```
### Why are the changes needed?
This allows connector to configure and opt out of strict schema alignement
behavior when needed.
### How was this patch tested?
Add Spark suite SchemaAlignmentConfigSuite covering the configuration
mechanism
Closes #58632 from johanl-db/schema-alignment-config.
Authored-by: Johan Lasperas <[email protected]>
Signed-off-by: Gengliang Wang <[email protected]>
---
.../internal/connector/SchemaAlignmentConfig.java | 57 +++
.../SupportsConfigurableSchemaAlignment.java | 35 ++
.../spark/sql/catalyst/analysis/Analyzer.scala | 11 +-
.../sql/catalyst/analysis/AssignmentUtils.scala | 44 ++-
.../ResolveRowLevelCommandAssignments.scala | 32 +-
.../catalyst/analysis/TableOutputResolver.scala | 120 ++++---
.../spark/sql/catalyst/types/DataTypeUtils.scala | 45 ++-
.../connector/write/RowLevelOperationTable.scala | 9 +-
.../sql/connector/catalog/InMemoryBaseTable.scala | 7 +-
.../catalog/InMemoryRowLevelOperationTable.scala | 22 +-
.../InMemoryRowLevelOperationTableCatalog.scala | 6 +-
.../sql/connector/catalog/InMemoryTable.scala | 9 +-
.../connector/catalog/InMemoryTableCatalog.scala | 7 +-
...InMemoryTableCatalystRuntimeFilterCatalog.scala | 5 +-
.../catalog/InMemoryTableWithV2FilterCatalog.scala | 5 +-
.../apache/spark/sql/connector/catalog/txns.scala | 3 +-
.../types/DataTypeWriteCompatibilitySuite.scala | 148 ++++++++
.../connector/KeyGroupedPartitioningSuite.scala | 6 +-
.../catalog/SchemaAlignmentConfigSuite.scala | 389 +++++++++++++++++++++
19 files changed, 859 insertions(+), 101 deletions(-)
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SchemaAlignmentConfig.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SchemaAlignmentConfig.java
new file mode 100644
index 000000000000..16ef14670cb0
--- /dev/null
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SchemaAlignmentConfig.java
@@ -0,0 +1,57 @@
+/*
+ * 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.internal.connector;
+
+import org.apache.spark.annotation.Evolving;
+import org.apache.spark.sql.connector.catalog.Table;
+
+/**
+ * Schema alignment configuration for DSv2 batch/row-level writes to a {@link
Table}, exposed via
+ * {@link SupportsConfigurableSchemaAlignment}.
+ *
+ * @since 4.4.0
+ */
+@Evolving
+public class SchemaAlignmentConfig {
+
+ /** The default data source v2 configuration. */
+ public static final SchemaAlignmentConfig DEFAULT = new
SchemaAlignmentConfig();
+
+ /**
+ * When the {@code ANSI} store-assignment cast check runs for writes to a
table, under
+ * {@code spark.sql.storeAssignmentPolicy=ANSI}. Has no effect under the
{@code STRICT} policy.
+ */
+ public enum AnsiStoreAssignmentCastCheck {
+ /** (default) Reject incompatible casts at analysis. See {@code
Cast.canANSIStoreAssign}. */
+ ANALYSIS,
+
+ /**
+ * Insert an ANSI cast check, so malformed values or overflows fail at
execution time instead
+ * of being rejected during analysis.
+ */
+ RUNTIME;
+
+ /** The default ANSI store-assignment cast check. */
+ public static final AnsiStoreAssignmentCastCheck DEFAULT = ANALYSIS;
+ }
+
+ /** When the {@code ANSI} store-assignment cast check runs. */
+ public AnsiStoreAssignmentCastCheck ansiStoreAssignmentCastCheck() {
+ return AnsiStoreAssignmentCastCheck.DEFAULT;
+ }
+}
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SupportsConfigurableSchemaAlignment.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SupportsConfigurableSchemaAlignment.java
new file mode 100644
index 000000000000..a92a431113c9
--- /dev/null
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/internal/connector/SupportsConfigurableSchemaAlignment.java
@@ -0,0 +1,35 @@
+/*
+ * 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.internal.connector;
+
+import org.apache.spark.annotation.Evolving;
+import org.apache.spark.sql.connector.catalog.Table;
+
+/**
+ * Allows connectors to configure schema alignment and casting behavior for
DSv2 batch/row-level
+ * writes to a {@link Table}. It is not consulted for streaming writes, where
alignment is
+ * delegated to the connector.
+ *
+ * @since 4.4.0
+ */
+@Evolving
+public interface SupportsConfigurableSchemaAlignment extends Table {
+
+ /** The schema alignment configuration for writes to this table. */
+ SchemaAlignmentConfig schemaAlignmentConfig();
+}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index 0efeda570b55..73f25cd9c2e4 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -59,10 +59,10 @@ import
org.apache.spark.sql.connector.catalog.functions.UnboundFunction
import org.apache.spark.sql.connector.catalog.procedures.{BoundProcedure,
ProcedureParameter, UnboundProcedure}
import org.apache.spark.sql.connector.expressions.{FieldReference,
IdentityTransform}
import org.apache.spark.sql.errors.QueryCompilationErrors
-import org.apache.spark.sql.execution.datasources.v2.{ChangelogTable,
DataSourceV2Relation}
+import org.apache.spark.sql.execution.datasources.v2.{ChangelogTable,
DataSourceV2Relation, ExtractV2Table}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.SQLConf.{PartitionOverwriteMode,
StoreAssignmentPolicy}
-import org.apache.spark.sql.internal.connector.V1Function
+import org.apache.spark.sql.internal.connector.{SchemaAlignmentConfig,
SupportsConfigurableSchemaAlignment, V1Function}
import org.apache.spark.sql.types._
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.util.ArrayImplicits._
@@ -4053,10 +4053,15 @@ class Analyzer(
case r: DataSourceV2Relation =>
GeneratedColumn.attachGenerationExpressions(r)
case _ => v2Write.table.output
}
+ val schemaAlignmentConfig = v2Write.table match {
+ case ExtractV2Table(table: SupportsConfigurableSchemaAlignment) =>
+ table.schemaAlignmentConfig()
+ case _ => SchemaAlignmentConfig.DEFAULT
+ }
val (projection, autoFilledGenCols) =
TableOutputResolver.resolveOutputColumnsWithGeneratedInfo(
v2Write.table.name, expected, v2Write.query, v2Write.isByName,
conf,
- defaultValueFillMode)
+ defaultValueFillMode,
schemaAlignmentConfig.ansiStoreAssignmentCastCheck())
if (projection != v2Write.query) {
val cleanedTable = v2Write.table match {
case r: DataSourceV2Relation =>
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AssignmentUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AssignmentUtils.scala
index df4b0646ed42..b35c824e6e91 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AssignmentUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AssignmentUtils.scala
@@ -30,6 +30,7 @@ import
org.apache.spark.sql.catalyst.util.ResolveDefaultColumns.getDefaultValueE
import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.SQLConf
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
import org.apache.spark.sql.types.{DataType, StructType}
import org.apache.spark.util.ArrayImplicits._
@@ -57,13 +58,17 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
* (preserving existing fields).
* @param coerceNestedTypes whether to coerce nested types to match the
target type
* for complex types
+ * @param ansiStoreAssignmentCastCheck when the ANSI store-assignment cast
check runs:
+ * ANALYSIS (default) rejects an unsafe
cast at analysis
+ * time; RUNTIME rejects invalid values
at execution time.
* @return aligned update assignments that match table attributes
*/
def alignUpdateAssignments(
attrs: Seq[Attribute],
assignments: Seq[Assignment],
fromStar: Boolean,
- coerceNestedTypes: Boolean): Seq[Assignment] = {
+ coerceNestedTypes: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Seq[Assignment] = {
val errors = new mutable.ArrayBuffer[String]()
@@ -75,7 +80,8 @@ object AssignmentUtils extends SQLConfHelper with CastSupport
{
addError = err => errors += err,
colPath = Seq(attr.name),
coerceNestedTypes,
- fromStar)
+ fromStar,
+ ansiStoreAssignmentCastCheck)
}
if (errors.nonEmpty) {
@@ -98,12 +104,16 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
* @param assignments insert assignments to align
* @param coerceNestedTypes whether to coerce nested types to match the
target type
* for complex types
+ * @param ansiStoreAssignmentCastCheck when the ANSI store-assignment cast
check runs:
+ * ANALYSIS (default) rejects an unsafe
cast at analysis
+ * time; RUNTIME rejects invalid values
at execution time.
* @return aligned insert assignments that match table attributes
*/
def alignInsertAssignments(
attrs: Seq[Attribute],
assignments: Seq[Assignment],
- coerceNestedTypes: Boolean = false): Seq[Assignment] = {
+ coerceNestedTypes: Boolean = false,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Seq[Assignment] = {
val errors = new mutable.ArrayBuffer[String]()
@@ -137,7 +147,8 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
val value = matchingAssignments.head.value
val coerceMode = if (coerceNestedTypes) RECURSE else NONE
TableOutputResolver.resolveUpdate(
- "", value, actualAttr, conf, err => errors += err, colPath,
coerceMode)
+ "", value, actualAttr, conf, err => errors += err, colPath,
coerceMode,
+ ansiStoreAssignmentCastCheck)
}
Assignment(attr, resolvedValue)
}
@@ -160,7 +171,8 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
addError: String => Unit,
colPath: Seq[String],
coerceNestedTypes: Boolean = false,
- updateStar: Boolean = false): Expression = {
+ updateStar: Boolean = false,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Expression
= {
val (exactAssignments, otherAssignments) = assignments.partition {
assignment =>
assignment.key.semanticEquals(colExpr)
@@ -188,21 +200,22 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
case _: StructType =>
// Expand assignments to leaf fields (fixNullExpansion is applied
inside)
applyNestedFieldAssignments(col, colExpr, value, addError, colPath,
- coerceNestedTypes)
+ coerceNestedTypes, ansiStoreAssignmentCastCheck)
case _ =>
// For non-struct types, resolve directly
val coerceMode = if (coerceNestedTypes) RECURSE else NONE
TableOutputResolver.resolveUpdate("", value, col, conf, addError,
colPath,
- coerceMode)
+ coerceMode, ansiStoreAssignmentCastCheck)
}
} else {
val value = exactAssignments.head.value
val coerceMode = if (coerceNestedTypes) RECURSE else NONE
TableOutputResolver.resolveUpdate("", value, col, conf, addError,
- colPath, coerceMode)
+ colPath, coerceMode, ansiStoreAssignmentCastCheck)
}
} else {
- applyFieldAssignments(col, colExpr, fieldAssignments, addError, colPath,
coerceNestedTypes)
+ applyFieldAssignments(col, colExpr, fieldAssignments, addError, colPath,
coerceNestedTypes,
+ ansiStoreAssignmentCastCheck)
}
}
@@ -212,7 +225,8 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
assignments: Seq[Assignment],
addError: String => Unit,
colPath: Seq[String],
- coerceNestedTypes: Boolean): Expression = {
+ coerceNestedTypes: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Expression
= {
col.dataType match {
case structType: StructType =>
@@ -222,7 +236,7 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
}
val updatedFieldExprs = fieldAttrs.zip(fieldExprs).map { case
(fieldAttr, fieldExpr) =>
applyAssignments(fieldAttr, fieldExpr, assignments, addError,
colPath :+ fieldAttr.name,
- coerceNestedTypes)
+ coerceNestedTypes, ansiStoreAssignmentCastCheck =
ansiStoreAssignmentCastCheck)
}
toNamedStruct(structType, updatedFieldExprs)
@@ -240,7 +254,8 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
value: Expression,
addError: String => Unit,
colPath: Seq[String],
- coerceNestedTypes: Boolean): Expression = {
+ coerceNestedTypes: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Expression
= {
col.dataType match {
case structType: StructType =>
@@ -273,12 +288,13 @@ object AssignmentUtils extends SQLConfHelper with
CastSupport {
case _: StructType =>
// Field is a struct, recurse
applyNestedFieldAssignments(fieldAttr, targetFieldExpr,
- sourceFieldValue, addError, fieldPath, coerceNestedTypes)
+ sourceFieldValue, addError, fieldPath, coerceNestedTypes,
+ ansiStoreAssignmentCastCheck)
case _ =>
// Field is not a struct, resolve with TableOutputResolver
val coerceMode = if (coerceNestedTypes) RECURSE else NONE
TableOutputResolver.resolveUpdate("", sourceFieldValue,
fieldAttr, conf, addError,
- fieldPath, coerceMode)
+ fieldPath, coerceMode, ansiStoreAssignmentCastCheck)
}
}
val namedStruct = toNamedStruct(structType, updatedFieldExprs)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
index 76035ea819ff..a31ac092e279 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveRowLevelCommandAssignments.scala
@@ -25,8 +25,11 @@ import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreePattern.COMMAND
import org.apache.spark.sql.catalyst.util.CharVarcharUtils
import org.apache.spark.sql.errors.QueryCompilationErrors
-import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
ExtractV2Table}
import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
+import
org.apache.spark.sql.internal.connector.SupportsConfigurableSchemaAlignment
/**
* A rule that resolves assignments in row-level commands.
@@ -43,7 +46,8 @@ object ResolveRowLevelCommandAssignments extends
Rule[LogicalPlan] {
validateStoreAssignmentPolicy()
val newTable = cleanAttrMetadata(u.table)
val newAssignments =
AssignmentUtils.alignUpdateAssignments(u.table.output, u.assignments,
- fromStar = false, coerceNestedTypes = false)
+ fromStar = false, coerceNestedTypes = false,
+ ansiStoreAssignmentCastCheck = ansiStoreAssignmentCastCheck(u.table))
u.copy(table = newTable, assignments = newAssignments)
case u: UpdateTable if !u.skipSchemaResolution && u.resolved && !u.aligned
=>
@@ -52,20 +56,24 @@ object ResolveRowLevelCommandAssignments extends
Rule[LogicalPlan] {
case m: MergeIntoTable if m.rewritable && shouldAlignAssignments(m) &&
containsFinalSchema(m) =>
validateStoreAssignmentPolicy()
val coerceNestedTypes = conf.coerceMergeNestedTypes &&
m.withSchemaEvolution
+ val castCheck = ansiStoreAssignmentCastCheck(m.targetTable)
m.copy(
targetTable = cleanAttrMetadata(m.targetTable),
matchedActions = alignActions(
m.targetTable.output,
m.matchedActions,
- coerceNestedTypes),
+ coerceNestedTypes,
+ castCheck),
notMatchedActions = alignActions(
m.targetTable.output,
m.notMatchedActions,
- coerceNestedTypes),
+ coerceNestedTypes,
+ castCheck),
notMatchedBySourceActions = alignActions(
m.targetTable.output,
m.notMatchedBySourceActions,
- coerceNestedTypes))
+ coerceNestedTypes,
+ castCheck))
case m: MergeIntoTable if shouldAlignAssignments(m) &&
containsFinalSchema(m) =>
resolveAssignments(m)
@@ -79,6 +87,13 @@ object ResolveRowLevelCommandAssignments extends
Rule[LogicalPlan] {
!m.schemaEvolutionEnabled || (m.schemaEvolutionReady &&
m.pendingSchemaChanges.isEmpty)
}
+ private def ansiStoreAssignmentCastCheck(target: LogicalPlan):
AnsiStoreAssignmentCastCheck = {
+ target.collectFirst {
+ case ExtractV2Table(table: SupportsConfigurableSchemaAlignment) =>
+ table.schemaAlignmentConfig().ansiStoreAssignmentCastCheck()
+ }.getOrElse(SchemaAlignmentConfig.DEFAULT.ansiStoreAssignmentCastCheck())
+ }
+
private def validateStoreAssignmentPolicy(): Unit = {
// SPARK-28730: LEGACY store assignment policy is disallowed in data
source v2
if (conf.storeAssignmentPolicy == StoreAssignmentPolicy.LEGACY) {
@@ -127,16 +142,17 @@ object ResolveRowLevelCommandAssignments extends
Rule[LogicalPlan] {
private def alignActions(
attrs: Seq[Attribute],
actions: Seq[MergeAction],
- coerceNestedTypes: Boolean): Seq[MergeAction] = {
+ coerceNestedTypes: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Seq[MergeAction] = {
actions.map {
case u @ UpdateAction(_, assignments, fromStar) =>
u.copy(assignments = AssignmentUtils.alignUpdateAssignments(attrs,
assignments,
- fromStar, coerceNestedTypes))
+ fromStar, coerceNestedTypes, ansiStoreAssignmentCastCheck))
case d: DeleteAction =>
d
case i @ InsertAction(_, assignments) =>
i.copy(assignments = AssignmentUtils.alignInsertAssignments(attrs,
assignments,
- coerceNestedTypes))
+ coerceNestedTypes, ansiStoreAssignmentCastCheck))
case other =>
throw new AnalysisException(
errorClass = "_LEGACY_ERROR_TEMP_3052",
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TableOutputResolver.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TableOutputResolver.scala
index 933513654dc1..719e32b02c34 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TableOutputResolver.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TableOutputResolver.scala
@@ -36,6 +36,7 @@ import
org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
import org.apache.spark.sql.types.{ArrayType, DataType, DecimalType,
IntegralType, MapType, StructType, UserDefinedType}
object TableOutputResolver extends SQLConfHelper with Logging {
@@ -94,9 +95,12 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
query: LogicalPlan,
byName: Boolean,
conf: SQLConf,
- defaultValueFillMode: DefaultValueFillMode.Value = NONE): LogicalPlan = {
+ defaultValueFillMode: DefaultValueFillMode.Value = NONE,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck =
+ AnsiStoreAssignmentCastCheck.DEFAULT): LogicalPlan = {
resolveOutputColumnsInternal(
- tableName, expected, query, byName, conf, defaultValueFillMode)._1
+ tableName, expected, query, byName, conf, defaultValueFillMode,
+ ansiStoreAssignmentCastCheck)._1
}
/**
@@ -111,10 +115,12 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
query: LogicalPlan,
byName: Boolean,
conf: SQLConf,
- defaultValueFillMode: DefaultValueFillMode.Value = NONE
+ defaultValueFillMode: DefaultValueFillMode.Value = NONE,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck
): (LogicalPlan, Set[String]) = {
resolveOutputColumnsInternal(
- tableName, expected, query, byName, conf, defaultValueFillMode)
+ tableName, expected, query, byName, conf, defaultValueFillMode,
+ ansiStoreAssignmentCastCheck)
}
private def resolveOutputColumnsInternal(
@@ -123,7 +129,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
query: LogicalPlan,
byName: Boolean,
conf: SQLConf,
- defaultValueFillMode: DefaultValueFillMode.Value
+ defaultValueFillMode: DefaultValueFillMode.Value,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck
): (LogicalPlan, Set[String]) = {
if (expected.size < query.output.size) {
@@ -149,7 +156,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
errors += _,
Nil,
defaultValueFillMode,
- enforceFullOutput = true)
+ enforceFullOutput = true,
+ ansiStoreAssignmentCastCheck)
} else {
if (expected.size > query.output.size && !fillDefaultValue) {
throw QueryCompilationErrors.cannotWriteNotEnoughColumnsToTableError(
@@ -157,7 +165,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
}
resolveColumnsByPosition(
tableName, query.output, expected, conf, errors += _,
- fillDefaultValue = fillDefaultValue)
+ fillDefaultValue = fillDefaultValue,
+ ansiStoreAssignmentCastCheck = ansiStoreAssignmentCastCheck)
}
if (errors.nonEmpty) {
@@ -180,14 +189,16 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
conf: SQLConf,
addError: String => Unit,
colPath: Seq[String],
- defaultValueFillMode: DefaultValueFillMode.Value): Expression = {
+ defaultValueFillMode: DefaultValueFillMode.Value,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Expression
= {
val fillChildDefaultValue = defaultValueFillMode == RECURSE
(value.dataType, col.dataType) match {
// no need to reorder inner fields or cast if types are already
compatible
case (valueType, colType) if
DataType.equalsIgnoreCompatibleNullability(valueType, colType) =>
val canWriteExpr = canWrite(
- tableName, valueType, colType, byName = true, conf, addError,
colPath)
+ tableName, valueType, colType, byName = true, conf, addError,
colPath,
+ ansiStoreAssignmentCastCheck)
if (canWriteExpr) {
val nullsHandled = checkNullability(value, col, conf, colPath)
applyColumnMetadata(nullsHandled, col)
@@ -196,21 +207,22 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
}
case (valueType: StructType, colType: StructType) =>
val resolvedValue = resolveStructType(
- tableName, value, valueType, col, colType,
- byName = true, conf, addError, colPath, fillChildDefaultValue,
enforceFullOutput = false)
+ tableName, value, valueType, col, colType, byName = true, conf,
addError, colPath,
+ fillChildDefaultValue, enforceFullOutput = false,
ansiStoreAssignmentCastCheck)
resolvedValue.getOrElse(value)
case (valueType: ArrayType, colType: ArrayType) =>
val resolvedValue = resolveArrayType(
- tableName, value, valueType, col, colType,
- byName = true, conf, addError, colPath, fillChildDefaultValue,
enforceFullOutput = false)
+ tableName, value, valueType, col, colType, byName = true, conf,
addError, colPath,
+ fillChildDefaultValue, enforceFullOutput = false,
ansiStoreAssignmentCastCheck)
resolvedValue.getOrElse(value)
case (valueType: MapType, colType: MapType) =>
val resolvedValue = resolveMapType(
- tableName, value, valueType, col, colType,
- byName = true, conf, addError, colPath, fillChildDefaultValue,
enforceFullOutput = false)
+ tableName, value, valueType, col, colType, byName = true, conf,
addError, colPath,
+ fillChildDefaultValue, enforceFullOutput = false,
ansiStoreAssignmentCastCheck)
resolvedValue.getOrElse(value)
case _ =>
- checkUpdate(tableName, value, col, conf, addError, colPath)
+ checkUpdate(
+ tableName, value, col, conf, addError, colPath,
ansiStoreAssignmentCastCheck)
}
}
@@ -220,7 +232,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
attr: Attribute,
conf: SQLConf,
addError: String => Unit,
- colPath: Seq[String]): Expression = {
+ colPath: Seq[String],
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Expression
= {
val attrTypeHasCharVarchar = CharVarcharUtils.hasCharVarchar(attr.dataType)
val attrTypeWithoutCharVarchar = if (attrTypeHasCharVarchar) {
@@ -231,7 +244,7 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
val canWriteValue = canWrite(
tableName, value.dataType, attrTypeWithoutCharVarchar,
- byName = true, conf, addError, colPath)
+ byName = true, conf, addError, colPath, ansiStoreAssignmentCastCheck)
if (canWriteValue) {
val nullCheckedValue = checkNullability(value, attr, conf, colPath)
@@ -354,12 +367,13 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
byName: Boolean,
conf: SQLConf,
addError: String => Unit,
- colPath: Seq[String]): Boolean = {
+ colPath: Seq[String],
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck): Boolean = {
conf.storeAssignmentPolicy match {
case StoreAssignmentPolicy.STRICT | StoreAssignmentPolicy.ANSI =>
DataTypeUtils.canWrite(
tableName, valueType, expectedType, byName, conf.resolver,
colPath.quoted,
- conf.storeAssignmentPolicy, addError)
+ conf.storeAssignmentPolicy, addError, ansiStoreAssignmentCastCheck)
case _ =>
true
}
@@ -373,7 +387,9 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
addError: String => Unit,
colPath: Seq[String] = Nil,
defaultValueFillMode: DefaultValueFillMode.Value,
- enforceFullOutput: Boolean = false): (Seq[NamedExpression], Set[String])
= {
+ enforceFullOutput: Boolean = false,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck
+ ): (Seq[NamedExpression], Set[String]) = {
// Names of generated columns that were auto-filled (not provided by the
user). Only populated
// for top-level columns, since generated columns cannot be nested.
val autoFilledGenCols = mutable.Set.empty[String]
@@ -423,18 +439,22 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
case (matchedType: StructType, expectedType: StructType) =>
resolveStructType(
tableName, matchedCol, matchedType, actualExpectedCol,
expectedType,
- byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput)
+ byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput,
+ ansiStoreAssignmentCastCheck)
case (matchedType: ArrayType, expectedType: ArrayType) =>
resolveArrayType(
tableName, matchedCol, matchedType, actualExpectedCol,
expectedType,
- byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput)
+ byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput,
+ ansiStoreAssignmentCastCheck)
case (matchedType: MapType, expectedType: MapType) =>
resolveMapType(
tableName, matchedCol, matchedType, actualExpectedCol,
expectedType,
- byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput)
+ byName = true, conf, addError, newColPath,
childFillDefaultValue, enforceFullOutput,
+ ansiStoreAssignmentCastCheck)
case _ =>
checkField(
- tableName, actualExpectedCol, matchedCol, byName = true, conf,
addError, newColPath)
+ tableName, actualExpectedCol, matchedCol, byName = true, conf,
addError, newColPath,
+ ansiStoreAssignmentCastCheck)
}
}
}
@@ -488,7 +508,9 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
conf: SQLConf,
addError: String => Unit,
colPath: Seq[String] = Nil,
- fillDefaultValue: Boolean = false): (Seq[NamedExpression], Set[String])
= {
+ fillDefaultValue: Boolean = false,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck
+ ): (Seq[NamedExpression], Set[String]) = {
// Names of generated columns that were auto-filled (not provided by the
user). Only populated
// for top-level columns, since generated columns cannot be nested.
val autoFilledGenCols = mutable.Set.empty[String]
@@ -528,17 +550,21 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
case (inputType: StructType, expectedType: StructType) =>
resolveStructType(
tableName, inputCol, inputType, expectedCol, expectedType,
- byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true)
+ byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true,
+ ansiStoreAssignmentCastCheck)
case (inputType: ArrayType, expectedType: ArrayType) =>
resolveArrayType(
tableName, inputCol, inputType, expectedCol, expectedType,
- byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true)
+ byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true,
+ ansiStoreAssignmentCastCheck)
case (inputType: MapType, expectedType: MapType) =>
resolveMapType(
tableName, inputCol, inputType, expectedCol, expectedType,
- byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true)
+ byName = false, conf, addError, newColPath, fillDefaultValue,
enforceFullOutput = true,
+ ansiStoreAssignmentCastCheck)
case _ =>
- checkField(tableName, expectedCol, inputCol, byName = false, conf,
addError, newColPath)
+ checkField(tableName, expectedCol, inputCol, byName = false, conf,
addError, newColPath,
+ ansiStoreAssignmentCastCheck)
}
}
@@ -622,7 +648,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
addError: String => Unit,
colPath: Seq[String],
fillDefaultValue: Boolean,
- enforceFullOutput: Boolean): Option[NamedExpression] = {
+ enforceFullOutput: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Option[NamedExpression] = {
val nullCheckedInput = checkNullability(input, expected, conf, colPath)
val fields = inputType.zipWithIndex.map { case (f, i) =>
Alias(GetStructField(nullCheckedInput, i, Some(f.name)), f.name)()
@@ -631,10 +658,11 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
// Generated columns cannot be nested, so the auto-filled set is always
empty here.
val (resolved, _) = if (byName) {
reorderColumnsByName(tableName, fields, toAttributes(expectedType),
conf, addError, colPath,
- defaultValueMode, enforceFullOutput)
+ defaultValueMode, enforceFullOutput, ansiStoreAssignmentCastCheck)
} else {
resolveColumnsByPosition(
- tableName, fields, toAttributes(expectedType), conf, addError,
colPath, fillDefaultValue)
+ tableName, fields, toAttributes(expectedType), conf, addError,
colPath, fillDefaultValue,
+ ansiStoreAssignmentCastCheck)
}
if (resolved.length == expectedType.length) {
val struct = CreateStruct(resolved)
@@ -665,7 +693,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
addError: String => Unit,
colPath: Seq[String],
fillDefaultValue: Boolean,
- enforceFullOutput: Boolean): Option[NamedExpression] = {
+ enforceFullOutput: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Option[NamedExpression] = {
val nullCheckedInput = checkNullability(input, expected, conf, colPath)
val param = NamedLambdaVariable("element", inputType.elementType,
inputType.containsNull)
val fakeAttr =
@@ -674,10 +703,11 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
val (res, _) = if (byName) {
val defaultValueMode = if (fillDefaultValue) RECURSE else NONE
reorderColumnsByName(tableName, Seq(param), Seq(fakeAttr), conf,
addError, colPath,
- defaultValueMode, enforceFullOutput)
+ defaultValueMode, enforceFullOutput, ansiStoreAssignmentCastCheck)
} else {
resolveColumnsByPosition(
- tableName, Seq(param), Seq(fakeAttr), conf, addError, colPath,
fillDefaultValue)
+ tableName, Seq(param), Seq(fakeAttr), conf, addError, colPath,
fillDefaultValue,
+ ansiStoreAssignmentCastCheck)
}
if (res.length == 1) {
val castedArray =
@@ -708,7 +738,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
addError: String => Unit,
colPath: Seq[String],
fillDefaultValue: Boolean,
- enforceFullOutput: Boolean): Option[NamedExpression] = {
+ enforceFullOutput: Boolean,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Option[NamedExpression] = {
val nullCheckedInput = checkNullability(input, expected, conf, colPath)
val keyParam = NamedLambdaVariable("key", inputType.keyType, nullable =
false)
@@ -717,10 +748,11 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
// Generated columns cannot be nested, so the auto-filled set is always
empty here.
val (resKey, _) = if (byName) {
reorderColumnsByName(tableName, Seq(keyParam), Seq(fakeKeyAttr), conf,
addError, colPath,
- defaultValueFillMode, enforceFullOutput)
+ defaultValueFillMode, enforceFullOutput, ansiStoreAssignmentCastCheck)
} else {
resolveColumnsByPosition(
- tableName, Seq(keyParam), Seq(fakeKeyAttr), conf, addError, colPath,
fillDefaultValue)
+ tableName, Seq(keyParam), Seq(fakeKeyAttr), conf, addError, colPath,
fillDefaultValue,
+ ansiStoreAssignmentCastCheck)
}
val valueParam =
@@ -730,10 +762,11 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
// Generated columns cannot be nested, so the auto-filled set is always
empty here.
val (resValue, _) = if (byName) {
reorderColumnsByName(tableName, Seq(valueParam), Seq(fakeValueAttr),
conf, addError, colPath,
- defaultValueFillMode, enforceFullOutput)
+ defaultValueFillMode, enforceFullOutput, ansiStoreAssignmentCastCheck)
} else {
resolveColumnsByPosition(
- tableName, Seq(valueParam), Seq(fakeValueAttr), conf, addError,
colPath, fillDefaultValue)
+ tableName, Seq(valueParam), Seq(fakeValueAttr), conf, addError,
colPath, fillDefaultValue,
+ ansiStoreAssignmentCastCheck)
}
if (resKey.length == 1 && resValue.length == 1) {
@@ -833,7 +866,8 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
byName: Boolean,
conf: SQLConf,
addError: String => Unit,
- colPath: Seq[String]): Option[NamedExpression] = {
+ colPath: Seq[String],
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck):
Option[NamedExpression] = {
val attrTypeHasCharVarchar =
CharVarcharUtils.hasCharVarchar(tableAttr.dataType)
val attrTypeWithoutCharVarchar = if (attrTypeHasCharVarchar) {
@@ -844,7 +878,7 @@ object TableOutputResolver extends SQLConfHelper with
Logging {
val canWriteExpr = canWrite(
tableName, queryExpr.dataType, attrTypeWithoutCharVarchar,
- byName, conf, addError, colPath)
+ byName, conf, addError, colPath, ansiStoreAssignmentCastCheck)
if (canWriteExpr) {
val prepared =
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/DataTypeUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/DataTypeUtils.scala
index f7ab8b06baf5..625a6720c03b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/DataTypeUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/DataTypeUtils.scala
@@ -25,6 +25,8 @@ import org.apache.spark.sql.connector.catalog.Column
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy.{ANSI,
STRICT}
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck.{ANALYSIS,
RUNTIME}
import org.apache.spark.sql.types._
import org.apache.spark.sql.types.DecimalType.{forType, fromDecimal}
@@ -111,13 +113,15 @@ object DataTypeUtils {
resolver: Resolver,
context: String,
storeAssignmentPolicy: StoreAssignmentPolicy.Value,
- addError: String => Unit): Boolean = {
+ addError: String => Unit,
+ ansiStoreAssignmentCastCheck: AnsiStoreAssignmentCastCheck =
+ AnsiStoreAssignmentCastCheck.DEFAULT): Boolean = {
(write, read) match {
case (wArr: ArrayType, rArr: ArrayType) =>
// run compatibility check first to produce all error messages
val typesCompatible = canWrite(
tableName, wArr.elementType, rArr.elementType, byName, resolver,
context + ".element",
- storeAssignmentPolicy, addError)
+ storeAssignmentPolicy, addError, ansiStoreAssignmentCastCheck)
if (wArr.containsNull && !rArr.containsNull) {
throw
QueryCompilationErrors.incompatibleDataToTableNullableArrayElementsError(
@@ -134,10 +138,10 @@ object DataTypeUtils {
// run compatibility check first to produce all error messages
val keyCompatible = canWrite(
tableName, wMap.keyType, rMap.keyType, byName, resolver, context +
".key",
- storeAssignmentPolicy, addError)
+ storeAssignmentPolicy, addError, ansiStoreAssignmentCastCheck)
val valueCompatible = canWrite(
tableName, wMap.valueType, rMap.valueType, byName, resolver, context
+ ".value",
- storeAssignmentPolicy, addError)
+ storeAssignmentPolicy, addError, ansiStoreAssignmentCastCheck)
if (wMap.valueContainsNull && !rMap.valueContainsNull) {
throw
QueryCompilationErrors.incompatibleDataToTableNullableMapValuesError(
@@ -155,7 +159,7 @@ object DataTypeUtils {
val fieldContext = s"$context.${rField.name}"
val typesCompatible = canWrite(
tableName, wField.dataType, rField.dataType, byName, resolver,
fieldContext,
- storeAssignmentPolicy, addError)
+ storeAssignmentPolicy, addError, ansiStoreAssignmentCastCheck)
if (byName && !nameMatch) {
throw
QueryCompilationErrors.incompatibleDataToTableUnexpectedColumnNameError(
@@ -198,7 +202,8 @@ object DataTypeUtils {
case (_: NullType, _) if storeAssignmentPolicy == ANSI => true
- case (w: AtomicType, r: AtomicType) if storeAssignmentPolicy == ANSI =>
+ case (w: AtomicType, r: AtomicType)
+ if storeAssignmentPolicy == ANSI && ansiStoreAssignmentCastCheck ==
ANALYSIS =>
if (!Cast.canANSIStoreAssign(w, r)) {
throw
QueryCompilationErrors.incompatibleDataToTableCannotSafelyCastError(
tableName, context, w.catalogString, r.catalogString
@@ -213,13 +218,34 @@ 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 {
+ // Some casts that pass checkAnalysis don't behave correctly at
runtime. Keep rejecting
+ // them for now:
+ // - Long/decimal -> timestamp can silently overflow.
+ // - Variant -> complex types doesn't enforce nested field
non-nullability.
+ case (LongType | _: DecimalType, TimestampType) |
+ (VariantType, _: StructType | _: ArrayType | _: MapType) =>
+ throw
QueryCompilationErrors.incompatibleDataToTableCannotSafelyCastError(
+ tableName, context, w.catalogString, r.catalogString)
+ // - Building a variant from TIME or nanosecond timestamps isn't
supported, whether the
+ // value is top-level or nested in an array.
+ case (_, VariantType) if w.existsRecursively(
+ t => t.isInstanceOf[TimeType] ||
t.isInstanceOf[AnyTimestampNanoType]) =>
+ throw
QueryCompilationErrors.incompatibleDataToTableCannotSafelyCastError(
+ tableName, context, w.catalogString, r.catalogString)
+ case _ => true
+ }
case (w, r) =>
throw
QueryCompilationErrors.incompatibleDataToTableCannotSafelyCastError(
tableName, context, w.catalogString, r.catalogString
@@ -331,4 +357,3 @@ object DataTypeUtils {
}
}
}
-
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala
index 0ed78b667db3..f8a903476819 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala
@@ -23,6 +23,7 @@ import org.apache.spark.sql.connector.catalog.{Column,
SupportsRead, SupportsRow
import org.apache.spark.sql.connector.catalog.constraints.Constraint
import org.apache.spark.sql.connector.expressions.Transform
import org.apache.spark.sql.connector.read.ScanBuilder
+import org.apache.spark.sql.internal.connector.{SchemaAlignmentConfig,
SupportsConfigurableSchemaAlignment}
import org.apache.spark.sql.util.CaseInsensitiveStringMap
/**
@@ -35,7 +36,8 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
*/
private[sql] case class RowLevelOperationTable(
table: Table with SupportsRowLevelOperations,
- operation: RowLevelOperation) extends Table with SupportsRead with
SupportsWrite {
+ operation: RowLevelOperation)
+ extends Table with SupportsRead with SupportsWrite with
SupportsConfigurableSchemaAlignment {
override def name: String = table.name
override def columns: Array[Column] = table.columns()
@@ -44,6 +46,11 @@ private[sql] case class RowLevelOperationTable(
override def partitioning(): Array[Transform] = table.partitioning()
override def toString: String = table.toString
+ override def schemaAlignmentConfig(): SchemaAlignmentConfig = table match {
+ case t: SupportsConfigurableSchemaAlignment => t.schemaAlignmentConfig()
+ case _ => SchemaAlignmentConfig.DEFAULT
+ }
+
override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder
= {
operation.newScanBuilder(options)
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
index 7f2f57e3438e..091a1a441f3b 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
@@ -44,7 +44,7 @@ import
org.apache.spark.sql.connector.read.streaming.{MicroBatchStream, Offset}
import org.apache.spark.sql.connector.write._
import
org.apache.spark.sql.connector.write.streaming.{StreamingDataWriterFactory,
StreamingWrite}
import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.internal.connector.{ColumnImpl,
SupportsRuntimeCatalystFiltering, SupportsStreamingUpdateAsAppend}
+import org.apache.spark.sql.internal.connector.{ColumnImpl,
SchemaAlignmentConfig, SupportsConfigurableSchemaAlignment,
SupportsRuntimeCatalystFiltering, SupportsStreamingUpdateAsAppend}
import org.apache.spark.sql.sources._
import org.apache.spark.sql.types._
import org.apache.spark.sql.util.CaseInsensitiveStringMap
@@ -65,9 +65,10 @@ abstract class InMemoryBaseTable(
val numPartitions: Option[Int] = None,
val advisoryPartitionSize: Option[Long] = None,
val isDistributionStrictlyRequired: Boolean = true,
- val numRowsPerSplit: Int = Int.MaxValue)
+ val numRowsPerSplit: Int = Int.MaxValue,
+ override val schemaAlignmentConfig: SchemaAlignmentConfig =
SchemaAlignmentConfig.DEFAULT)
extends Table with SupportsRead with SupportsWrite with
SupportsMetadataColumns
- with SupportsSchemaEvolution {
+ with SupportsSchemaEvolution with SupportsConfigurableSchemaAlignment {
// Tracks the current version number of the table.
protected var tableVersion: Int = 0
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
index c08dfef04a5b..3e04ab719b7b 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
@@ -28,6 +28,7 @@ import
org.apache.spark.sql.connector.expressions.filter.Predicate
import org.apache.spark.sql.connector.read.{InputPartition, Scan, ScanBuilder}
import org.apache.spark.sql.connector.write.{BatchWrite, DeltaBatchWrite,
DeltaWrite, DeltaWriteBuilder, DeltaWriter, DeltaWriterFactory,
LogicalWriteInfo, PhysicalWriteInfo, RequiresDistributionAndOrdering,
RowLevelOperation, RowLevelOperationBuilder, RowLevelOperationInfo,
SupportsDelta, Write, WriteBuilder, WriterCommitMessage}
import org.apache.spark.sql.connector.write.RowLevelOperation.Command
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.unsafe.types.UTF8String
@@ -47,14 +48,16 @@ class InMemoryRowLevelOperationTable private (
partitioning: Array[Transform],
properties: util.Map[String, String],
constraints: Array[Constraint],
- tableId: String)
+ tableId: String,
+ schemaAlignmentConfig: SchemaAlignmentConfig)
extends InMemoryTable(
name,
columns,
partitioning,
properties,
constraints,
- id = tableId)
+ id = tableId,
+ schemaAlignmentConfig = schemaAlignmentConfig)
with SupportsRowLevelOperations {
def this(
@@ -63,14 +66,16 @@ class InMemoryRowLevelOperationTable private (
partitioning: Array[Transform],
properties: util.Map[String, String],
constraints: Array[Constraint] = Array.empty,
- tableId: String = java.util.UUID.randomUUID().toString) = {
+ tableId: String = java.util.UUID.randomUUID().toString,
+ schemaAlignmentConfig: SchemaAlignmentConfig =
SchemaAlignmentConfig.DEFAULT) = {
this(
name = name,
columns = CatalogV2Util.structTypeToV2Columns(schema),
partitioning = partitioning,
properties = properties,
constraints = constraints,
- tableId = tableId)
+ tableId = tableId,
+ schemaAlignmentConfig = schemaAlignmentConfig)
}
private final val PARTITION_COLUMN_REF =
FieldReference(PartitionKeyColumn.name)
@@ -106,7 +111,8 @@ class InMemoryRowLevelOperationTable private (
partitioning = partitioning,
properties = properties,
constraints = constraints,
- tableId = id)
+ tableId = id,
+ schemaAlignmentConfig = schemaAlignmentConfig)
dataMap.synchronized {
dataMap.foreach { case (key, splits) =>
val copiedSplits = splits.map { bufferedRows =>
@@ -373,9 +379,11 @@ object InMemoryRowLevelOperationTable {
partitioning: Array[Transform],
properties: util.Map[String, String],
constraints: Array[Constraint] = Array.empty,
- tableId: String = java.util.UUID.randomUUID().toString):
InMemoryRowLevelOperationTable = {
+ tableId: String = java.util.UUID.randomUUID().toString,
+ schemaAlignmentConfig: SchemaAlignmentConfig =
SchemaAlignmentConfig.DEFAULT)
+ : InMemoryRowLevelOperationTable = {
new InMemoryRowLevelOperationTable(
- name, columns, partitioning, properties, constraints, tableId)
+ name, columns, partitioning, properties, constraints, tableId,
schemaAlignmentConfig)
}
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
index 95e6659956ca..5c9060fcca6c 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
@@ -97,7 +97,8 @@ class InMemoryRowLevelOperationTableCatalog
partitioning = partitioning,
properties = properties,
constraints = constraints,
- tableId = table.id)
+ tableId = table.id,
+ schemaAlignmentConfig = table.schemaAlignmentConfig)
newTable.alterTableWithData(table.data, schema)
newTable.setVersionAndValidatedVersionFrom(table)
@@ -139,7 +140,8 @@ class PartialSchemaEvolutionCatalog extends
InMemoryRowLevelOperationTableCatalo
schema = schema,
partitioning = table.partitioning,
properties = properties,
- constraints = table.constraints)
+ constraints = table.constraints,
+ schemaAlignmentConfig = table.schemaAlignmentConfig)
newTable.alterTableWithData(table.data, table.schema)
newTable.setVersionAndValidatedVersionFrom(table)
tables.put(ident, newTable)
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
index c783bfbece14..20ce2af03ac6 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
@@ -26,6 +26,7 @@ import
org.apache.spark.sql.connector.distributions.{Distribution, Distributions
import org.apache.spark.sql.connector.expressions.{SortOrder, Transform}
import org.apache.spark.sql.connector.read._
import org.apache.spark.sql.connector.write.{LogicalWriteInfo,
SupportsOverwrite, WriteBuilder, WriterCommitMessage}
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
import org.apache.spark.sql.sources._
import org.apache.spark.sql.types.{LongType, StructField, StructType}
import org.apache.spark.sql.util.CaseInsensitiveStringMap
@@ -47,10 +48,11 @@ class InMemoryTable(
advisoryPartitionSize: Option[Long] = None,
isDistributionStrictlyRequired: Boolean = true,
override val numRowsPerSplit: Int = Int.MaxValue,
- override val id: String = UUID.randomUUID().toString)
+ override val id: String = UUID.randomUUID().toString,
+ override val schemaAlignmentConfig: SchemaAlignmentConfig =
SchemaAlignmentConfig.DEFAULT)
extends InMemoryBaseTable(name, columns, partitioning, properties,
constraints, distribution,
ordering, numPartitions, advisoryPartitionSize,
isDistributionStrictlyRequired,
- numRowsPerSplit) with SupportsDelete {
+ numRowsPerSplit, schemaAlignmentConfig) with SupportsDelete {
def this(
name: String,
@@ -143,7 +145,8 @@ class InMemoryTable(
advisoryPartitionSize,
isDistributionStrictlyRequired,
numRowsPerSplit,
- id)
+ id,
+ schemaAlignmentConfig = schemaAlignmentConfig)
dataMap.synchronized {
dataMap.foreach { case (key, splits) =>
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
index cc5c75dd779b..dc9253e42540 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
@@ -32,6 +32,7 @@ import
org.apache.spark.sql.connector.catalog.procedures.{BoundProcedure, Proced
import org.apache.spark.sql.connector.distributions.{Distribution,
Distributions}
import org.apache.spark.sql.connector.expressions.{SortOrder, Transform}
import org.apache.spark.sql.connector.read.{LocalScan, Scan}
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
import org.apache.spark.sql.types.{DataTypes, StructType}
import org.apache.spark.sql.util.CaseInsensitiveStringMap
@@ -219,11 +220,13 @@ class BasicInMemoryTableCatalog extends TableCatalog {
advisoryPartitionSize: Option[Long],
distributionStrictlyRequired: Boolean,
numRowsPerSplit: Int,
- id: String): InMemoryBaseTable = {
+ id: String,
+ schemaAlignmentConfig: SchemaAlignmentConfig =
SchemaAlignmentConfig.DEFAULT
+ ): InMemoryBaseTable = {
// scalastyle:on argcount
new InMemoryTable(name, columns, partitioning, properties, constraints,
distribution,
ordering, requiredNumPartitions, advisoryPartitionSize,
distributionStrictlyRequired,
- numRowsPerSplit, id)
+ numRowsPerSplit, id, schemaAlignmentConfig)
}
override def alterTable(ident: Identifier, changes: TableChange*): Table = {
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalystRuntimeFilterCatalog.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalystRuntimeFilterCatalog.scala
index e699f439223f..63389e3f941d 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalystRuntimeFilterCatalog.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalystRuntimeFilterCatalog.scala
@@ -22,6 +22,7 @@ import java.util
import org.apache.spark.sql.connector.catalog.constraints.Constraint
import org.apache.spark.sql.connector.distributions.Distribution
import org.apache.spark.sql.connector.expressions.{SortOrder, Transform}
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
/**
* Mix-in that constructs [[InMemoryCatalystRuntimeFilterTable]] from the
shared in-memory
@@ -41,7 +42,9 @@ trait InMemoryCatalystRuntimeFilterTableFactory { self:
BasicInMemoryTableCatalo
advisoryPartitionSize: Option[Long],
distributionStrictlyRequired: Boolean,
numRowsPerSplit: Int,
- id: String): InMemoryBaseTable = {
+ id: String,
+ // InMemoryCatalystRuntimeFilterTable carries no schema-alignment
config, so this is ignored.
+ schemaAlignmentConfig: SchemaAlignmentConfig): InMemoryBaseTable = {
// scalastyle:on argcount
new InMemoryCatalystRuntimeFilterTable(
name, columns, partitioning, properties, constraints, distribution,
ordering,
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2FilterCatalog.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2FilterCatalog.scala
index be5a907f5212..0c0a79975ac1 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2FilterCatalog.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2FilterCatalog.scala
@@ -22,6 +22,7 @@ import java.util
import org.apache.spark.sql.connector.catalog.constraints.Constraint
import org.apache.spark.sql.connector.distributions.Distribution
import org.apache.spark.sql.connector.expressions.{SortOrder, Transform}
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
class InMemoryTableWithV2FilterCatalog extends InMemoryTableCatalog {
// scalastyle:off argcount
@@ -37,7 +38,9 @@ class InMemoryTableWithV2FilterCatalog extends
InMemoryTableCatalog {
advisoryPartitionSize: Option[Long],
distributionStrictlyRequired: Boolean,
numRowsPerSplit: Int,
- id: String): InMemoryBaseTable = {
+ id: String,
+ // InMemoryTableWithV2Filter carries no schema-alignment config, so this
is ignored.
+ schemaAlignmentConfig: SchemaAlignmentConfig): InMemoryBaseTable = {
// scalastyle:on argcount
new InMemoryTableWithV2Filter(
name, columns, partitioning, properties, constraints, distribution,
ordering,
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/txns.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/txns.scala
index f48f99994a8c..0a18f32e7029 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/txns.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/txns.scala
@@ -110,7 +110,8 @@ class TxnTable(
schema,
delegate.partitioning,
delegate.properties,
- delegate.constraints) {
+ delegate.constraints,
+ schemaAlignmentConfig = delegate.schemaAlignmentConfig) {
// Expose the same id as the delegate so that identity checks during
transaction re-resolution
// don't false-positive on the TxnTable wrapper having a different UUID.
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/types/DataTypeWriteCompatibilitySuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/types/DataTypeWriteCompatibilitySuite.scala
index 131eab34762f..b30e0d8fd7c1 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/types/DataTypeWriteCompatibilitySuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/types/DataTypeWriteCompatibilitySuite.scala
@@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.types.DataTypeUtils
import org.apache.spark.sql.catalyst.util.TypeUtils.toSQLType
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.SQLConf.StoreAssignmentPolicy
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
class StrictDataTypeWriteCompatibilitySuite extends
DataTypeWriteCompatibilityBaseSuite {
override def storeAssignmentPolicy: SQLConf.StoreAssignmentPolicy.Value =
@@ -152,6 +153,153 @@ class ANSIDataTypeWriteCompatibilitySuite extends
DataTypeWriteCompatibilityBase
)
}
+ test("RUNTIME does not relax struct field ordering") {
+ val writeStruct = StructType(Seq(
+ StructField("first", FloatType, nullable = false),
+ StructField("second", StringType, nullable = false)))
+ val readStruct = StructType(Seq(
+ StructField("first", FloatType, nullable = false),
+ StructField("renamed", IntegerType, nullable = false)))
+
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", writeStruct, readStruct, byName = true,
+ analysis.caseSensitiveResolution, "t", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck = AnsiStoreAssignmentCastCheck.RUNTIME)
+ ),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.UNEXPECTED_COLUMN_NAME",
+ parameters = Map(
+ "tableName" -> "``",
+ "colName" -> "`t`",
+ "order" -> "1",
+ "expected" -> "`renamed`",
+ "found" -> "`second`")
+ )
+ }
+
+ test("RUNTIME does not relax array element nullability") {
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", ArrayType(LongType, containsNull = true),
+ ArrayType(LongType, containsNull = false), byName = true,
+ analysis.caseSensitiveResolution, "arr", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck =
AnsiStoreAssignmentCastCheck.RUNTIME)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.NULLABLE_ARRAY_ELEMENTS",
+ parameters = Map("tableName" -> "``", "colName" -> "`arr`"))
+ }
+
+ test("RUNTIME does not relax map value nullability") {
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", MapType(StringType, LongType,
valueContainsNull = true),
+ MapType(StringType, LongType, valueContainsNull = false), byName =
true,
+ analysis.caseSensitiveResolution, "m", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck =
AnsiStoreAssignmentCastCheck.RUNTIME)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.NULLABLE_MAP_VALUES",
+ parameters = Map("tableName" -> "``", "colName" -> "`m`"))
+ }
+
+ test("RUNTIME does not relax struct field nullability") {
+ val writeType = StructType(Seq(StructField("x", LongType, nullable =
true)))
+ val readType = StructType(Seq(StructField("x", LongType, nullable =
false)))
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", writeType, readType, byName = true,
+ analysis.caseSensitiveResolution, "t", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck =
AnsiStoreAssignmentCastCheck.RUNTIME)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.NULLABLE_COLUMN",
+ parameters = Map("tableName" -> "``", "colName" -> "`t`.`x`"))
+ }
+
+ test("RUNTIME allows complex-to-string casts") {
+ Seq(
+ ArrayType(IntegerType),
+ MapType(StringType, IntegerType),
+ StructType(Seq(StructField("a", IntegerType)))).foreach { writeType =>
+ assert(
+ DataTypeUtils.canWrite("", writeType, StringType, byName = true,
+ analysis.caseSensitiveResolution, "c", storeAssignmentPolicy,
+ _ => (),
+ ansiStoreAssignmentCastCheck = AnsiStoreAssignmentCastCheck.RUNTIME),
+ s"$writeType -> string should be allowed under RUNTIME")
+ }
+ }
+
+ test("RUNTIME rejects unsupported datetime-to-variant casts") {
+ val unsupportedTypes = Seq(
+ TimeType(TimeType.DEFAULT_PRECISION),
+ TimeType(TimeType.MAX_PRECISION),
+ TimestampLTZNanosType(TimestampLTZNanosType.MAX_PRECISION),
+ TimestampNTZNanosType(TimestampNTZNanosType.MAX_PRECISION))
+ val arrays = unsupportedTypes.map(ArrayType(_))
+ val nestedArrays = unsupportedTypes.map(t => ArrayType(ArrayType(t)))
+ (unsupportedTypes ++ arrays ++ nestedArrays).foreach { writeType =>
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", writeType, VariantType, byName = true,
+ analysis.caseSensitiveResolution, "v", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck =
AnsiStoreAssignmentCastCheck.RUNTIME)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+ parameters = Map(
+ "tableName" -> "``",
+ "colName" -> "`v`",
+ "srcType" -> toSQLType(writeType),
+ "targetType" -> toSQLType(VariantType)))
+ }
+ }
+
+ test("RUNTIME allows supported array-to-variant casts") {
+ Seq(ArrayType(DateType), ArrayType(ArrayType(TimestampType))).foreach {
writeType =>
+ assert(
+ DataTypeUtils.canWrite("", writeType, VariantType, byName = true,
+ analysis.caseSensitiveResolution, "v", storeAssignmentPolicy,
+ _ => (),
+ ansiStoreAssignmentCastCheck = AnsiStoreAssignmentCastCheck.RUNTIME),
+ s"$writeType -> variant should be allowed under RUNTIME")
+ }
+ }
+
+ test("RUNTIME does not relax UDT field-name validation") {
+ // A UDT backed by STRUCT<b, a> written by name into STRUCT<a, b> must be
rejected on the
+ // field-name mismatch; deferring the ANSI cast check must not unwrap the
UDT and silently swap
+ // fields positionally.
+ val udtType = new UserDefinedType[Any] {
+ override def sqlType: DataType = StructType(Seq(
+ StructField("b", IntegerType, nullable = false),
+ StructField("a", IntegerType, nullable = false)))
+ override def userClass: java.lang.Class[Any] = null
+ override def serialize(obj: Any): Any = null
+ override def deserialize(datum: Any): Any = null
+ }
+ val readType = StructType(Seq(
+ StructField("a", IntegerType, nullable = false),
+ StructField("b", IntegerType, nullable = false)))
+ val errs = new mutable.ArrayBuffer[String]()
+ checkError(
+ exception = intercept[AnalysisException](
+ DataTypeUtils.canWrite("", udtType, readType, byName = true,
+ analysis.caseSensitiveResolution, "t", storeAssignmentPolicy,
+ errMsg => errs += errMsg,
+ ansiStoreAssignmentCastCheck =
AnsiStoreAssignmentCastCheck.RUNTIME)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.UNEXPECTED_COLUMN_NAME",
+ parameters = Map(
+ "tableName" -> "``",
+ "colName" -> "`t`",
+ "order" -> "0",
+ "expected" -> "`a`",
+ "found" -> "`b`"))
+ }
+
private val stringPoint2 = StructType(Seq(
StructField("x", StringType, nullable = false),
StructField("y", StringType, nullable = false)))
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
index 0b037ed6829b..fb92a17161e0 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
@@ -60,6 +60,7 @@ import org.apache.spark.sql.execution.window.{Final, Partial,
WindowGroupLimitEx
import org.apache.spark.sql.functions.{col, max}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.SQLConf._
+import org.apache.spark.sql.internal.connector.SchemaAlignmentConfig
import org.apache.spark.sql.types._
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.tags.ExtendedSQLTest
@@ -9944,11 +9945,12 @@ class CustomReportingCatalog extends
InMemoryTableCatalog {
advisoryPartitionSize: Option[Long],
distributionStrictlyRequired: Boolean,
numRowsPerSplit: Int,
- id: String): InMemoryBaseTable = {
+ id: String,
+ schemaAlignmentConfig: SchemaAlignmentConfig): InMemoryBaseTable = {
// scalastyle:on argcount
new InMemoryTable(name, columns, partitioning, properties, constraints,
distribution,
ordering, requiredNumPartitions, advisoryPartitionSize,
distributionStrictlyRequired,
- numRowsPerSplit, id) {
+ numRowsPerSplit, id, schemaAlignmentConfig) {
override def newScanBuilder(options: CaseInsensitiveStringMap):
ScanBuilder =
new InMemoryScanBuilder(schema(), options) {
override def build(): Scan =
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/SchemaAlignmentConfigSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/SchemaAlignmentConfigSuite.scala
new file mode 100644
index 000000000000..536d10e93fd0
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/SchemaAlignmentConfigSuite.scala
@@ -0,0 +1,389 @@
+/*
+ * 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, Try}
+
+import org.apache.spark.SparkConf
+import org.apache.spark.SparkThrowable
+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.internal.connector.SchemaAlignmentConfig
+import
org.apache.spark.sql.internal.connector.SchemaAlignmentConfig.AnsiStoreAssignmentCastCheck
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StructType}
+
+/**
+ * A catalog that creates [[InMemoryRowLevelOperationTable]]s carrying the
fixed
+ * [[SchemaAlignmentConfig]] supplied by the concrete subclass.
+ */
+abstract class SchemaAlignmentTestCatalog extends
InMemoryRowLevelOperationTableCatalog {
+
+ protected def tableConfig: SchemaAlignmentConfig
+
+ 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 defer the ANSI store-assignment cast check to
runtime. */
+class RelaxedSchemaAlignmentCatalog extends SchemaAlignmentTestCatalog {
+ override protected def tableConfig: SchemaAlignmentConfig = new
SchemaAlignmentConfig {
+ override def ansiStoreAssignmentCastCheck(): AnsiStoreAssignmentCastCheck =
+ AnsiStoreAssignmentCastCheck.RUNTIME
+ }
+}
+
+/** 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 withAnsiPolicy(f: => Unit): Unit =
+ withSQLConf(
+ SQLConf.STORE_ASSIGNMENT_POLICY.key ->
StoreAssignmentPolicy.ANSI.toString)(f)
+
+ test("RUNTIME: 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("RUNTIME: 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("RUNTIME: 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("RUNTIME: 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)")
+ }
+ }
+ }
+ }
+
+ test("RUNTIME: complex-to-string cast is deferred") {
+ withTable(s"$relaxed.t", s"$strict.t") {
+ sql(s"CREATE TABLE $relaxed.t (c STRING) USING foo")
+ sql(s"CREATE TABLE $strict.t (c STRING) USING foo")
+ withAnsiPolicy {
+ sql(s"INSERT INTO $relaxed.t VALUES (array(1, 2))")
+ checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row("[1, 2]"))
+ val strictInsert = s"INSERT INTO $strict.t VALUES (array(1, 2))"
+ checkError(
+ exception = intercept[AnalysisException](sql(strictInsert)),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+ parameters = Map(
+ "tableName" -> s"`$strict`.`t`",
+ "colName" -> "`c`",
+ "srcType" -> "\"ARRAY<INT>\"",
+ "targetType" -> "\"STRING\""))
+ }
+ }
+ }
+
+ test("RUNTIME: long and decimal to timestamp casts are rejected") {
+ withTable(s"$relaxed.t") {
+ sql(s"CREATE TABLE $relaxed.t (c TIMESTAMP) USING foo")
+ withAnsiPolicy {
+ Seq(
+ "9223372036854775807L",
+ "cast(99999999999999999999999999999999999999 as decimal(38, 0))"
+ ).foreach { value =>
+ val error = intercept[AnalysisException] {
+ sql(s"INSERT INTO $relaxed.t VALUES ($value)")
+ }
+ assert(error.getCondition ==
"INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST")
+ }
+ }
+ }
+ }
+
+ test("RUNTIME: variant-to-complex casts are rejected") {
+ Seq(
+ "STRUCT<a: INT>" -> "parse_json('{}')",
+ "ARRAY<INT>" -> "parse_json('[]')",
+ "MAP<STRING, INT>" -> "parse_json('{}')"
+ ).foreach { case (targetType, value) =>
+ withTable(s"$relaxed.t") {
+ sql(s"CREATE TABLE $relaxed.t (c $targetType) USING foo")
+ withAnsiPolicy {
+ val error = intercept[AnalysisException] {
+ sql(s"INSERT INTO $relaxed.t VALUES ($value)")
+ }
+ assert(error.getCondition ==
"INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST")
+ }
+ }
+ }
+ }
+
+ test("schema alignment config survives ALTER TABLE ADD COLUMNS") {
+ 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")
+ sql(s"ALTER TABLE $relaxed.t ADD COLUMNS (data INT)")
+ sql(s"ALTER TABLE $strict.t ADD COLUMNS (data INT)")
+ withAnsiPolicy {
+ sql(s"INSERT INTO $relaxed.t VALUES (1, '5')")
+ checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, 5))
+ checkError(
+ exception = intercept[AnalysisException](sql(s"INSERT INTO $strict.t
VALUES (1, '5')")),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+ parameters = Map(
+ "tableName" -> s"`$strict`.`t`",
+ "colName" -> "`data`",
+ "srcType" -> "\"STRING\"",
+ "targetType" -> "\"INT\""))
+ }
+ }
+ }
+
+ test("schema alignment config survives schema evolution") {
+ 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")
+ val evolving = spark.createDataFrame(
+ java.util.Arrays.asList(Row(1, 5)),
+ new StructType().add("id", IntegerType).add("data", IntegerType))
+
evolving.write.mode("append").withSchemaEvolution().insertInto(s"$relaxed.t")
+
evolving.write.mode("append").withSchemaEvolution().insertInto(s"$strict.t")
+ withAnsiPolicy {
+ sql(s"INSERT INTO $relaxed.t VALUES (2, '5')")
+ checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Seq(Row(1, 5), Row(2,
5)))
+ checkError(
+ exception = intercept[AnalysisException](sql(s"INSERT INTO $strict.t
VALUES (2, '5')")),
+ condition = "INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_SAFELY_CAST",
+ parameters = Map(
+ "tableName" -> s"`$strict`.`t`",
+ "colName" -> "`data`",
+ "srcType" -> "\"STRING\"",
+ "targetType" -> "\"INT\""))
+ }
+ }
+ }
+
+ 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 assertBothReject(
+ targetSchema: StructType, source: DataFrame, errorClass: String): Unit =
+ withAnsiPolicy {
+ Seq(relaxed, strict).foreach { catalog =>
+ appendByName(catalog, targetSchema, source) match {
+ case Failure(error: SparkThrowable) =>
+ assert(error.getCondition == errorClass,
+ s"$catalog: expected $errorClass, got ${error.getCondition}")
+ case other =>
+ fail(s"$catalog: expected rejection with $errorClass, got $other")
+ }
+ }
+ }
+
+ test("RUNTIME: renamed nested struct field is still rejected") {
+ val target = new StructType()
+ .add("s", new StructType().add("a", IntegerType).add("b", IntegerType))
+ val source = spark.createDataFrame(
+ java.util.Arrays.asList(Row(Row(1, 2))),
+ new StructType().add("s", new StructType().add("a",
IntegerType).add("c", IntegerType)))
+ assertBothReject(target, source,
"INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA")
+ }
+
+ // Invalid source values and the error the cast or null check inserted under
RUNTIME raises. The
+ // values are read from a table, so the casts aren't constant-folded during
analysis.
+ private val invalidValues = Seq(
+ // Malformed string.
+ "'abc'" -> "CAST_INVALID_INPUT",
+ // Out-of-range numeric string.
+ "'9999999999'" -> "CAST_INVALID_INPUT",
+ // NULL written to a NOT NULL column.
+ "CAST(NULL AS STRING)" -> "NOT_NULL_ASSERT_VIOLATION")
+
+ Seq(true, false).foreach { ansiEnabled =>
+ test(s"RUNTIME: INSERT of invalid values fails at execution,
ansi.enabled=$ansiEnabled") {
+ invalidValues.foreach { case (value, expectedCondition) =>
+ withTable(s"$relaxed.src", s"$relaxed.t") {
+ sql(s"CREATE TABLE $relaxed.src (v STRING) USING foo")
+ sql(s"INSERT INTO $relaxed.src VALUES ($value)")
+ sql(s"CREATE TABLE $relaxed.t (c INT NOT NULL) USING foo")
+ withSQLConf(
+ SQLConf.STORE_ASSIGNMENT_POLICY.key ->
StoreAssignmentPolicy.ANSI.toString,
+ SQLConf.ANSI_ENABLED.key -> ansiEnabled.toString) {
+ val error = intercept[SparkThrowable] {
+ sql(s"INSERT INTO $relaxed.t SELECT v FROM $relaxed.src")
+ }
+ assert(error.getCondition == expectedCondition, s"value: $value")
+ }
+ checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Seq.empty)
+ }
+ }
+ }
+
+ test(s"RUNTIME: UPDATE with invalid values fails at execution,
ansi.enabled=$ansiEnabled") {
+ invalidValues.foreach { case (value, expectedCondition) =>
+ withTable(s"$relaxed.t") {
+ sql(s"CREATE TABLE $relaxed.t (id INT, data INT NOT NULL, s STRING)
USING foo")
+ sql(s"INSERT INTO $relaxed.t VALUES (1, 0, $value)")
+ withSQLConf(
+ SQLConf.STORE_ASSIGNMENT_POLICY.key ->
StoreAssignmentPolicy.ANSI.toString,
+ SQLConf.ANSI_ENABLED.key -> ansiEnabled.toString) {
+ val error = intercept[SparkThrowable] {
+ sql(s"UPDATE $relaxed.t SET data = s WHERE id = 1")
+ }
+ assert(error.getCondition == expectedCondition, s"value: $value")
+ }
+ checkAnswer(sql(s"SELECT id, data FROM $relaxed.t"), Row(1, 0))
+ }
+ }
+ }
+
+ test(s"RUNTIME: MERGE with invalid values fails at execution,
ansi.enabled=$ansiEnabled") {
+ invalidValues.foreach { case (value, expectedCondition) =>
+ Seq(
+ "WHEN MATCHED THEN UPDATE SET t.data = s.v" -> 1,
+ "WHEN NOT MATCHED THEN INSERT (id, data) VALUES (s.id, s.v)" -> 2
+ ).foreach { case (clause, sourceId) =>
+ withTable(s"$relaxed.src", s"$relaxed.t") {
+ sql(s"CREATE TABLE $relaxed.src (id INT, v STRING) USING foo")
+ sql(s"INSERT INTO $relaxed.src VALUES ($sourceId, $value)")
+ sql(s"CREATE TABLE $relaxed.t (id INT, data INT NOT NULL) USING
foo")
+ sql(s"INSERT INTO $relaxed.t VALUES (1, 0)")
+ withSQLConf(
+ SQLConf.STORE_ASSIGNMENT_POLICY.key ->
StoreAssignmentPolicy.ANSI.toString,
+ SQLConf.ANSI_ENABLED.key -> ansiEnabled.toString) {
+ val error = intercept[SparkThrowable] {
+ sql(
+ s"""MERGE INTO $relaxed.t t
+ |USING $relaxed.src s
+ |ON t.id = s.id
+ |$clause""".stripMargin)
+ }
+ assert(error.getCondition == expectedCondition, s"value: $value,
clause: $clause")
+ }
+ checkAnswer(sql(s"SELECT * FROM $relaxed.t"), Row(1, 0))
+ }
+ }
+ }
+ }
+ }
+
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]