sunchao commented on code in PR #56575:
URL: https://github.com/apache/spark/pull/56575#discussion_r3777406955


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -178,72 +178,63 @@ object GetJsonObject {
 }
 
 /**
- * Extracts multiple simple object-key and array-index paths from a JSON 
string in one parse. This
- * is an internal expression used to share sibling [[GetJsonObject]] 
expressions; unsupported and
- * prefix-conflicting JSON paths remain as independent GetJsonObject 
expressions.
+ * Builds the internal expression that extracts multiple simple object-key and 
array-index paths
+ * from a JSON string in one parse, used to share sibling [[GetJsonObject]] 
expressions; unsupported
+ * and prefix-conflicting paths remain as independent `GetJsonObject` 
expressions.
+ *
+ * It is inserted by `OptimizeCsvJsonExprs` (after analysis, so its inputs are 
resolved), and is the
+ * optimizer-constructed showcase for [[DelegateExpression]]: instead of 
hand-written
+ * eval/doGenCode, it builds a typed delegate directly -- the high-level call
+ * `multi_get_json_object(json, p1, ..., pn)` stays visible via `inputs`, 
while the `definition`
+ * delegates evaluation to [[MultiGetJsonObjectEvaluator]] through an 
`Invoke`. The delegate stays
+ * in logical plans and is lowered to its definition before physical planning.
  */
-case class MultiGetJsonObject(
-    json: Expression,
-    fallbackPaths: Seq[String])
-  extends UnaryExpression
-  with ExpectsInputTypes {
-
-  // OptimizeCsvJsonExprs caps shared path depth to keep evaluator recursion 
stack-safe.
-  require(fallbackPaths.nonEmpty)
-
-  override def child: Expression = json
-
-  override def inputTypes: Seq[AbstractDataType] =
-    Seq(StringTypeWithCollation(supportsTrimCollation = true))
-
-  override lazy val dataType: DataType = StructType(fallbackPaths.indices.map 
{ index =>
-    StructField(s"_$index", StringType, nullable = true)
-  })
-
-  override def nullable: Boolean = true
-
-  // This internal unary expression always returns null when its JSON child is 
null.
-  override def nullIntolerant: Boolean = true
-
-  override def prettyName: String = "multi_get_json_object"
-
-  final override val nodePatterns: Seq[TreePattern] = Seq(GET_JSON_OBJECT)
-
-  @transient
-  private lazy val simplePaths = fallbackPaths.map { path =>
-    GetJsonObject.simplePath(UTF8String.fromString(path)).getOrElse {
-      throw new IllegalArgumentException(s"Unsupported shared JSON path: 
$path")
+object MultiGetJsonObject {
+  val name: String = "multi_get_json_object"
+
+  def apply(json: Expression, fallbackPaths: Seq[String]): DelegateExpression 
= {
+    // OptimizeCsvJsonExprs caps shared path depth to keep evaluator recursion 
stack-safe.
+    require(fallbackPaths.nonEmpty)
+    val resultType = StructType(fallbackPaths.indices.map { index =>
+      StructField(s"_$index", StringType, nullable = true)
+    })
+    val utf8Paths = fallbackPaths.map(UTF8String.fromString)
+    val simplePaths = utf8Paths.map { path =>
+      GetJsonObject.simplePath(path).getOrElse {
+        throw new IllegalArgumentException(s"Unsupported shared JSON path: 
$path")
+      }
     }
+    val evaluator = MultiGetJsonObjectEvaluator(utf8Paths, simplePaths)
+    // `propagateNull = true` reproduces the old null-intolerant behavior: 
null json -> null result.
+    val definition = Invoke(
+      Literal.create(evaluator, 
ObjectType(classOf[MultiGetJsonObjectEvaluator])),

Review Comment:
   [P1] Follow-up on the current head: forwarding `DelegateExpression.stateful` 
addresses the original stateful assertion, but the new 
`MultiGetJsonObjectEvaluatorHolder` is a stateful `LeafExpression` and does not 
override `withNewChildrenInternal`. `LeafLike` explicitly documents that 
stateful expressions must override this hook; its default implementation 
returns `this`. Consequently, `freshCopyIfContainsStatefulExpression()` copies 
the `Invoke` but retains the same holder and mutable evaluator. Once the new 
test's compile error is fixed, its evaluator-identity assertion will fail. 
Could the holder override the copy hook to return a genuinely fresh instance, 
such as `copy()`?



##########
sql/core/src/test/scala/org/apache/spark/sql/DelegateExpressionQuerySuite.scala:
##########
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql
+
+import org.apache.spark.sql.catalyst.analysis.resolver.ResolverRunner
+import org.apache.spark.sql.catalyst.expressions.{Alias, Cast, 
DelegateExpression, ImplicitCastInput, Literal, MultiGetJsonObject, 
TypeCheckInput}
+import org.apache.spark.sql.catalyst.plans.logical.{OneRowRelation, Project}
+import org.apache.spark.sql.execution.{LowerDelegateExpression, 
WholeStageCodegenExec}
+import org.apache.spark.sql.execution.window.WindowExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.StringType
+
+/**
+ * End-to-end proof for the delegate-expression redesign: `right()` is built 
as a
+ * [[DelegateExpression]] -- a logical-phase wrapper that stays readable in 
the optimized plan and
+ * is lowered to its real definition (by `LowerDelegateExpression`) before 
physical execution, so
+ * the planner, pushdown, columnar rules and codegen see the actual executed 
expression.
+ */
+class DelegateExpressionQuerySuite extends QueryTest with SharedSparkSession {
+
+  test("right() is a DelegateExpression in the optimized plan, lowered before 
execution") {
+    val df = spark.range(0, 3).selectExpr("right(concat('row', cast(id as 
string)), 1) as r")
+    checkAnswer(df, Seq(Row("0"), Row("1"), Row("2")))
+
+    // Readable in the optimized logical plan ...
+    assert(df.queryExecution.optimizedPlan.exists(
+      _.expressions.exists(_.exists(_.isInstanceOf[DelegateExpression]))),
+      s"expected a DelegateExpression in the optimized 
plan:\n${df.queryExecution.optimizedPlan}")
+    // ... but lowered away before physical execution, so engines see the real 
expression.
+    val executed = df.queryExecution.executedPlan
+    
assert(!executed.exists(_.expressions.exists(_.exists(_.isInstanceOf[DelegateExpression]))),
+      s"DelegateExpression should be lowered before execution:\n$executed")
+    assert(executed.exists(_.isInstanceOf[WholeStageCodegenExec]),
+      s"expected whole-stage codegen in the executed plan:\n$executed")
+  }
+
+  test("right() implicit-casts a non-string arg via the standard coercion rule 
(no extra step)") {
+    // The old plain-form `right` was ImplicitCastInputTypes; the delegate 
form preserves this by
+    // wrapping the arg in an ImplicitCastInput shim that the standard 
TypeCoercion rule handles.
+    checkAnswer(spark.sql("SELECT right(12345, 2)"), Row("45"))
+  }
+
+  test("internal input shims are stripped at the end of analysis") {
+    val df = spark.range(0, 3).selectExpr("right(concat('row', cast(id as 
string)), 2) as r")
+    val analyzed = df.queryExecution.analyzed
+    // The high-level delegate remains in the plan ...
+    
assert(analyzed.exists(_.expressions.exists(_.exists(_.isInstanceOf[DelegateExpression]))))
+    // ... but the internal coercion shims are gone (they were inserted, then 
stripped).
+    assert(!analyzed.exists(_.expressions.exists(_.exists(e =>
+      e.isInstanceOf[ImplicitCastInput] || e.isInstanceOf[TypeCheckInput]))),
+      s"input shims should be stripped after analysis:\n$analyzed")
+  }
+
+  test("right() produces identical results with whole-stage codegen on and 
off") {
+    Seq("true", "false").foreach { flag =>
+      withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> flag) {
+        checkAnswer(
+          spark.range(0, 3).selectExpr("right(concat('row', cast(id as 
string)), 2) as r"),
+          Seq(Row("w0"), Row("w1"), Row("w2")))
+      }
+    }
+  }
+
+  test("right() extracts a window input only once") {
+    val df = spark.sql(
+      """SELECT right(
+        |  listagg(CAST(id AS STRING), chr(44))
+        |    OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT 
ROW),
+        |  1)
+        |FROM range(5)""".stripMargin)
+
+    val windowExpressions = df.queryExecution.executedPlan.collect {

Review Comment:
   [P1] Follow-up on the current head: mixing in `AdaptiveSparkPlanHelper` 
alone does not fix this call site. `df.queryExecution.executedPlan.collect { 
... }` still resolves to `TreeNode.collect`, which stops at the 
`AdaptiveSparkPlanExec` leaf. Invoke the helper explicitly instead:
   
   ```scala
   val windowExpressions = collect(df.queryExecution.executedPlan) {
     case window: WindowExec => window.windowExpression
   }.flatten
   ```
   
   Otherwise this assertion still observes zero windows with default AQE once 
compilation succeeds.



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