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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -806,35 +987,131 @@ 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, 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)
-    })
+  private def getFieldExpr(ordinal: Int): Expression = structExpr match {
+    case c: CreateNamedStruct => c.valExprs(ordinal)
+    case _ => GetStructField(structExpr, ordinal)
+  }
 
-    if (structExpr.nullable) {
-      If(IsNull(structExpr), Literal(null, dataType), createNamedStructExpr)
+  lazy val evalExpr: Expression = {
+    def buildExpression(structRef: Expression): Expression = {
+      val source = structExpr match {
+        case namedStruct: CreateNamedStruct => namedStruct
+        case _ => structRef
+      }
+      val fieldSources = UpdateFields.fieldSources(source, fieldOps)
+      val updatedValues = ArrayBuffer.empty[Expression]
+      val outputOrdinals = fieldSources.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 += expr
+            -1
+        }
+      }
+      UpdateFieldsExpression(structRef, updatedValues.toSeq, outputOrdinals, 
newFields)

Review Comment:
   **Non-blocking (P2):** For a `CreateNamedStruct` input, every output value 
already comes from `namedStruct.valExprs` (all `outputOrdinals` are -1), but 
the original struct is still passed here as `structExpr`, and it is also kept 
as the `With` definition on line 1030 for nested paths. `needsSource` is false, 
so it is never evaluated per row, but it is still a child, together with the 
values that `dropFields` or a later `withField` removed.
   
   Rules that walk the whole expression tree after 
`ReplaceUpdateFieldsExpression` still see those values. For example, with ANSI 
on:
   
   ```scala
   Seq((1L, 0L)).toDF("x", "z")
     .agg(struct(sum($"x").as("a"), sum($"x" / $"z").as("b")).dropFields("b"))
   ```
   
   `PhysicalAggregation` collects `AggregateExpression`s from the whole result 
expression (`planning/patterns.scala:301-307`), so it still computes `sum(x / 
z)` and fails with DIVIDE_BY_ZERO. Before this PR the same query returned 
`Row(Row(1))`. Python UDFs in a dropped field are extracted and run the same 
way, because `ExtractPythonUDFs` runs after the optimizer batches, and a scalar 
subquery in a dropped field is still planned. Inputs reach this path through 
direct `struct(...).dropFields/withField` calls and also when `CollapseProject` 
inlines a projected `struct(...)` into a later update.
   
   Row-level evaluation in a plain projection is not affected, which is why 
`dropFields should not evaluate omitted CreateNamedStruct fields` passes. For 
nested paths over `struct(...)` inputs, the retained `With` definition also 
keeps a full copy of the input struct at every nesting level, so the lowered 
tree grows quadratically with depth there.
   
   **Verification:**
   - **Regression:** An aggregate query that drops a struct(...) field 
computing a failing ANSI expression returns the surviving fields instead of 
throwing.
   - **Regression:** A dropped struct(...) field holding a side-effecting or 
failing value is not evaluated when the update is consumed by an aggregate or a 
projection.
   - **Inspection:** The lowered expression for a nested-path update over a 
struct(...) input contains no With and holds each surviving value once.
   - **Compatibility:** Existing withField/dropFields results and nullability 
over struct(...) inputs are unchanged.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ComplexTypes.scala:
##########
@@ -32,9 +32,9 @@ 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] =>
+      case GetStructField(u: UpdateFields, ordinal, _) =>

Review Comment:
   **Non-blocking (P2):** Before this PR, the guard `if 
!u.structExpr.isInstanceOf[UpdateFields]` left an extraction from an update 
chain alone until `OptimizeUpdateFields` had merged the chain. 
`OptimizeUpdateFields` runs before `SimplifyExtractValueOps` in the batch, so 
without the guard this arm can see a chain that is not merged yet and rewrite 
it to `If(IsNull(innerUpdate), null, value)` whenever the inner update is 
nullable. `NullPropagation` only folds `IsNull` over non-nullable children, so 
after lowering `IsNull` evaluates the whole inner update, including values the 
extracted field does not depend on.
   
   For example, with a non-nullable struct `s` whose field `a` is a nullable 
struct, ANSI on, and `z = 0`:
   
   ```scala
   col("s").withField("a.x", lit(1) / col("z")).getField("a")
     .withField("y", lit(2)).getField("y")
   ```
   
   In one pass the inner extraction becomes `UpdateFields(s.a, [x = 1 / z])` 
and the outer one becomes `If(IsNull(UpdateFields(s.a, [x = 1 / z])), null, 
2)`, which throws DIVIDE_BY_ZERO. With the guard, the next iteration merged the 
chain into `UpdateFields(s.a, [x, y])` and simplified the read to 
`If(IsNull(s.a), null, 2)`, which returns 2. The same shape appears when 
`CollapseProject` inlines an earlier `withColumn` that updated `s.a`.
   
   Nullable top-level inputs already behaved this way before the PR; 
non-nullable ones are newly affected. Keeping the guard costs one more 
optimizer iteration, which the merge target already paid.
   
   **Verification:**
   - **Regression:** An extraction from a re-updated, extracted nested struct 
does not evaluate the discarded inner update values, for non-nullable and 
nullable inputs.
   - **Compatibility:** Existing SimplifyExtractValueOps and UpdateFields 
optimizer results for single-level updates are unchanged.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -759,9 +737,212 @@ case class WithField(name: String, valExpr: Expression)
 /**
  * Drop a field by name.
  */
-case class DropField(name: String) extends StructFieldsOperation with 
LeafLike[Expression] {
-  override def apply(values: Seq[(StructField, Expression)]): 
Seq[(StructField, Expression)] =
-    values.filterNot { case (field, _) => resolver(field.name, name) }
+case class DropField(name: String) extends StructFieldsOperation with 
LeafLike[Expression]
+
+/**
+ * Updates the current value of a field without duplicating the parent struct 
expression.
+ */
+case class UpdateNestedField(name: String, fieldOps: 
Seq[StructFieldsOperation])
+  extends StructFieldsOperation {
+
+  override def children: Seq[Expression] = fieldOps
+
+  override protected def withNewChildrenInternal(
+      newChildren: IndexedSeq[Expression]): Expression =
+    copy(fieldOps = newChildren.map(_.asInstanceOf[StructFieldsOperation]))
+}
+
+/**
+ * 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 ConditionalExpression {
+
+  override def children: Seq[Expression] = structExpr +: updatedValues
+
+  override def nullable: Boolean = structExpr.nullable
+
+  private lazy val indexedOrdinals = outputOrdinals.toArray
+  private lazy val indexedUpdatedValues = updatedValues.toArray
+  private lazy val needsSource = nullable || indexedOrdinals.exists(_ >= 0)
+
+  override def alwaysEvaluatedInputs: Seq[Expression] =
+    (if (needsSource) Seq(structExpr) else Nil) ++ (if (nullable) Nil else 
updatedValues)
+
+  override def branchGroups: Seq[Seq[Expression]] = Nil
+
+  override def withNewAlwaysEvaluatedInputs(
+      inputs: Seq[Expression]): ConditionalExpression =
+    copy(
+      structExpr = if (needsSource) inputs.head else structExpr,
+      updatedValues = if (nullable) updatedValues else if (needsSource) 
inputs.tail else inputs)
+
+  override lazy val dataType: StructType = {

Review Comment:
   **Nit (P3):** `outputFields(index)` and `updatedValues(updatedIndex)` are 
positional lookups, and the sequences `evalExpr` passes in are `List`s 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.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/SubexpressionEliminationSuite.scala:
##########
@@ -546,12 +546,65 @@ class SubexpressionEliminationSuite extends SparkFunSuite 
with ExpressionEvalHel
     assert(equivalence1.getCommonSubexpressions.size == 1)
   }
 
-  test("SPARK-58902: no candidate below a With, with or without the 
short-circuit peel") {
+  test("With exposes independent subexpressions but not unbound references") {
+    val input = AttributeReference("input", IntegerType)()
+    val shared = Add(input, Literal(1))
+    val expression = With(input) { case Seq(ref) =>
+      Add(ref, Multiply(shared, shared))
+    }
+    val equivalence = new EquivalentExpressions
+    equivalence.addExprTree(expression)
+    assert(equivalence.getCommonSubexpressions == Seq(shared))
+    assert(equivalence.getAllExprStates().forall { state =>
+      state.expr == expression || 
!state.expr.exists(_.isInstanceOf[CommonExpressionRef])
+    })
+  }
+
+  test("With candidates must bind references in their lexical scope") {
+    val input = AttributeReference("input", IntegerType)()
+    val shared = Add(input, Literal(1))
+    var inner: Expression = null
+    val outer = With(input) { case Seq(outerRef) =>
+      inner = With(input) { case Seq(innerRef) =>
+        Add(Add(outerRef, innerRef), shared)
+      }
+      Add(inner, shared)
+    }
+    val equivalence = new EquivalentExpressions
+    equivalence.addExprTree(outer)
+    assert(equivalence.getExprState(outer).nonEmpty)
+    assert(equivalence.getExprState(inner).isEmpty)
+    assert(equivalence.getCommonSubexpressions == Seq(shared))
+    assert(!equivalence.addExpr(inner))
+    val closed = With(input) { case Seq(ref) => Add(ref, Literal(1)) }
+    val sibling = new CommonExpressionRef(closed.defs.head)
+    assert(!equivalence.addExpr(Add(closed, sibling)))
+  }
+
+  test("With does not expose lazy definitions or interpreted children") {
+    val input = AttributeReference("input", IntegerType)()
+    val shared = Add(input, Literal(1))
+    val lazyDefinition = With(Multiply(shared, shared)) { case Seq(ref) =>
+      If(Literal(false), ref, input)
+    }
+    val fallback = With(input) { case Seq(ref) =>
+      Add(CodegenFallbackExpression(ref), Multiply(shared, shared))
+    }
+    val guarded = With(input) { case Seq(ref) =>
+      If(GreaterThan(ref, Literal(0)), Multiply(shared, shared), Literal(0))
+    }
+    Seq(lazyDefinition, fallback, guarded).foreach { expression =>
+      val equivalence = new EquivalentExpressions
+      equivalence.addExprTree(expression)
+      assert(equivalence.getCommonSubexpressions.isEmpty)
+    }
+  }
+
+  test("SPARK-58902: candidates below a With cannot hold unbound references") {
     // A `CommonExpressionRef` can only be evaluated inside the `With` that 
binds it: the codegen
     // slots exist only while that `With` is being generated, and 
`getCommonExpr` throws otherwise.
     // Subexpression elimination generates its candidates outside every `With` 
scope, so a candidate
-    // holding a reference would fail the query. `childrenToRecurse` therefore 
stops at a `With`,
-    // including after `skipForShortcut` has peeled an `And`/`Or` down to one.
+    // holding an unbound reference would fail the query.

Review Comment:
   **Nit (P3):** This lead comment now describes the bound-reference rule, but 
two later comments in the same test still describe the removed one: around line 
629 the guard is said to drop the `With` "along with its subtree", and around 
line 637 the comment says that below a `With` "anything below it is not" a 
legal candidate.
   
   `childrenToRecurse` now returns `Seq(withExpr.child)` unless 
`refUnderCodegenFallback` is set, and `supportedExpression` only rejects 
candidates with unbound references. The new `With exposes independent 
subexpressions but not unbound references` test asserts that a reference-free 
subexpression below a `With` is a candidate, which contradicts both comments. 
Updating them to the bound-reference rule would keep this test's explanation 
accurate.
   
   **Verification:**
   - **Inspection:** The test comments describe the bound-reference rule 
enforced by supportedExpression and asserted by the new With tests.



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

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



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -759,9 +737,212 @@ case class WithField(name: String, valExpr: Expression)
 /**
  * Drop a field by name.
  */
-case class DropField(name: String) extends StructFieldsOperation with 
LeafLike[Expression] {
-  override def apply(values: Seq[(StructField, Expression)]): 
Seq[(StructField, Expression)] =
-    values.filterNot { case (field, _) => resolver(field.name, name) }
+case class DropField(name: String) extends StructFieldsOperation with 
LeafLike[Expression]
+
+/**
+ * Updates the current value of a field without duplicating the parent struct 
expression.
+ */
+case class UpdateNestedField(name: String, fieldOps: 
Seq[StructFieldsOperation])

Review Comment:
   **Nit (P3):** `UpdateNestedField` overrides neither `prettyName` nor `sql`, 
so `Expression.sql` renders it as `updatenestedfield(<child ops>)`: the 
lowercased class name, without the parent field name. Unaliased nested-path 
`withField`/`dropFields` columns get their name from this rendering. 
`df.select(col("s").withField("a.b", lit(1)))` used to produce a column named 
`update_fields(s, WithField(update_fields(s.a, WithField(1))))` and now 
produces `update_fields(s, updatenestedfield(WithField(1)))`; 
`dropFields("a.b")` changes the same way to `update_fields(s, 
updatenestedfield(dropfield()))`.
   
   This exposes an internal class name in the output schema, in classic and 
Connect, and updates on different parents now collide: `withField("a.x", 
lit(1))` and `withField("c.x", lit(1))` get the same name. Analyzed-plan 
EXPLAIN output and `sqlExpr` error parameters that render the outer update 
change the same way. Query results are not affected, and no test pins the 
generated names.
   
   **Verification:**
   - **Compatibility:** Generated column names of unaliased nested-path 
withField and dropFields match the merge-target names.
   - **Regression:** Two nested updates on different parent fields produce 
distinct generated names.



##########
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:
   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.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4235965395","thread_id":"inline:4235965395","verdict_sha256":"ab2b0fd9bcfc655d854e51bb3e2b7b7c0e60761ac6cbf11e41a5d24bd9a888fd"}
 -->



##########
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:
   Thanks, verified. The apply implementations and their scaladoc are gone, and 
name matching now lives only in fieldSources.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4235965400","thread_id":"inline:4235965400","verdict_sha256":"ab2b0fd9bcfc655d854e51bb3e2b7b7c0e60761ac6cbf11e41a5d24bd9a888fd"}
 -->



##########
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:
   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.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4235965404","thread_id":"inline:4235965404","verdict_sha256":"ab2b0fd9bcfc655d854e51bb3e2b7b7c0e60761ac6cbf11e41a5d24bd9a888fd"}
 -->



##########
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:
   Thanks, verified. Copied fields now go through CodeGenerator.getValue per 
type group, and the new vectorized Parquet tests cover TIME, GEOMETRY, and 
GEOGRAPHY.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4235965407","thread_id":"inline:4235965407","verdict_sha256":"ab2b0fd9bcfc655d854e51bb3e2b7b7c0e60761ac6cbf11e41a5d24bd9a888fd"}
 -->



##########
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:
   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.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4235965413","thread_id":"inline:4235965413","verdict_sha256":"ab2b0fd9bcfc655d854e51bb3e2b7b7c0e60761ac6cbf11e41a5d24bd9a888fd"}
 -->



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