Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.expressions.Cast._
import org.apache.spark.sql.catalyst.expressions.codegen._
import org.apache.spark.sql.catalyst.expressions.codegen.Block._
import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
import org.apache.spark.sql.catalyst.trees.{LeafLike, UnaryLike}
import org.apache.spark.sql.catalyst.trees.{LeafLike, TreeNodeTag, UnaryLike}
import org.apache.spark.sql.catalyst.trees.TreePattern._
import org.apache.spark.sql.catalyst.util._
import org.apache.spark.sql.errors.QueryCompilationErrors
Expand Down Expand Up @@ -764,12 +764,175 @@ case class DropField(name: String) extends StructFieldsOperation with LeafLike[E
values.filterNot { case (field, _) => resolver(field.name, name) }
}

/**
* Builds a struct from fields copied from `structExpr` and values computed by `updatedValues`.
*
* `outputOrdinals` describes each output position. A nonnegative entry copies that field from
* `structExpr`; a negative entry evaluates the next expression in `updatedValues`. Storing copied
* fields as ordinals avoids adding a `GetStructField` child for every copied field. Row construction
* still visits every output field.
*
* For example, to transform `(a, b, c)` into `(a, newB, c, newD)`, `outputOrdinals` is
* `Seq(0, -1, 2, -1)` and `updatedValues` is `Seq(newB, newD)`. The first and third output fields
* copy source fields 0 and 2. The negative entries use `newB` and `newD`, in that order.
*
* If a nullable `structExpr` evaluates to null, the result is null and `updatedValues` are not
* evaluated.
*/
private case class UpdateFieldsExpression(
structExpr: Expression,
updatedValues: Seq[Expression],
outputOrdinals: Seq[Int],
outputFields: Seq[StructField]) extends Expression {

override def children: Seq[Expression] = structExpr +: updatedValues

override def nullable: Boolean = structExpr.nullable

override lazy val dataType: StructType = {

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.

Nit (P3): outputFields(index) and updatedValues(updatedIndex) are positional lookups, and the sequences evalExpr passes in are Lists on Scala 2.13 (updatedValues.toSeq from an ArrayBuffer, and the fieldSources result after any WithField). Each lookup is O(index), so building this type is O(F^2) for an F-field struct, and because it is a per-instance lazy val it is recomputed for every copy a tree transform makes. doGenCode has the same pattern in outputOrdinals.indices.filter(outputOrdinals(_) < 0) (line 891).

The per-row eval loop already uses indexedOrdinals and indexedUpdatedValues after the earlier review. Using those arrays here too, or walking the sequences with iterators, would keep planning and codegen linear in width for the wide structs this PR targets.

Verification:

  • Inspection: dataType and doGenCode access outputs through arrays or a single iterator pass.
  • Behavior: Wide-struct updates (hundreds of fields) keep identical output types and results under interpreted and codegen evaluation.

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.

Fixed in 049f52e. Type construction uses indexed ordinals and iterators; codegen uses indexed ordinals. Added 100/500-field checks for output types, nullability, null inputs, and interpreted/codegen results.

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.

Thanks, verified. dataType now walks the indexed ordinals and updated values with iterators, and doGenCode uses indexedOrdinals. The 100- and 500-field tests cover types, nullability, and interpreted and codegen results.

val sourceFields = structExpr.dataType.asInstanceOf[StructType]
var updatedIndex = 0
StructType(outputOrdinals.zipWithIndex.map { case (sourceOrdinal, index) =>
if (sourceOrdinal >= 0) {
sourceFields(sourceOrdinal)
} else {
val updatedValue = updatedValues(updatedIndex)
updatedIndex += 1
outputFields(index).copy(
dataType = updatedValue.dataType,
nullable = updatedValue.nullable)
}
})
}

override def eval(input: InternalRow): Any = {
val needsSource = nullable || outputOrdinals.exists(_ >= 0)
val sourceRow = if (needsSource) {
structExpr.eval(input).asInstanceOf[InternalRow]
} else {
null
}
if (sourceRow == null && nullable) {
null
} else {
val values = new Array[Any](outputOrdinals.length)
var updatedIndex = 0
var i = 0
while (i < outputOrdinals.length) {
val sourceOrdinal = outputOrdinals(i)

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.

Nit (P3): outputOrdinals and updatedValues are Scala 2.13 Lists here whenever a WithField is present (ArrayBuffer.toSeq returns a List, and so does its .map). This loop calls outputOrdinals.length, outputOrdinals(i), and updatedValues(updatedIndex) for every field of every row, so interpreted evaluation is O(width^2) per row. That's roughly 125k+ list hops per row for a 500-field struct. needsSource is also recomputed with an exists scan on every row. Codegen isn't affected because it copies into an int[], but interpreted mode is what you get when codegen falls back on very large expressions. Building indexed collections (toArray/IndexedSeq, or @transient lazy val arrays) and hoisting needsSource into a lazy val would keep this linear.

Verification:

  • Inspection: The per-row eval loop indexes only array-backed or IndexedSeq state, and needsSource is computed once per expression instance.

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.

Converted per-row indexed state to arrays and cached needsSource. The evaluation loop no longer indexes Scala Lists or rescans source requirements.

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.

Thanks, the per-row loop now uses the arrays and is linear. The same List indexing is still in dataType (outputFields(index), updatedValues(updatedIndex)) and in doGenCode (outputOrdinals.indices.filter(outputOrdinals(_) < 0)), which makes planning quadratic in width. Reusing indexedOrdinals/indexedUpdatedValues or zipped iterators there would fix it.

if (sourceOrdinal >= 0) {
values(i) = if (sourceRow == null) {
null
} else {
sourceRow.get(sourceOrdinal, dataType(i).dataType)
}
} else {
values(i) = updatedValues(updatedIndex).eval(input)
updatedIndex += 1
}
i += 1
}
new GenericInternalRow(values)
}
}

override protected def withNewChildrenInternal(
newChildren: IndexedSeq[Expression]): UpdateFieldsExpression =
copy(structExpr = newChildren.head, updatedValues = newChildren.tail)

override protected def doGenCode(ctx: CodegenContext, ev: ExprCode): ExprCode = {
val rowClass = classOf[GenericInternalRow].getName
val values = ctx.freshName("values")
val needsSource = nullable || outputOrdinals.exists(_ >= 0)
val sourceCode = if (needsSource) Some(structExpr.genCode(ctx)) else None
val sourceRow = ctx.freshName("sourceRow")

val copyValuesCode = if (outputOrdinals.exists(_ >= 0)) {
val index = ctx.freshName("i")
val sourceOrdinal = ctx.freshName("sourceOrdinal")
val sourceOrdinals = ctx.addReferenceObj("sourceOrdinals", outputOrdinals.toArray, "int[]")
val fieldTypes = ctx.addReferenceObj(
"fieldTypes",
dataType.fields.map(_.dataType).toArray,
s"${classOf[DataType].getName}[]")
s"""
|for (int $index = 0; $index < ${outputOrdinals.length}; $index++) {
| int $sourceOrdinal = $sourceOrdinals[$index];
| if ($sourceOrdinal >= 0) {
| $values[$index] = $sourceRow == null ? null :
| $sourceRow.get($sourceOrdinal, $fieldTypes[$index]);

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.

Non-blocking (P2): The previous lowering read each copied field through GetStructField codegen, i.e. CodeGenerator.getValue with typed getters (getLong, getBinaryView, getInterval, ...). The generic get(ordinal, DataType) here isn't supported by every InternalRow: ColumnarRow.get (ColumnarRow.java:191-235) has no branch for TimeType, GeometryType, GeographyType, or CalendarIntervalType and throws _LEGACY_ERROR_TEMP_3155. Structs from the vectorized Parquet reader arrive as ColumnarRow (vector.getStruct(rowId)), so with geospatial enabled by default, spark.read.parquet(p).select($"s".withField("b", lit(1))) on s STRUCT<g: GEOMETRY(4326), a: INT> now fails at runtime, where it used to succeed. The same goes for TIME fields when the time type is enabled. This only affects the code-generated path; interpreted GetStructField already used the generic get. Emitting CodeGenerator.getValue(sourceRow, fieldType, ordinal) per copied ordinal would keep the previous compatibility.

Verification:

  • Regression: A vectorized Parquet struct with an unchanged GEOMETRY or TIME field passes through withField under default codegen without SparkUnsupportedOperationException.

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.

Generated copies now use CodeGenerator.getValue, grouped by field type. Added vectorized Parquet regressions for unchanged TIME, GEOMETRY, and GEOGRAPHY fields.

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.

Thanks, verified. Copied fields now go through CodeGenerator.getValue per type group, and the new vectorized Parquet tests cover TIME, GEOMETRY, and GEOGRAPHY.

| }
|}
""".stripMargin
} else {
""
}

val updatedOutputOrdinals = outputOrdinals.indices.filter(outputOrdinals(_) < 0)
assert(updatedOutputOrdinals.length == updatedValues.length)
val updatedCodes = updatedValues.zip(updatedOutputOrdinals).map { case (expr, ordinal) =>
val eval = expr.genCode(ctx)
s"""
|${eval.code}
|if (${eval.isNull}) {
| $values[$ordinal] = null;
|} else {
| $values[$ordinal] = ${eval.value};
|}
""".stripMargin
}
val updatedValuesCode = if (updatedCodes.nonEmpty) {
ctx.splitExpressionsWithCurrentInputs(
expressions = updatedCodes,
funcName = "updateFields",
extraArguments = "Object[]" -> values :: Nil)
} else {
""
}

val outputCode = s"""
|Object[] $values = new Object[${outputOrdinals.length}];
|${copyValuesCode}
|${updatedValuesCode}
|${ev.value} = new $rowClass($values);
|$values = null;
""".stripMargin

sourceCode match {
case Some(source) if nullable =>
ev.copy(code = code"""
|${source.code}
|boolean ${ev.isNull} = ${source.isNull};
|InternalRow ${ev.value} = null;
|if (!${ev.isNull}) {
| InternalRow $sourceRow = ${source.value};
| $outputCode
|}
""".stripMargin)
case Some(source) =>
ev.copy(code = code"""
|${source.code}
|InternalRow $sourceRow = ${source.value};
|${CodeGenerator.javaType(dataType)} ${ev.value};
|$outputCode
""".stripMargin, isNull = FalseLiteral)
case None =>
ev.copy(code = code"""
|${CodeGenerator.javaType(dataType)} ${ev.value};
|$outputCode
""".stripMargin, isNull = FalseLiteral)
}
}
}

/**
* Updates fields in a struct.
*/
case class UpdateFields(structExpr: Expression, fieldOps: Seq[StructFieldsOperation])
extends Unevaluable {

private lazy val generatedStructReadOwner = UpdateFields.generatedStructReadOwner(this)

final override val nodePatterns: Seq[TreePattern] = Seq(UPDATE_FIELDS)

override def checkInputDataTypes(): TypeCheckResult = {
Expand All @@ -783,7 +946,7 @@ case class UpdateFields(structExpr: Expression, fieldOps: Seq[StructFieldsOperat
"inputSql" -> toSQLExpr(structExpr),
"inputType" -> toSQLType(structExpr.dataType))
)
} else if (newExprs.isEmpty) {
} else if (newFieldSources.isEmpty) {
DataTypeMismatch(
errorSubClass = "CANNOT_DROP_ALL_FIELDS",
messageParameters = Map.empty
Expand All @@ -806,35 +969,129 @@ case class UpdateFields(structExpr: Expression, fieldOps: Seq[StructFieldsOperat

override def prettyName: String = "update_fields"

private lazy val newFieldExprs: Seq[(StructField, Expression)] = {
def getFieldExpr(i: Int): Expression = structExpr match {
case c: CreateNamedStruct => c.valExprs(i)
case _ => GetStructField(structExpr, i)
}
val fieldsWithIndex = structExpr.dataType.asInstanceOf[StructType].fields.zipWithIndex
val existingFieldExprs: Seq[(StructField, Expression)] =
fieldsWithIndex.map { case (field, i) => (field, getFieldExpr(i)) }.toImmutableArraySeq
fieldOps.foldLeft(existingFieldExprs)((exprs, op) => op(exprs))
}
private lazy val newFieldSources: Seq[(StructField, UpdateFields.FieldSource)] =
UpdateFields.fieldSources(structExpr.dataType.asInstanceOf[StructType], fieldOps)

private lazy val newFields: Seq[StructField] = newFieldExprs.map(_._1)
private lazy val newFields: Seq[StructField] = newFieldSources.map(_._1)

lazy val newExprs: Seq[Expression] = newFieldExprs.map(_._2)
/** Returns the expression for one output field without constructing the complete struct. */
def newExpr(ordinal: Int): Expression = newFieldSources(ordinal)._2 match {
case UpdateFields.Original(sourceOrdinal) => getFieldExpr(sourceOrdinal)
case UpdateFields.Updated(expr) => expr
}

lazy val evalExpr: Expression = {
val createNamedStructExpr = CreateNamedStruct(newFieldExprs.flatMap {
case (field, expr) => Seq(Literal(field.name), expr)
})
/**
* Returns an input field expression and marks generated reads so they can share `structExpr`
* without changing equivalent expressions supplied to `withField`.
*/
private def getFieldExpr(ordinal: Int): Expression = structExpr match {
case c: CreateNamedStruct => c.valExprs(ordinal)
case _ => UpdateFields.markGeneratedStructRead(

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.

Non-blocking (P2): newExpr's only caller is SimplifyExtractValueOps, so this tag ends up on ordinary single-level extractions, not just on nested-path reads. $"t".withField("y", lit(2)).getField("a") becomes a tagged GetStructField(t, a), and the tag stays because copyTagsFrom only fills untagged nodes. If t is later inlined as s.withField("x", lit(1) / $"s.b") (e.g. by CollapseProject), the new !isGeneratedStructRead(field) guard refuses to simplify GetStructField(UpdateFields(s, x), a) to s.a. The full update, including x, is then evaluated, so with ANSI on and s.b = 0 this now fails with DIVIDE_BY_ZERO where it used to return rows. A simplified read like this inside an ops list also trips the flatten guard at UpdateFields.scala:73, even though no nested-path read would be separated. This conflicts with the comment that says generated reads are "field accesses introduced while expanding nested paths".

See Shared repair plan 1 in the review body.

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.

Removed the generated-read tags and simplification guards. Ordinary field extraction simplifies again, with a regression covering an unused throwing update.

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.

Thanks, the tag-based block is gone and ordinary extraction simplifies again. One related regression: the revision also removed the merge-target !u.structExpr.isInstanceOf[UpdateFields] guard in SimplifyExtractValueOps. An extraction from a two-level chain is now rewritten to If(IsNull(innerUpdate), ...) before OptimizeUpdateFields can merge the chain, so the inner update's values run. I left the details in a new comment on ComplexTypes.scala.

GetStructField(structExpr, ordinal), generatedStructReadOwner)
}

if (structExpr.nullable) {
If(IsNull(structExpr), Literal(null, dataType), createNamedStructExpr)
} else {
createNamedStructExpr
lazy val evalExpr: Expression = With(structExpr) { case Seq(structRef) =>
// Read fields used to rebuild the input struct from structRef so structExpr is evaluated once.
// Do not rewrite expressions passed to withField; they must be evaluated independently.

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.

Nit (P3): This reads as the opposite of what the next lines do. replaceStruct is applied to every Updated(expr), and for a nested path that value is the generated UpdateFields(GetStructField(structExpr, a), ...) whose tagged read is rewritten to structRef. The actual rule is narrower: only reads generated by this update's own nested-path expansion are rebound, and other reads of structExpr inside withField values are left alone. Maybe something like: "Leave other reads in withField values unchanged; they must be evaluated independently."

Verification:

  • Inspection: The comment states the owner-tagged nested-read rule and no longer claims that withField values are never rewritten.

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.

Removed the comment along with the identity/tag-based rewriting machinery. Nested updates are now represented explicitly.

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.

Thanks, verified. The comment went away with the rewriting machinery it described.

def replaceStruct(expr: Expression): Expression = expr.transformDown {
case field @ GetStructField(child, _, _)
if UpdateFields.isGeneratedStructRead(field, generatedStructReadOwner) &&
child.eq(structExpr) =>

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.

Blocking (P1): This only shares the input when the generated read's child is the same object as structExpr. On the common Column API path the struct is unresolved when UpdateFields.apply builds the tree, for example col("s").withField("a.b", lit(1)).withField("a.c", lit(2)). ColumnResolutionHelper.innerResolve then resolves each occurrence of that shared instance separately (mapChildren, no memoization), so after analysis the two positions hold distinct but equal copies and eq fails. Because the new guards in OptimizeUpdateFields and SimplifyExtractValueOps also skip these chains, nothing collapses the copies either. At each level of the chain the previous update appears twice, so n chained nested-path calls run about 2^(n-1) lowered copies of the first update per row, and CSE can't merge them because it doesn't descend into With. The merge target reduced the same chain to a single UpdateFields(s, a -> UpdateFields(GetStructField(s, a), ...)). For nondeterministic inputs, the evaluate-once guarantee doesn't hold on this path either.

See Shared repair plan 1 in the review body.

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.

Replaced identity-based sharing with UpdateNestedField, which applies nested operations to the current field value without duplicating the parent input. Added unresolved-column and input-dependent nondeterministic regressions.

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.

Thanks, verified on the current head. UpdateNestedField applies nested operations to the current field value, so the parent input is no longer duplicated, and the With in evalExpr evaluates a non-literal input once. The new unresolved-column and nondeterministic-input tests cover the path I raised.

field.copy(child = structRef)
}

val updatedValues = ArrayBuffer.empty[Expression]
val outputOrdinals = newFieldSources.map { case (_, source) =>
source match {
case UpdateFields.Original(ordinal) => structExpr match {
case namedStruct: CreateNamedStruct =>
updatedValues += namedStruct.valExprs(ordinal)
-1
case _ => ordinal
}
case UpdateFields.Updated(expr) =>
updatedValues += replaceStruct(expr)
-1
}
}
UpdateFieldsExpression(structRef, updatedValues.toSeq, outputOrdinals, newFields)
}
}

object UpdateFields {
/** Describes where an output field gets its value without creating an expression for a copy. */
private sealed trait FieldSource

/** Copies the field at `ordinal` from the input struct. */
private case class Original(ordinal: Int) extends FieldSource

/** Computes the field from an expression supplied to `withField`. */
private case class Updated(expr: Expression) extends FieldSource

/**
* Applies the requested field operations while retaining whether each output field is copied or
* computed. This provenance lets evaluation copy unchanged fields by ordinal and lets extraction
* return one field without constructing the complete struct.
*/
private def fieldSources(
structType: StructType,
fieldOps: Seq[StructFieldsOperation]): Seq[(StructField, FieldSource)] = {
val fields: Seq[(StructField, FieldSource)] = structType.fields.zipWithIndex.map {
case (field, ordinal) =>
field -> Original(ordinal)
}.toImmutableArraySeq
fieldOps.foldLeft(fields) {
case (current, op: WithField) =>
val updated = StructField(op.name, op.valExpr.dataType, op.valExpr.nullable) ->
Updated(op.valExpr)
val result = ArrayBuffer.empty[(StructField, FieldSource)]
var hasMatch = false
current.foreach { fieldSource =>
if (op.resolver(fieldSource._1.name, op.name)) {
hasMatch = true
result += updated
} else {
result += fieldSource
}
}
if (!hasMatch) result += updated
result.toSeq
case (current, op: DropField) =>
current.filterNot { case (field, _) => op.resolver(field.name, op.name) }
case (_, op) =>

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.

Nit (P3): fieldSources re-implements the WithField/DropField name matching instead of going through StructFieldsOperation.apply, and nothing in production calls apply anymore. That leaves two copies of the same semantics that can drift apart, a trait scaladoc ("...used as the fields argument for StructType and as the children argument for CreateNamedStruct inside UpdateFields") that is no longer true, and an internal error for any other operation. Could apply be made generic over the value type (e.g. apply[T](values: Seq[(StructField, T)], updated: Expression => T)) and called from here? Or remove it and update the doc so there's a single source of truth.

Verification:

  • Compatibility: Existing withField/dropFields name-matching behavior, including case sensitivity and duplicate names, is unchanged.

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.

Removed the unused StructFieldsOperation.apply implementations and updated the documentation. Name matching now lives in fieldSources.

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.

Thanks, verified. The apply implementations and their scaladoc are gone, and name matching now lives only in fieldSources.

throw SparkException.internalError(
s"Unsupported StructFieldsOperation: ${op.getClass.getName}")
}
}

// Generated reads identify field accesses introduced while expanding nested paths. The owner
// prevents one UpdateFields from rewriting reads that belong to another UpdateFields.
private val GENERATED_STRUCT_READ = TreeNodeTag[ExprId]("generatedStructRead")
private val GENERATED_STRUCT_READ_OWNER = TreeNodeTag[ExprId]("generatedStructReadOwner")

/** Returns the stable identity used to associate generated reads with `expr`. */
private def generatedStructReadOwner(expr: Expression): ExprId = {
expr.getTagValue(GENERATED_STRUCT_READ_OWNER).getOrElse {
val owner = NamedExpression.newExprId
expr.setTagValue(GENERATED_STRUCT_READ_OWNER, owner)
owner
}
}

/** Marks a field read as generated by the UpdateFields identified by `owner`. */
private def markGeneratedStructRead[T <: Expression](expr: T, owner: ExprId): T = {
expr.setTagValue(GENERATED_STRUCT_READ, owner)
expr
}

/** Tests whether a field read was generated by the UpdateFields identified by `owner`. */
private def isGeneratedStructRead(expr: Expression, owner: ExprId): Boolean =
expr.getTagValue(GENERATED_STRUCT_READ).contains(owner)

/** Tests whether a field read was generated while expanding any nested update path. */
private[catalyst] def isGeneratedStructRead(expr: Expression): Boolean =
expr.containsTag(GENERATED_STRUCT_READ)

private def nameParts(fieldName: String): Seq[String] = {
require(fieldName != null, "fieldName cannot be null")

Expand Down Expand Up @@ -878,7 +1135,9 @@ object UpdateFields {
structExpr = newStruct,
namePartsRemaining = namePartsRemaining.tail,
valueFunc = valueFunc)
UpdateFields(structExpr, WithField(fieldName, newValue) :: Nil)
val updated = UpdateFields(structExpr, WithField(fieldName, newValue) :: Nil)
markGeneratedStructRead(newStruct, generatedStructReadOwner(updated))
updated
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ object SimplifyExtractValueOps extends Rule[LogicalPlan] {
// Remove redundant field extraction.
case GetStructField(createNamedStruct: CreateNamedStruct, ordinal, _) =>
createNamedStruct.valExprs(ordinal)
case GetStructField(u: UpdateFields, ordinal, _)if !u.structExpr.isInstanceOf[UpdateFields] =>
// UpdateFields handles its nested-path reads after binding the input once.
case field @ GetStructField(u: UpdateFields, ordinal, _)
if !u.structExpr.isInstanceOf[UpdateFields] &&
!UpdateFields.isGeneratedStructRead(field) =>
val structExpr = u.structExpr
u.newExprs(ordinal) match {
u.newExpr(ordinal) match {
// if the struct itself is null, then any value extracted from it (expr) will be null
// so we don't need to wrap expr in If(IsNull(struct), Literal(null, expr.dataType), expr)
case expr: GetStructField if expr.child.semanticEquals(structExpr) => expr
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,9 @@ abstract class Optimizer(catalogManager: CatalogManager)
RemoveNoopOperators),
// This batch must be executed after the `RewriteSubquery` batch, which creates joins.
Batch("NormalizeFloatingNumbers", Once, NormalizeFloatingNumbers),
Batch("ReplaceUpdateFieldsExpression", Once, ReplaceUpdateFieldsExpression)))
// Repeat because an update can contain another update. Keep the resulting With expressions so

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.

Nit (P3): ReplaceUpdateFieldsExpression is transformAllExpressionsWithPruning(...) { case u: UpdateFields => u.evalExpr } (UpdateFields.scala:99-103), a top-down transform that also recurses into the children of each replacement. Every UpdateFields that evalExpr creates (the nested updates built by fieldSources, or an input kept as a With definition) is a child of the returned UpdateFieldsExpression (structExpr +: updatedValues) or With (child +: defs), so the same pass lowers it. The second fixed-point iteration only confirms that nothing changed, so the comment describes a requirement the rule does not have.

Moving off Once also takes this batch out of the test-mode idempotence check that RuleExecutor runs on Once batches, and adds one more traversal of the plan.

Verification:

  • Regression: Nested-path and chained withField/dropFields queries optimize with no remaining UpdateFields, and the test-mode Once idempotence check passes.

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.

Restored Once and removed the incorrect comment in 049f52e. Tests verify that nested updates fully lower in one pass and that applying the rule again changes nothing.

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.

Thanks, verified. The batch is Once again and the comment is gone. The new suite checks that nested updates lower fully in one pass and that a second application changes nothing.

// their definitions remain inside conditional branches until evaluation.
Batch("ReplaceUpdateFieldsExpression", fixedPoint, ReplaceUpdateFieldsExpression)))

// remove any batches with no rules. this may happen when subclasses do not add optional rules.
batches.filter(_.rules.nonEmpty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ object OptimizeUpdateFields extends Rule[LogicalPlan] {
val newWithFields = newNames.map(n => WithField(nameMap(n), newValues(n)))
UpdateFields(structExpr, newWithFields.toSeq)

case UpdateFields(UpdateFields(struct, fieldOps1), fieldOps2) =>
// Flattening would separate a nested field read from the struct it reads.
case UpdateFields(UpdateFields(struct, fieldOps1), fieldOps2)
if !(fieldOps1 ++ fieldOps2).exists(_.exists(UpdateFields.isGeneratedStructRead)) =>

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.

Blocking (P1): This guard also runs in the analyzer: OptimizeUpdateFields is in the Substitution batch (Analyzer.scala:641-645) specifically because "when manipulating deeply nested schema, UpdateFields expression tree could be very complex and make analysis impossible". Each nested-path step u_k = UpdateFields(u_{k-1}, a -> UpdateFields(ExtractValue(u_{k-1}, a), op_k)) references u_{k-1} twice. Before, flatten plus dedupe collapsed every step before resolution, so the tree stayed linear. Now (fieldOps1 ++ fieldOps2).exists(_.exists(isGeneratedStructRead)) is true for every nested-path step, so nothing collapses, and resolution and later traversals walk about 2^N occurrences for N chained calls. Examples are col("s").withField("a.x1", ..)...withField("a.xN", ..) or dropFields("a.x1", ..., "a.xN"), which Column.dropFields folds into such a chain. Programmatic updates of many nested fields can stall analysis before execution even starts.

See Shared repair plan 1 in the review body.

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.

Removed the guard and restored chain flattening. Nested operations compose before resolution. Added full-pipeline tests for chained nested withField and dropFields calls on unresolved columns.

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.

Thanks, verified. The flatten case is back without the guard, nested operations merge in optimizeFieldOps, and the new test bounds analyzed and optimized plan size for chained nested updates on unresolved columns.

UpdateFields(struct, fieldOps1 ++ fieldOps2)
}

Expand Down
Loading