This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 6fbcf0652a35 [SPARK-58124][SQL] Wire sort-merge ASOF JOIN physical
execution
6fbcf0652a35 is described below
commit 6fbcf0652a35d6aee2e1f2e113c68e774b2e6cdd
Author: Serge Rielau <[email protected]>
AuthorDate: Fri Jul 17 11:31:48 2026 +0800
[SPARK-58124][SQL] Wire sort-merge ASOF JOIN physical execution
### What changes were proposed in this pull request?
Wires sort-merge physical execution for SQL ASOF JOIN on top of the parser
(#57251) and analysis (#57264) work.
**`SortMergeAsOfJoinExec`**
- Sort by multi-column `leftSortExprs` / `rightSortExprs` from analysis
(replacing single `leftAsOfExpr` / `rightAsOfExpr`)
- Detect backward scan direction from `asOfCondition` for composite
`MATCH_CONDITION` operands
**Planner / optimizer**
- `AsOfJoinSelection` and `RewriteAsOfJoin` use sort-merge when
`requiresSortMergeAsOfJoin` is set (SQL path) or
`spark.sql.join.sortMergeAsOfJoin.enabled` is on (DataFrame path)
- Typed `case j: AsOfJoin` in `AsOfJoinSelection` (avoids brittle
positional match)
**Analysis / errors**
- Remove the temporary `AS_OF_JOIN.UNSUPPORTED_MATCH_CONDITION_OPERAND`
gate and its error condition
- Remove `AS_OF_JOIN.SORT_MERGE_REQUIRED` — SQL ASOF JOIN no longer
requires the DataFrame sort-merge conf
- Stop populating DataFrame sort expressions in `AsOfJoin.apply()` while
operands may still be unresolved
**Tests**
- Add `AsOfJoinSortMergeSQLSuite` for SQL execution only (scalar, struct,
array, forward/strict, `USING`, left outer, etc.)
- Keep analysis-negative `MATCH_CONDITION` cases in `AsOfJoinSQLSuite` only
(removed duplicates from the execution suite)
- Add parser tests for invalid `<>` / `<=>` operator text; rename
`sqlAsofJoinDisabled` → `sqlAsOfJoinDisabled`
### Why are the changes needed?
Analysis materializes per-side sort expressions for struct/tuple/array
`MATCH_CONDITION` operands. The physical operator and planner must use those
expressions for correct sort-merge ASOF semantics.
### Does this PR introduce _any_ user-facing change?
Yes. With `spark.sql.join.asofJoin.enabled` (default off), valid SQL ASOF
JOIN queries execute via `SortMergeAsOfJoinExec` — no second conf is required
for SQL. `spark.sql.join.sortMergeAsOfJoin.enabled` remains the opt-in for
DataFrame `joinAsOf`.
### How was this patch tested?
```bash
build/sbt -Dscalastyle.skip=true \
"sql/testOnly org.apache.spark.sql.AsOfJoinSQLSuite \
org.apache.spark.sql.AsOfJoinSortMergeSQLSuite \
org.apache.spark.sql.SortMergeAsOfJoinSuite \
org.apache.spark.sql.DataFrameAsOfJoinSuite"
build/sbt -Dscalastyle.skip=true "core/testOnly
org.apache.spark.SparkThrowableSuite"
```
32/32 in `AsOfJoinSQLSuite` + `AsOfJoinSortMergeSQLSuite`;
`SparkThrowableSuite` clean.
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with Cursor.
Closes #57277 from srielau/SPARK-58124.
Authored-by: Serge Rielau <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit bd511b588e4eb9c5d070d9b651a6d433fd83156d)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 10 -
.../spark/sql/errors/QueryParsingErrors.scala | 2 +-
.../sql/catalyst/analysis/CheckAnalysis.scala | 7 -
.../sql/catalyst/analysis/ResolveAsOfJoin.scala | 26 -
.../sql/catalyst/optimizer/RewriteAsOfJoin.scala | 11 +-
.../spark/sql/catalyst/parser/AstBuilder.scala | 4 +-
.../org/apache/spark/sql/internal/SQLConf.scala | 12 +-
.../sql/catalyst/parser/PlanParserSuite.scala | 32 ++
.../spark/sql/execution/SparkStrategies.scala | 27 +-
.../execution/joins/SortMergeAsOfJoinExec.scala | 70 ++-
.../org/apache/spark/sql/AsOfJoinSQLSuite.scala | 71 +--
.../spark/sql/AsOfJoinSortMergeSQLSuite.scala | 596 +++++++++++++++++++++
12 files changed, 723 insertions(+), 145 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index a58fd0ad924a..b2e0fec02eb1 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -191,11 +191,6 @@
"Invalid as-of join."
],
"subClass" : {
- "SORT_MERGE_REQUIRED" : {
- "message" : [
- "SQL ASOF JOIN requires the sort-merge physical operator. Set
<config> to true."
- ]
- },
"TOLERANCE_IS_NON_NEGATIVE" : {
"message" : [
"The input argument `tolerance` must be non-negative."
@@ -210,11 +205,6 @@
"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/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
index d7554838ead1..80d2d0982526 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
@@ -237,7 +237,7 @@ private[sql] object QueryParsingErrors extends
DataTypeErrorsBase {
ctx)
}
- def sqlAsofJoinDisabled(configKey: String, ctx: ParserRuleContext):
Throwable = {
+ def sqlAsOfJoinDisabled(configKey: String, ctx: ParserRuleContext):
Throwable = {
new ParseException(
errorClass = "UNSUPPORTED_FEATURE.ASOF_JOIN",
messageParameters = Map("config" -> toSQLConf(configKey)),
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 b7394d325f12..ff482e940d3f 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,13 +676,6 @@ trait CheckAnalysis extends LookupCatalog with
QueryErrorsBase with PlanToString
"joinCondition" -> toSQLExpr(condition),
"conditionType" -> toSQLType(condition.dataType)))
- 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(
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 41c79b2905d6..fb3b5a425e78 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
@@ -80,8 +80,6 @@ object ResolveAsOfJoin extends Rule[LogicalPlan] with
SQLConfHelper {
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(
@@ -160,30 +158,6 @@ private[analysis] object AsOfJoinValidation extends
QueryErrorsBase {
}
}
- 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.
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 8359bed4eb57..8cb66e1cc9d7 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
@@ -49,14 +49,6 @@ import org.apache.spark.sql.catalyst.rules._
*/
object RewriteAsOfJoin extends Rule[LogicalPlan] {
def apply(plan: LogicalPlan): LogicalPlan = {
- // When the sort-merge AS-OF join operator is enabled, skip this rewrite
- // so that the AsOfJoin logical node reaches the planner intact and
- // AsOfJoinSelection can produce a dedicated physical operator.
- // This conf-based gating (rather than excludedRules) is used because
- // the planner strategy and this optimizer rule must be kept in sync:
- // if the rewrite runs, the planner never sees AsOfJoin.
- if (conf.sortMergeAsOfJoinEnabled) return plan
-
plan.transformUpWithNewOutput {
case j @ AsOfJoin(
left,
@@ -72,7 +64,8 @@ object RewriteAsOfJoin extends Rule[LogicalPlan] {
_,
_,
_,
- _) =>
+ _)
+ if !conf.useSortMergeAsOfJoinOperator(j.requiresSortMergeAsOfJoin) =>
val conditionWithOuterReference =
condition.map(And(_,
asOfCondition)).getOrElse(asOfCondition).transformUp {
case a: AttributeReference if left.outputSet.contains(a) =>
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 1c09cea951d2..f3f963a5d60e 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
@@ -2554,6 +2554,8 @@ class AstBuilder extends DataTypeAstBuilder
private def asOfMatchConditionInvalidOperatorText(expr: Expression): String
= expr match {
case EqualTo(_, _) => "="
+ case Not(EqualTo(_, _)) => "<>"
+ case EqualNullSafe(_, _) => "<=>"
case And(_, _) => "AND"
case Or(_, _) => "OR"
case _ => expr.prettyName
@@ -2567,7 +2569,7 @@ class AstBuilder extends DataTypeAstBuilder
base: LogicalPlan,
criteria: AsofJoinCriteriaContext): AsOfJoin = {
if (!conf.sqlAsOfJoinEnabled) {
- throw
QueryParsingErrors.sqlAsofJoinDisabled(SQLConf.SQL_ASOF_JOIN_ENABLED.key, ctx)
+ throw
QueryParsingErrors.sqlAsOfJoinDisabled(SQLConf.SQL_ASOF_JOIN_ENABLED.key, ctx)
}
val joinType = Option(ctx.asofJoinType) match {
case None => Inner
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 8b52f308403c..3d2c618732bc 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -947,8 +947,8 @@ object SQLConf {
val SQL_ASOF_JOIN_ENABLED =
buildConf("spark.sql.join.asofJoin.enabled")
- .doc("When true, enable SQL ASOF JOIN syntax with MATCH_CONDITION. When
false, " +
- "ASOF JOIN fails at parse time.")
+ .doc("When true, enable SQL ASOF JOIN syntax with MATCH_CONDITION and
plan it with " +
+ "the sort-merge ASOF join physical operator. When false, ASOF JOIN
fails at parse time.")
.version("4.3.0")
.withBindingPolicy(ConfigBindingPolicy.SESSION)
.booleanConf
@@ -8573,6 +8573,14 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def sqlAsOfJoinEnabled: Boolean = getConf(SQL_ASOF_JOIN_ENABLED)
+ /**
+ * Whether to keep [[AsOfJoin]] for the sort-merge physical operator instead
of the
+ * correlated-subquery rewrite. SQL ASOF JOIN always requires sort-merge;
the DataFrame
+ * API is gated separately on [[sortMergeAsOfJoinEnabled]].
+ */
+ def useSortMergeAsOfJoinOperator(requiresSortMergeAsOfJoin: Boolean):
Boolean =
+ sortMergeAsOfJoinEnabled || requiresSortMergeAsOfJoin
+
def enableRadixSort: Boolean = getConf(RADIX_SORT_ENABLED)
def isParquetSchemaMergingEnabled: Boolean =
getConf(PARQUET_SCHEMA_MERGING_ENABLED)
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 35510686cbe0..2b28aee84307 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
@@ -1031,6 +1031,38 @@ class PlanParserSuite extends AnalysisTest {
}
}
+ test("asof join - not equal match operator") {
+ withSQLConf(SQLConf.SQL_ASOF_JOIN_ENABLED.key -> "true") {
+ 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)))
+ }
+ }
+
+ test("asof join - null-safe equal match operator") {
+ withSQLConf(SQLConf.SQL_ASOF_JOIN_ENABLED.key -> "true") {
+ 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 = 56)))
+ }
+ }
+
test("asof join - compound match condition rejected") {
withSQLConf(SQLConf.SQL_ASOF_JOIN_ENABLED.key -> "true") {
checkError(
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 022c16c1acc5..996ecac61b72 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
@@ -425,24 +425,28 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
}
/**
- * Plans AS-OF joins using a dedicated sort-merge operator when the
- * conf is enabled.
+ * Plans AS-OF joins using a dedicated sort-merge operator when enabled for
the
+ * DataFrame API, or implicitly for SQL ASOF JOIN
(`requiresSortMergeAsOfJoin`).
*/
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 =>
- val (leftKeys, rightKeys, residual) = condition match {
- case Some(cond) => extractEquiJoinKeys(cond, left, right)
+ case j: AsOfJoin if
conf.useSortMergeAsOfJoinOperator(j.requiresSortMergeAsOfJoin) =>
+ val (leftKeys, rightKeys, residual) = j.condition match {
+ case Some(cond) => extractEquiJoinKeys(cond, j.left, j.right)
case None => (Seq.empty[Expression], Seq.empty[Expression], None)
}
- val (leftAsOf, rightAsOf) = extractAsOfExprs(
- asOfCondition, orderExpression, left, right)
+ val (leftSort, rightSort) =
+ if (j.leftSortExprs.nonEmpty && j.rightSortExprs.nonEmpty) {
+ (j.leftSortExprs, j.rightSortExprs)
+ } else {
+ val (leftAsOf, rightAsOf) = extractAsOfExprs(j.orderExpression,
j.left, j.right)
+ (Seq(leftAsOf), Seq(rightAsOf))
+ }
joins.SortMergeAsOfJoinExec(
- leftKeys, rightKeys, leftAsOf, rightAsOf,
- asOfCondition, orderExpression, joinType, residual,
- planLater(left), planLater(right)) :: Nil
+ leftKeys, rightKeys, leftSort, rightSort,
+ j.asOfCondition, j.orderExpression, j.joinType, residual,
+ planLater(j.left), planLater(j.right)) :: Nil
case _ => Nil
}
@@ -492,7 +496,6 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
* allowExactMatches variations that complicate asOfCondition's shape.
*/
private def extractAsOfExprs(
- asOfCondition: Expression,
orderExpression: Expression,
left: LogicalPlan,
right: LogicalPlan): (Expression, Expression) = {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala
index 955fc7a185c8..5bdfbca78abf 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala
@@ -45,8 +45,8 @@ import org.apache.spark.sql.execution.metric.{SQLMetric,
SQLMetrics}
case class SortMergeAsOfJoinExec(
leftKeys: Seq[Expression],
rightKeys: Seq[Expression],
- leftAsOfExpr: Expression,
- rightAsOfExpr: Expression,
+ leftSortExprs: Seq[Expression],
+ rightSortExprs: Seq[Expression],
asOfCondition: Expression,
orderExpression: Expression,
joinType: JoinType,
@@ -55,6 +55,10 @@ case class SortMergeAsOfJoinExec(
right: SparkPlan,
isSkewJoin: Boolean = false) extends ShuffledJoin {
+ require(leftSortExprs.nonEmpty && rightSortExprs.nonEmpty &&
+ leftSortExprs.length == rightSortExprs.length,
+ s"$nodeName requires matching non-empty sort expressions on both sides")
+
require(Seq(Inner, LeftOuter).exists(joinType == _),
s"$nodeName does not support join type: $joinType")
@@ -79,27 +83,37 @@ case class SortMergeAsOfJoinExec(
}
override def requiredChildOrdering: Seq[Seq[SortOrder]] = {
- val leftOrdering = leftKeys.map(SortOrder(_, Ascending)) :+
- SortOrder(leftAsOfExpr, Ascending)
- val rightOrdering = rightKeys.map(SortOrder(_, Ascending)) :+
- SortOrder(rightAsOfExpr, Ascending)
+ val leftOrdering = leftKeys.map(SortOrder(_, Ascending)) ++
+ leftSortExprs.map(SortOrder(_, Ascending))
+ val rightOrdering = rightKeys.map(SortOrder(_, Ascending)) ++
+ rightSortExprs.map(SortOrder(_, Ascending))
leftOrdering :: rightOrdering :: Nil
}
override def outputOrdering: Seq[SortOrder] = left.outputOrdering
- // Determine scan direction based on the order expression (distance metric).
- // This is a performance heuristic only -- if it misclassifies, the scan
- // still produces the correct result; only the early-termination shortcut
- // is lost.
- //
- // orderExpression is direction-unique by construction:
- // Backward: Subtract(leftAsOf, rightAsOf) -> backward mode
- // Forward: Subtract(rightAsOf, leftAsOf) -> forward mode
- // Nearest: If(...) -> forward mode
- private val isBackwardJoin: Boolean = orderExpression match {
- case Subtract(l, _, _) if l.semanticEquals(leftAsOfExpr) => true
- case _ => false
+ // Backward joins scan the right-side buffer forward and keep the last match.
+ // Detect from asOfCondition so composite MATCH_CONDITION sort keys still
work.
+ private val isBackwardJoin: Boolean =
+ isBackwardAsOfCondition(asOfCondition, left.output, right.output)
+
+ private def isBackwardAsOfCondition(
+ condition: Expression,
+ leftOutput: Seq[Attribute],
+ rightOutput: Seq[Attribute]): Boolean = {
+ val leftAttrs = AttributeSet(leftOutput)
+ val rightAttrs = AttributeSet(rightOutput)
+ def isBackwardPredicate(expr: Expression): Boolean = expr match {
+ case GreaterThanOrEqual(l, r)
+ if l.references.subsetOf(leftAttrs) &&
r.references.subsetOf(rightAttrs) =>
+ true
+ case GreaterThan(l, r)
+ if l.references.subsetOf(leftAttrs) &&
r.references.subsetOf(rightAttrs) =>
+ true
+ case And(c, _) => isBackwardPredicate(c)
+ case _ => false
+ }
+ isBackwardPredicate(condition)
}
protected override def doExecute(): RDD[InternalRow] = {
@@ -165,8 +179,14 @@ private[joins] class SortMergeAsOfJoinScanner(
private val joinedOutput = leftOutput ++ rightOutput
private val joinedRow = new JoinedRow()
- private val resultProjection =
- UnsafeProjection.create(joinedOutput, joinedOutput)
+ // Use nullable bound references so outer-join null padding is safe even when
+ // right attributes are NOT NULL in the catalog schema.
+ private val resultProjection = {
+ val nullableRefs = joinedOutput.zipWithIndex.map { case (attr, i) =>
+ BoundReference(i, attr.dataType, nullable = true)
+ }
+ UnsafeProjection.create(nullableRefs, joinedOutput)
+ }
private val boundAsOfCond = bindReference(asOfCondition, joinedOutput)
private val boundOrderExpr = bindReference(orderExpression, joinedOutput)
@@ -190,7 +210,15 @@ private[joins] class SortMergeAsOfJoinScanner(
private val distanceOrdering =
TypeUtils.getInterpretedOrdering(orderExpression.dataType)
- private val nullRightRow = new GenericInternalRow(rightOutput.length)
+ // Materialize an all-null right row as UnsafeRow. GenericInternalRow cannot
be
+ // passed through identity UnsafeProjection when right columns are NOT NULL.
+ private val nullRightRow: InternalRow = {
+ val nullableRefs = rightOutput.zipWithIndex.map { case (attr, i) =>
+ BoundReference(i, attr.dataType, nullable = true)
+ }
+ val proj = UnsafeProjection.create(nullableRefs, rightOutput)
+ proj(new GenericInternalRow(rightOutput.length))
+ }
// Spill-backed right-side buffer
private val rightGroupBuffer = new ExternalAppendOnlyUnsafeRowArray(
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 215a8a1bbb36..e75ebbe04a04 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
@@ -17,6 +17,8 @@
package org.apache.spark.sql
+import java.sql.Timestamp
+
import org.apache.spark.sql.catalyst.parser.ParseException
import org.apache.spark.sql.catalyst.plans.logical.AsOfJoin
import org.apache.spark.sql.internal.SQLConf
@@ -25,7 +27,7 @@ import org.apache.spark.sql.test.SharedSparkSession
/**
* 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.
+ * `AsOfJoinSortMergeSQLSuite`.
*/
class AsOfJoinSQLSuite extends QueryTest with SharedSparkSession {
@@ -84,7 +86,7 @@ class AsOfJoinSQLSuite extends QueryTest with
SharedSparkSession {
stop = 114)))
}
- test("SQL ASOF JOIN requires sort-merge conf") {
+ test("valid TIMESTAMP MATCH_CONDITION passes analysis") {
setupTradeQuoteViews()
val sqlText =
"""
@@ -93,22 +95,16 @@ class AsOfJoinSQLSuite extends QueryTest with
SharedSparkSession {
| 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)))
- }
+ 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("valid TIMESTAMP MATCH_CONDITION passes analysis with sort-merge
enabled") {
+ test("SQL ASOF JOIN uses sort-merge without DataFrame sort-merge conf") {
setupTradeQuoteViews()
val sqlText =
"""
@@ -117,46 +113,9 @@ class AsOfJoinSQLSuite extends QueryTest with
SharedSparkSession {
| 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)))
+ withSQLConf(SQLConf.SORT_MERGE_AS_OF_JOIN_ENABLED.key -> "false") {
+ checkAnswer(sql(sqlText), Row(Timestamp.valueOf("2026-06-29 10:00:05"),
+ Timestamp.valueOf("2026-06-29 10:00:00")))
}
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala
new file mode 100644
index 000000000000..cb49f26d9c22
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala
@@ -0,0 +1,596 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql
+
+import java.sql.{Date, Timestamp}
+
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.joins.SortMergeAsOfJoinExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * SQL ASOF JOIN tests that exercise the sort-merge physical operator.
+ * SQL ASOF JOIN uses sort-merge implicitly; only
`spark.sql.join.asofJoin.enabled`
+ * needs to be set (unlike the DataFrame API, which still requires
+ * `spark.sql.join.sortMergeAsOfJoin.enabled`).
+ *
+ * Parser and analysis negative paths live in `AsOfJoinSQLSuite`.
+ */
+class AsOfJoinSortMergeSQLSuite extends QueryTest
+ with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+
+ override def beforeAll(): Unit = {
+ super.beforeAll()
+ spark.conf.set(SQLConf.SQL_ASOF_JOIN_ENABLED.key, "true")
+ }
+
+ override def afterAll(): Unit = {
+ spark.conf.unset(SQLConf.SQL_ASOF_JOIN_ENABLED.key)
+ super.afterAll()
+ }
+
+ private def assertUsesSortMergeAsOfJoin(query: DataFrame): Unit = {
+ val plan = query.queryExecution.executedPlan
+ assert(collectWithSubqueries(plan) {
+ case _: SortMergeAsOfJoinExec => true
+ }.nonEmpty, s"Expected SortMergeAsOfJoinExec in plan:\n$plan")
+ }
+
+ private def checkSortMergeAsOf(query: => DataFrame, expected: Seq[Row]):
Unit = {
+ val df = query
+ assertUsesSortMergeAsOfJoin(df)
+ checkAnswer(df, expected)
+ }
+
+ private def setupTradeQuoteViews(): Unit = {
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW trades(trade_time, symbol, quantity) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:05', 'AAPL', 100),
+ | (TIMESTAMP '2026-06-29 10:00:11', 'AAPL', 200),
+ | (TIMESTAMP '2026-06-29 10:00:12', 'MSFT', 50),
+ | (TIMESTAMP '2026-06-29 09:59:59', 'GOOG', 30)
+ |""".stripMargin)
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW quotes(quote_time, symbol, bid_price) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:00', 'AAPL', 180.10),
+ | (TIMESTAMP '2026-06-29 10:00:07', 'AAPL', 180.15),
+ | (TIMESTAMP '2026-06-29 10:00:10', 'AAPL', 180.20),
+ | (TIMESTAMP '2026-06-29 10:00:08', 'MSFT', 420.50)
+ |""".stripMargin)
+ }
+
+ test("INNER ASOF JOIN with TIMESTAMP MATCH_CONDITION") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.trade_time, t.symbol, t.quantity, q.bid_price
+ |FROM trades t
+ |ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON t.symbol = q.symbol
+ |""".stripMargin),
+ Seq(
+ Row(Timestamp.valueOf("2026-06-29 10:00:05"), "AAPL", 100, 180.10),
+ Row(Timestamp.valueOf("2026-06-29 10:00:11"), "AAPL", 200, 180.20),
+ Row(Timestamp.valueOf("2026-06-29 10:00:12"), "MSFT", 50, 420.50)))
+ }
+
+ test("LEFT ASOF JOIN preserves unmatched left rows") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.trade_time, t.symbol, q.bid_price
+ |FROM trades t
+ |LEFT ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON t.symbol = q.symbol
+ |ORDER BY t.trade_time
+ |""".stripMargin),
+ Seq(
+ Row(Timestamp.valueOf("2026-06-29 09:59:59"), "GOOG", null),
+ Row(Timestamp.valueOf("2026-06-29 10:00:05"), "AAPL", 180.10),
+ Row(Timestamp.valueOf("2026-06-29 10:00:11"), "AAPL", 180.20),
+ Row(Timestamp.valueOf("2026-06-29 10:00:12"), "MSFT", 420.50)))
+ }
+
+ test("USING is equivalent to ON symbol equality") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT trade_time, symbol, bid_price
+ |FROM trades
+ |ASOF JOIN quotes
+ | MATCH_CONDITION (trades.trade_time >= quotes.quote_time)
+ | USING (symbol)
+ |ORDER BY trade_time
+ |""".stripMargin),
+ Seq(
+ Row(Timestamp.valueOf("2026-06-29 10:00:05"), "AAPL", 180.10),
+ Row(Timestamp.valueOf("2026-06-29 10:00:11"), "AAPL", 180.20),
+ Row(Timestamp.valueOf("2026-06-29 10:00:12"), "MSFT", 420.50)))
+ }
+
+ test("forward match with <=") {
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW alerts(alert_time, host) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:00', 'db-01')
+ |""".stripMargin)
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW maintenance(window_start, host) AS
+ |VALUES (TIMESTAMP '2026-06-29 08:00:00', 'db-01'),
+ | (TIMESTAMP '2026-06-29 12:00:00', 'db-01')
+ |""".stripMargin)
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT a.alert_time, a.host, m.window_start
+ |FROM alerts a
+ |ASOF JOIN maintenance m
+ | MATCH_CONDITION (a.alert_time <= m.window_start)
+ | ON a.host = m.host
+ |""".stripMargin),
+ Row(
+ Timestamp.valueOf("2026-06-29 10:00:00"),
+ "db-01",
+ Timestamp.valueOf("2026-06-29 12:00:00")) :: Nil)
+ }
+
+ test("DATE scalar MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.d, r.d AS matched_d
+ |FROM VALUES (DATE '2026-06-29') AS t(d) ASOF JOIN
+ | VALUES (DATE '2026-06-28'), (DATE '2026-06-29') AS r(d)
+ | MATCH_CONDITION (t.d >= r.d)
+ |""".stripMargin),
+ Row(Date.valueOf("2026-06-29"), Date.valueOf("2026-06-29")) :: Nil)
+ }
+
+ test("INT scalar MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES (10) AS t(k) ASOF JOIN VALUES (5), (7) AS r(k)
+ | MATCH_CONDITION (t.k >= r.k)
+ |""".stripMargin),
+ Row(10, 7) :: Nil)
+ }
+
+ test("STRING scalar MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES ('c') AS t(k) ASOF JOIN VALUES ('a'), ('b') AS r(k)
+ | MATCH_CONDITION (t.k >= r.k)
+ |""".stripMargin),
+ Row("c", "b") :: Nil)
+ }
+
+ test("STRING scalar forward MATCH_CONDITION with <=") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES ('b') AS t(k) ASOF JOIN VALUES ('c'), ('d'), ('z') AS
r(k)
+ | MATCH_CONDITION (t.k <= r.k)
+ |""".stripMargin),
+ Row("b", "c") :: Nil)
+ }
+
+ test("DECIMAL scalar MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES (CAST(10.50 AS DECIMAL(10, 2))) AS t(k) ASOF JOIN
+ | VALUES (CAST(5.00 AS DECIMAL(10, 2))),
+ | (CAST(7.25 AS DECIMAL(10, 2))),
+ | (CAST(9.99 AS DECIMAL(10, 2))) AS r(k)
+ | MATCH_CONDITION (t.k >= r.k)
+ |""".stripMargin),
+ Row(
+ new java.math.BigDecimal("10.50"),
+ new java.math.BigDecimal("9.99")) :: Nil)
+ }
+
+ test("DECIMAL scalar forward MATCH_CONDITION with <=") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES (CAST(10.50 AS DECIMAL(10, 2))) AS t(k) ASOF JOIN
+ | VALUES (CAST(11.00 AS DECIMAL(10, 2))),
+ | (CAST(12.50 AS DECIMAL(10, 2))),
+ | (CAST(15.00 AS DECIMAL(10, 2))) AS r(k)
+ | MATCH_CONDITION (t.k <= r.k)
+ |""".stripMargin),
+ Row(
+ new java.math.BigDecimal("10.50"),
+ new java.math.BigDecimal("11.00")) :: Nil)
+ }
+
+ test("DOUBLE scalar MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES (CAST(10.5 AS DOUBLE)) AS t(k) ASOF JOIN
+ | VALUES (CAST(5.0 AS DOUBLE)),
+ | (CAST(7.25 AS DOUBLE)),
+ | (CAST(9.99 AS DOUBLE)) AS r(k)
+ | MATCH_CONDITION (t.k >= r.k)
+ |""".stripMargin),
+ Row(10.5, 9.99) :: Nil)
+ }
+
+ test("DOUBLE scalar forward MATCH_CONDITION with <=") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.k, r.k AS matched_k
+ |FROM VALUES (CAST(10.5 AS DOUBLE)) AS t(k) ASOF JOIN
+ | VALUES (CAST(11.0 AS DOUBLE)),
+ | (CAST(12.5 AS DOUBLE)),
+ | (CAST(15.0 AS DOUBLE)) AS r(k)
+ | MATCH_CONDITION (t.k <= r.k)
+ |""".stripMargin),
+ Row(10.5, 11.0) :: Nil)
+ }
+
+ test("STRUCT tuple MATCH_CONDITION") {
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW requests(req_ts, seq, service) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:00', 5, 'api'),
+ | (TIMESTAMP '2026-06-29 10:03:00', 1, 'api')
+ |""".stripMargin)
+ 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'),
+ | (TIMESTAMP '2026-06-29 10:00:00', 3, 'api', 'v1.1')
+ |""".stripMargin)
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.req_ts, r.seq, 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
+ |ORDER BY r.req_ts, r.seq
+ |""".stripMargin),
+ Seq(
+ Row(Timestamp.valueOf("2026-06-29 10:00:00"), 5, "v1.1"),
+ Row(Timestamp.valueOf("2026-06-29 10:03:00"), 1, "v1.1")))
+ }
+
+ test("scalar leaf STRUCT tuple MATCH_CONDITION") {
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW left_nested(inner_ts, inner_seq, tag) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:00', 2, 'a')
+ |""".stripMargin)
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW right_nested(inner_ts, inner_seq, tag) AS
+ |VALUES (TIMESTAMP '2026-06-29 09:00:00', 1, 'a'),
+ | (TIMESTAMP '2026-06-29 10:00:00', 1, 'a')
+ |""".stripMargin)
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.tag, r.inner_seq
+ |FROM left_nested t
+ |ASOF JOIN right_nested r
+ | MATCH_CONDITION ((t.inner_ts, t.inner_seq, t.tag) >= (r.inner_ts,
r.inner_seq, r.tag))
+ |""".stripMargin),
+ Row("a", 1) :: Nil)
+ }
+
+ test("whole STRUCT column MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.k.tag, r.k.inner_seq
+ |FROM VALUES (named_struct(
+ | 'inner_ts', TIMESTAMP '2026-06-29 10:00:00',
+ | 'inner_seq', 2,
+ | 'tag', 'a')) AS t(k)
+ |ASOF JOIN (
+ | SELECT * FROM VALUES
+ | (named_struct(
+ | 'inner_ts', TIMESTAMP '2026-06-29 09:00:00',
+ | 'inner_seq', 1,
+ | 'tag', 'a')),
+ | (named_struct(
+ | 'inner_ts', TIMESTAMP '2026-06-29 10:00:00',
+ | 'inner_seq', 1,
+ | 'tag', 'a')) AS r(k)
+ |) r
+ | MATCH_CONDITION (t.k >= r.k)
+ |""".stripMargin),
+ Row("a", 1) :: Nil)
+ }
+
+ test("nested whole STRUCT column MATCH_CONDITION") {
+ sql(
+ """
+ |CREATE OR REPLACE TEMP VIEW requests(req_ts, seq, service) AS
+ |VALUES (TIMESTAMP '2026-06-29 10:00:00', 5, 'api'),
+ | (TIMESTAMP '2026-06-29 10:03:00', 1, 'api')
+ |""".stripMargin)
+ 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'),
+ | (TIMESTAMP '2026-06-29 10:00:00', 3, 'api', 'v1.1')
+ |""".stripMargin)
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.req_ts, r.seq, d.version
+ |FROM (
+ | SELECT named_struct('ts', req_ts, 'seq', seq) AS k, service,
req_ts, seq
+ | FROM requests
+ |) r
+ |ASOF JOIN (
+ | SELECT named_struct('ts', deploy_ts, 'seq', seq) AS k, service,
version, deploy_ts, seq
+ | FROM deploys
+ |) d
+ | MATCH_CONDITION (r.k >= d.k)
+ | ON r.service = d.service
+ |ORDER BY r.req_ts, r.seq
+ |""".stripMargin),
+ Seq(
+ Row(Timestamp.valueOf("2026-06-29 10:00:00"), 5, "v1.1"),
+ Row(Timestamp.valueOf("2026-06-29 10:03:00"), 1, "v1.1")))
+ }
+
+ test("ARRAY<INT> MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.a
+ |FROM VALUES (ARRAY(1, 3)) AS t(a)
+ |ASOF JOIN VALUES (ARRAY(1, 2)), (ARRAY(1, 4)) AS r(a)
+ |MATCH_CONDITION (t.a >= r.a)
+ |""".stripMargin),
+ Row(Seq(1, 2)) :: Nil)
+ }
+
+ test("ARRAY<STRUCT> whole column MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.a
+ |FROM VALUES (ARRAY(named_struct('seq', 1, 'val', 3))) AS t(a)
+ |ASOF JOIN (
+ | SELECT * FROM VALUES
+ | (ARRAY(named_struct('seq', 1, 'val', 2))),
+ | (ARRAY(named_struct('seq', 1, 'val', 4))) AS r(a)
+ |) r
+ | MATCH_CONDITION (t.a >= r.a)
+ |""".stripMargin),
+ Row(Seq(Row(1, 2))) :: Nil)
+ }
+
+ test("STRUCT tuple from scalar columns MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.c1, r.c2
+ |FROM VALUES (1, 2) AS t(c1, c2)
+ |ASOF JOIN VALUES (1, 1) AS r(c1, c2)
+ |MATCH_CONDITION ((t.c1, t.c2) >= (r.c1, r.c2))
+ |""".stripMargin),
+ Row(1, 1) :: Nil)
+ }
+
+ test("MATCH_CONDITION operand arithmetic with ON") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT count(*) AS cnt
+ |FROM trades t ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= date_trunc('hour', q.quote_time))
+ | ON t.symbol = q.symbol
+ |""".stripMargin),
+ Row(3L) :: Nil)
+ }
+
+ test("non-equi ON filters right rows before closest match") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |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 > 200
+ |ORDER BY t.symbol
+ |""".stripMargin),
+ Row("MSFT", 420.50) :: Nil)
+ }
+
+ test("non-equi range predicate in ON") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.symbol, q.bid_price
+ |FROM trades t ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON abs(unix_timestamp(t.trade_time) -
unix_timestamp(q.quote_time)) < 30
+ |ORDER BY t.symbol, t.trade_time
+ |""".stripMargin),
+ Seq(
+ Row("AAPL", 180.10),
+ Row("AAPL", 180.20),
+ Row("MSFT", 180.20)))
+ }
+
+ test("disjunction in ON") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT count(*) AS cnt
+ |FROM VALUES
+ | (CAST(NULL AS STRING), TIMESTAMP '2026-06-29 10:00:00') AS
t(symbol, trade_time)
+ |ASOF JOIN VALUES
+ | (CAST(NULL AS STRING), TIMESTAMP '2026-06-29 09:00:00') AS
q(symbol, quote_time)
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON t.symbol = q.symbol OR (t.symbol IS NULL AND q.symbol IS NULL)
+ |""".stripMargin),
+ Row(1L) :: Nil)
+ }
+
+ test("ON predicate with expression over both tables") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT t.symbol, q.bid_price
+ |FROM trades t ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON substr(t.symbol, 1, 1) = substr(q.symbol, 1, 1)
+ |ORDER BY t.symbol, t.trade_time
+ |""".stripMargin),
+ Seq(
+ Row("AAPL", 180.10),
+ Row("AAPL", 180.20),
+ Row("MSFT", 420.50)))
+ }
+
+ test("ON TRUE matches whole right side") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT count(*) AS cnt
+ |FROM trades t ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON TRUE
+ |""".stripMargin),
+ Row(3L) :: Nil)
+ }
+
+ test("ON FALSE empties right search space") {
+ setupTradeQuoteViews()
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT count(*) AS inner_cnt
+ |FROM trades t ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON FALSE
+ |""".stripMargin),
+ Row(0L) :: Nil)
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT count(*) AS left_cnt
+ |FROM trades t LEFT ASOF JOIN quotes q
+ | MATCH_CONDITION (t.trade_time >= q.quote_time)
+ | ON FALSE
+ |""".stripMargin),
+ Row(4L) :: Nil)
+ }
+
+ test("LEFT ASOF JOIN null-pads NOT NULL right catalog columns") {
+ withTable("right_nn", "left_nn") {
+ sql("CREATE TABLE right_nn (k INT NOT NULL, v STRING NOT NULL) USING
parquet")
+ sql("INSERT INTO right_nn VALUES (3, 'a')")
+ sql("CREATE TABLE left_nn (k INT, label STRING) USING parquet")
+ sql("INSERT INTO left_nn VALUES (2, 'unmatched')")
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT l.k, l.label, r.v
+ |FROM left_nn l LEFT ASOF JOIN right_nn r
+ | MATCH_CONDITION (l.k >= r.k)
+ |""".stripMargin),
+ Row(2, "unmatched", null) :: Nil)
+ }
+ }
+
+ test("strict > MATCH_CONDITION excludes equal right rows") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT q.quote_time
+ |FROM VALUES (TIMESTAMP '2026-06-29 10:00:00', 'X') AS t(trade_time,
symbol)
+ |ASOF JOIN VALUES
+ | (TIMESTAMP '2026-06-29 10:00:00', 'X'),
+ | (TIMESTAMP '2026-06-29 09:00:00', 'X') AS q(quote_time, symbol)
+ | MATCH_CONDITION (t.trade_time > q.quote_time)
+ | ON t.symbol = q.symbol
+ |""".stripMargin),
+ Row(Timestamp.valueOf("2026-06-29 09:00:00")) :: Nil)
+ }
+
+ test("strict < forward MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.ts
+ |FROM VALUES (TIMESTAMP '2026-06-29 10:00:00') AS t(ts)
+ |ASOF JOIN VALUES
+ | (TIMESTAMP '2026-06-29 10:00:00'),
+ | (TIMESTAMP '2026-06-29 11:00:00') AS r(ts)
+ | MATCH_CONDITION (t.ts < r.ts)
+ |""".stripMargin),
+ Row(Timestamp.valueOf("2026-06-29 11:00:00")) :: Nil)
+ }
+
+ test("forward ARRAY<INT> <= MATCH_CONDITION") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.a
+ |FROM VALUES (ARRAY(1, 5)) AS t(a)
+ |ASOF JOIN VALUES (ARRAY(1, 6)), (ARRAY(1, 8)) AS r(a)
+ | MATCH_CONDITION (t.a <= r.a)
+ |""".stripMargin),
+ Row(Seq(1, 6)) :: Nil)
+ }
+
+ test("ARRAY<STRUCT> operands with different field names") {
+ checkSortMergeAsOf(
+ sql(
+ """
+ |SELECT r.a
+ |FROM VALUES (ARRAY(named_struct('x', 1, 'y', 3))) AS t(a)
+ |ASOF JOIN VALUES (ARRAY(named_struct('p', 1, 'q', 2))) AS r(a)
+ | MATCH_CONDITION (t.a >= r.a)
+ |""".stripMargin),
+ Row(Seq(Row(1, 2))) :: Nil)
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]