This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new ea9d6a7c9180 [SPARK-58123][SQL] Add ResolveAsOfJoin analysis for SQL 
ASOF JOIN
ea9d6a7c9180 is described below

commit ea9d6a7c9180e4fb342643b5f5c5aa2df03c385b
Author: Serge Rielau <[email protected]>
AuthorDate: Thu Jul 16 23:20:44 2026 +0800

    [SPARK-58123][SQL] Add ResolveAsOfJoin analysis for SQL ASOF JOIN
    
    ### What changes were proposed in this pull request?
    
    Second stacked PR for 
[SPARK-58092](https://issues.apache.org/jira/browse/SPARK-58092) / 
[SPARK-58123](https://issues.apache.org/jira/browse/SPARK-58123).
    
    **Follows #57251** (parser, merged). Rebased onto current `master`; this PR 
is a **2-commit** analysis-only delta.
    
    **Analysis rule**
    - Add `ResolveAsOfJoin` to materialize `MATCH_CONDITION` into 
`asOfCondition`, `orderExpression`, and per-side sort expressions
    - Expand `USING (...)` into equi-join predicates via 
`NaturalAndUsingJoinResolution`
    - Validate MATCH_CONDITION operands: cross-side references, determinism, 
and type compatibility (including struct/array)
    
    **Logical plan**
    - Extend `AsOfJoin` with `leftSortExprs`, `rightSortExprs`, and 
`requiresSortMergeAsOfJoin`
    - Add `resolveMatchComparison` / `matchSortExpressions` helpers shared with 
the DataFrame API path
    - Resolve `matchLeftOperand` / `matchRightOperand` via the standard 
analyzer expression walk before materialization
    
    **Gating**
    - `CheckAnalysis` rejects SQL ASOF plans when 
`spark.sql.join.sortMergeAsOfJoin.enabled=false` 
(`AS_OF_JOIN.SORT_MERGE_REQUIRED`)
    
    **Out of scope (follow-up PR)**
    - Sort-merge physical operator wiring for multi-column sort keys and SQL 
execution tests — **#57277**
    
    ### Why are the changes needed?
    
    Parser (#57251) produces unresolved `AsOfJoin` nodes with parsed 
`MATCH_CONDITION` operands. Analysis must resolve operands, validate semantics, 
and produce the same logical shape the existing DataFrame `joinAsOf` path uses 
before physical planning can run.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes (behind `spark.sql.join.asofJoin.enabled`, default off). With the 
parser conf enabled, SQL ASOF JOIN now progresses through analysis: valid 
queries become analyzable logical plans; invalid MATCH_CONDITION expressions 
fail with structured analysis errors instead of reaching execution unresolved.
    
    ### How was this patch tested?
    
    Added analysis negative tests to `AsOfJoinSQLSuite`:
    - `AS_OF_JOIN.SORT_MERGE_REQUIRED`
    - `ASOF_JOIN_MATCH_CONDITION_TABLE_REFERENCE` (cross-side and invalid refs)
    - `ASOF_JOIN_MATCH_CONDITION_INVALID_EXPRESSION`
    - `ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE`
    
    Locally:
    ```
    build/sbt "sql/testOnly org.apache.spark.sql.AsOfJoinSQLSuite" \
              "catalyst/testOnly 
org.apache.spark.sql.catalyst.optimizer.RewriteAsOfJoinSuite"
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Co-authored with Cursor.
    
    Closes #57264 from srielau/SPARK-58123.
    
    Authored-by: Serge Rielau <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |   5 +
 .../spark/sql/catalyst/analysis/Analyzer.scala     |   3 +-
 .../sql/catalyst/analysis/CheckAnalysis.scala      |  11 +-
 .../catalyst/analysis/DeduplicateRelations.scala   |   2 +-
 .../sql/catalyst/analysis/ResolveAsOfJoin.scala    | 230 +++++++++++++++++
 .../sql/catalyst/optimizer/RewriteAsOfJoin.scala   |  17 +-
 .../plans/logical/basicLogicalOperators.scala      | 276 ++++++++++++++++++++-
 .../sql/catalyst/rules/RuleIdCollection.scala      |   1 +
 .../spark/sql/errors/QueryCompilationErrors.scala  |  10 +
 .../spark/sql/execution/SparkStrategies.scala      |   2 +-
 .../org/apache/spark/sql/AsOfJoinSQLSuite.scala    | 183 +++++++++++++-
 11 files changed, 730 insertions(+), 10 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 83ace844cfaa..60c990066e24 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -210,6 +210,11 @@
         "message" : [
           "Unsupported as-of join direction '<direction>'. Supported as-of 
join direction include: <supported>."
         ]
+      },
+      "UNSUPPORTED_MATCH_CONDITION_OPERAND" : {
+        "message" : [
+          "The MATCH_CONDITION operands (<type1> and <type2>) use types that 
require multi-column sort-merge ASOF join execution. Only scalar numeric and 
datetime operands are currently supported."
+        ]
       }
     },
     "sqlState" : "42604"
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index 49fdbd45b91c..7d22ab263059 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -636,7 +636,8 @@ class Analyzer(
       typeCoercionRules() ++
       Seq(
         ResolveWithCTE,
-        ExtractDistributedSequenceID) ++
+        ExtractDistributedSequenceID,
+        ResolveAsOfJoin) ++
       Seq(ResolveUpdateEventTimeWatermarkColumn) ++
       extendedResolutionRules ++
       Seq(NameStreamingSources) : _*),
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
index f457ac1ba852..b7394d325f12 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
@@ -676,7 +676,14 @@ trait CheckAnalysis extends LookupCatalog with 
QueryErrorsBase with PlanToString
                 "joinCondition" -> toSQLExpr(condition),
                 "conditionType" -> toSQLType(condition.dataType)))
 
-          case j @ AsOfJoin(_, _, _, Some(condition), _, _, _, _, _, _, _)
+          case j @ AsOfJoin(_, _, _, _, _, _, _, _, _, _, _, _, _, true)
+              if !SQLConf.get.sortMergeAsOfJoinEnabled =>
+            j.failAnalysis(
+              errorClass = "AS_OF_JOIN.SORT_MERGE_REQUIRED",
+              messageParameters = Map(
+                "config" -> SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key))
+
+          case j @ AsOfJoin(_, _, _, Some(condition), _, _, _, _, _, _, _, _, 
_, _)
               if condition.dataType != BooleanType =>
             throw SparkException.internalError(
               msg = s"join condition '${toSQLExpr(condition)}' " +
@@ -684,7 +691,7 @@ trait CheckAnalysis extends LookupCatalog with 
QueryErrorsBase with PlanToString
               context = j.origin.getQueryContext,
               summary = j.origin.context.summary)
 
-          case j @ AsOfJoin(_, _, _, _, _, _, Some(toleranceAssertion), _, _, 
_, _) =>
+          case j @ AsOfJoin(_, _, _, _, _, _, Some(toleranceAssertion), _, _, 
_, _, _, _, _) =>
             if (!toleranceAssertion.foldable) {
               j.failAnalysis(
                 errorClass = "AS_OF_JOIN.TOLERANCE_IS_UNFOLDABLE",
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
index 18045214e565..ce29706501f2 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
@@ -48,7 +48,7 @@ object DeduplicateRelations extends Rule[LogicalPlan] {
           if right.resolved && !j.duplicateResolved && 
noMissingInput(right.plan) =>
         j.copy(right = right.withNewPlan(dedupRight(left, right.plan)))
       // Resolve duplicate output for AsOfJoin.
-      case j @ AsOfJoin(left, right, _, _, _, _, _, _, _, _, _)
+      case j @ AsOfJoin(left, right, _, _, _, _, _, _, _, _, _, _, _, _)
           if !j.duplicateResolved && noMissingInput(right) =>
         j.copy(right = dedupRight(left, right))
       // Resolve duplicate output for NearestByJoin.
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoin.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoin.scala
new file mode 100644
index 000000000000..41c79b2905d6
--- /dev/null
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoin.scala
@@ -0,0 +1,230 @@
+/*
+ * 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.analysis
+
+import org.apache.spark.sql.catalyst.SQLConfHelper
+import org.apache.spark.sql.catalyst.expressions.{
+  Expression,
+  RowOrdering,
+  SubqueryExpression,
+  WindowExpression
+}
+import org.apache.spark.sql.catalyst.expressions.AttributeSet
+import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
+import org.apache.spark.sql.catalyst.plans.logical.{AsOfJoin, LogicalPlan, 
Project}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreePattern.{AS_OF_JOIN, GENERATOR}
+import org.apache.spark.sql.catalyst.util._
+import org.apache.spark.sql.errors.QueryErrorsBase
+import org.apache.spark.sql.types.{ArrayType, DataType, DatetimeType, 
StringType, StructType}
+
+/**
+ * Resolves SQL [[AsOfJoin]] operators: materializes `MATCH_CONDITION` into 
`asOfCondition` and
+ * `orderExpression`, and expands `USING` column lists into equi-join 
predicates.
+ */
+object ResolveAsOfJoin extends Rule[LogicalPlan] with SQLConfHelper {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = 
plan.resolveOperatorsUpWithPruning(
+    _.containsPattern(AS_OF_JOIN), ruleId) {
+    case j @ AsOfJoin(
+        left,
+        right,
+        _,
+        condition,
+        _,
+        _,
+        _,
+        usingColumns,
+        matchLeft,
+        matchOp,
+        matchRight,
+        _,
+        _,
+        _)
+        if left.resolved && right.resolved && condition.forall(_.resolved) =>
+      val (joinBase, usingProjection) = usingColumns match {
+        case Some(cols) if condition.isEmpty =>
+          val (projectList, hiddenList, newCondition) =
+            NaturalAndUsingJoinResolution.computeJoinOutputsAndNewCondition(
+              left,
+              left.output,
+              right,
+              right.output,
+              j.joinType,
+              cols,
+              None,
+              (l, r) => conf.resolver(l, r))
+          (j.copy(condition = newCondition, usingColumns = None), 
Some((projectList, hiddenList)))
+        case _ => (j, None)
+      }
+      val resolvedJoin = (matchLeft, matchOp, matchRight) match {
+        case (Some(leftExpr), Some(operator), Some(rightExpr)) =>
+          AsOfJoinValidation.validateMatchConditionTableReferences(
+            joinBase, left, right, leftExpr, rightExpr)
+          if (leftExpr.resolved && rightExpr.resolved) {
+            AsOfJoinValidation.validateMatchConditionOperands(joinBase, 
leftExpr, rightExpr)
+            val (leftOperand, rightOperand, normalizedOp) =
+              AsOfJoin.normalizeMatchOperands(left, right, leftExpr, operator, 
rightExpr)
+            AsOfJoinValidation.validateMatchConditionPlannerSupport(
+              joinBase, leftOperand, rightOperand)
+            val (asOfCondition, orderExpression, leftSortExprs, 
rightSortExprs) =
+              AsOfJoin.materializeMatchComparison(leftOperand, rightOperand, 
normalizedOp)
+            joinBase.copy(
+              asOfCondition = asOfCondition,
+              orderExpression = orderExpression,
+              leftSortExprs = leftSortExprs,
+              rightSortExprs = rightSortExprs,
+              matchLeftOperand = None,
+              matchOperator = None,
+              matchRightOperand = None)
+          } else {
+            joinBase
+          }
+        case (None, None, None) => joinBase
+        case _ => joinBase
+      }
+      usingProjection match {
+        case Some((projectList, hiddenList)) =>
+          val project = Project(projectList, resolvedJoin)
+          project.setTagValue(
+            Project.hiddenOutputTag,
+            hiddenList.map(_.markAsQualifiedAccessOnly()))
+          project
+        case None => resolvedJoin
+      }
+  }
+}
+
+private[analysis] object AsOfJoinValidation extends QueryErrorsBase {
+
+  def validateMatchConditionTableReferences(
+      join: AsOfJoin,
+      left: LogicalPlan,
+      right: LogicalPlan,
+      leftExpr: Expression,
+      rightExpr: Expression): Unit = {
+    val leftSet = left.outputSet
+    val rightSet = right.outputSet
+
+    def referencesBothJoinSides(refs: AttributeSet): Boolean = {
+      refs.nonEmpty &&
+        refs.intersect(leftSet).nonEmpty &&
+        refs.intersect(rightSet).nonEmpty
+    }
+
+    val leftRefs = leftExpr.references
+    val rightRefs = rightExpr.references
+    if (referencesBothJoinSides(leftRefs) || 
referencesBothJoinSides(rightRefs)) {
+      join.failAnalysis(
+        errorClass = "ASOF_JOIN_MATCH_CONDITION_TABLE_REFERENCE",
+        messageParameters = Map(
+          "refs1" -> toSQLExpr(leftExpr),
+          "refs2" -> toSQLExpr(rightExpr)))
+    }
+  }
+
+  def validateMatchConditionOperands(
+      join: AsOfJoin,
+      leftExpr: Expression,
+      rightExpr: Expression): Unit = {
+    Seq(leftExpr, rightExpr).foreach { expr =>
+      findInvalidMatchConditionExpression(expr).foreach { invalidExpr =>
+        join.failAnalysis(
+          errorClass = "ASOF_JOIN_MATCH_CONDITION_INVALID_EXPRESSION",
+          messageParameters = Map("expr" -> toSQLExpr(invalidExpr)))
+      }
+    }
+    if (!RowOrdering.isOrderable(leftExpr.dataType) ||
+        !RowOrdering.isOrderable(rightExpr.dataType) ||
+        !areMatchConditionTypesCompatible(leftExpr.dataType, 
rightExpr.dataType)) {
+      join.failAnalysis(
+        errorClass = "ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE",
+        messageParameters = Map(
+          "type1" -> toSQLType(leftExpr.dataType),
+          "type2" -> toSQLType(rightExpr.dataType)))
+    }
+  }
+
+  def validateMatchConditionPlannerSupport(
+      join: AsOfJoin,
+      leftOperand: Expression,
+      rightOperand: Expression): Unit = {
+    if (!areScalarSubtractBasedOperands(leftOperand, rightOperand)) {
+      join.failAnalysis(
+        errorClass = "AS_OF_JOIN.UNSUPPORTED_MATCH_CONDITION_OPERAND",
+        messageParameters = Map(
+          "type1" -> toSQLType(leftOperand.dataType),
+          "type2" -> toSQLType(rightOperand.dataType)))
+    }
+  }
+
+  /**
+   * Until multi-column sort-merge ASOF execution lands (SPARK-58124), the 
planner can only
+   * consume MATCH_CONDITION plans whose `orderExpression` is a scalar 
`Subtract`. STRING and
+   * composite operands use other distance expressions that `findFromOrder` 
cannot parse.
+   */
+  private def areScalarSubtractBasedOperands(
+      leftExpr: Expression,
+      rightExpr: Expression): Boolean = {
+    AsOfJoin.supportsSubtract(leftExpr.dataType) && 
AsOfJoin.supportsSubtract(rightExpr.dataType)
+  }
+
+  /**
+   * Tuple/struct operands may use different field names on each side; compare 
field-wise by
+   * position when [[TypeCoercion.findWiderTypeForTwo]] does not apply.
+   */
+  private def areMatchConditionTypesCompatible(t1: DataType, t2: DataType): 
Boolean = {
+    if (isIncompatibleMatchConditionPair(t1, t2)) {
+      false
+    } else {
+      TypeCoercion.findWiderTypeForTwo(t1, t2).isDefined ||
+        areStructurallyComparableTypes(t1, t2)
+    }
+  }
+
+  private def isIncompatibleMatchConditionPair(t1: DataType, t2: DataType): 
Boolean = {
+    def isString(dt: DataType): Boolean = dt.isInstanceOf[StringType]
+    def isTemporal(dt: DataType): Boolean = dt.isInstanceOf[DatetimeType]
+    (isTemporal(t1) && isString(t2)) || (isString(t1) && isTemporal(t2))
+  }
+
+  private def areStructurallyComparableTypes(t1: DataType, t2: DataType): 
Boolean = {
+    (t1, t2) match {
+      case (s1: StructType, s2: StructType) if s1.length == s2.length =>
+        s1.zip(s2).forall { case (f1, f2) =>
+          RowOrdering.isOrderable(f1.dataType) &&
+            RowOrdering.isOrderable(f2.dataType) &&
+            areMatchConditionTypesCompatible(f1.dataType, f2.dataType)
+        }
+      case (ArrayType(e1, _), ArrayType(e2, _)) =>
+        RowOrdering.isOrderable(e1) && RowOrdering.isOrderable(e2) &&
+          areMatchConditionTypesCompatible(e1, e2)
+      case _ => false
+    }
+  }
+
+  private def findInvalidMatchConditionExpression(expr: Expression): 
Option[Expression] = {
+    expr.collect {
+      case e: SubqueryExpression => e
+      case e: AggregateExpression => e
+      case e: WindowExpression => e
+      case e if e.containsPattern(GENERATOR) => e
+      case e if !e.deterministic => e
+    }.headOption
+  }
+}
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
index 31625b25b378..8359bed4eb57 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
@@ -59,7 +59,20 @@ object RewriteAsOfJoin extends Rule[LogicalPlan] {
 
     plan.transformUpWithNewOutput {
       case j @ AsOfJoin(
-          left, right, asOfCondition, condition, joinType, orderExpression, _, 
_, _, _, _) =>
+          left,
+          right,
+          asOfCondition,
+          condition,
+          joinType,
+          orderExpression,
+          _,
+          _,
+          _,
+          _,
+          _,
+          _,
+          _,
+          _) =>
         val conditionWithOuterReference =
           condition.map(And(_, 
asOfCondition)).getOrElse(asOfCondition).transformUp {
             case a: AttributeReference if left.outputSet.contains(a) =>
@@ -70,7 +83,7 @@ object RewriteAsOfJoin extends Rule[LogicalPlan] {
         val orderExpressionWithOuterReference = orderExpression.transformUp {
             case a: AttributeReference if left.outputSet.contains(a) =>
               OuterReference(a)
-          }
+        }
         val rightStruct = CreateStruct(right.output)
         val nearestRight = MinBy(rightStruct, 
orderExpressionWithOuterReference)
           .toAggregateExpression()
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
index 244614a2c962..c2e42df00b68 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
@@ -2537,7 +2537,10 @@ case class AsOfJoin(
     usingColumns: Option[Seq[String]] = None,
     matchLeftOperand: Option[Expression] = None,
     matchOperator: Option[MatchComparisonOperator] = None,
-    matchRightOperand: Option[Expression] = None)
+    matchRightOperand: Option[Expression] = None,
+    leftSortExprs: Seq[Expression] = Nil,
+    rightSortExprs: Seq[Expression] = Nil,
+    requiresSortMergeAsOfJoin: Boolean = false)
     extends BinaryNode {
 
   require(Seq(Inner, LeftOuter).contains(joinType),
@@ -2621,7 +2624,276 @@ object AsOfJoin {
       usingColumns = usingColumns,
       matchLeftOperand = Some(leftExpr),
       matchOperator = Some(operator),
-      matchRightOperand = Some(rightExpr))
+      matchRightOperand = Some(rightExpr),
+      requiresSortMergeAsOfJoin = true)
+  }
+
+  private[catalyst] def materializeMatchComparison(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      normalizedOp: MatchComparisonOperator)
+      : (Expression, Expression, Seq[Expression], Seq[Expression]) = {
+    val (asOfCondition, orderExpression) =
+      buildMatchExpressions(leftOperand, rightOperand, normalizedOp)
+    val (leftSortExprs, rightSortExprs) = matchSortExpressions(leftOperand, 
rightOperand)
+    (asOfCondition, orderExpression, leftSortExprs, rightSortExprs)
+  }
+
+  /**
+   * Sort-merge ASOF join sorts each side by these expressions (after 
equi-keys) so the
+   * right-side buffer is ordered consistently with MATCH_CONDITION 
lexicographic comparison.
+   *
+   * SQL tuple literals `(t.a, t.b)` are flattened to scalar leaves. Whole 
struct columns
+   * (`t.k >= r.k`) sort by the struct value directly so nested struct shapes 
stay intact.
+   */
+  def matchSortExpressions(
+      leftOperand: Expression,
+      rightOperand: Expression): (Seq[Expression], Seq[Expression]) = {
+    (leftOperand.dataType, rightOperand.dataType) match {
+      case (leftStruct: StructType, rightStruct: StructType)
+          if leftStruct.sameType(rightStruct) && leftStruct.nonEmpty =>
+        if (isSqlTupleStructOperand(leftOperand) || 
isSqlTupleStructOperand(rightOperand)) {
+          val pairs = collectStructLeafPairs(leftOperand, rightOperand, 
leftStruct)
+          (pairs.map(_._1), pairs.map(_._2))
+        } else {
+          (Seq(leftOperand), Seq(rightOperand))
+        }
+      case _ =>
+        (Seq(leftOperand), Seq(rightOperand))
+    }
+  }
+
+  /** True for SQL `(col1, col2, ...)` tuple operands, which become 
[[CreateNamedStruct]]. */
+  private def isSqlTupleStructOperand(operand: Expression): Boolean =
+    operand.isInstanceOf[CreateNamedStruct]
+
+  private[catalyst] def normalizeMatchOperands(
+      left: LogicalPlan,
+      right: LogicalPlan,
+      expr1: Expression,
+      operator: MatchComparisonOperator,
+      expr2: Expression): (Expression, Expression, MatchComparisonOperator) = {
+    val leftSet = left.outputSet
+    val rightSet = right.outputSet
+    val expr1Side = operandJoinSide(expr1, leftSet, rightSet)
+    val expr2Side = operandJoinSide(expr2, leftSet, rightSet)
+    (expr1Side, expr2Side) match {
+      case (Some(true), Some(false)) => (expr1, expr2, operator)
+      case (Some(false), Some(true)) => (expr2, expr1, operator.flip)
+      case _ =>
+        throw 
QueryCompilationErrors.asOfJoinMatchConditionTableReferenceError(expr1, expr2)
+    }
+  }
+
+  private def operandJoinSide(
+      expr: Expression,
+      leftSet: AttributeSet,
+      rightSet: AttributeSet): Option[Boolean] = {
+    val refs = expr.references
+    if (refs.isEmpty) {
+      None
+    } else if (refs.subsetOf(leftSet)) {
+      Some(true)
+    } else if (refs.subsetOf(rightSet)) {
+      Some(false)
+    } else {
+      None
+    }
+  }
+
+  private def buildMatchExpressions(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      operator: MatchComparisonOperator): (Expression, Expression) = {
+    val (leftForCompare, rightForCompare) =
+      alignOperandsForComparison(leftOperand, rightOperand)
+    val orderExpression = buildOrderExpression(leftOperand, rightOperand, 
operator)
+    operator match {
+      case GreaterThanOrEqualOp =>
+        (GreaterThanOrEqual(leftForCompare, rightForCompare), orderExpression)
+      case GreaterThanOp =>
+        (GreaterThan(leftForCompare, rightForCompare), orderExpression)
+      case LessThanOrEqualOp =>
+        (LessThanOrEqual(leftForCompare, rightForCompare), orderExpression)
+      case LessThanOp =>
+        (LessThan(leftForCompare, rightForCompare), orderExpression)
+    }
+  }
+
+  private def buildOrderExpression(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      operator: MatchComparisonOperator): Expression = {
+    (leftOperand.dataType, rightOperand.dataType) match {
+      case (ArrayType(leftElem, _), ArrayType(rightElem, _))
+          if DataTypeUtils.sameType(leftElem, rightElem) =>
+        buildArrayOrderExpression(leftOperand, rightOperand, leftElem, 
operator)
+      case (leftStruct: StructType, rightStruct: StructType)
+          if leftStruct.length == rightStruct.length && leftStruct.nonEmpty =>
+        buildFlattenedStructOrderExpression(
+          leftOperand, rightOperand, leftStruct, operator)
+      case _ =>
+        buildLeafOrderExpression(leftOperand, rightOperand, operator)
+    }
+  }
+
+  /**
+   * Tuple/struct operands may use different field names on each side. Rewrite 
them to positional
+   * structs with matching schemas so comparison and ordering type-check.
+   */
+  private def alignOperandsForComparison(
+      leftOperand: Expression,
+      rightOperand: Expression): (Expression, Expression) = {
+    decomposeStructOperands(leftOperand, rightOperand) match {
+      case Some(pairs) =>
+        val aligned = pairs.map { case (left, right) =>
+          alignOperandsForComparison(left, right)
+        }
+        (CreateStruct(aligned.map(_._1)), CreateStruct(aligned.map(_._2)))
+      case None =>
+        (leftOperand, rightOperand)
+    }
+  }
+
+  private def buildLeafOrderExpression(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      operator: MatchComparisonOperator): Expression = {
+    if (supportsSubtract(leftOperand.dataType)) {
+      operator match {
+        case GreaterThanOrEqualOp | GreaterThanOp =>
+          Subtract(leftOperand, rightOperand)
+        case LessThanOrEqualOp | LessThanOp =>
+          Subtract(rightOperand, leftOperand)
+      }
+    } else {
+      buildSignedComparisonDistance(leftOperand, rightOperand, operator)
+    }
+  }
+
+  private[catalyst] def supportsSubtract(dataType: DataType): Boolean = {
+    dataType match {
+      case _: NumericType | _: DayTimeIntervalType | _: YearMonthIntervalType |
+           _: CalendarIntervalType | _: TimestampType | _: TimestampNTZType | 
_: DateType =>
+        true
+      case _ =>
+        false
+    }
+  }
+
+  private def buildSignedComparisonDistance(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      operator: MatchComparisonOperator): Expression = {
+    val (greaterValue, lesserValue) = operator match {
+      case GreaterThanOrEqualOp | GreaterThanOp => (Literal(1), Literal(-1))
+      case LessThanOrEqualOp | LessThanOp => (Literal(-1), Literal(1))
+    }
+    If(
+      EqualTo(leftOperand, rightOperand),
+      Literal(0),
+      If(GreaterThan(leftOperand, rightOperand), greaterValue, lesserValue))
+  }
+
+  private def buildArrayOrderExpression(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      elementType: DataType,
+      operator: MatchComparisonOperator): Expression = {
+    elementType match {
+      case struct: StructType =>
+        val leftElement = NamedLambdaVariable("left_elem", struct, nullable = 
true)
+        val rightElement = NamedLambdaVariable("right_elem", struct, nullable 
= true)
+        val leafDiffs = collectStructLeafPairs(leftElement, rightElement, 
struct).map {
+          case (left, right) => buildLeafOrderExpression(left, right, operator)
+        }
+        val elementOrder = wrapCompositeOrderExpression(
+          leafDiffs,
+          ArrayType(struct, containsNull = true))
+        ZipWith(
+          leftOperand,
+          rightOperand,
+          LambdaFunction(elementOrder, Seq(leftElement, rightElement)))
+      case _ =>
+        val leftElement = NamedLambdaVariable("left_elem", elementType, 
nullable = true)
+        val rightElement = NamedLambdaVariable("right_elem", elementType, 
nullable = true)
+        val elementOrder = buildLeafOrderExpression(leftElement, rightElement, 
operator)
+        ZipWith(
+          leftOperand,
+          rightOperand,
+          LambdaFunction(elementOrder, Seq(leftElement, rightElement)))
+    }
+  }
+
+  private def buildFlattenedStructOrderExpression(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      structType: StructType,
+      operator: MatchComparisonOperator): Expression = {
+    val leafDiffs = collectStructLeafPairs(leftOperand, rightOperand, 
structType).map {
+      case (left, right) => buildLeafOrderExpression(left, right, operator)
+    }
+    wrapCompositeOrderExpression(leafDiffs, structType)
+  }
+
+  private def collectStructLeafPairs(
+      leftOperand: Expression,
+      rightOperand: Expression,
+      structType: StructType): Seq[(Expression, Expression)] = {
+    structFieldExprs(leftOperand, structType)
+      .zip(structFieldExprs(rightOperand, structType))
+      .flatMap {
+        case (left, right) =>
+          (left.dataType, right.dataType) match {
+            case (leftStruct: StructType, rightStruct: StructType)
+                if leftStruct.length == rightStruct.length && 
leftStruct.nonEmpty =>
+              collectStructLeafPairs(left, right, leftStruct)
+            case _ =>
+              Seq((left, right))
+          }
+      }
+  }
+
+  private def wrapCompositeOrderExpression(
+      diffs: Seq[Expression],
+      compositeType: DataType): Expression = {
+    diffs match {
+      case Seq(single) => single
+      case _ =>
+        compositeType match {
+          case _: ArrayType => CreateArray(diffs)
+          case _ => CreateStruct(diffs)
+        }
+    }
+  }
+
+  /** Positional struct fields when both operands are the same struct shape. */
+  private def decomposeStructOperands(
+      leftOperand: Expression,
+      rightOperand: Expression): Option[Seq[(Expression, Expression)]] = {
+    (leftOperand.dataType, rightOperand.dataType) match {
+      case (leftStruct: StructType, rightStruct: StructType)
+          if leftStruct.length == rightStruct.length && leftStruct.nonEmpty =>
+        val leftFields = structFieldExprs(leftOperand, leftStruct)
+        val rightFields = structFieldExprs(rightOperand, rightStruct)
+        if (leftFields.length == rightFields.length) {
+          Some(leftFields.zip(rightFields))
+        } else {
+          None
+        }
+      case _ => None
+    }
+  }
+
+  private def structFieldExprs(
+      operand: Expression,
+      structType: StructType): Seq[Expression] = {
+    operand match {
+      case ns: CreateNamedStruct => ns.valExprs
+      case _ =>
+        structType.indices.map(index =>
+          GetStructField(operand, index, Some(structType(index).name)))
+    }
   }
 
   private def makeAsOfCond(
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
index 12fcf3ed9aa3..2953c09e183d 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
@@ -101,6 +101,7 @@ object RuleIdCollection {
       "org.apache.spark.sql.catalyst.analysis.ResolveCursors" ::
       "org.apache.spark.sql.catalyst.analysis.ResolveFetchCursor" ::
       "org.apache.spark.sql.catalyst.analysis.ResolveBinBy" ::
+      "org.apache.spark.sql.catalyst.analysis.ResolveAsOfJoin" ::
       "org.apache.spark.sql.catalyst.analysis.ResolveSetVariable" ::
       "org.apache.spark.sql.catalyst.analysis.ResolveTableConstraints" ::
       "org.apache.spark.sql.catalyst.analysis.ResolveTableSpec" ::
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index 34242fe25f41..d6f9de4cab54 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -4971,6 +4971,16 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
       messageParameters = Map.empty)
   }
 
+  def asOfJoinMatchConditionTableReferenceError(
+      expr1: Expression,
+      expr2: Expression): Throwable = {
+    new AnalysisException(
+      errorClass = "ASOF_JOIN_MATCH_CONDITION_TABLE_REFERENCE",
+      messageParameters = Map(
+        "refs1" -> toSQLExpr(expr1),
+        "refs2" -> toSQLExpr(expr2)))
+  }
+
   def nestedSequentialStreamingUnionError(): Throwable = {
     new AnalysisException(
       errorClass = "NESTED_SEQUENTIAL_STREAMING_UNION",
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index 1329e0d5d6b3..022c16c1acc5 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -431,7 +431,7 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
   object AsOfJoinSelection extends Strategy with PredicateHelper {
     def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
       case j @ AsOfJoin(left, right, asOfCondition, condition, joinType,
-          orderExpression, _, _, _, _, _) if conf.sortMergeAsOfJoinEnabled =>
+          orderExpression, _, _, _, _, _, _, _, _) if 
conf.sortMergeAsOfJoinEnabled =>
         val (leftKeys, rightKeys, residual) = condition match {
           case Some(cond) => extractEquiJoinKeys(cond, left, right)
           case None => (Seq.empty[Expression], Seq.empty[Expression], None)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala
index d8fead52af94..215a8a1bbb36 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala
@@ -18,11 +18,12 @@
 package org.apache.spark.sql
 
 import org.apache.spark.sql.catalyst.parser.ParseException
+import org.apache.spark.sql.catalyst.plans.logical.AsOfJoin
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 
 /**
- * SQL ASOF JOIN surface tests (parser and feature gating).
+ * SQL ASOF JOIN surface tests (parser, analysis, and feature gating).
  * Execution semantics and complex MATCH_CONDITION types are covered by
  * `AsOfJoinSortMergeSQLSuite`, which requires sort-merge ASOF join.
  */
@@ -38,6 +39,19 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
     super.afterAll()
   }
 
+  private def setupTradeQuoteViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW trades(trade_time, symbol) AS
+        |VALUES (TIMESTAMP '2026-06-29 10:00:05', 'AAPL')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW quotes(quote_time, symbol) AS
+        |VALUES (TIMESTAMP '2026-06-29 10:00:00', 'AAPL')
+        |""".stripMargin)
+  }
+
   test("equality operator is rejected in MATCH_CONDITION") {
     sql(
       """
@@ -69,4 +83,171 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
           start = 24,
           stop = 114)))
   }
+
+  test("SQL ASOF JOIN requires sort-merge conf") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT t.trade_time, q.quote_time
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    withSQLConf(SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key -> "false") {
+      checkError(
+        exception = intercept[AnalysisException](sql(sqlText)),
+        condition = "AS_OF_JOIN.SORT_MERGE_REQUIRED",
+        parameters = Map("config" -> 
SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key),
+        queryContext = Array(
+          ExpectedContext(
+            fragment = """ASOF JOIN quotes q
+                         |  MATCH_CONDITION (t.trade_time >= q.quote_time)
+                         |  ON t.symbol = q.symbol""".stripMargin,
+            start = 49,
+            stop = 140)))
+    }
+  }
+
+  test("valid TIMESTAMP MATCH_CONDITION passes analysis with sort-merge 
enabled") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT t.trade_time, q.quote_time
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    withSQLConf(SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key -> "true") {
+      val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+        case j: AsOfJoin => j
+      }.get
+      assert(asOfJoin.asOfCondition.resolved)
+      assert(asOfJoin.leftSortExprs.nonEmpty)
+      assert(asOfJoin.rightSortExprs.nonEmpty)
+      assert(asOfJoin.matchLeftOperand.isEmpty)
+    }
+  }
+
+  test("MATCH_CONDITION rejects STRING operands until composite sort-merge 
lands") {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW left_s(k) AS VALUES ('c')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW right_s(k) AS VALUES ('a'), ('b')
+        |""".stripMargin)
+    val sqlText =
+      """
+        |SELECT l.k
+        |FROM left_s l ASOF JOIN right_s r
+        |  MATCH_CONDITION (l.k >= r.k)
+        |""".stripMargin
+    withSQLConf(SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key -> "true") {
+      checkError(
+        exception = intercept[AnalysisException](sql(sqlText)),
+        condition = "AS_OF_JOIN.UNSUPPORTED_MATCH_CONDITION_OPERAND",
+        sqlState = Some("42604"),
+        parameters = Map(
+          "type1" -> "\"STRING\"",
+          "type2" -> "\"STRING\""),
+        queryContext = Array(
+          ExpectedContext(
+            fragment = """ASOF JOIN right_s r
+                         |  MATCH_CONDITION (l.k >= r.k)""".stripMargin,
+            start = 26,
+            stop = 75)))
+    }
+  }
+
+  test("MATCH_CONDITION rejects cross-side operand references") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT count(*)
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time + q.quote_time >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    checkError(
+      exception = intercept[AnalysisException](sql(sqlText)),
+      condition = "ASOF_JOIN_MATCH_CONDITION_TABLE_REFERENCE",
+      sqlState = Some("42K0E"),
+      parameters = Map(
+        "refs1" -> "\"(trade_time + quote_time)\"",
+        "refs2" -> "\"quote_time\""),
+      queryContext = Array(
+        ExpectedContext(
+          fragment = """ASOF JOIN quotes q
+                       |  MATCH_CONDITION (t.trade_time + q.quote_time >= 
q.quote_time)
+                       |  ON t.symbol = q.symbol""".stripMargin,
+          start = 31,
+          stop = 137)))
+  }
+
+  test("MATCH_CONDITION rejects invalid table references") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT count(*)
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.symbol >= t.symbol)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    checkError(
+      exception = intercept[AnalysisException](sql(sqlText)),
+      condition = "ASOF_JOIN_MATCH_CONDITION_TABLE_REFERENCE",
+      sqlState = Some("42K0E"),
+      parameters = Map(
+        "refs1" -> "\"symbol\"",
+        "refs2" -> "\"symbol\""))
+  }
+
+  test("MATCH_CONDITION rejects non-deterministic expressions") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT t.trade_time
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (rand() >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    checkError(
+      exception = intercept[AnalysisException](sql(sqlText)),
+      condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_EXPRESSION",
+      sqlState = Some("42903"),
+      parameters = Map("expr" -> "\"rand()\""),
+      queryContext = Array(
+        ExpectedContext(
+          fragment = """ASOF JOIN quotes q
+                       |  MATCH_CONDITION (rand() >= q.quote_time)
+                       |  ON t.symbol = q.symbol""".stripMargin,
+          start = 35,
+          stop = 120)))
+  }
+
+  test("MATCH_CONDITION rejects incompatible operand types") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT t.trade_time
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= q.symbol)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    checkError(
+      exception = intercept[AnalysisException](sql(sqlText)),
+      condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE",
+      sqlState = Some("42K09"),
+      parameters = Map(
+        "type1" -> "\"TIMESTAMP\"",
+        "type2" -> "\"STRING\""),
+      queryContext = Array(
+        ExpectedContext(
+          fragment = """ASOF JOIN quotes q
+                       |  MATCH_CONDITION (t.trade_time >= q.symbol)
+                       |  ON t.symbol = q.symbol""".stripMargin,
+          start = 35,
+          stop = 122)))
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to