Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions common/utils/src/main/resources/error/error-conditions.json
Original file line number Diff line number Diff line change
Expand Up @@ -6742,6 +6742,14 @@
],
"sqlState" : "0A000"
},
"PIPELINE_NESTED_COMPLEX_TYPE_SCHEMA_EVOLUTION_UNSUPPORTED" : {
"message" : [
"Schema evolution within a complex type (array or map) whose element is itself a complex type is not supported.",
"Column path <columnPath> has element type <currentType> but the target element type is <targetType>.",
"Flatten the nesting or apply the change manually."
],
"sqlState" : "0A000"
},
"PIPELINE_RUN_FAILED" : {
"message" : [
"<message>"
Expand All @@ -6762,6 +6770,12 @@
],
"sqlState" : "42K03"
},
"PIPELINE_TIGHTEN_NULLABILITY_UNSUPPORTED" : {
"message" : [
"Cannot tighten nullability of <columnPath> from nullable to non-nullable. Existing data may already contain nulls."
],
"sqlState" : "0A000"
},
"PIPE_OPERATOR_AGGREGATE_EXPRESSION_CONTAINS_NO_AGGREGATE_FUNCTION" : {
"message" : [
"Non-grouping expression <expr> is provided as an argument to the |> AGGREGATE pipe operator but does not contain any aggregate function; please update it to include an aggregate function and then retry the query again."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package org.apache.spark.sql.pipelines.util

import scala.util.control.NonFatal

import org.apache.spark.SparkUnsupportedOperationException
import org.apache.spark.internal.Logging
import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.catalyst.analysis.{
caseInsensitiveResolution,
Expand All @@ -34,10 +36,13 @@ import org.apache.spark.sql.pipelines.graph.{
GraphErrors,
ResolvedFlow
}
import org.apache.spark.sql.types.{StructField, StructType}
import org.apache.spark.sql.types.{
ArrayType, DataType, MapType, Metadata, MetadataBuilder,
StructField, StructType
}


object SchemaInferenceUtils {
object SchemaInferenceUtils extends Logging {

def resolverFor(caseSensitive: Boolean): Resolver = {
if (caseSensitive) {
Expand Down Expand Up @@ -207,12 +212,12 @@ object SchemaInferenceUtils {
}

/**
* Determines the column changes needed to transform the current schema into the target schema.
*
* This function compares the current schema with the target schema and produces a sequence of
* TableChange objects representing:
* 1. New columns that need to be added
* 2. Existing columns that need type updates
* Produces the [[TableChange]] sequence needed to transform `currentSchema` into
* `targetSchema`: additions, type updates, deletions, nullability and comment changes.
* Recurses into structs, arrays, and maps so changes are emitted at the leaf level.
* Similar to [[org.apache.spark.sql.catalyst.analysis.ResolveSchemaEvolution]], but
* produces a full bidirectional sync (deletes, nullability, and comment changes) rather
* than additive-only evolution.
*
* Column identity is keyed on the exact field name, not on a case-normalized one. On the
* incremental streaming-table path, `targetSchema` is the merge of the current and desired
Expand All @@ -227,59 +232,234 @@ object SchemaInferenceUtils {
* @param targetSchema The target schema that we want the table to have
* @return A sequence of TableChange objects representing the necessary changes
*/
def diffSchemas(currentSchema: StructType, targetSchema: StructType): Seq[TableChange] = {
val changes = scala.collection.mutable.ArrayBuffer.empty[TableChange]
def diffSchemas(currentSchema: StructType, targetSchema: StructType): Seq[TableChange] =
diffStructs(
currentStruct = currentSchema,
targetStruct = targetSchema,
// Root call: path is empty because current and target are the top-level schemas.
pathToStruct = Seq.empty
)

// Helper function to get a map of field name to field
def getFieldMap(schema: StructType): Map[String, StructField] = {
schema.fields.map(field => field.name -> field).toMap
}
/**
* Diffs two structs field-by-field, matching fields by exact name.
*
* @param currentStruct The struct as it exists in the current schema.
* @param targetStruct The struct as it should look in the target schema.
* @param pathToStruct Path segments from the top-level schema to this
* struct, if this is a nested struct. Empty for the
* root call.
*/
private def diffStructs(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the PR description, field order is intentionally not considered when diffing two structs.

It's fair to argue that it should, but that would be out of scope for this PR; I'm choosing to keep previous behavior, albeit now it applies recursively to nested struct comparisons too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact key matches (case-sensitive) is maintained behavior from the previous implementation too, and the scaladoc section that discusses it for incremental/full refresh cases is carried as-is.

currentStruct: StructType,
targetStruct: StructType,
pathToStruct: Seq[String]): Seq[TableChange] = {
val topLevelFieldsInCurrent = currentStruct.fields.map(field => field.name -> field).toMap
val topLevelFieldsInTarget = targetStruct.fields.map(field => field.name -> field).toMap

val currentFields = getFieldMap(currentSchema)
val targetFields = getFieldMap(targetSchema)
// Fields present in target but not in current are columns that need to be added.
val columnsAdded = topLevelFieldsInTarget.values.toSeq
.filterNot(fieldInTarget =>
topLevelFieldsInCurrent.contains(fieldInTarget.name)
)
.map { fieldInTarget =>
TableChange.addColumn(
(pathToStruct :+ fieldInTarget.name).toArray,
fieldInTarget.dataType,
fieldInTarget.nullable,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should newly added fields be forced nullable when existing rows are retained? On the incremental streaming-table path, a new nested field has no value in old rows. Preserving nullable = false here can either make the catalog reject evolution or expose null values under a non-null schema. ResolveSchemaEvolution handles this by adding missing fields as nullable and making their nested data types nullable as well. The new test that expects false appears to enshrine the unsafe contract; could the additive evolution path emit a nullable field instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explicit validation that nullability may only be widening. Throws PIPELINE_TIGHTEN_NULLABILITY_UNSUPPORTED otherwise.

fieldInTarget.getComment().orNull
)
}

// Find columns to add (in target but not in current)
val columnsToAdd = targetFields.keySet.diff(currentFields.keySet)
columnsToAdd.foreach { columnName =>
val field = targetFields(columnName)
changes += TableChange.addColumn(
Array(columnName),
field.dataType,
field.nullable,
field.getComment().orNull
// Fields present in current but not in target are columns that need to be removed.
val columnsDeleted = topLevelFieldsInCurrent.values.toSeq
.filterNot(fieldInCurrent =>
topLevelFieldsInTarget.contains(fieldInCurrent.name)
)
.map(fieldInCurrent =>
TableChange
.deleteColumn(
(pathToStruct :+ fieldInCurrent.name).toArray,
false
)
)

// Fields in both current and target but vary in metadata or nested sub-fields represent
// columns that need to be updated.
val columnsUpdated = topLevelFieldsInCurrent.values.toSeq.flatMap {
fieldInCurrent =>
topLevelFieldsInTarget.get(fieldInCurrent.name).toSeq.flatMap {
fieldInTarget =>
diffField(
currentField = fieldInCurrent,
targetField = fieldInTarget,
pathToField = pathToStruct :+ fieldInCurrent.name
)
}
}

// Find columns to delete (in current but not in target)
val columnsToDelete = currentFields.keySet.diff(targetFields.keySet)
columnsToDelete.foreach { columnName =>
changes += TableChange.deleteColumn(Array(columnName), false)
columnsAdded ++ columnsDeleted ++ columnsUpdated
}

/**
* Diffs the type, nullability, and comment of one field present in both schemas. Other
* StructField.metadata entries (defaults, generated-column expressions, connector-specific
* metadata) are not diffed: pipeline schema synchronization does not support propagating
* them, and Spark's own ResolveSchemaEvolution likewise ignores them.
*/
private def diffField(
currentField: StructField,
targetField: StructField,
pathToField: Seq[String]): Seq[TableChange] = {
warnOnFieldMetadataDrift(currentField, targetField, pathToField)
diffDataTypes(currentField.dataType, targetField.dataType, pathToField) ++
diffNullability(currentField.nullable, targetField.nullable, pathToField) ++
diffComment(currentField.getComment(), targetField.getComment(), pathToField)
}

/**
* Logs a warning when two fields' metadata bags differ beyond the "comment" key
* (which is already handled by [[diffComment]]). Pipeline schema synchronization does not
* support propagating other metadata entries (defaults, generated-column expressions,
* connector-specific metadata), so these differences are left for the user to reconcile.
*/
private def warnOnFieldMetadataDrift(
currentField: StructField,
targetField: StructField,
pathToField: Seq[String]): Unit = {
val current = stripMetadataComment(currentField.metadata)
val target = stripMetadataComment(targetField.metadata)
if (current != target) {
logWarning(
s"Field ${pathToField.mkString(".")} has metadata changes that pipeline schema " +
s"synchronization does not propagate and will be ignored. " +
s"Current: ${current.json}, Target: ${target.json}")
}
}

// Find columns with type changes (in both but with different types)
val commonColumns = currentFields.keySet.intersect(targetFields.keySet)
commonColumns.foreach { columnName =>
val currentField = currentFields(columnName)
val targetField = targetFields(columnName)
private def stripMetadataComment(m: Metadata): Metadata =
new MetadataBuilder().withMetadata(m).remove("comment").build()

// If data types are different, add a type update change
if (currentField.dataType != targetField.dataType) {
changes += TableChange.updateColumnType(Array(columnName), targetField.dataType)
}
private def diffNullability(
currentNullable: Boolean,
targetNullable: Boolean,
pathToField: Seq[String]
): Option[TableChange] = {
if (currentNullable && !targetNullable) {
throw new SparkUnsupportedOperationException(
errorClass = "PIPELINE_TIGHTEN_NULLABILITY_UNSUPPORTED",
messageParameters = Map("columnPath" -> pathToField.mkString(".")))
}
Option.when(currentNullable != targetNullable)(
TableChange.updateColumnNullability(pathToField.toArray, targetNullable)
)
}

// If nullability is different, add a nullability update change
if (currentField.nullable != targetField.nullable) {
changes += TableChange.updateColumnNullability(Array(columnName), targetField.nullable)
}
private def diffComment(
currentComment: Option[String],
targetComment: Option[String],
pathToField: Seq[String]
): Option[TableChange] = {
Option.when(currentComment != targetComment)(
TableChange.updateColumnComment(pathToField.toArray, targetComment.orNull)
)
}

/**
* Diffs two data types at `path`, descending through matching complex types.
*
* Recurses freely through structs, arrays, and maps. After recursing into an array element
* or map key/value, any actual changes are rejected when the child type is itself an array
* or map, because [[org.apache.spark.sql.connector.catalog.CatalogV2Util]] cannot resolve
* paths with consecutive `element`/`key`/`value` segments (see SPARK-59188).
*/
private def diffDataTypes(
currentType: DataType,
targetType: DataType,
pathToField: Seq[String]): Seq[TableChange] = (currentType, targetType) match {
case (currentStruct: StructType, targetStruct: StructType) =>
diffStructs(currentStruct, targetStruct, pathToField)

case (currentArray: ArrayType, targetArray: ArrayType) =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw turns out DSv2 doesn't support TableChanges to an array or map nested into another array/map. But I believe this is a bug with DSv2 and not how we construct the nested path here.

Filed a spark issue at https://issues.apache.org/jira/browse/SPARK-59188

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on SPARK-59188: can we avoid recursing through arrays/maps until that issue is fixed, or reject unsupported shapes here? For example, dropping y from array<array<struct<x: int, y: int>>> emits the path [a, element, element, y], but CatalogV2Util.replace only descends through an array when its immediate element is a StructType. InMemoryTableCatalog therefore rejects this path, while the previous implementation emitted an UpdateColumnType for a and could apply it. The catalog test in this PR only covers one array/map directly wrapping a struct, so it misses this failure.

@AnishMahto AnishMahto Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chose to be explicit and reject via the PIPELINE_NESTED_COMPLEX_TYPE_SCHEMA_EVOLUTION_UNSUPPORTED exception.

Btw I just tested on Spark 4.1 and the Iceberg catalog + connector locally to confirm, all nested schema changes threw an IllegalArgumentException: Cannot update '<col>', not a primitive type: <type>, not just an array/map nested in another array/map.

So although SDP wasn't throwing in SchemaInferenceUtils specifically before (and emitting an UpdateColumnType instead), a production catalog would throw - and throw for a much wider range of schemas.

val elementPath = pathToField :+ "element"
val dataTypeChanges = diffDataTypes(
currentType = currentArray.elementType,
targetType = targetArray.elementType,
pathToField = elementPath
)
val nullabilityChanges = diffNullability(
currentNullable = currentArray.containsNull,
targetNullable = targetArray.containsNull,
pathToField = elementPath
)
rejectUnsupportedNestedTypeChanges(
currentType = currentArray.elementType,
targetType = targetArray.elementType,
typeChanges = dataTypeChanges,
pathToElement = elementPath
)
dataTypeChanges ++ nullabilityChanges

// If comments are different, add a comment update change
val currentComment = currentField.getComment().orNull
val targetComment = targetField.getComment().orNull
if (currentComment != targetComment) {
changes += TableChange.updateColumnComment(Array(columnName), targetComment)
case (currentMap: MapType, targetMap: MapType) =>
val keyPath = pathToField :+ "key"
val valuePath = pathToField :+ "value"
val keyTypeChanges = diffDataTypes(
currentType = currentMap.keyType,
targetType = targetMap.keyType,
pathToField = keyPath
)
val valueTypeChanges = diffDataTypes(
currentType = currentMap.valueType,
targetType = targetMap.valueType,
pathToField = valuePath
)
val valueNullabilityChanges = diffNullability(
currentNullable = currentMap.valueContainsNull,
targetNullable = targetMap.valueContainsNull,
pathToField = valuePath
)
rejectUnsupportedNestedTypeChanges(
currentType = currentMap.keyType,
targetType = targetMap.keyType,
typeChanges = keyTypeChanges,
pathToElement = keyPath
)
rejectUnsupportedNestedTypeChanges(
currentType = currentMap.valueType,
targetType = targetMap.valueType,
typeChanges = valueTypeChanges,
pathToElement = valuePath
)
keyTypeChanges ++ valueTypeChanges ++ valueNullabilityChanges

case _ if currentType == targetType =>
Seq.empty

case _ =>
Seq(TableChange.updateColumnType(pathToField.toArray, targetType))
}

/**
* Throws when an array element or map key/value is itself an array or map and the
* recursive diff found changes. The resulting paths would contain consecutive
* `element`/`key`/`value` segments that
* [[org.apache.spark.sql.connector.catalog.CatalogV2Util]] cannot resolve (SPARK-59188).
*/
private def rejectUnsupportedNestedTypeChanges(
currentType: DataType,
targetType: DataType,
typeChanges: Seq[TableChange],
pathToElement: Seq[String]): Unit = {
if (typeChanges.nonEmpty) {
(currentType, targetType) match {
case (_: ArrayType | _: MapType, _: ArrayType | _: MapType) =>
throw new SparkUnsupportedOperationException(
errorClass = "PIPELINE_NESTED_COMPLEX_TYPE_SCHEMA_EVOLUTION_UNSUPPORTED",
messageParameters = Map(
"columnPath" -> pathToElement.mkString("."),
"currentType" -> currentType.simpleString,
"targetType" -> targetType.simpleString))
case _ =>
}
}

changes.toSeq
}
}
Loading