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


##########
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:
   **Nit (P3):** When a key already holds an `UpdateNestedField`, this runs 
`optimizeFieldOps` again on `previous.fieldOps ++ nested.fieldOps`. That 
re-maps every child already optimized and rebuilds the `LinkedHashMap` over the 
whole accumulated list. The flatten case first turns 
`names.foldLeft(col("s"))((c, n) => c.withField(s"a.$n", v))` into a single 
`UpdateFields(s, [UpdateNestedField(a, [f1]), ..., UpdateNestedField(a, 
[fm])])`. The Substitution-batch application then does about m^2/2 operation 
visits, each with a key normalization, where the merge-target dedupe was a 
single linear pass. Programmatic updates of many fields under one parent are 
the wide-schema workload this PR targets, and the new linear-growth test bounds 
node counts rather than merge work. Only planning time is affected; results are 
unchanged.
   
   Accumulating each key's nested operations in order and optimizing each key's 
list once, or merging into a per-key builder as operations arrive, would keep 
this linear. The output should stay exactly as today. That includes the point 
where a nested list containing `DropField` stops further deduplication, the 
handling of a preceding or following `WithField` on the same key, and the 
last-seen casing.
   
   **Verification:**
   - **Compatibility:** Optimized operation lists for mixed 
WithField/UpdateNestedField chains, including overwrites, nested lists 
containing DropField, and case-insensitive keys, are identical to the current 
output.
   - **Regression:** A chain of thousands of same-parent nested withField calls 
analyzes with merge work that grows linearly with chain length.



##########
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:
   **Non-blocking (P2):** `fieldSources` finds the parent field with the 
`UpdateNestedField`'s own resolver (`ExtractValue(..., nested.resolver)` a few 
lines up). It then applies the update through this new `WithField`, whose 
`resolver` is `SQLConf.get.resolver` evaluated when it is constructed (line 
710). `fieldSources` runs once at analysis (`newFieldSources`, which backs 
`dataType` and `newFields`) and again when `ReplaceUpdateFieldsExpression` 
lowers the node (`evalExpr` -> `buildExpression`). `buildExpression` pairs the 
recomputed ordinals with the analysis-time `newFields`.
   
   So the two passes disagree when `spark.sql.caseSensitive` changes between 
analysis and execution. Take a struct column `s` with field `a` under 
case-insensitive resolution: `val df2 = df.select(col("s").withField("A.x", 
lit(9)))` analyzes to `struct<A: struct<..., x>>`. After 
`spark.conf.set("spark.sql.caseSensitive", "true")`, `df2.collect()` optimizes 
under the new setting. The nested lookup still finds ordinal 0, but 
`WithField("A")` no longer matches `a`, so it appends. That gives 
`outputOrdinals = [0, -1]` against a single output field. 
`UpdateFieldsExpression.dataType` zips them down to the original `a`, so `x` is 
silently dropped and the optimized type no longer matches the analyzed 
attribute. Going the other way on a struct that has both `a` and `A` overwrites 
both. A temp view that stores the analyzed plan reaches the same path. Before 
this PR, the nested `WithField` was built once at `Column` conversion, so this 
also needed an optimizer rewrite of that operation. This af
 fects classic execution only; Spark Connect re-analyzes on every execution.
   
   The parent's ordinal is already known at this point. Replacing 
`current(field.ordinal)` in place with `StructField(nested.name, 
updated.dataType, updated.nullable) -> Updated(updated)` would keep the 
analysis-time layout, and the rename to the call's casing, without a second 
name match.
   
   **Verification:**
   - **Regression:** Apply withField("A.x", ...) to a struct with field a under 
case-insensitive analysis, then execute after switching to case-sensitive: the 
result has the analyzed schema with x populated.
   - **Compatibility:** Nested withField and dropFields results, schemas, and 
generated names under case-sensitive and case-insensitive resolution are 
unchanged when the setting does not change.



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