Copilot commented on code in PR #58823:
URL: https://github.com/apache/spark/pull/58823#discussion_r4017168553
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/logical/AsOfJoinMatchConditionTypesSuite.scala:
##########
@@ -29,9 +29,39 @@ class AsOfJoinMatchConditionTypesSuite extends SparkFunSuite
{
assert(!MatchConditionTypes.usesStructDecomposition(IntegerType, LongType))
}
- test("string and temporal types are incompatible") {
- assert(!MatchConditionTypes.areOperandsCompatible(StringType,
TimestampType))
- assert(!MatchConditionTypes.areOperandsCompatible(DateType, StringType))
+ test("scalar string and temporal types coerce like the comparison operator")
{
+ // SPARK-59527: a scalar string vs DATE/TIMESTAMP pair is accepted and
coerced, matching `>=`.
+ assert(MatchConditionTypes.areOperandsCompatible(StringType,
TimestampType))
+ assert(MatchConditionTypes.areOperandsCompatible(DateType, StringType))
+ // The shared common type is the temporal type (string is cast to it), not
string, so the
+ // sort-merge sort key and the comparison order agree.
+ assert(MatchConditionTypes.matchComparisonCommonType(DateType,
StringType).contains(DateType))
+ assert(
+ MatchConditionTypes.matchComparisonCommonType(StringType, TimestampType)
+ .contains(TimestampType))
+ }
Review Comment:
`matchComparisonCommonType` depends on `SQLConf.get.ansiEnabled`, but this
suite doesn’t explicitly exercise both ANSI and non-ANSI modes. Since the PR’s
behavior is explicitly mode-dependent (and the SQL suite covers both), consider
adding ANSI-enabled/disabled variants here as well to pin the per-mode
common-type selection at the unit level and avoid regressions if SQLConf
defaults change.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:
##########
@@ -2688,12 +2688,39 @@ object AsOfJoin {
rightOperand: Expression,
normalizedOp: MatchComparisonOperator)
: (Expression, Expression, Seq[Expression], Seq[Expression]) = {
+ val (coercedLeft, coercedRight) = coerceMatchLeafOperands(leftOperand,
rightOperand)
val (asOfCondition, orderExpression) =
- buildMatchExpressions(leftOperand, rightOperand, normalizedOp)
- val (leftSortExprs, rightSortExprs) = matchSortExpressions(leftOperand,
rightOperand)
+ buildMatchExpressions(coercedLeft, coercedRight, normalizedOp)
+ val (leftSortExprs, rightSortExprs) = matchSortExpressions(coercedLeft,
coercedRight)
(asOfCondition, orderExpression, leftSortExprs, rightSortExprs)
}
+ /**
+ * Casts a scalar operand pair to the common type the comparison operator
would use, so the
+ * comparison, ordering distance, and per-side sort keys all agree. This is
required for a
+ * string vs DATE/TIMESTAMP/number pair: the sort-merge scan sorts the right
buffer by its raw
+ * sort key, and only a shared type keeps that order consistent with the
coerced comparison.
+ * STRUCT and ARRAY operands are left untouched; their sort key is the whole
value, so a
+ * per-field cast could not reach it.
Review Comment:
The rationale in this Scaladoc is not always accurate for STRUCT operands:
`matchSortExpressions` can decompose SQL tuple structs into per-field sort
expressions (i.e., the sort key is not necessarily the whole struct value).
Since the code intentionally keeps STRUCT/ARRAY strict anyway, consider
rewording this comment to reflect the actual reason (e.g., consistency with
existing strict rules / inability to safely apply comparison-style string casts
in composite ordering semantics), rather than claiming per-field casts cannot
reach the sort key.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:
##########
@@ -2703,7 +2730,56 @@ object AsOfJoin {
def isValidOperandType(dataType: DataType): Boolean =
RowOrdering.isOrderable(dataType) && !containsEmptyStructType(dataType)
+ /**
+ * Top-level operand compatibility. A scalar pair is compatible when the
comparison operator
+ * would accept it, so DATE/TIMESTAMP vs STRING (and string vs number)
coerce like `>=`.
+ * STRUCT/ARRAY operands keep the stricter widening rule via
[[areFieldTypesCompatible]],
+ * because their sort key is the whole value and cannot carry a per-field
string cast.
+ */
def areOperandsCompatible(leftType: DataType, rightType: DataType):
Boolean = {
+ if (!isValidOperandType(leftType) || !isValidOperandType(rightType)) {
+ false
+ } else {
+ (leftType, rightType) match {
+ case (_: StructType, _) | (_, _: StructType) | (_: ArrayType, _) |
(_, _: ArrayType) =>
+ areFieldTypesCompatible(leftType, rightType)
+ case _ =>
+ matchComparisonCommonType(leftType, rightType).isDefined ||
+ TypeCoercion.findWiderTypeForTwo(leftType, rightType).isDefined
+ }
Review Comment:
`areOperandsCompatible` validates operand types up front, but
`areFieldTypesCompatible` (called in the STRUCT/ARRAY branch) also performs
`isValidOperandType` checks internally. Since `areFieldTypesCompatible` is
private, consider consolidating the validity check in one place (either rely on
the outer check and remove the inner one, or remove the outer check for the
STRUCT/ARRAY branch) to reduce duplicated logic.
##########
sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala:
##########
@@ -200,14 +201,41 @@ class AsOfJoinSQLSuite extends QueryTest with
SharedSparkSession {
sqlState = Some("42K09"),
parameters = Map(
"type1" -> "\"TIMESTAMP\"",
- "type2" -> "\"STRING\""),
+ "type2" -> "\"DECIMAL(5,2)\""),
queryContext = Array(
ExpectedContext(
fragment = """ASOF JOIN quotes q
- | MATCH_CONDITION (t.trade_time >= q.symbol)
+ | MATCH_CONDITION (t.trade_time >= q.bid_price)
| ON t.symbol = q.symbol""".stripMargin,
start = 35,
- stop = 122)))
+ stop = 125)))
+ }
+
+ // SPARK-59527 reproduces with ANSI on and off, so both are checked.
+ Seq(true, false).foreach { ansi =>
+ test(s"MATCH_CONDITION coerces DATE vs STRING like the comparison operator
(ansi=$ansi)") {
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi.toString) {
+ // `l.d >= r.s` type-checks: the string is coerced to DATE, the same
as a bare `>=`
+ // comparison. Before SPARK-59527 this failed with INVALID_TYPE.
+ val sqlText =
+ """
+ |SELECT l.d, r.s
+ |FROM VALUES (DATE '2024-01-03') AS l(d) ASOF JOIN
+ | VALUES ('2024-01-01') AS r(s)
+ | MATCH_CONDITION (l.d >= r.s)
+ |""".stripMargin
+ val asOfJoin = sql(sqlText).queryExecution.analyzed.collectFirst {
+ case j: AsOfJoin => j
+ }.get
Review Comment:
Using `.get` here will fail with a `NoSuchElementException` if the analyzed
plan shape changes, which makes failures harder to diagnose. Consider using
`getOrElse(fail(...))` (or an equivalent assertion) to produce a clearer test
failure message indicating that an `AsOfJoin` node was not found.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]