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


##########
sql/connect/common/src/test/resources/query-tests/explain-results/function_right.explain:
##########
@@ -1,2 +1,2 @@
-Project [if (isnull(g#0)) null else if ((cast(g#0 as int) <= 0))  else 
substring(g#0, -cast(g#0 as int), 2147483647) AS right(g, g)#0]
+Project [right(g#0, g#0) AS right(g, g)#0]

Review Comment:
   [P2] Regenerate the Connect golden with the actual `With` projection
   
   `ProtoToParsedPlanTestSuite.function_right` currently fails because 
`RewriteWithExpression` inserts an intermediate `Project [..., cast(g#0 as int) 
AS _common_expr_1#0]` between this project and `LocalRelation`, but this golden 
contains only two lines. The Connect job on the current head shows that exact 
mismatch. Please regenerate this golden from the actual analyzed plan, or 
change the lowering so the additional projection is not produced.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala:
##########
@@ -2630,29 +2630,40 @@ case class Substring(str: Expression, pos: Expression, 
len: Expression)
   since = "2.3.0",
   group = "string_funcs")
 // scalastyle:on line.size.limit
-case class Right(str: Expression, len: Expression) extends RuntimeReplaceable
-  with ImplicitCastInputTypes with BinaryLike[Expression] {
-
-  override lazy val replacement: Expression = If(
-    IsNull(str),
-    Literal(null, str.dataType),
-    If(
-      LessThanOrEqual(len, Literal(0)),
-      Literal(UTF8String.EMPTY_UTF8, str.dataType),
-      new Substring(str, UnaryMinus(len, failOnError = false))
-    )
-  )
+object Right extends DelegateFunction {
+  override val name: String = "right"
 
   override def inputTypes: Seq[AbstractDataType] =
-    Seq(
-      StringTypeWithCollation(supportsTrimCollation = true),
-      IntegerType
-    )
-  override def left: Expression = str
-  override def right: Expression = len
-  override protected def withNewChildrenInternal(
-      newLeft: Expression, newRight: Expression): Expression = {
-    copy(str = newLeft, len = newRight)
+    Seq(StringTypeWithCollation(supportsTrimCollation = true), IntegerType)
+
+  // At build time `str` is the not-yet-coerced argument (wrapped in an 
`ImplicitCastInput` marker
+  // that delegates `dataType` to its child), so `str.dataType` is the *input* 
type, which is not
+  // necessarily a string yet -- e.g. `right(12345, 2)` has an `IntegerType` 
child the implicit cast
+  // will turn into a string. Use it for the null/empty branch literals only 
when it is already a
+  // string-family type, so a CHAR(N)/VARCHAR(N) result (under
+  // `spark.sql.preserveCharVarcharTypeInfo`) or a non-default collation is 
preserved through the
+  // `If` branch unification; otherwise fall back to plain `StringType`, the 
type the implicit cast
+  // produces. Typing a UTF8String literal with a non-string type would be 
invalid.
+  override def lower(args: Seq[Expression]): Expression = {
+    val str = args(0)
+    val len = args(1)
+    val litType = str.dataType match {
+      case _: StringType | _: CharType | _: VarcharType => str.dataType
+      case _ => StringType
+    }
+    // Keep each argument single-use while the analyzer extracts window 
expressions. The
+    // definition needs to reference both arguments more than once, but 
extracting those repeated
+    // trees independently can produce duplicate window aggregates. 
RewriteWithExpression expands
+    // these bindings later, after window extraction.
+    With(str, len) { case Seq(strRef, lenRef) =>

Review Comment:
   [P1] Preserve `right()`'s NULL short-circuit before evaluating `len`
   
   Wrapping both arguments in an outer `With` lets `RewriteWithExpression` 
precompute a non-cheap, twice-referenced `len` in a child `Project` before 
`If(IsNull(str), ...)`. For example:
   
   ```sql
   SELECT right(
     IF(id = 0, CAST(NULL AS STRING), 'abc'),
     raise_error(concat('boom-', CAST(id AS STRING)))
   )
   FROM range(1)
   ```
   
   On master this returns `NULL`; with this change the hoisted length raises 
`USER_RAISED_EXCEPTION` even though the string is null. Dynamic ANSI casts fail 
the same way. Please keep the length evaluation inside the non-null branch 
while preserving single-use window extraction.



##########
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] Traverse the adaptive inner plan in this window regression test
   
   `AdaptiveSparkPlanExec` is a `LeafExecNode`, so `.collect` on 
`df.queryExecution.executedPlan` stops at the adaptive wrapper when AQE is 
enabled by default. Current CI consequently fails with `List() had size 0`, 
even though the rendered adaptive inner plan contains exactly one `Window`. Use 
`AdaptiveSparkPlanHelper.collect`, unwrap `AdaptiveSparkPlanExec.executedPlan`, 
or disable AQE for this assertion.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/ResolverGuard.scala:
##########
@@ -600,7 +600,8 @@ class ResolverGuard(
           _: TryValidateUTF8 | _: StringReplace | _: Overlay | _: 
StringTranslate | _: FindInSet |
           _: String2TrimExpression | _: StringTrimBoth | _: StringInstr | _: 
SubstringIndex |
           _: StringLocate | _: StringLPad | _: BinaryPad | _: StringRPad | _: 
FormatString |
-          _: InitCap | _: StringRepeat | _: StringSpace | _: Substring | _: 
Right | _: Left |
+          _: InitCap | _: StringRepeat | _: StringSpace | _: Substring | _: 
DelegateExpression |

Review Comment:
   [P2] Allow the actual `With` subtree used by `right()`
   
   Adding `DelegateExpression` alone is insufficient because `checkExpression` 
recursively checks `expression.children`. Migrated `right` has 
`DelegateExpression.definition = With(...)`, but `With`, `CommonExpressionDef`, 
and `CommonExpressionRef` are not accepted here. With 
`spark.sql.analyzer.singlePassResolver.enabledTentatively=true`, a chained 
operation such as `spark.range(3).selectExpr("right(cast(id as string), 1) AS 
r").selectExpr("r")` traverses the already-analyzed first `Project` and falls 
back to fixed-point analysis. Previously `Right` and its argument children 
passed the guard. Please support the full subtree or treat an already-resolved 
delegate as opaque, and add chained-DataFrame coverage.



##########
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] Preserve both JSON statefulness and evaluator-per-copy isolation
   
   The previous `MultiGetJsonObject` had `stateful = true` and a per-expression 
lazy evaluator. This factory now returns a `DelegateExpression` reporting 
`stateful = false`, which already fails existing `JsonExpressionsSuite` on this 
head: `multiGetJsonObject.stateful was false`.
   
   There is a second problem beyond that assertion: `Invoke` itself is stateful 
and gets copied, but its target is this stateless `ObjectType` literal 
containing the same `MultiGetJsonObjectEvaluator`. Fresh expression copies 
therefore still share the evaluator's mutable `outputBuffer` and fallback 
evaluators. Simply forwarding `definition.stateful` fixes the assertion but not 
evaluator isolation; please preserve both contracts.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala:
##########
@@ -2630,29 +2630,40 @@ case class Substring(str: Expression, pos: Expression, 
len: Expression)
   since = "2.3.0",
   group = "string_funcs")
 // scalastyle:on line.size.limit
-case class Right(str: Expression, len: Expression) extends RuntimeReplaceable
-  with ImplicitCastInputTypes with BinaryLike[Expression] {
-
-  override lazy val replacement: Expression = If(
-    IsNull(str),
-    Literal(null, str.dataType),
-    If(
-      LessThanOrEqual(len, Literal(0)),
-      Literal(UTF8String.EMPTY_UTF8, str.dataType),
-      new Substring(str, UnaryMinus(len, failOnError = false))
-    )
-  )
+object Right extends DelegateFunction {
+  override val name: String = "right"
 
   override def inputTypes: Seq[AbstractDataType] =
-    Seq(
-      StringTypeWithCollation(supportsTrimCollation = true),
-      IntegerType
-    )
-  override def left: Expression = str
-  override def right: Expression = len
-  override protected def withNewChildrenInternal(
-      newLeft: Expression, newRight: Expression): Expression = {
-    copy(str = newLeft, len = newRight)
+    Seq(StringTypeWithCollation(supportsTrimCollation = true), IntegerType)
+
+  // At build time `str` is the not-yet-coerced argument (wrapped in an 
`ImplicitCastInput` marker
+  // that delegates `dataType` to its child), so `str.dataType` is the *input* 
type, which is not
+  // necessarily a string yet -- e.g. `right(12345, 2)` has an `IntegerType` 
child the implicit cast
+  // will turn into a string. Use it for the null/empty branch literals only 
when it is already a
+  // string-family type, so a CHAR(N)/VARCHAR(N) result (under
+  // `spark.sql.preserveCharVarcharTypeInfo`) or a non-default collation is 
preserved through the
+  // `If` branch unification; otherwise fall back to plain `StringType`, the 
type the implicit cast
+  // produces. Typing a UTF8String literal with a non-string type would be 
invalid.
+  override def lower(args: Seq[Expression]): Expression = {
+    val str = args(0)
+    val len = args(1)
+    val litType = str.dataType match {
+      case _: StringType | _: CharType | _: VarcharType => str.dataType
+      case _ => StringType
+    }
+    // Keep each argument single-use while the analyzer extracts window 
expressions. The
+    // definition needs to reference both arguments more than once, but 
extracting those repeated
+    // trees independently can produce duplicate window aggregates. 
RewriteWithExpression expands
+    // these bindings later, after window extraction.
+    With(str, len) { case Seq(strRef, lenRef) =>
+      If(
+        IsNull(strRef),
+        Literal(null, litType),
+        If(
+          LessThanOrEqual(lenRef, Literal(0)),

Review Comment:
   [P2] Keep failed length coercions attributed to `right(...)`
   
   `With.children` visits its main child before its definitions, so 
`CheckAnalysis` reaches this `LessThanOrEqual(CommonExpressionRef, 0)` before 
the invalid `ImplicitCastInput` in the length definition. `SELECT right('abc', 
array(1))` now fails with `DATATYPE_MISMATCH.BINARY_OP_DIFF_TYPES` and `sqlExpr 
= "(commonexpressionref() <= 0)"`, instead of the previous 
`DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE` for the second `right` argument. The 
new `DelegateExpressionQuerySuite` already fails with exactly this mismatch. 
Please validate the argument marker before exposing internal `With` references.



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