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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala:
##########
@@ -817,7 +817,10 @@ object QueryExecution {
       plan: LogicalPlan): SparkPlan = {
     // TODO: We use next(), i.e. take the first plan returned by the planner, 
here for now,
     //       but we will implement to choose the best plan.
-    planner.plan(ReturnAnswer(plan)).next()
+    // Strip DelegateExpression to its definition right before planning, so 
the planner and every
+    // physical consumer (pushdown, columnar rules, codegen) sees the real 
executed expression. The
+    // wrapper is purely informational and stays in the optimized logical plan 
for EXPLAIN.
+    planner.plan(ReturnAnswer(LowerDelegateExpression(plan))).next()

Review Comment:
   [P2] Expose delegated equalities to logical join consumers
   
   This lowering is late enough for physical join selection, but several 
logical consumers run before `createSparkPlan`. `ExtractEquiJoinKeys` splits 
the join condition and matches only a top-level `EqualTo`/`EqualNullSafe`; 
making `definition` a child does not help that pattern match. A supported 
boolean delegate whose definition is `EqualTo(leftKey, rightKey)` therefore 
reaches the physical planner as a valid equi-join, but earlier consumers miss 
the keys: `PartitionPruning`/`InjectRuntimeFilter` lose DPP and runtime 
filters, `JoinEstimation`/`DistinctKeyVisitor` lose equi-key statistics, and 
`StreamingJoinHelper` can reject a watermarked stream-stream outer join. The 
new V2 tests already treat predicate delegates as a supported shape.
   
   Could the logical join consumers see the exposed definition as well 
(including `PartitionPruning`'s direct conjunct match), with focused 
DPP/runtime-filter and watermarked streaming-join regressions? This is distinct 
from the older physical join-key thread: that planner path is fixed by this 
line; the remaining gap is before planning.
   
   _[ :robot: posted by Codex on behalf of sunchao using the code-review-for-me 
skill :robot: ]_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/NormalizePlan.scala:
##########
@@ -86,7 +86,15 @@ object NormalizePlan extends PredicateHelper {
    * we must normalize them to check if two different queries are identical.
    */
   def normalizeExprIds(plan: LogicalPlan): LogicalPlan = {
-    plan.transformAllExpressions {
+    // Defined as a named rule (rather than inline) so it can also be applied 
to a
+    // `DelegateExpression`'s `inputs`, which are display-only metadata -- not 
children -- and so
+    // are never reached by `transformAllExpressions`. Normalizing them 
explicitly keeps the
+    // informational call deterministic across runs (e.g. `right(g#0, g#0)` in 
EXPLAIN), since expr
+    // ids come from a process-global counter; the `definition` child is 
reached by the normal
+    // traversal.
+    lazy val rule: PartialFunction[Expression, Expression] = {
+      case d: DelegateExpression =>
+        d.copy(inputs = d.inputs.map(_.transform(rule)))

Review Comment:
   [P2] Apply the full expression normalization to metadata inputs
   
   This special case applies only the expr-id rule to `inputs`. The earlier 
`normalizeExpressions` pass also uses `transformAllExpressions`, so it skips 
these non-child inputs entirely. Random seeds, `CommonExpressionId`s, and 
`TempResolvedColumn` wrappers in the display metadata therefore remain 
unnormalized. In Hybrid Analyzer mode, for example, fixed-point and single-pass 
resolution can assign different seeds to `right(rand(), 1)`; the `Rand` inside 
`definition` is normalized to seed 0, but the `Rand` in `inputs` is not, so the 
structurally compared plans can still fail with a false 
`HYBRID_ANALYZER_LOGICAL_PLAN_COMPARISON_MISMATCH`.
   
   Could delegate inputs go through the same full expression-normalization 
pipeline rather than only this expr-id partial function? A dual-run 
`right(rand(), 1)` case plus an aggregate/HAVING case that carries 
`TempResolvedColumn` metadata would protect both dimensions.
   
   _[ :robot: posted by Codex on behalf of sunchao using the code-review-for-me 
skill :robot: ]_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/package.scala:
##########
@@ -127,6 +127,16 @@ package object util extends Logging {
         ),
         dataType = r.dataType
       )
+    case d: DelegateExpression =>
+      // `inputs` are display-only metadata, not children, so `transform` 
never descends into them.
+      // Render the high-level call with each input prettified (qualifiers 
stripped, string literals
+      // unquoted, ...) so generated column names match the pre-delegate 
function, e.g. the column
+      // name for `right(c7, 2)` stays `right(c7, 2)` rather than 
`right(spark_catalog....c7, 2)`.
+      PrettyAttribute(
+        name = s"${d.name}(${d.inputs
+            .map(i => toPrettySQL(i, 
shouldTrimTempResolvedColumn)).mkString(", ")})",

Review Comment:
   [P2] Trim temporary resolution markers before rendering delegate inputs
   
   Passing `shouldTrimTempResolvedColumn = true` into `toPrettySQL` does not 
actually unwrap a `TempResolvedColumn`: `usePrettyExpression` has no marker 
case, so it only prettifies the marker's child and leaves 
`tempresolvedcolumn(...)` around it. The `InheritAnalysisRules` branch avoids 
this by explicitly applying `trimTempResolvedColumn` to its non-child 
parameters first. Delegate inputs are also non-children, so aggregate/HAVING 
alias generation can now produce names such as 
`count(right(tempresolvedcolumn(v), 1))` instead of the prior `count(right(v, 
1))`.
   
   Could this mirror the `proposedParameters` handling above and pre-trim 
`d.inputs` when the flag is set? A SPARK-52385-style aggregate/HAVING 
regression using `right` would catch the generated-name change.
   
   _[ :robot: posted by Codex on behalf of sunchao using the code-review-for-me 
skill :robot: ]_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/DelegateExpression.scala:
##########
@@ -0,0 +1,183 @@
+/*
+ * 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.catalyst.expressions
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.analysis.ExpressionBuilder
+import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
ExprCode}
+import org.apache.spark.sql.catalyst.trees.TreePattern.{DELEGATE_EXPRESSION, 
INPUT_TYPE_MARKER, TreePattern}
+import org.apache.spark.sql.catalyst.trees.UnaryLike
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.{AbstractDataType, AnyDataType, DataType}
+
+/**
+ * A transparent, named delegate over a `definition` expression -- a 
LOGICAL-phase construct.
+ *
+ * `DelegateExpression` lets a high-level function (e.g. `right(a, b)`) stay 
readable in the
+ * analyzed and optimized logical plan, and lets optimizer rules introduce 
such nodes (e.g.
+ * `multi_get_json_object`), without hand-written `eval`/`doGenCode`. Every 
behavior delegates to
+ * `definition`, a real child fully visible to the analyzer and optimizer.
+ *
+ * `name`/`inputs` are purely informational (EXPLAIN/SQL): nothing enforces 
that `definition`
+ * matches what they claim, so the wrapper is never exposed to physical 
planning or external
+ * systems.
+ * `LowerDelegateExpression` strips it to `definition` in 
`QueryExecution.createSparkPlan` -- the
+ * single entry point to the planner, used by both the main query and AQE 
re-planning -- so the
+ * planner and every physical consumer (join-key extraction, data source 
pushdown, columnar rules,
+ * codegen) sees the real executed expression. (Data source V2 pushdown runs 
earlier, in the logical
+ * optimizer, so it unfolds the wrapper directly in `V2ExpressionBuilder`.) 
The wrapper survives the
+ * logical optimizer, so the optimized plan stays readable and optimizer rules 
can introduce these
+ * nodes; `eval`/`doGenCode` still delegate, as a safety net if a delegate 
ever reaches execution.
+ *
+ * Note: because the strip runs before planning, a `DelegateExpression` 
created by a *physical* rule
+ * (after `createSparkPlan`) is not stripped and may reach an external system 
un-lowered. That is
+ * acceptable -- like any other expression the system does not recognize, it 
simply falls back, and
+ * `eval`/`doGenCode` keep it correct within Spark. Analysis- and 
optimizer-inserted nodes (the
+ * common case) are always stripped, so physical-rule insertion is the only 
uncovered path.
+ */
+case class DelegateExpression(
+    name: String,
+    inputs: Seq[Expression],
+    definition: Expression)
+  extends Expression with UnaryLike[Expression] {
+
+  override def child: Expression = definition
+  override def dataType: DataType = definition.dataType
+  override def nullable: Boolean = definition.nullable
+  override def foldable: Boolean = definition.foldable
+  // Delegate `nullIntolerant` too (it is not derived from children, unlike 
`throwable`), so that
+  // null-intolerance optimizations -- `IsNotNull`-constraint inference in
+  // `QueryPlanConstraints.scanNullIntolerantAttribute` and 
`NullPropagation`'s `IsNull`/`IsNotNull`
+  // simplifications -- still fire while the wrapper is in the logical plan 
(e.g. for the
+  // `multi_get_json_object` delegate, whose `Invoke` definition is 
null-intolerant).
+  override def nullIntolerant: Boolean = definition.nullIntolerant
+  override lazy val deterministic: Boolean = definition.deterministic
+  override lazy val canonicalized: Expression = definition.canonicalized
+
+  override def eval(input: InternalRow): Any = definition.eval(input)
+  override protected def doGenCode(ctx: CodegenContext, ev: ExprCode): 
ExprCode =
+    definition.genCode(ctx)
+
+  final override val nodePatterns: Seq[TreePattern] = Seq(DELEGATE_EXPRESSION)
+  override protected def withNewChildInternal(newChild: Expression): 
DelegateExpression =
+    copy(definition = newChild)
+
+  override def prettyName: String = name
+  override def sql: String = s"$name(${inputs.map(_.sql).mkString(", ")})"
+  override def toString: String = s"$name(${inputs.mkString(", ")})"
+}
+
+/**
+ * Analysis-only marker that requests an implicit cast of `child` to 
`expectedType`: it declares the
+ * expected type so the standard `TypeCoercion` rule casts the child, then is 
removed at the end of
+ * analysis by 
[[org.apache.spark.sql.catalyst.analysis.RemoveInputTypeMarkers]]. It never 
reaches
+ * execution, hence [[Unevaluable]]. Modeled on
+ * [[org.apache.spark.sql.catalyst.analysis.TempResolvedColumn]].
+ */
+case class ImplicitCastInput(child: Expression, expectedType: AbstractDataType)
+  extends UnaryExpression with Unevaluable with ImplicitCastInputTypes {
+  override def inputTypes: Seq[AbstractDataType] = Seq(expectedType)
+  override def dataType: DataType = child.dataType
+  override def nullable: Boolean = child.nullable
+  override lazy val canonicalized: Expression = child.canonicalized
+  final override val nodePatterns: Seq[TreePattern] = Seq(INPUT_TYPE_MARKER)
+  override protected def withNewChildInternal(newChild: Expression): 
ImplicitCastInput =
+    copy(child = newChild)
+}
+
+/**
+ * Analysis-only marker that requires `child` to already match `expectedType` 
(no cast is inserted),
+ * failing analysis otherwise. Removed at the end of analysis like 
[[ImplicitCastInput]].
+ */
+case class TypeCheckInput(child: Expression, expectedType: AbstractDataType)
+  extends UnaryExpression with Unevaluable with ExpectsInputTypes {
+  override def inputTypes: Seq[AbstractDataType] = Seq(expectedType)
+  override def dataType: DataType = child.dataType
+  override def nullable: Boolean = child.nullable
+  override lazy val canonicalized: Expression = child.canonicalized
+  final override val nodePatterns: Seq[TreePattern] = Seq(INPUT_TYPE_MARKER)
+  override protected def withNewChildInternal(newChild: Expression): 
TypeCheckInput =
+    copy(child = newChild)
+}
+
+/**
+ * The per-function object each built-in function defines (e.g. `object Right 
extends
+ * DelegateFunction`). It is just an [[ExpressionBuilder]] -- registered with 
the ordinary
+ * `expressionBuilder(...)`, with its `@ExpressionDescription` annotation read 
off the object as
+ * usual -- specialized for the delegate pattern: replace the 
`InheritAnalysisRules` ceremony with
+ * one `lower` method plus a couple of flags. `apply` is the 
direct-construction entry point.
+ *
+ * Input-type contract, covering all three cases (applied per argument):
+ *   - `inputTypes` empty (or `AnyDataType` for a position): accept any type 
(no check, no cast).
+ *   - `inputTypes` set, `implicitCast = true`  (default): implicit-cast each 
arg to its type.
+ *   - `inputTypes` set, `implicitCast = false`           : type-check each 
arg, no cast.
+ */
+trait DelegateFunction extends ExpressionBuilder {
+  def name: String
+  def inputTypes: Seq[AbstractDataType] = Nil
+  def implicitCast: Boolean = true
+
+  /** Lower the function into the expression it delegates to. */
+  def lower(args: Seq[Expression]): Expression
+
+  /**
+   * ExpressionBuilder contract: invoked by the registry during function 
resolution. ONLY this
+   * (analysis-time) path inserts the input-type markers, because the 
analyzer's `TypeCoercion`
+   * casts them and `RemoveInputTypeMarkers` strips them afterwards.
+   */
+  override def build(funcName: String, expressions: Seq[Expression]): 
Expression = {
+    // `inputTypes` carries one entry per argument position (`AnyDataType` for 
an accept-any-type
+    // position), so when it is set its length is the function's arity. 
Validate it here so a
+    // wrong-arity call fails with the structured WRONG_NUM_ARGS error rather 
than an
+    // IndexOutOfBounds from `lower` (too few args) or a silently-ignored 
extra argument (too many).
+    // An empty `inputTypes` marks a variadic function whose `lower` accepts 
any argument count.
+    if (inputTypes.nonEmpty && expressions.length != inputTypes.length) {
+      throw QueryCompilationErrors.wrongNumArgsError(
+        funcName, Seq(inputTypes.length), expressions.length)
+    }
+    val args = expressions.zipWithIndex.map { case (e, i) =>
+      val expected = if (i < inputTypes.length) inputTypes(i) else AnyDataType
+      expected match {
+        case AnyDataType => e
+        case t if implicitCast => ImplicitCastInput(e, t)

Review Comment:
   [P2] Keep failed coercions attributed to the delegate call
   
   A failed cast leaves this `ImplicitCastInput` in the tree (correctly, so 
`CheckAnalysis` can reject it), but `CheckAnalysis` walks bottom-up and calls 
`failOnTypeCheckResult` on the marker before it reaches the outer delegate. For 
example, `SELECT right('abc', array(1))` is now reported against 
`implicitcastinput(array(1))`, and because the marker has one child it 
identifies the *first* parameter. On master the failing expression is 
`right('abc', array(1))` and the bad input is the second parameter. This 
exposes an analysis-only implementation detail and changes the public error 
parameters despite the stated no-user-facing-change goal.
   
   Could the marker retain enough delegate/argument context (or have validation 
routed through `DelegateExpression`) so the error remains attributed to `right` 
and argument 2? A regression asserting the 
`DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE` message parameters for an uncastable 
`right` argument would cover this.
   
   _[ :robot: posted by Codex on behalf of sunchao using the code-review-for-me 
skill :robot: ]_



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