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

uros-b 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 6f0c4791a1f4 [SPARK-59749][SQL] Accept empty STRUCT operands in ASOF 
JOIN MATCH_CONDITION
6f0c4791a1f4 is described below

commit 6f0c4791a1f4bceaa9749c913dc80cef47918a5b
Author: Luka Zdravić <[email protected]>
AuthorDate: Wed Sep 30 13:07:18 2026 +0000

    [SPARK-59749][SQL] Accept empty STRUCT operands in ASOF JOIN MATCH_CONDITION
    
    ### What changes were proposed in this pull request?
    
      ASOF JOIN `MATCH_CONDITION` now accepts operands that are, or contain, an 
empty struct (`STRUCT<>`). The operand check was "orderable and contains no 
empty struct". It is now only "orderable", the same rule `min_by` and `max_by` 
use for their ordering column. An array of empty structs compares each element 
as one value.
    
      Now accepted (same type on both sides): `STRUCT<>`, `STRUCT<x: 
STRUCT<>>`, `ARRAY<STRUCT<>>`. An empty struct against a non-empty struct stays 
rejected.
    
      While testing these operands, I found and fixed one more bug. An 
`ARRAY<STRUCT>` operand whose fields have different types (for example `INT` 
and `TIMESTAMP`) passed the type check, then failed with 
`DATATYPE_MISMATCH.DATA_DIFF_TYPES`. The per-element distance now keeps the 
field distances in a struct instead of an array.
    
      Forward matches (`<`, `<=`) stay as they are on master. SPARK-59829 
(#59102) changes them to keep the first match.
    
      ### Why are the changes needed?
    
      The comparison operator already accepts these pairs: `named_struct() >= 
named_struct()` is `true`, because two empty structs are equal. ASOF rejected 
them with an error that says the operands must be orderable, but `STRUCT<>` is 
orderable.
    
      The `ARRAY<STRUCT>` bug gave a confusing error for a valid query.
    
      ### Does this PR introduce _any_ user-facing change?
    
      Yes. This query failed and now runs:
    
      ```sql
      SELECT * FROM VALUES (named_struct()) AS l(s)
      ASOF JOIN VALUES (named_struct()) AS r(s)
      MATCH_CONDITION (l.s >= r.s);
      -- before: [ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE] ... Got: "STRUCT<>" 
and "STRUCT<>".
      ```
    
      Two empty structs are equal, so `>=` and `<=` match, and `>` and `<` find 
no match. A NULL empty struct never matches.
    
      `ARRAY<STRUCT>` operands with fields of different types now run instead 
of failing with `DATATYPE_MISMATCH.DATA_DIFF_TYPES`.
    
      ### How was this patch tested?
    
      Unit tests in `AsOfJoinMatchConditionTypesSuite`, `ResolveAsOfJoinSuite`, 
and `AsOfJoinSQLSuite` cover the accepted and rejected type pairs and the array 
element distance. Execution tests in `AsOfJoinSortMergeSQLSuite` check the 
matched row for all four operators, for: an empty struct, a struct with only an 
empty field, a NULL empty struct, a struct with an empty field and an int field 
(column and tuple form), arrays of empty structs, and `ARRAY<STRUCT>` operands 
with fields of diff [...]
    
      ### Was this patch authored or co-authored using generative AI tooling?
    
      Generated-by: Claude Code
    
    Closes #59016 from LukaZdravic/SPARK-59749-asof-empty-struct.
    
    Authored-by: Luka Zdravić <[email protected]>
    Signed-off-by: Uros Bojanic <[email protected]>
---
 .../plans/logical/basicLogicalOperators.scala      |  30 ++----
 .../catalyst/analysis/ResolveAsOfJoinSuite.scala   |  38 ++++++++
 .../logical/AsOfJoinMatchConditionTypesSuite.scala |  40 +++++---
 .../analyzer-results/join-asof-datatypes.sql.out   |  78 ++++++++++++++++
 .../sql-tests/inputs/join-asof-datatypes.sql       |  39 ++++++++
 .../sql-tests/results/join-asof-datatypes.sql.out  |  63 +++++++++++++
 .../org/apache/spark/sql/AsOfJoinSQLSuite.scala    |  10 +-
 .../spark/sql/AsOfJoinSortMergeSQLSuite.scala      | 101 +++++++++++++++++++++
 8 files changed, 358 insertions(+), 41 deletions(-)

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 e4c5d955cf81..dd44a8b25912 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
@@ -2772,8 +2772,7 @@ object AsOfJoin {
    */
   private[catalyst] object MatchConditionTypes {
 
-    def isValidOperandType(dataType: DataType): Boolean =
-      RowOrdering.isOrderable(dataType) && !containsEmptyStructType(dataType)
+    def isValidOperandType(dataType: DataType): Boolean = 
RowOrdering.isOrderable(dataType)
 
     /** Whether the `>=` this join builds can compare the two operands. */
     def areOperandsCompatible(leftType: DataType, rightType: DataType): 
Boolean = {
@@ -2866,13 +2865,6 @@ object AsOfJoin {
           leftStruct.sameType(rightStruct) && leftStruct.nonEmpty
         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
-    }
   }
 
   /**
@@ -3057,15 +3049,14 @@ object AsOfJoin {
     val leftArray = castArrayElementType(leftOperand, elementType)
     val rightArray = castArrayElementType(rightOperand, elementType)
     elementType match {
-      case struct: StructType =>
+      // An empty struct has no fields to split into, so it takes the 
whole-value arm below.
+      case struct: StructType if struct.nonEmpty =>
         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))
+        val elementOrder = wrapCompositeOrderExpression(leafDiffs)
         ZipWith(
           leftArray,
           rightArray,
@@ -3103,7 +3094,7 @@ object AsOfJoin {
     val leafDiffs = collectStructLeafPairs(leftOperand, rightOperand, 
structType).map {
       case (left, right) => buildLeafOrderExpression(left, right, operator)
     }
-    wrapCompositeOrderExpression(leafDiffs, structType)
+    wrapCompositeOrderExpression(leafDiffs)
   }
 
   private def collectStructLeafPairs(
@@ -3123,16 +3114,11 @@ object AsOfJoin {
       }
   }
 
-  private def wrapCompositeOrderExpression(
-      diffs: Seq[Expression],
-      compositeType: DataType): Expression = {
+  /** Groups multiple field distances in a struct because their types may 
differ. */
+  private def wrapCompositeOrderExpression(diffs: Seq[Expression]): Expression 
= {
     diffs match {
       case Seq(single) => single
-      case _ =>
-        compositeType match {
-          case _: ArrayType => CreateArray(diffs)
-          case _ => CreateStruct(diffs)
-        }
+      case _ => CreateStruct(diffs)
     }
   }
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoinSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoinSuite.scala
index 80674833065c..04d572e695dc 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoinSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoinSuite.scala
@@ -81,6 +81,20 @@ class ResolveAsOfJoinSuite extends AnalysisTest {
   private val leftStruct: LogicalPlan = LocalRelation(lstruct)
   private val rightStruct: LogicalPlan = LocalRelation(rstruct)
 
+  // Arrays of empty structs: each element has no fields, so it is compared as 
one value.
+  private val lemptyArr = AttributeReference("ea", 
ArrayType(StructType(Nil)))()
+  private val remptyArr = AttributeReference("ea", 
ArrayType(StructType(Nil)))()
+  private val leftEmptyArr: LogicalPlan = LocalRelation(lemptyArr)
+  private val rightEmptyArr: LogicalPlan = LocalRelation(remptyArr)
+
+  // Arrays of structs whose fields differ in type, so their per-field 
distances differ in type.
+  private val mixedElement =
+    StructType(StructField("f", IntegerType) :: StructField("ts", 
TimestampType) :: Nil)
+  private val lmixedArr = AttributeReference("ma", ArrayType(mixedElement))()
+  private val rmixedArr = AttributeReference("ma", ArrayType(mixedElement))()
+  private val leftMixedArr: LogicalPlan = LocalRelation(lmixedArr)
+  private val rightMixedArr: LogicalPlan = LocalRelation(rmixedArr)
+
   /** Build an [[AsOfJoin]] from a `MATCH_CONDITION` whose operands are 
already resolved. */
   private def asOf(
       leftExpr: Expression = la,
@@ -186,6 +200,30 @@ class ResolveAsOfJoinSuite extends AnalysisTest {
     assert(body.isInstanceOf[Subtract], s"expected a Subtract element 
distance, got $body")
   }
 
+  test("materializes an array of empty structs into a whole-element signed 
distance") {
+    val resolved = ResolveAsOfJoin.apply(
+      asOf(leftExpr = lemptyArr, rightExpr = remptyArr, l = leftEmptyArr, r = 
rightEmptyArr))
+      .asInstanceOf[AsOfJoin]
+    val zip = resolved.orderExpression.asInstanceOf[ZipWith]
+    // An empty struct has no fields to split, so each element gets one 
whole-value distance.
+    val body = zip.function.asInstanceOf[LambdaFunction].function
+    assert(body.isInstanceOf[If], s"expected a signed element distance, got 
$body")
+    assert(body.dataType == IntegerType)
+  }
+
+  test("materializes an ARRAY<STRUCT> with mixed field types into a struct 
element distance") {
+    val resolved = ResolveAsOfJoin.apply(
+      asOf(leftExpr = lmixedArr, rightExpr = rmixedArr, l = leftMixedArr, r = 
rightMixedArr))
+      .asInstanceOf[AsOfJoin]
+    val zip = resolved.orderExpression.asInstanceOf[ZipWith]
+    // The INT and TIMESTAMP field distances differ in type, so one array 
could not hold both.
+    val body = zip.function.asInstanceOf[LambdaFunction].function
+    assert(body.isInstanceOf[CreateNamedStruct], s"expected a struct element 
distance, got $body")
+    val fields = body.asInstanceOf[CreateNamedStruct].valExprs
+    assert(fields.size == 2)
+    assert(fields.forall(_.isInstanceOf[Subtract]), s"expected per-field 
Subtracts, got $fields")
+  }
+
   test("materializes a positional struct operand into a flattened per-field 
distance") {
     val resolved = ResolveAsOfJoin.apply(
       asOf(leftExpr = lstruct, rightExpr = rstruct, l = leftStruct, r = 
rightStruct))
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
index c8063f35ad9d..018633767af9 100644
--- 
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
@@ -18,7 +18,6 @@
 package org.apache.spark.sql.catalyst.plans.logical
 
 import org.apache.spark.SparkFunSuite
-import org.apache.spark.sql.catalyst.expressions.RowOrdering
 import org.apache.spark.sql.catalyst.plans.logical.AsOfJoin.MatchConditionTypes
 import org.apache.spark.sql.types._
 
@@ -231,17 +230,33 @@ class AsOfJoinMatchConditionTypesSuite extends 
SparkFunSuite {
     assert(!MatchConditionTypes.usesArrayOrderExpression(leftArray, 
diffNameArray))
   }
 
-  test("empty struct operands are invalid") {
+  test("empty struct operands are valid and compare as a whole value") {
     val emptyStruct = StructType(Nil)
-    assert(!MatchConditionTypes.isValidOperandType(emptyStruct))
-    assert(!MatchConditionTypes.areOperandsCompatible(emptyStruct, 
emptyStruct))
+    assert(MatchConditionTypes.isValidOperandType(emptyStruct))
+    assert(MatchConditionTypes.areOperandsCompatible(emptyStruct, emptyStruct))
+    // Splitting into zero fields would compare two empty literals, so a NULL 
operand would match.
     assert(!MatchConditionTypes.usesStructDecomposition(emptyStruct, 
emptyStruct))
   }
 
-  test("nested empty struct operands are invalid") {
+  test("nested empty struct operands are valid") {
     val nestedEmptyStruct = StructType(StructField("x", StructType(Nil)) :: 
Nil)
-    assert(!MatchConditionTypes.isValidOperandType(nestedEmptyStruct))
-    assert(!MatchConditionTypes.areOperandsCompatible(nestedEmptyStruct, 
nestedEmptyStruct))
+    assert(MatchConditionTypes.isValidOperandType(nestedEmptyStruct))
+    assert(MatchConditionTypes.areOperandsCompatible(nestedEmptyStruct, 
nestedEmptyStruct))
+    assert(MatchConditionTypes.usesStructDecomposition(nestedEmptyStruct, 
nestedEmptyStruct))
+  }
+
+  test("empty struct and non-empty struct operands are incompatible") {
+    val emptyStruct = StructType(Nil)
+    val nonEmptyStruct = StructType(StructField("a", IntegerType) :: Nil)
+    assert(!MatchConditionTypes.areOperandsCompatible(emptyStruct, 
nonEmptyStruct))
+    assert(!MatchConditionTypes.areOperandsCompatible(nonEmptyStruct, 
emptyStruct))
+    val nestedEmpty = StructType(StructField("x", emptyStruct) :: Nil)
+    val nestedNonEmpty = StructType(StructField("x", nonEmptyStruct) :: Nil)
+    assert(!MatchConditionTypes.areOperandsCompatible(nestedEmpty, 
nestedNonEmpty))
+    val emptyArray = ArrayType(emptyStruct)
+    val nonEmptyArray = ArrayType(nonEmptyStruct)
+    assert(!MatchConditionTypes.areOperandsCompatible(emptyArray, 
nonEmptyArray))
+    assert(!MatchConditionTypes.usesArrayOrderExpression(emptyArray, 
nonEmptyArray))
   }
 
   test("identical struct schemas enable whole-struct sort") {
@@ -300,14 +315,11 @@ class AsOfJoinMatchConditionTypesSuite extends 
SparkFunSuite {
     assert(!MatchConditionTypes.areOperandsCompatible(leftStruct, rightStruct))
   }
 
-  test("array operands with empty struct elements are invalid") {
-    // An array whose element contains an empty struct is invalid even though 
the array itself
-    // is orderable (the ArrayType arm of containsEmptyStructType).
+  test("array operands with empty struct elements are valid") {
     val arrayOfEmptyStruct = ArrayType(StructType(Nil))
-    // Pin orderability so the rejection is due to the empty struct, not 
non-orderability.
-    assert(RowOrdering.isOrderable(arrayOfEmptyStruct))
-    assert(!MatchConditionTypes.isValidOperandType(arrayOfEmptyStruct))
-    assert(!MatchConditionTypes.areOperandsCompatible(arrayOfEmptyStruct, 
arrayOfEmptyStruct))
+    assert(MatchConditionTypes.isValidOperandType(arrayOfEmptyStruct))
+    assert(MatchConditionTypes.areOperandsCompatible(arrayOfEmptyStruct, 
arrayOfEmptyStruct))
+    assert(MatchConditionTypes.usesArrayOrderExpression(arrayOfEmptyStruct, 
arrayOfEmptyStruct))
   }
 
   test("array operands with incompatible struct element field types are 
rejected") {
diff --git 
a/sql/core/src/test/resources/sql-tests/analyzer-results/join-asof-datatypes.sql.out
 
b/sql/core/src/test/resources/sql-tests/analyzer-results/join-asof-datatypes.sql.out
index 161005b5bef4..d398440f1edf 100644
--- 
a/sql/core/src/test/resources/sql-tests/analyzer-results/join-asof-datatypes.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/analyzer-results/join-asof-datatypes.sql.out
@@ -264,6 +264,20 @@ Project [k#x.a AS matched#xL]
       +- LocalRelation [k#x]
 
 
+-- !query
+SELECT t.id, r.tag AS matched
+FROM VALUES (1, named_struct()) AS t(id, k) ASOF JOIN
+     VALUES (named_struct(), 'hit') AS r(k, tag)
+  MATCH_CONDITION (t.k >= r.k)
+-- !query analysis
+Project [id#x, tag#x AS matched#x]
++- AsOfJoin (k#x >= k#x), Inner
+   :- SubqueryAlias t
+   :  +- LocalRelation [id#x, k#x]
+   +- SubqueryAlias r
+      +- LocalRelation [k#x, tag#x]
+
+
 -- !query
 SELECT t.k, r.k AS matched_k
 FROM VALUES (CAST(10 AS TINYINT)) AS t(k) ASOF JOIN VALUES (CAST(5 AS BIGINT)) 
AS r(k)
@@ -462,3 +476,67 @@ Project [c1#x, c2#x]
    :  +- LocalRelation [c1#x, c2#x]
    +- SubqueryAlias r
       +- LocalRelation [c1#x, c2#x]
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a >= r.a)
+-- !query analysis
+Project [size(a#x, false) AS matched_size#x]
++- AsOfJoin (a#x >= a#x), Inner
+   :- SubqueryAlias t
+   :  +- LocalRelation [a#x]
+   +- SubqueryAlias r
+      +- LocalRelation [a#x]
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a > r.a)
+-- !query analysis
+Project [size(a#x, false) AS matched_size#x]
++- AsOfJoin (a#x > a#x), Inner
+   :- SubqueryAlias t
+   :  +- LocalRelation [a#x]
+   +- SubqueryAlias r
+      +- LocalRelation [a#x]
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a <= r.a)
+-- !query analysis
+Project [size(a#x, false) AS matched_size#x]
++- AsOfJoin (a#x <= a#x), Inner
+   :- SubqueryAlias t
+   :  +- LocalRelation [a#x]
+   +- SubqueryAlias r
+      +- LocalRelation [a#x]
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a < r.a)
+-- !query analysis
+Project [size(a#x, false) AS matched_size#x]
++- AsOfJoin (a#x < a#x), Inner
+   :- SubqueryAlias t
+   :  +- LocalRelation [a#x]
+   +- SubqueryAlias r
+      +- LocalRelation [a#x]
diff --git 
a/sql/core/src/test/resources/sql-tests/inputs/join-asof-datatypes.sql 
b/sql/core/src/test/resources/sql-tests/inputs/join-asof-datatypes.sql
index c8ae66b7d444..87c4a8b09158 100644
--- a/sql/core/src/test/resources/sql-tests/inputs/join-asof-datatypes.sql
+++ b/sql/core/src/test/resources/sql-tests/inputs/join-asof-datatypes.sql
@@ -120,6 +120,12 @@ ASOF JOIN VALUES
     (named_struct('a', CAST(4 AS BIGINT))) AS r(k)
   MATCH_CONDITION (t.k >= r.k);
 
+-- FVT-ASOF-4-012c: empty STRUCT operands (SPARK-59749); two empty structs are 
equal
+SELECT t.id, r.tag AS matched
+FROM VALUES (1, named_struct()) AS t(id, k) ASOF JOIN
+     VALUES (named_struct(), 'hit') AS r(k, tag)
+  MATCH_CONDITION (t.k >= r.k);
+
 -- FVT-ASOF-4-013: coercion TINYINT vs BIGINT
 SELECT t.k, r.k AS matched_k
 FROM VALUES (CAST(10 AS TINYINT)) AS t(k) ASOF JOIN VALUES (CAST(5 AS BIGINT)) 
AS r(k)
@@ -207,6 +213,39 @@ 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));
 
+-- FVT-ASOF-4-018b: ARRAY of empty STRUCT (SPARK-59749) with >=. The elements 
tie, so length
+-- orders the arrays. Sizes 1 and 2 pass; the closest is size 2.
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a >= r.a);
+
+-- FVT-ASOF-4-018c: ARRAY of empty STRUCT with >. Only size 1 passes.
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a > r.a);
+
+-- FVT-ASOF-4-018d: ARRAY of empty STRUCT with <=. Sizes 2 and 3 pass; the 
closest is size 2.
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a <= r.a);
+
+-- FVT-ASOF-4-018e: ARRAY of empty STRUCT with <. Only size 3 passes.
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a < r.a);
+
 -- FVT-ASOF-4-019: MAP operand rejection — covered by FVT-ASOF-3-010
 
 -- FVT-ASOF-4-020: ARRAY<MAP> rejection — covered by FVT-ASOF-3-011
diff --git 
a/sql/core/src/test/resources/sql-tests/results/join-asof-datatypes.sql.out 
b/sql/core/src/test/resources/sql-tests/results/join-asof-datatypes.sql.out
index be069595721a..4b3aa32582ba 100644
--- a/sql/core/src/test/resources/sql-tests/results/join-asof-datatypes.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/join-asof-datatypes.sql.out
@@ -207,6 +207,17 @@ struct<matched:bigint>
 1
 
 
+-- !query
+SELECT t.id, r.tag AS matched
+FROM VALUES (1, named_struct()) AS t(id, k) ASOF JOIN
+     VALUES (named_struct(), 'hit') AS r(k, tag)
+  MATCH_CONDITION (t.k >= r.k)
+-- !query schema
+struct<id:int,matched:string>
+-- !query output
+1      hit
+
+
 -- !query
 SELECT t.k, r.k AS matched_k
 FROM VALUES (CAST(10 AS TINYINT)) AS t(k) ASOF JOIN VALUES (CAST(5 AS BIGINT)) 
AS r(k)
@@ -361,3 +372,55 @@ FROM VALUES (1, 2) AS t(c1, c2) ASOF JOIN VALUES (1, 1) AS 
r(c1, c2)
 struct<c1:int,c2:int>
 -- !query output
 1      1
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a >= r.a)
+-- !query schema
+struct<matched_size:int>
+-- !query output
+2
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a > r.a)
+-- !query schema
+struct<matched_size:int>
+-- !query output
+1
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a <= r.a)
+-- !query schema
+struct<matched_size:int>
+-- !query output
+2
+
+
+-- !query
+SELECT size(r.a) AS matched_size
+FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a) ASOF JOIN
+     VALUES (ARRAY(named_struct())),
+            (ARRAY(named_struct(), named_struct())),
+            (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+  MATCH_CONDITION (t.a < r.a)
+-- !query schema
+struct<matched_size:int>
+-- !query output
+3
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 edbc6d866747..f9cdcbeeea5d 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
@@ -511,11 +511,11 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
     assert(asOfJoin.asOfCondition.resolved)
   }
 
-  test("MATCH_CONDITION rejects empty STRUCT operands") {
+  test("MATCH_CONDITION rejects an empty STRUCT paired with a non-empty 
STRUCT") {
     val sqlText =
       """
         |SELECT *
-        |FROM VALUES (named_struct()) AS t(s) ASOF JOIN VALUES 
(named_struct()) AS r(s)
+        |FROM VALUES (named_struct()) AS t(s) ASOF JOIN VALUES 
(named_struct('a', 1)) AS r(s)
         |  MATCH_CONDITION (t.s >= r.s)
         |""".stripMargin
     checkError(
@@ -524,12 +524,12 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
       sqlState = Some("42K09"),
       parameters = Map(
         "type1" -> "\"STRUCT<>\"",
-        "type2" -> "\"STRUCT<>\""),
+        "type2" -> "\"STRUCT<a: INT NOT NULL>\""),
       queryContext = Array(
         ExpectedContext(
-          fragment = """ASOF JOIN VALUES (named_struct()) AS r(s)
+          fragment = """ASOF JOIN VALUES (named_struct('a', 1)) AS r(s)
                        |  MATCH_CONDITION (t.s >= r.s)""".stripMargin,
           start = 47,
-          stop = 118)))
+          stop = 124)))
   }
 }
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
index ee2debcbfc92..200800f08ab3 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala
@@ -435,6 +435,67 @@ class AsOfJoinSortMergeSQLSuite extends QueryTest
       Row(Row(5, Seq(1, 1))) :: Nil)
   }
 
+  test("empty STRUCT column MATCH_CONDITION") {
+    // Empty structs are equal, also when nested, so inclusive operators match 
and strict do not.
+    for {
+      emptyStruct <- Seq("named_struct()", "named_struct('e', named_struct())")
+      (op, tag) <- Seq(">=" -> "hit", "<=" -> "hit", ">" -> null, "<" -> null)
+    } {
+      checkSortMergeAsOf(
+        sql(
+          s"""
+             |SELECT r.tag
+             |FROM VALUES ($emptyStruct) AS t(s)
+             |LEFT ASOF JOIN VALUES ($emptyStruct, 'hit') AS r(s, tag)
+             |  MATCH_CONDITION (t.s $op r.s)
+             |""".stripMargin),
+        Row(tag) :: Nil)
+    }
+  }
+
+  test("NULL empty STRUCT operand never matches") {
+    // A NULL operand fails every comparison, though any two non-NULL empty 
structs are equal.
+    for {
+      (leftValue, rightValue) <- Seq(
+        "CAST(NULL AS STRUCT<>)" -> "named_struct()",
+        "named_struct()" -> "CAST(NULL AS STRUCT<>)")
+      op <- Seq(">=", ">", "<=", "<")
+    } {
+      checkSortMergeAsOf(
+        sql(
+          s"""
+             |SELECT r.tag
+             |FROM VALUES ($leftValue) AS t(s)
+             |LEFT ASOF JOIN VALUES ($rightValue, 'hit') AS r(s, tag)
+             |  MATCH_CONDITION (t.s $op r.s)
+             |""".stripMargin),
+        Row(null) :: Nil)
+    }
+  }
+
+  test("STRUCT with an empty STRUCT field MATCH_CONDITION") {
+    // The empty field always ties, so field 'seq' alone picks the nearest 
match to 5.
+    for {
+      (left, right) <- Seq("t.k" -> "r.k", "(t.k.e, t.k.seq)" -> "(r.k.e, 
r.k.seq)")
+      (op, seq) <- Seq(">=" -> 5, ">" -> 4, "<=" -> 5, "<" -> 6)
+    } {
+      checkSortMergeAsOf(
+        sql(
+          s"""
+             |SELECT r.k.seq
+             |FROM VALUES (named_struct('e', named_struct(), 'seq', 5)) AS t(k)
+             |ASOF JOIN VALUES
+             |  (named_struct('e', named_struct(), 'seq', 3)),
+             |  (named_struct('e', named_struct(), 'seq', 4)),
+             |  (named_struct('e', named_struct(), 'seq', 5)),
+             |  (named_struct('e', named_struct(), 'seq', 6)),
+             |  (named_struct('e', named_struct(), 'seq', 7)) AS r(k)
+             |  MATCH_CONDITION ($left $op $right)
+             |""".stripMargin),
+        Row(seq) :: Nil)
+    }
+  }
+
   test("ARRAY<INT> MATCH_CONDITION") {
     checkSortMergeAsOf(
       sql(
@@ -547,6 +608,24 @@ class AsOfJoinSortMergeSQLSuite extends QueryTest
       Row(Seq(Row(1, 4))) :: Nil)
   }
 
+  test("ARRAY of empty STRUCT MATCH_CONDITION") {
+    // Empty struct elements always tie, so the arrays order by length alone.
+    Seq(">=" -> 2, ">" -> 1, "<=" -> 2, "<" -> 3).foreach { case (op, size) =>
+      checkSortMergeAsOf(
+        sql(
+          s"""
+             |SELECT size(r.a)
+             |FROM VALUES (ARRAY(named_struct(), named_struct())) AS t(a)
+             |ASOF JOIN VALUES
+             |  (ARRAY(named_struct())),
+             |  (ARRAY(named_struct(), named_struct())),
+             |  (ARRAY(named_struct(), named_struct(), named_struct())) AS r(a)
+             |  MATCH_CONDITION (t.a $op r.a)
+             |""".stripMargin),
+        Row(size) :: Nil)
+    }
+  }
+
   test("STRUCT tuple from scalar columns MATCH_CONDITION") {
     checkSortMergeAsOf(
       sql(
@@ -728,6 +807,28 @@ class AsOfJoinSortMergeSQLSuite extends QueryTest
       Row(Seq(1, 6)) :: Nil)
   }
 
+  test("ARRAY<STRUCT> MATCH_CONDITION with fields of different types") {
+    // Each element has an INT distance and an INTERVAL distance.
+    def element(first: String, time: String): String =
+      s"ARRAY(named_struct('f', $first, 'ts', TIMESTAMP '2026-06-29 $time'))"
+    for {
+      first <- Seq("1", "named_struct()")
+      (op, tag) <- Seq(">=" -> "r9", ">" -> "r9", "<=" -> "r11", "<" -> "r11")
+    } {
+      checkSortMergeAsOf(
+        sql(
+          s"""
+             |SELECT r.tag
+             |FROM VALUES (${element(first, "10:00:00")}) AS t(a)
+             |ASOF JOIN VALUES
+             |  (${element(first, "09:00:00")}, 'r9'),
+             |  (${element(first, "11:00:00")}, 'r11') AS r(a, tag)
+             |  MATCH_CONDITION (t.a $op r.a)
+             |""".stripMargin),
+        Row(tag) :: Nil)
+    }
+  }
+
   test("ARRAY<STRUCT> operands with different field names") {
     checkSortMergeAsOf(
       sql(


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

Reply via email to