HyukjinKwon commented on code in PR #59283:
URL: https://github.com/apache/spark/pull/59283#discussion_r4235965395


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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(
+      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.
+    def replaceStruct(expr: Expression): Expression = expr.transformDown {
+      case field @ GetStructField(child, _, _)
+          if UpdateFields.isGeneratedStructRead(field, 
generatedStructReadOwner) &&
+            child.eq(structExpr) =>

Review Comment:
   **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 non
 deterministic inputs, the evaluate-once guarantee doesn't hold on this path 
either.
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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 = {
+    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)
+        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]);

Review Comment:
   **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 k
 eep the previous compatibility.
   
   **Verification:**
   - **Regression:** A vectorized Parquet struct with an unchanged GEOMETRY or 
TIME field passes through withField under default codegen without 
SparkUnsupportedOperationException.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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(

Review Comment:
   **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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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(
+      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.

Review Comment:
   **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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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 = {
+    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)

Review Comment:
   **Nit (P3):** `outputOrdinals` and `updatedValues` are Scala 2.13 `List`s 
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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UpdateFields.scala:
##########
@@ -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)) =>

Review Comment:
   **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.



##########
sql/core/src/test/scala/org/apache/spark/sql/ColumnExpressionSuite.scala:
##########
@@ -445,6 +446,141 @@ class ColumnExpressionSuite extends SharedSparkSession {
       SQLConf.CODEGEN_FACTORY_MODE.key -> "CODEGEN_ONLY")(f)
   }
 
+  test("withField should evaluate a nondeterministic struct expression once") {
+    onEachEvalPath {
+      val counter = new java.util.concurrent.atomic.AtomicLong()
+      val nextStruct = udf(() => {
+        val value = counter.getAndIncrement()
+        (value, value)
+      }).asNondeterministic()
+      val structExpr = nextStruct()
+      val df = spark.range(0, 10, 1, 1)
+      df.select(structExpr.withField("copy", structExpr)).collect().foreach { 
row =>
+        val result = row.getStruct(0)
+        val copy = result.getStruct(2)
+        assert(result.getLong(0) == result.getLong(1))
+        assert(copy.getLong(0) == copy.getLong(1))
+        assert(result.getLong(0) != copy.getLong(0))
+      }
+    }
+  }
+
+  test("withField should evaluate a reused nondeterministic value separately") 
{
+    onEachEvalPath {
+      val counter = new java.util.concurrent.atomic.AtomicLong()
+      val nextStruct = udf(() => {
+        val value = counter.getAndIncrement()
+        (value, value)
+      }).asNondeterministic()
+      val structExpr = nextStruct().expr
+      val updated = Column(UpdateFields(structExpr,
+        Seq(WithField("copy", GetStructField(structExpr, 0)))))
+      val df = spark.range(0, 10, 1, 1)
+      df.select(updated).collect().foreach { row =>
+        val result = row.getStruct(0)
+        assert(result.getLong(0) == result.getLong(1))
+        assert(result.getLong(0) != result.getLong(2))
+      }
+    }
+  }
+
+  test("withField should evaluate a reused nested update separately") {
+    onEachEvalPath {
+      val counter = new java.util.concurrent.atomic.AtomicLong()
+      val nextStruct = udf(() => {
+        val value = counter.getAndIncrement()
+        (value, value)
+      }).asNondeterministic()
+      val structExpr = nextStruct().expr
+      val nestedUpdate = UpdateFields(structExpr, Seq(WithField("extra", 
Literal(1))))
+      val updated = Column(UpdateFields(structExpr,
+        Seq(WithField("copy", GetStructField(nestedUpdate, 0)))))
+      val df = spark.range(0, 10, 1, 1)
+      df.select(updated).collect().foreach { row =>
+        val result = row.getStruct(0)
+        assert(result.getLong(0) == result.getLong(1))
+        assert(result.getLong(0) != result.getLong(2))
+      }
+    }
+  }
+
+  test("withField should reuse an updated struct for a nested path") {

Review Comment:
   **Non-blocking (P2):** This test (and `preserve a nested path through a 
later update`) builds `UpdateFields` directly over `nextStruct().expr`, a 
zero-argument UDF that is already resolved. Identity therefore survives 
analysis and the `eq`-based sharing works. Users reach this code through 
`Column.withField` on input-dependent expressions such as 
`udf(...)($"id").withField("_1.copy", lit(1))` or `col("s")...`, where analysis 
rebuilds the struct. `ReplaceUpdateFieldsExpressionSuite` also runs only 
`ReplaceUpdateFieldsExpression` on pre-resolved `AttributeReference` inputs, 
skipping both the analyzer and the operator-optimization batch. So none of the 
new tests would fail for the exponential chains, the evaluated dropped values, 
or the double evaluation on the normal DataFrame/Connect path. Could you add 
full-pipeline DataFrame tests for chained nested-path `withField`/`dropFields` 
on unresolved columns, including a drop or overwrite after a nested update?
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -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(
+      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.
+    def replaceStruct(expr: Expression): Expression = expr.transformDown {
+      case field @ GetStructField(child, _, _)
+          if UpdateFields.isGeneratedStructRead(field, 
generatedStructReadOwner) &&
+            child.eq(structExpr) =>
+        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) =>

Review Comment:
   **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.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to