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

Yicong-Huang 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 43826cde7f7f [SPARK-58224][SQL] ASOF JOIN analysis fixes found during 
FVT review
43826cde7f7f is described below

commit 43826cde7f7f5c57c335932d4d89d3bcb1989b76
Author: Serge Rielau <[email protected]>
AuthorDate: Mon Jul 20 22:29:54 2026 +0000

    [SPARK-58224][SQL] ASOF JOIN analysis fixes found during FVT review
    
    ### What changes were proposed in this pull request?
    
    Product fixes uncovered while landing ASOF JOIN golden-file tests 
(SPARK-58173):
    
    1. **Non-boolean `ON`** — report `JOIN_CONDITION_IS_NOT_BOOLEAN_TYPE` 
(`CheckAnalysis`)
    2. **Invalid `MATCH_CONDITION` operator errors** — recover SQL operator 
text (`!=` vs `<>`, `IS [NOT] DISTINCT FROM`) via parse-tree inspection instead 
of Catalyst pretty names (`AstBuilder`)
    3. **Foldable `MATCH_CONDITION` operands** — assign literals, 
`CURRENT_TIMESTAMP()`, and session variables to join sides by syntactic 
position (`AsOfJoin.normalizeMatchOperands`)
    4. **Subquery resolution** — resolve subqueries in `AsOfJoin` `ON` / 
`MATCH_CONDITION` like regular `Join` (`Analyzer.ResolveSubquery`, 
`ValidateSubqueryExpression`)
    5. **Struct/array `MATCH_CONDITION` analysis** — reject empty structs; 
require identical array element types so analysis matches order-expression 
construction (`ResolveAsOfJoin`)
    
    ### Why are the changes needed?
    
    Without these fixes, FVT and integration tests surfaced misleading errors 
(e.g. `TABLE_OR_VIEW_NOT_FOUND` for unresolved subqueries) or 
analysis/execution mismatches on array operands.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes — clearer analysis errors and correct behavior for foldable operands 
and subqueries in ASOF JOIN.
    
    ### How was this patch tested?
    
    - `PlanParserSuite` — invalid `MATCH_CONDITION` operator text
    - `AsOfJoinSQLSuite` — foldable operands, subqueries, struct/array operands
    
    ```bash
    build/sbt -Dscalastyle.skip=true \
      "sql/testOnly org.apache.spark.sql.catalyst.parser.PlanParserSuite -- -z 
asof" \
      "sql/testOnly org.apache.spark.sql.AsOfJoinSQLSuite"
    ```
    
    Stacked PR: [SPARK-58173](https://github.com/apache/spark/pull/57306) (FVT 
goldens) builds on this branch.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    No.
    
    Closes #57380 from srielau/SPARK-58224.
    
    Authored-by: Serge Rielau <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
---
 .../spark/sql/catalyst/analysis/Analyzer.scala     |   2 +
 .../sql/catalyst/analysis/CheckAnalysis.scala      |  10 +-
 .../sql/catalyst/analysis/ResolveAsOfJoin.scala    |  43 +-----
 .../analysis/ValidateSubqueryExpression.scala      |   2 +-
 .../spark/sql/catalyst/parser/AstBuilder.scala     |  71 ++++++++-
 .../plans/logical/basicLogicalOperators.scala      | 148 ++++++++++++++----
 .../sql/catalyst/parser/PlanParserSuite.scala      |  38 +++++
 .../logical/AsOfJoinMatchConditionTypesSuite.scala | 120 ++++++++++++++
 .../org/apache/spark/sql/AsOfJoinSQLSuite.scala    | 172 ++++++++++++++++++++-
 9 files changed, 523 insertions(+), 83 deletions(-)

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 51882a9dd983..f8e79f9d8da9 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
@@ -2633,6 +2633,8 @@ class Analyzer(
         resolveSubQueries(r, r)
       case j: Join if j.childrenResolved && j.duplicateResolved =>
         resolveSubQueries(j, j)
+      case j: AsOfJoin if j.childrenResolved && j.duplicateResolved =>
+        resolveSubQueries(j, j)
       case tvf: UnresolvedTableValuedFunction =>
         resolveSubQueries(tvf, tvf)
       case s: SupportsSubquery if s.childrenResolved =>
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 86e789b0b2b1..1d78d455084a 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
@@ -678,11 +678,11 @@ trait CheckAnalysis extends LookupCatalog with 
QueryErrorsBase with PlanToString
 
           case j @ AsOfJoin(_, _, _, Some(condition), _, _, _, _, _, _, _, _, 
_, _)
               if condition.dataType != BooleanType =>
-            throw SparkException.internalError(
-              msg = s"join condition '${toSQLExpr(condition)}' " +
-                s"of type ${toSQLType(condition.dataType)} is not a boolean.",
-              context = j.origin.getQueryContext,
-              summary = j.origin.context.summary)
+            j.failAnalysis(
+              errorClass = "JOIN_CONDITION_IS_NOT_BOOLEAN_TYPE",
+              messageParameters = Map(
+                "joinCondition" -> toSQLExpr(condition),
+                "conditionType" -> toSQLType(condition.dataType)))
 
           case j @ AsOfJoin(_, _, _, _, _, _, Some(toleranceAssertion), _, _, 
_, _, _, _, _) =>
             if (!toleranceAssertion.foldable) {
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
index fb3b5a425e78..f09612d5902b 100644
--- 
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
@@ -20,18 +20,17 @@ 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.plans.logical.AsOfJoin.MatchConditionTypes
 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
@@ -147,9 +146,9 @@ private[analysis] object AsOfJoinValidation extends 
QueryErrorsBase {
           messageParameters = Map("expr" -> toSQLExpr(invalidExpr)))
       }
     }
-    if (!RowOrdering.isOrderable(leftExpr.dataType) ||
-        !RowOrdering.isOrderable(rightExpr.dataType) ||
-        !areMatchConditionTypesCompatible(leftExpr.dataType, 
rightExpr.dataType)) {
+    if (!MatchConditionTypes.isValidOperandType(leftExpr.dataType) ||
+        !MatchConditionTypes.isValidOperandType(rightExpr.dataType) ||
+        !MatchConditionTypes.areOperandsCompatible(leftExpr.dataType, 
rightExpr.dataType)) {
       join.failAnalysis(
         errorClass = "ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE",
         messageParameters = Map(
@@ -158,40 +157,6 @@ private[analysis] object AsOfJoinValidation extends 
QueryErrorsBase {
     }
   }
 
-  /**
-   * 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
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala
index f88e2d112b55..7f41a980a382 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala
@@ -225,7 +225,7 @@ object ValidateSubqueryExpression
 
       case inSubqueryOrExistsSubquery =>
         plan match {
-          case _: Filter | _: SupportsSubquery | _: Join |
+          case _: Filter | _: SupportsSubquery | _: Join | _: AsOfJoin |
             _: Project | _: Aggregate | _: Window => // Ok
           case _ =>
             expr.failAnalysis(
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
index fe3e7dd25d33..0d3f678da456 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
@@ -2562,17 +2562,72 @@ class AstBuilder extends DataTypeAstBuilder
       case LessThan(left, right) => (left, LessThanOp, right)
       case _ =>
         throw QueryParsingErrors.sqlAsOfJoinMatchConditionInvalidOperator(
-          asOfMatchConditionInvalidOperatorText(expr), ctx)
+          asOfMatchConditionInvalidOperatorText(expr, ctx), ctx)
     }
   }
 
-  private def asOfMatchConditionInvalidOperatorText(expr: Expression): String 
= expr match {
-    case EqualTo(_, _) => "="
-    case Not(EqualTo(_, _)) => "<>"
-    case EqualNullSafe(_, _) => "<=>"
-    case And(_, _) => "AND"
-    case Or(_, _) => "OR"
-    case _ => expr.prettyName
+  /**
+   * Map a rejected MATCH_CONDITION expression back to the SQL operator the 
user wrote.
+   *
+   * Catalyst has no `NotEqualTo` class: both `<>` and `!=` parse to 
`Not(EqualTo(...))`, and
+   * both `<=>` and `IS NOT DISTINCT FROM` parse to `EqualNullSafe(...)`. Walk 
the match
+   * expression parse tree (not `prettyName`) to recover the token the user 
wrote.
+   */
+  private def asOfMatchConditionInvalidOperatorText(
+      expr: Expression,
+      ctx: ParserRuleContext): String = {
+    expr match {
+      case And(_, _) => "AND"
+      case Or(_, _) => "OR"
+      case _ =>
+        findFirstComparisonContext(ctx)
+          .map(comparisonOperatorText)
+          .orElse(findPredicatedContext(ctx).flatMap(distinctFromOperatorText))
+          .getOrElse(getOriginalText(ctx).trim)
+    }
+  }
+
+  private def findFirstComparisonContext(ctx: ParserRuleContext): 
Option[ComparisonContext] = {
+    ctx match {
+      case comparison: ComparisonContext => Some(comparison)
+      case _ =>
+        Option(ctx.children).iterator.flatMap(_.asScala).collectFirst {
+          case child: ParserRuleContext => findFirstComparisonContext(child)
+        }.flatten
+    }
+  }
+
+  private def findPredicatedContext(ctx: ParserRuleContext): 
Option[PredicatedContext] = {
+    ctx match {
+      case predicated: PredicatedContext => Some(predicated)
+      case _ =>
+        Option(ctx.children).iterator.flatMap(_.asScala).collectFirst {
+          case child: ParserRuleContext => findPredicatedContext(child)
+        }.flatten
+    }
+  }
+
+  private def comparisonOperatorText(ctx: ComparisonContext): String = {
+    val operator = 
ctx.comparisonOperator().getChild(0).asInstanceOf[TerminalNode]
+    operator.getSymbol.getType match {
+      case SqlBaseParser.EQ => "="
+      case SqlBaseParser.NSEQ => "<=>"
+      case SqlBaseParser.NEQ => "<>"
+      case SqlBaseParser.NEQJ => "!="
+      case SqlBaseParser.LT => "<"
+      case SqlBaseParser.LTE => "<="
+      case SqlBaseParser.GT => ">"
+      case SqlBaseParser.GTE => ">="
+      case _ => source(ctx.comparisonOperator())
+    }
+  }
+
+  private def distinctFromOperatorText(predicated: PredicatedContext): 
Option[String] = {
+    Option(predicated.predicate).flatMap { predicate =>
+      Option(predicate.kind).filter(_.getType == SqlBaseParser.DISTINCT).map { 
_ =>
+        if (predicate.errorCapturingNot != null) "IS NOT DISTINCT FROM" else 
"IS DISTINCT FROM"
+      }
+    }
   }
 
   /**
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 c2e42df00b68..3cff953cbbca 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
@@ -2639,19 +2639,103 @@ object AsOfJoin {
     (asOfCondition, orderExpression, leftSortExprs, rightSortExprs)
   }
 
+  /**
+   * Shared MATCH_CONDITION operand type rules used by analysis validation and 
by expression
+   * materialization so the two paths cannot drift.
+   */
+  private[catalyst] object MatchConditionTypes {
+
+    def isValidOperandType(dataType: DataType): Boolean =
+      RowOrdering.isOrderable(dataType) && !containsEmptyStructType(dataType)
+
+    def areOperandsCompatible(leftType: DataType, rightType: DataType): 
Boolean = {
+      if (!isValidOperandType(leftType) || !isValidOperandType(rightType)) {
+        false
+      } else if (isStringTemporalMismatch(leftType, rightType)) {
+        false
+      } else {
+        (leftType, rightType) match {
+          case (ArrayType(_, _), ArrayType(_, _)) =>
+            usesArrayOrderExpression(leftType, rightType)
+          case _ =>
+            TypeCoercion.findWiderTypeForTwo(leftType, rightType).isDefined ||
+              arePositionalStructsCompatible(leftType, rightType)
+        }
+      }
+    }
+
+    def usesArrayOrderExpression(leftType: DataType, rightType: DataType): 
Boolean =
+      (leftType, rightType) match {
+        case (ArrayType(leftElem, _), ArrayType(rightElem, _)) =>
+          areArrayElementsCompatible(leftElem, rightElem)
+        case _ => false
+      }
+
+    private def areArrayElementsCompatible(leftElem: DataType, rightElem: 
DataType): Boolean = {
+      if (DataTypeUtils.sameType(leftElem, rightElem)) {
+        RowOrdering.isOrderable(leftElem)
+      } else {
+        arePositionalStructsCompatible(leftElem, rightElem)
+      }
+    }
+
+    /** Positional struct operands with the same field count (names may 
differ). */
+    def usesStructDecomposition(leftType: DataType, rightType: DataType): 
Boolean =
+      (leftType, rightType) match {
+        case (leftStruct: StructType, rightStruct: StructType) =>
+          leftStruct.length == rightStruct.length && leftStruct.nonEmpty
+        case _ => false
+      }
+
+    /** Whole struct columns with identical schemas sort as a single struct 
value. */
+    def usesIdenticalStructSort(leftType: DataType, rightType: DataType): 
Boolean =
+      (leftType, rightType) match {
+        case (leftStruct: StructType, rightStruct: StructType) =>
+          leftStruct.sameType(rightStruct) && leftStruct.nonEmpty
+        case _ => false
+      }
+
+    private def isStringTemporalMismatch(leftType: DataType, rightType: 
DataType): Boolean = {
+      def isString(dataType: DataType): Boolean = 
dataType.isInstanceOf[StringType]
+      def isTemporal(dataType: DataType): Boolean = 
dataType.isInstanceOf[DatetimeType]
+      (isTemporal(leftType) && isString(rightType)) || (isString(leftType) && 
isTemporal(rightType))
+    }
+
+    private def arePositionalStructsCompatible(
+        leftType: DataType,
+        rightType: DataType): Boolean = {
+      (leftType, rightType) match {
+        case (leftStruct: StructType, rightStruct: StructType)
+            if usesStructDecomposition(leftType, rightType) =>
+          leftStruct.zip(rightStruct).forall { case (leftField, rightField) =>
+            areOperandsCompatible(leftField.dataType, rightField.dataType)
+          }
+        case _ => false
+      }
+    }
+
+    private def containsEmptyStructType(dataType: DataType): Boolean = 
dataType match {
+      case struct: StructType =>
+        struct.isEmpty || struct.exists(field => 
containsEmptyStructType(field.dataType))
+      case ArrayType(elementType, _) => containsEmptyStructType(elementType)
+      case _ => false
+    }
+  }
+
   /**
    * 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.
+   * Array operands sort element-wise; length mismatches follow Spark array 
ordering semantics.
    */
   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 MatchConditionTypes.usesIdenticalStructSort(leftStruct, 
rightStruct) =>
         if (isSqlTupleStructOperand(leftOperand) || 
isSqlTupleStructOperand(rightOperand)) {
           val pairs = collectStructLeafPairs(leftOperand, rightOperand, 
leftStruct)
           (pairs.map(_._1), pairs.map(_._2))
@@ -2675,8 +2759,8 @@ object AsOfJoin {
       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)
+    val expr1Side = operandJoinSide(expr1, leftSet, rightSet, syntacticIsLeft 
= true)
+    val expr2Side = operandJoinSide(expr2, leftSet, rightSet, syntacticIsLeft 
= false)
     (expr1Side, expr2Side) match {
       case (Some(true), Some(false)) => (expr1, expr2, operator)
       case (Some(false), Some(true)) => (expr2, expr1, operator.flip)
@@ -2688,10 +2772,13 @@ object AsOfJoin {
   private def operandJoinSide(
       expr: Expression,
       leftSet: AttributeSet,
-      rightSet: AttributeSet): Option[Boolean] = {
+      rightSet: AttributeSet,
+      syntacticIsLeft: Boolean): Option[Boolean] = {
     val refs = expr.references
     if (refs.isEmpty) {
-      None
+      // Literals, CURRENT_TIMESTAMP(), session variables, etc. have no column 
refs;
+      // use MATCH_CONDITION syntactic position (expr1/expr2) for join-side 
assignment.
+      Some(syntacticIsLeft)
     } else if (refs.subsetOf(leftSet)) {
       Some(true)
     } else if (refs.subsetOf(rightSet)) {
@@ -2725,13 +2812,17 @@ object AsOfJoin {
       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 =>
+      case (ArrayType(elementType, _), _)
+          if MatchConditionTypes.usesArrayOrderExpression(
+            leftOperand.dataType, rightOperand.dataType) =>
+        // MATCH_CONDITION array comparison uses Spark lexicographic ordering 
(including length).
+        // The ordering distance below is element-wise via ZipWith, padding 
the shorter side with
+        // null when lengths differ (e.g. [0, null]), not a lexicographic 
length tie-break.
+        buildArrayOrderExpression(leftOperand, rightOperand, elementType, 
operator)
+      case (leftType, rightType)
+          if MatchConditionTypes.usesStructDecomposition(leftType, rightType) 
=>
         buildFlattenedStructOrderExpression(
-          leftOperand, rightOperand, leftStruct, operator)
+          leftOperand, rightOperand, leftType.asInstanceOf[StructType], 
operator)
       case _ =>
         buildLeafOrderExpression(leftOperand, rightOperand, operator)
     }
@@ -2844,12 +2935,11 @@ object AsOfJoin {
       .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))
+          if (MatchConditionTypes.usesStructDecomposition(left.dataType, 
right.dataType)) {
+            collectStructLeafPairs(
+              left, right, left.dataType.asInstanceOf[StructType])
+          } else {
+            Seq((left, right))
           }
       }
   }
@@ -2871,17 +2961,19 @@ object AsOfJoin {
   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
+    if (MatchConditionTypes.usesStructDecomposition(
+        leftOperand.dataType, rightOperand.dataType)) {
+      val leftStruct = leftOperand.dataType.asInstanceOf[StructType]
+      val rightStruct = rightOperand.dataType.asInstanceOf[StructType]
+      val leftFields = structFieldExprs(leftOperand, leftStruct)
+      val rightFields = structFieldExprs(rightOperand, rightStruct)
+      if (leftFields.length == rightFields.length) {
+        Some(leftFields.zip(rightFields))
+      } else {
+        None
+      }
+    } else {
+      None
     }
   }
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
index 278c9a1bc1e4..2d8e60aee22c 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
@@ -1044,6 +1044,17 @@ class PlanParserSuite extends AnalysisTest {
             fragment = "asof join u match_condition (t.a <> u.a)",
             start = 16,
             stop = 55)))
+      checkError(
+        exception = parseException(
+          "select * from t asof join u match_condition (t.a != u.a)"),
+        condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_OPERATOR",
+        sqlState = Some("42K0E"),
+        parameters = Map("operator" -> "!="),
+        queryContext = Array(
+          ExpectedContext(
+            fragment = "asof join u match_condition (t.a != u.a)",
+            start = 16,
+            stop = 55)))
     }
   }
 
@@ -1060,6 +1071,33 @@ class PlanParserSuite extends AnalysisTest {
             fragment = "asof join u match_condition (t.a <=> u.a)",
             start = 16,
             stop = 56)))
+      checkError(
+        exception = parseException(
+          "select * from t asof join u match_condition (t.a is not distinct 
from u.a)"),
+        condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_OPERATOR",
+        sqlState = Some("42K0E"),
+        parameters = Map("operator" -> "IS NOT DISTINCT FROM"),
+        queryContext = Array(
+          ExpectedContext(
+            fragment = "asof join u match_condition (t.a is not distinct from 
u.a)",
+            start = 16,
+            stop = 73)))
+    }
+  }
+
+  test("asof join - is distinct from match operator") {
+    withSQLConf(SQLConf.SQL_ASOF_JOIN_ENABLED.key -> "true") {
+      checkError(
+        exception = parseException(
+          "select * from t asof join u match_condition (t.a is distinct from 
u.a)"),
+        condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_OPERATOR",
+        sqlState = Some("42K0E"),
+        parameters = Map("operator" -> "IS DISTINCT FROM"),
+        queryContext = Array(
+          ExpectedContext(
+            fragment = "asof join u match_condition (t.a is distinct from 
u.a)",
+            start = 16,
+            stop = 69)))
     }
   }
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/logical/AsOfJoinMatchConditionTypesSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/logical/AsOfJoinMatchConditionTypesSuite.scala
new file mode 100644
index 000000000000..048eac353df9
--- /dev/null
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/logical/AsOfJoinMatchConditionTypesSuite.scala
@@ -0,0 +1,120 @@
+/*
+ * 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.plans.logical
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.plans.logical.AsOfJoin.MatchConditionTypes
+import org.apache.spark.sql.types._
+
+class AsOfJoinMatchConditionTypesSuite extends SparkFunSuite {
+
+  test("scalar types widen via TypeCoercion") {
+    assert(MatchConditionTypes.areOperandsCompatible(IntegerType, LongType))
+    assert(!MatchConditionTypes.usesArrayOrderExpression(IntegerType, 
LongType))
+    assert(!MatchConditionTypes.usesStructDecomposition(IntegerType, LongType))
+  }
+
+  test("string and temporal types are incompatible") {
+    assert(!MatchConditionTypes.areOperandsCompatible(StringType, 
TimestampType))
+    assert(!MatchConditionTypes.areOperandsCompatible(DateType, StringType))
+  }
+
+  test("positional struct operands with different field names are compatible") 
{
+    val leftStruct = StructType(
+      StructField("a", IntegerType) ::
+        StructField("b", LongType) ::
+        Nil)
+    val rightStruct = StructType(
+      StructField("x", IntegerType) ::
+        StructField("y", LongType) ::
+        Nil)
+    assert(MatchConditionTypes.areOperandsCompatible(leftStruct, rightStruct))
+    assert(MatchConditionTypes.usesStructDecomposition(leftStruct, 
rightStruct))
+    assert(!MatchConditionTypes.usesIdenticalStructSort(leftStruct, 
rightStruct))
+  }
+
+  test("nested struct operands are compatible when fields match positionally") 
{
+    val innerLeft = StructType(StructField("k", IntegerType) :: Nil)
+    val innerRight = StructType(StructField("z", IntegerType) :: Nil)
+    val leftStruct = StructType(StructField("outer", innerLeft) :: Nil)
+    val rightStruct = StructType(StructField("other", innerRight) :: Nil)
+    assert(MatchConditionTypes.areOperandsCompatible(leftStruct, rightStruct))
+    assert(MatchConditionTypes.usesStructDecomposition(leftStruct, 
rightStruct))
+  }
+
+  test("array operands require identical element types") {
+    val leftArray = ArrayType(IntegerType)
+    val rightArray = ArrayType(IntegerType)
+    assert(MatchConditionTypes.areOperandsCompatible(leftArray, rightArray))
+    assert(MatchConditionTypes.usesArrayOrderExpression(leftArray, rightArray))
+  }
+
+  test("array operands with different struct element field names are 
compatible") {
+    val leftArray = ArrayType(
+      StructType(
+        StructField("x", IntegerType, nullable = false) ::
+          StructField("y", IntegerType, nullable = false) ::
+          Nil))
+    val rightArray = ArrayType(
+      StructType(
+        StructField("p", IntegerType, nullable = false) ::
+          StructField("q", IntegerType, nullable = false) ::
+          Nil))
+    assert(MatchConditionTypes.areOperandsCompatible(leftArray, rightArray))
+    assert(MatchConditionTypes.usesArrayOrderExpression(leftArray, rightArray))
+  }
+
+  test("array operands with different element types are rejected") {
+    val leftArray = ArrayType(IntegerType)
+    val rightArray = ArrayType(StringType)
+    assert(!MatchConditionTypes.areOperandsCompatible(leftArray, rightArray))
+    assert(!MatchConditionTypes.usesArrayOrderExpression(leftArray, 
rightArray))
+  }
+
+  test("empty struct operands are invalid") {
+    val emptyStruct = StructType(Nil)
+    assert(!MatchConditionTypes.isValidOperandType(emptyStruct))
+    assert(!MatchConditionTypes.areOperandsCompatible(emptyStruct, 
emptyStruct))
+    assert(!MatchConditionTypes.usesStructDecomposition(emptyStruct, 
emptyStruct))
+  }
+
+  test("nested empty struct operands are invalid") {
+    val nestedEmptyStruct = StructType(StructField("x", StructType(Nil)) :: 
Nil)
+    assert(!MatchConditionTypes.isValidOperandType(nestedEmptyStruct))
+    assert(!MatchConditionTypes.areOperandsCompatible(nestedEmptyStruct, 
nestedEmptyStruct))
+  }
+
+  test("identical struct schemas enable whole-struct sort") {
+    val structType = StructType(
+      StructField("ts", TimestampType) ::
+        StructField("seq", IntegerType) ::
+        Nil)
+    assert(MatchConditionTypes.usesIdenticalStructSort(structType, structType))
+    assert(MatchConditionTypes.usesStructDecomposition(structType, structType))
+  }
+
+  test("struct field count mismatch is not decomposable") {
+    val leftStruct = StructType(StructField("a", IntegerType) :: Nil)
+    val rightStruct = StructType(
+      StructField("a", IntegerType) ::
+        StructField("b", IntegerType) ::
+        Nil)
+    assert(!MatchConditionTypes.usesStructDecomposition(leftStruct, 
rightStruct))
+    assert(!MatchConditionTypes.areOperandsCompatible(leftStruct, rightStruct))
+  }
+}
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 e75ebbe04a04..36962df5af45 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
@@ -49,8 +49,8 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
         |""".stripMargin)
     sql(
       """
-        |CREATE OR REPLACE TEMP VIEW quotes(quote_time, symbol) AS
-        |VALUES (TIMESTAMP '2026-06-29 10:00:00', 'AAPL')
+        |CREATE OR REPLACE TEMP VIEW quotes(quote_time, symbol, bid_price) AS
+        |VALUES (TIMESTAMP '2026-06-29 10:00:00', 'AAPL', 180.10)
         |""".stripMargin)
   }
 
@@ -209,4 +209,172 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
           start = 35,
           stop = 122)))
   }
+
+  test("MATCH_CONDITION accepts CURRENT_TIMESTAMP as left operand") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT count(*)
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (current_timestamp() >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+      case j: AsOfJoin => j
+    }.get
+    assert(asOfJoin.asOfCondition.resolved)
+    assert(asOfJoin.leftSortExprs.nonEmpty)
+    assert(asOfJoin.rightSortExprs.nonEmpty)
+  }
+
+  test("MATCH_CONDITION accepts literal constant as right operand") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT count(*)
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= TIMESTAMP '2026-06-29 10:00:00')
+        |  ON t.symbol = q.symbol
+        |""".stripMargin
+    val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+      case j: AsOfJoin => j
+    }.get
+    assert(asOfJoin.asOfCondition.resolved)
+  }
+
+  test("MATCH_CONDITION rejects scalar subquery operand") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT * FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= (SELECT max(trade_time) FROM 
trades))
+        |  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" -> "\"scalarsubquery()\""),
+      queryContext = Array(
+        ExpectedContext(
+          fragment = """ASOF JOIN quotes q
+                       |  MATCH_CONDITION (t.trade_time >= (SELECT 
max(trade_time) FROM trades))
+                       |  ON t.symbol = q.symbol""".stripMargin,
+          start = 24,
+          stop = 139)))
+  }
+
+  test("uncorrelated scalar subquery in ON clause is allowed") {
+    setupTradeQuoteViews()
+    checkAnswer(
+      sql(
+        """
+          |SELECT t.symbol
+          |FROM trades t ASOF JOIN quotes q
+          |  MATCH_CONDITION (t.trade_time >= q.quote_time)
+          |  ON t.symbol = q.symbol
+          | AND t.trade_time >= (SELECT max(trade_time) FROM trades)
+          |""".stripMargin),
+      Row("AAPL"))
+  }
+
+  test("correlated scalar subquery in ON clause is rejected like regular 
JOIN") {
+    setupTradeQuoteViews()
+    val sqlText =
+      """
+        |SELECT t.symbol, q.bid_price
+        |FROM trades t ASOF JOIN quotes q
+        |  MATCH_CONDITION (t.trade_time >= q.quote_time)
+        |  ON t.symbol = q.symbol
+        | AND q.bid_price > (
+        |   SELECT avg(bid_price) FROM quotes qq WHERE qq.symbol = q.symbol)
+        |""".stripMargin
+    val e = intercept[AnalysisException](sql(sqlText))
+    assert(
+      e.getCondition ===
+        
"UNSUPPORTED_SUBQUERY_EXPRESSION_CATEGORY.UNSUPPORTED_CORRELATED_SCALAR_SUBQUERY")
+  }
+
+  test("MATCH_CONDITION accepts STRUCT tuple operands") {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW deploys(deploy_ts, seq, service, version) 
AS
+        |VALUES (TIMESTAMP '2026-06-29 10:00:00', 1, 'api', 'v1.0')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW requests(req_ts, seq, service) AS
+        |VALUES (TIMESTAMP '2026-06-29 10:03:00', 1, 'api')
+        |""".stripMargin)
+    val sqlText =
+      """
+        |SELECT r.req_ts, d.version
+        |FROM requests r ASOF JOIN deploys d
+        |  MATCH_CONDITION ((r.req_ts, r.seq) >= (d.deploy_ts, d.seq))
+        |  ON r.service = d.service
+        |""".stripMargin
+    val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+      case j: AsOfJoin => j
+    }.get
+    assert(asOfJoin.asOfCondition.resolved)
+    assert(asOfJoin.leftSortExprs.nonEmpty)
+    assert(asOfJoin.rightSortExprs.nonEmpty)
+  }
+
+  test("MATCH_CONDITION accepts ARRAY operands") {
+    val sqlText =
+      """
+        |SELECT t.a
+        |FROM VALUES (ARRAY(1, 3)) AS t(a) ASOF JOIN VALUES (ARRAY(1, 2)) AS 
r(a)
+        |  MATCH_CONDITION (t.a >= r.a)
+        |""".stripMargin
+    val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+      case j: AsOfJoin => j
+    }.get
+    assert(asOfJoin.asOfCondition.resolved)
+    assert(asOfJoin.leftSortExprs.nonEmpty)
+    assert(asOfJoin.rightSortExprs.nonEmpty)
+  }
+
+  test("MATCH_CONDITION accepts nested STRUCT column operands") {
+    val sqlText =
+      """
+        |SELECT r.k.tag
+        |FROM VALUES (named_struct(
+        |  'inner_ts', TIMESTAMP '2026-06-29 10:00:00',
+        |  'inner_seq', 2,
+        |  'tag', 'a')) AS t(k)
+        |ASOF JOIN VALUES (named_struct(
+        |  'inner_ts', TIMESTAMP '2026-06-29 09:00:00',
+        |  'inner_seq', 1,
+        |  'tag', 'a')) AS r(k)
+        |  MATCH_CONDITION (t.k >= r.k)
+        |""".stripMargin
+    val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+      case j: AsOfJoin => j
+    }.get
+    assert(asOfJoin.asOfCondition.resolved)
+  }
+
+  test("MATCH_CONDITION rejects empty STRUCT operands") {
+    val sqlText =
+      """
+        |SELECT *
+        |FROM VALUES (named_struct()) AS t(s) ASOF JOIN VALUES 
(named_struct()) AS r(s)
+        |  MATCH_CONDITION (t.s >= r.s)
+        |""".stripMargin
+    checkError(
+      exception = intercept[AnalysisException](sql(sqlText)),
+      condition = "ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE",
+      sqlState = Some("42K09"),
+      parameters = Map(
+        "type1" -> "\"STRUCT<>\"",
+        "type2" -> "\"STRUCT<>\""),
+      queryContext = Array(
+        ExpectedContext(
+          fragment = """ASOF JOIN VALUES (named_struct()) AS r(s)
+                       |  MATCH_CONDITION (t.s >= r.s)""".stripMargin,
+          start = 47,
+          stop = 118)))
+  }
 }


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


Reply via email to