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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -806,35 +989,151 @@ 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)
+  private[sql] def sqlString(
+      render: Expression => String,
+      quoteField: String => String): String = {
+    def updateSql(inputSql: String, operations: Seq[StructFieldsOperation]): 
String = {
+      val operationsSql = operations.map {
+        case nested: UpdateNestedField =>
+          val fieldSql = if (structExpr.resolved) {
+            s"$inputSql.${quoteField(nested.name)}"
+          } else {
+            s"$inputSql[${render(Literal(nested.name))}]"
+          }
+          s"WithField(${updateSql(fieldSql, nested.fieldOps)})"
+        case operation => render(operation)
+      }
+      s"$prettyName(${(inputSql +: operationsSql).mkString(", ")})"
     }
-    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))
+    updateSql(render(structExpr), fieldOps)
   }
 
-  private lazy val newFields: Seq[StructField] = newFieldExprs.map(_._1)
+  override def sql: String = sqlString(_.sql, quoteIdentifier)
 
-  lazy val newExprs: Seq[Expression] = newFieldExprs.map(_._2)
+  private lazy val newFieldSources: Seq[(StructField, 
UpdateFields.FieldSource)] =
+    UpdateFields.fieldSources(structExpr, fieldOps)
 
-  lazy val evalExpr: Expression = {
-    val createNamedStructExpr = CreateNamedStruct(newFieldExprs.flatMap {
-      case (field, expr) => Seq(Literal(field.name), expr)
-    })
+  private lazy val newFields: Seq[StructField] = newFieldSources.map(_._1)
 
-    if (structExpr.nullable) {
-      If(IsNull(structExpr), Literal(null, dataType), createNamedStructExpr)
-    } else {
-      createNamedStructExpr
+  /** 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
+  }
+
+  private def getFieldExpr(ordinal: Int): Expression = structExpr match {
+    case c: CreateNamedStruct => c.valExprs(ordinal)
+    case _ => GetStructField(structExpr, ordinal)
+  }
+
+  lazy val evalExpr: Expression = {
+    def buildExpression(structRef: Expression): Expression = {
+      val fieldSources = UpdateFields.fieldSources(structRef, fieldOps)
+      val updatedValues = ArrayBuffer.empty[Expression]
+      val outputOrdinals = fieldSources.map {
+        case (_, UpdateFields.Original(ordinal)) => ordinal
+        case (_, UpdateFields.Updated(expr)) =>
+          updatedValues += expr
+          -1
+      }
+      UpdateFieldsExpression(structRef, updatedValues.toSeq, outputOrdinals, 
newFields)
+    }
+    structExpr match {
+      case namedStruct: CreateNamedStruct =>
+        val values = namedStruct.valExprs.toArray
+        CreateNamedStruct(newFieldSources.flatMap { case (field, source) =>
+          val value = source match {
+            case UpdateFields.Original(ordinal) => values(ordinal)
+            case UpdateFields.Updated(expr) => expr
+          }
+          Seq(Literal(field.name), value)
+        })
+      case _ if fieldOps.exists(_.isInstanceOf[UpdateNestedField]) =>
+        With(structExpr) { case Seq(structRef) => buildExpression(structRef) }

Review Comment:
   **Nit (P3):** This `With` keeps the struct input only in a definition, and 
`EquivalentExpressions.childrenToRecurse` visits `With.child` but never the 
definitions. The input therefore stops being a subexpression-elimination 
candidate. Before, the lowered update read it through `GetStructField` copies 
(and `IsNull` for a nullable input), which CSE could share with other uses in 
the same projection.
   
   For example, take `val s = from_json($"j", schema)` or a deterministic 
struct UDF. `df.select(s.withField("a.b", lit(1)), s.getField("c"))` used to 
evaluate `s` once per row. It now evaluates it twice: once for the `With` 
definition and once for the extraction. Results are the same, but an expensive 
input costs twice as much on a path meant to make updates cheaper. Flat updates 
and `struct(...)` inputs are not affected. The new 'field updates should 
preserve shared value subexpression elimination' test shares only `withField` 
values, not the input, so it does not catch this.
   
   **Recommended change:** When subexpression elimination recurses into a With, 
also recurse into each definition whose reference the With body reaches through 
always-evaluated positions, so the nested-path lowering's struct input can be 
shared with other uses again. Add subexpression-elimination unit coverage and 
Column withField/dropFields execution tests for a shared struct input.
   
   **Why this works:** Walk the With child with the existing always-evaluated 
rules (ConditionalExpression.alwaysEvaluatedInputs, short-circuit peeling, 
CodegenFallback and HigherOrderFunction stops, nested With scopes) and collect 
the CommonExpressionRef ids reached. Recurse into the definitions with those 
ids as always-evaluated children. supportedExpression still rejects candidates 
that hold unbound references, so a hoisted definition child is evaluated before 
the With exactly once, and codegen and interpreted With evaluation pick up the 
shared value.
   
   **Scope:** Let CSE see With definitions that the body always evaluates, and 
cover a shared struct input end to end.
   
   **Compatibility:** Definitions that the With body reaches only lazily stay 
unevaluated when their reference is not reached.
   
   **Risks:** Treating a conditionally evaluated definition as always evaluated 
would hoist it eagerly and could raise ANSI errors or run side effects; the 
walk must use exactly the existing always-evaluated rules.
   
   **Constraints:** Keep the existing With scope-safety tests passing: lazy 
definitions, CodegenFallback children and guarded values must still produce no 
common subexpression.
   
   **Success:** With subexpression elimination enabled, a deterministic struct 
input read by a nested-path withField/dropFields and by another expression in 
the same projection is evaluated once per row on interpreted and code-generated 
paths. A With definition that the body references only under a conditional 
branch or a CodegenFallback, or does not reference, is still not hoisted.
   
   **Verification:**
   - **Behavior:** A counting deterministic struct-producing UDF shared between 
a nested-path withField and a separate extraction from the same input is 
evaluated once per row on each evaluation path, with correct results.
   - **Regression:** Lazy, CodegenFallback and guarded With definitions still 
yield no common subexpression, and no candidate holds an unbound reference.
   - **Compatibility:** Existing nested withField/dropFields results, 
nullability and nondeterministic once-per-row evaluation are unchanged.



##########
sql/core/src/test/scala/org/apache/spark/sql/ColumnExpressionSuite.scala:
##########
@@ -445,6 +446,428 @@ 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("field updates should preserve shared value subexpression elimination") 
{
+    withSQLConf(SQLConf.SUBEXPRESSION_ELIMINATION_ENABLED.key -> "true",
+      SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true") {
+      for {
+        nested <- Seq(false, true)
+        external <- Seq(false, true)
+        opaque <- Seq(false, true)
+      } {
+        val calls = spark.sparkContext.longAccumulator
+        val sourceCalls = spark.sparkContext.longAccumulator
+        val value = udf((id: Long) => {
+          calls.add(1)
+          id + 1
+        }).apply($"id")
+        val source = if (opaque) {
+          udf((id: Long) => {
+            sourceCalls.add(1)
+            ((id, id), id)
+          }).asNonNullable().asNondeterministic()($"id")
+        } else {
+          struct(struct($"id", $"id").as("_1"), $"id".as("_2"))
+        }
+        val prefix = if (nested) "_1." else ""
+        val updated = source.withField(s"${prefix}first", value)
+          .withField(s"${prefix}second", value)
+        val columns = if (external) Seq(updated, value) else Seq(updated)
+        val rows = spark.range(0, 10, 1, 1).select(columns: _*).collect()
+        rows.zipWithIndex.foreach { case (row, index) =>
+          val result = if (nested) row.getStruct(0).getStruct(0) else 
row.getStruct(0)
+          assert(result.getLong(2) == index + 1L)
+          assert(result.getLong(3) == index + 1L)
+          if (external) assert(row.getLong(1) == index + 1L)
+        }
+        val expectedCalls = if (nested && opaque) 10 * (if (external) 3 else 
2) else 10

Review Comment:
   **Nit (P3):** This expectation is the same whether or not CSE descends into 
`With` bodies. `struct(...)` sources lower to a `CreateNamedStruct` with no 
`With`, and opaque flat updates build no `With`. In the nested opaque case, the 
tuple encoder makes `_1` nullable, so the inner `UpdateFieldsExpression` keeps 
both values conditional, and they are evaluated 20/30 times either way. 
Reverting `childrenToRecurse` to `case _: With => Nil` would still pass every 
case here. Only the map-level `SubexpressionEliminationSuite` checks would 
notice.
   
   As a result, no test executes generated or interpreted code that hoists a 
value out of a `With` body, which is the new behavior. A case with a 
non-`struct(...)` input whose nested parent field is non-nullable, sharing one 
value between two `withField` calls and another column, would give that path a 
failure signal.
   
   **Verification:**
   - **Regression:** The new test fails when With bodies are excluded from 
subexpression-elimination traversal.
   - **Behavior:** The shared value is evaluated once per row and every updated 
field holds the expected value on interpreted, codegen-only and whole-stage 
paths.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -806,35 +989,151 @@ 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)
+  private[sql] def sqlString(
+      render: Expression => String,
+      quoteField: String => String): String = {
+    def updateSql(inputSql: String, operations: Seq[StructFieldsOperation]): 
String = {
+      val operationsSql = operations.map {
+        case nested: UpdateNestedField =>
+          val fieldSql = if (structExpr.resolved) {
+            s"$inputSql.${quoteField(nested.name)}"
+          } else {
+            s"$inputSql[${render(Literal(nested.name))}]"
+          }
+          s"WithField(${updateSql(fieldSql, nested.fieldOps)})"
+        case operation => render(operation)
+      }
+      s"$prettyName(${(inputSql +: operationsSql).mkString(", ")})"
     }
-    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))
+    updateSql(render(structExpr), fieldOps)
   }
 
-  private lazy val newFields: Seq[StructField] = newFieldExprs.map(_._1)
+  override def sql: String = sqlString(_.sql, quoteIdentifier)
 
-  lazy val newExprs: Seq[Expression] = newFieldExprs.map(_._2)
+  private lazy val newFieldSources: Seq[(StructField, 
UpdateFields.FieldSource)] =
+    UpdateFields.fieldSources(structExpr, fieldOps)
 
-  lazy val evalExpr: Expression = {
-    val createNamedStructExpr = CreateNamedStruct(newFieldExprs.flatMap {
-      case (field, expr) => Seq(Literal(field.name), expr)
-    })
+  private lazy val newFields: Seq[StructField] = newFieldSources.map(_._1)
 
-    if (structExpr.nullable) {
-      If(IsNull(structExpr), Literal(null, dataType), createNamedStructExpr)
-    } else {
-      createNamedStructExpr
+  /** 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
+  }
+
+  private def getFieldExpr(ordinal: Int): Expression = structExpr match {
+    case c: CreateNamedStruct => c.valExprs(ordinal)
+    case _ => GetStructField(structExpr, ordinal)
+  }
+
+  lazy val evalExpr: Expression = {
+    def buildExpression(structRef: Expression): Expression = {
+      val fieldSources = UpdateFields.fieldSources(structRef, fieldOps)
+      val updatedValues = ArrayBuffer.empty[Expression]
+      val outputOrdinals = fieldSources.map {
+        case (_, UpdateFields.Original(ordinal)) => ordinal
+        case (_, UpdateFields.Updated(expr)) =>
+          updatedValues += expr
+          -1
+      }
+      UpdateFieldsExpression(structRef, updatedValues.toSeq, outputOrdinals, 
newFields)
+    }
+    structExpr match {
+      case namedStruct: CreateNamedStruct =>
+        val values = namedStruct.valExprs.toArray
+        CreateNamedStruct(newFieldSources.flatMap { case (field, source) =>
+          val value = source match {
+            case UpdateFields.Original(ordinal) => values(ordinal)
+            case UpdateFields.Updated(expr) => expr
+          }
+          Seq(Literal(field.name), value)
+        })
+      case _ if fieldOps.exists(_.isInstanceOf[UpdateNestedField]) =>
+        With(structExpr) { case Seq(structRef) => buildExpression(structRef) }
+      case _ =>
+        buildExpression(structExpr)
     }
   }
 }
 
 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(
+      structExpr: Expression,
+      fieldOps: Seq[StructFieldsOperation]): Seq[(StructField, FieldSource)] = 
{
+    val structType = structExpr.dataType.asInstanceOf[StructType]
+    def expression(source: FieldSource): Expression = source match {
+      case Original(ordinal) => structExpr match {
+        case namedStruct: CreateNamedStruct => namedStruct.valExprs(ordinal)
+        case _ => GetStructField(structExpr, ordinal)
+      }
+      case Updated(expr) => expr
+    }
+    val fields: Seq[(StructField, FieldSource)] = 
structType.fields.zipWithIndex.map {
+      case (field, ordinal) =>
+      field -> Original(ordinal)
+    }.toImmutableArraySeq
+    def applyOperation(
+        current: Seq[(StructField, FieldSource)],
+        operation: StructFieldsOperation): Seq[(StructField, FieldSource)] = 
operation match {
+      case 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 op: DropField =>
+        current.filterNot { case (field, _) => op.resolver(field.name, 
op.name) }
+      case op =>
+        throw SparkException.internalError(
+          s"Unsupported StructFieldsOperation: ${op.getClass.getName}")
+    }
+    fieldOps.foldLeft(fields) { (current, operation) =>
+      val expanded = operation match {
+        case nested: UpdateNestedField =>
+          val currentType = StructType(current.map(_._1))
+          val field = ExtractValue(
+            Literal.create(null, currentType), Literal(nested.name), 
nested.resolver)
+            .asInstanceOf[GetStructField]
+          val updated = CurrentOrigin.withOrigin(nested.origin) {
+            expression(current(field.ordinal)._2) match {
+              case UpdateFields(input, previousOps) =>
+                UpdateFields(input, previousOps ++ nested.fieldOps)
+              case input => UpdateFields(input, nested.fieldOps)
+            }
+          }
+          if (updated.checkInputDataTypes().isFailure) {
+            TypeCoercionValidation.failOnTypeCheckResult(updated)
+          }
+          WithField(nested.name, updated)

Review Comment:
   Thanks, verified on the current head. fieldSources now replaces the resolved 
parent at its ordinal instead of matching its name again, and the new test 
checks results and schemas after both case-sensitivity transitions for 
withField and dropFields, on the DataFrame and on a temp view.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4263829473","thread_id":"inline:4263829473","verdict_sha256":"3f2093f210bfe4bb52b320d8780eb19308936a0ca46433dce90c2bb41bccd99f"}
 -->



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/UpdateFields.scala:
##########
@@ -39,37 +39,54 @@ object OptimizeUpdateFields extends Rule[LogicalPlan] {
     }
   }
 
+  private def optimizeFieldOps(
+      fieldOps: Seq[StructFieldsOperation]): Seq[StructFieldsOperation] = {
+    val operations = fieldOps.map {
+      case nested: UpdateNestedField =>
+        nested.copy(fieldOps = optimizeFieldOps(nested.fieldOps))
+      case other => other
+    }
+    if (!operations.forall {
+        case _: WithField | _: UpdateNestedField => true
+        case _ => false
+      }) {
+      operations
+    } else {
+      val combined = mutable.LinkedHashMap.empty[String, StructFieldsOperation]
+      operations.foreach { operation =>
+        val name = operation match {
+          case WithField(name, _) => name
+          case UpdateNestedField(name, _) => name
+        }
+        val key = if (conf.caseSensitiveAnalysis) name else 
name.toLowerCase(Locale.ROOT)
+        val updated = operation match {
+          case nested: UpdateNestedField => combined.get(key) match {
+            case Some(previous: UpdateNestedField) =>
+              nested.copy(fieldOps = optimizeFieldOps(previous.fieldOps ++ 
nested.fieldOps))

Review Comment:
   Thanks, verified. optimizeFieldOps now collects each parent's nested 
operations and optimizes the mergeable prefix once, keeping overwrite order, 
the drop barrier and the last-seen casing, and the 2,000-update test bounds the 
merge to linear operation visits.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4263829487","thread_id":"inline:4263829487","verdict_sha256":"3f2093f210bfe4bb52b320d8780eb19308936a0ca46433dce90c2bb41bccd99f"}
 -->



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