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

ulysses-you 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 14e89c142f94 [SPARK-58757][SQL] Allow CollapseWindow to merge windows 
with an empty order spec
14e89c142f94 is described below

commit 14e89c142f94a0633c86150cfd1f7bd28cb240aa
Author: Xiduo You <[email protected]>
AuthorDate: Thu Aug 20 10:50:53 2026 +0800

    [SPARK-58757][SQL] Allow CollapseWindow to merge windows with an empty 
order spec
    
    ### What changes were proposed in this pull request?
    
    Currently, `CollapseWindow` collapses two adjacent `Window` operators only 
when their partition specs and order specs are identical. This PR relaxes it to 
also merge two windows with the same partition spec when **one of them has an 
empty order spec**, as long as every window expression of the empty-order 
window is order-insensitive.
    
    A window expression is treated as order-insensitive when its frame is the 
whole partition (`ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`): 
such a frame always covers every row of the partition regardless of the 
ordering. Since the order spec of the window is empty, the query does not fix 
the row order, so evaluating its expressions under any ordering yields a valid 
result, even though the value may differ for order-dependent expressions such 
as `first`, `collect_list`, or [...]
    
    The merged window keeps the non-empty order spec of the other window.
    
    When the empty-order window is the child (inner) window, merging it into an 
ordered parent can disable `InferWindowGroupLimit` for top-k queries, so that 
direction is gated by a new config 
`spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild` (default false).
    
    ### Why are the changes needed?
    
    For queries that mix an ordered window function with an unordered aggregate 
over the same partition, e.g.:
    
    ```sql
    SELECT c2, c1,
           row_number() OVER (PARTITION BY c1 ORDER BY c2) AS rk,
           count(1)     OVER (PARTITION BY c1)
    FROM t3
    ```
    
    the current rule keeps two `Window` operators even though they share 
`PARTITION BY c1`. After this change they are collapsed into a single window 
operator, saving one `WindowExec` pass (the `[c1, c2]` sort is already shared 
in both plans). A local benchmark on 4M rows showed roughly 16% faster runtime 
in the non-spill case and 20% in the spill case.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Adds `spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild` (default 
false) to control whether an empty-order child window is merged into an ordered 
parent.
    
    The query result is unchanged for the common shape (the empty-order window 
written after the ordered one), where the merge does not change the input order 
of any window expression. When the empty-order window appears before an ordered 
sibling in the SELECT list and the config is enabled, the merge evaluates its 
expressions under the sibling's order; for functions documented as 
non-deterministic without an order (`first`, `last`, `collect_list`) the value 
may differ, which is already a [...]
    
    ### How was this patch tested?
    
    Added tests to `CollapseWindowSuite` covering:
    
    - collapse when the empty-order window has a whole-partition frame 
(`row_number` + `count`);
    - collapse when the empty-order window has a `RANGE` whole-partition frame;
    - collapse when the empty-order window has multiple window expressions;
    - collapse when the empty-order window has `first` over the whole partition;
    - the SPARK-34565 shape with a `Project` between the windows;
    - no collapse when the empty-order window has a bounded frame;
    - collapse when the empty-order window is the inner window with the config 
enabled, and no collapse by default.
    
    Added a test to `InferWindowGroupLimitSuite` asserting the child-empty 
direction keeps `WindowGroupLimit` by default and drops it when the config is 
enabled.
    
    Added an execution-level test to `DataFrameWindowFunctionsSuite` that runs 
the merged window and checks its result.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, developed with assistance from Claude Code.
    
    Generated-by: Claude Code
    
    Closes #57986 from ulysses-you/collapse-window-empty-order.
    
    Authored-by: Xiduo You <[email protected]>
    Signed-off-by: Xiduo You <[email protected]>
    (cherry picked from commit 8565f0d2a6887e15f55c48b7a2d0e433f0beeaaa)
    Signed-off-by: Xiduo You <[email protected]>
---
 .../spark/sql/catalyst/optimizer/Optimizer.scala   |  53 ++++-
 .../org/apache/spark/sql/internal/SQLConf.scala    |  14 ++
 .../catalyst/optimizer/CollapseWindowSuite.scala   | 250 +++++++++++++++++++++
 .../optimizer/InferWindowGroupLimitSuite.scala     |  44 +++-
 .../spark/sql/DataFrameWindowFunctionsSuite.scala  |  22 ++
 5 files changed, 378 insertions(+), 5 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index 7cd919feca99..f758dbbdd753 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -1754,6 +1754,10 @@ object OptimizeWindowFunctions extends Rule[LogicalPlan] 
{
  * Collapse Adjacent Window Expression.
  * - If the partition specs and order specs are the same and the window 
expression are
  *   independent and are of the same window function type, collapse into the 
parent.
+ * - If the partition specs are the same and one of the order specs is empty, 
collapse into the
+ *   parent when the window expressions of the empty-order window can be 
evaluated under any row
+ *   order. The merged window keeps the non-empty order spec. Merging an 
empty-order child is
+ *   gated by `spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild`.
  */
 object CollapseWindow extends Rule[LogicalPlan] {
   private def specCompatible(s1: Seq[Expression], s2: Seq[Expression]): 
Boolean = {
@@ -1761,9 +1765,46 @@ object CollapseWindow extends Rule[LogicalPlan] {
       s1.zip(s2).forall(e => e._1.semanticEquals(e._2))
   }
 
+  /**
+   * Returns true if the given window expression can still be evaluated 
correctly when the rows
+   * of the partition are reordered, so that it can be merged into another 
window with a different
+   * (non-empty) order spec.
+   *
+   * The frame determines whether reordering is safe. When the frame is the 
whole partition
+   * (`UNBOUNDED PRECEDING` to `UNBOUNDED FOLLOWING`), it always covers all 
the rows of the
+   * partition regardless of the ordering, so reordering changes only the 
order in which the rows
+   * are seen, never which rows are in the frame. Since the order spec of the 
window is empty,
+   * the query does not fix the row order, so evaluating its expressions under 
any ordering
+   * yields a valid result, even though the value may differ for 
order-dependent expressions
+   * such as `first`, `collect_list`, or floating-point `sum`/`avg`. On the 
other hand, a bounded
+   * frame (e.g. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`) is 
order-sensitive: which
+   * rows are in the frame depends on the ordering, so even `count` or `sum` 
would change value,
+   * and such a window must not be merged.
+   */
+  private def canEvaluateUnderAnyOrder(windowExpression: NamedExpression): 
Boolean =
+    windowExpression match {
+      case Alias(WindowExpression(_, WindowSpecDefinition(_, _,
+          SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing))), 
_) => true
+      case _ => false
+    }
+
   private def windowsCompatible(w1: Window, w2: Window): Boolean = {
     specCompatible(w1.partitionSpec, w2.partitionSpec) &&
-      specCompatible(w1.orderSpec, w2.orderSpec) &&
+      // The order specs can differ when one of them is empty, as long as the 
window expressions
+      // of the window with the empty order spec are safe to evaluate under 
any row order. In that
+      // case, they can be evaluated under the non-empty order spec of the 
other window. The
+      // operator then keeps the non-empty order spec while the merged-in 
expressions keep their
+      // own empty order spec; this divergence is safe because after analysis 
only
+      // OptimizeWindowFunctions reads an expression's own order spec, and it 
is a no-op without
+      // one. Merging an empty-order child into an ordered parent can disable
+      // InferWindowGroupLimit and LimitPushDownThroughWindow, so that 
direction is gated by
+      // conf.collapseWindowWithEmptyOrderSpecInChild.
+      (specCompatible(w1.orderSpec, w2.orderSpec) ||
+        (w1.orderSpec.isEmpty && w2.orderSpec.nonEmpty &&
+          w1.windowExpressions.forall(canEvaluateUnderAnyOrder)) ||
+        (conf.collapseWindowWithEmptyOrderSpecInChild && w2.orderSpec.isEmpty 
&&
+          w1.orderSpec.nonEmpty &&
+          w2.windowExpressions.forall(canEvaluateUnderAnyOrder))) &&
       w1.references.intersect(w2.windowOutputSet).isEmpty &&
       w1.windowExpressions.nonEmpty && w2.windowExpressions.nonEmpty &&
       // This assumes Window contains the same type of window expressions. 
This is ensured
@@ -1776,13 +1817,19 @@ object CollapseWindow extends Rule[LogicalPlan] {
     _.containsPattern(WINDOW), ruleId) {
     case w1 @ Window(we1, _, _, w2 @ Window(we2, _, _, grandChild, _), _)
         if windowsCompatible(w1, w2) =>
-      w1.copy(windowExpressions = we2 ++ we1, child = grandChild)
+      w1.copy(
+        orderSpec = if (w1.orderSpec.nonEmpty) w1.orderSpec else w2.orderSpec,
+        windowExpressions = we2 ++ we1,
+        child = grandChild)
 
     case w1 @ Window(we1, _, _, Project(pl, w2 @ Window(we2, _, _, grandChild, 
_)), _)
         if windowsCompatible(w1, w2) && 
w1.references.subsetOf(grandChild.outputSet) =>
       Project(
         pl ++ w1.windowOutputSet,
-        w1.copy(windowExpressions = we2 ++ we1, child = grandChild))
+        w1.copy(
+          orderSpec = if (w1.orderSpec.nonEmpty) w1.orderSpec else 
w2.orderSpec,
+          windowExpressions = we2 ++ we1,
+          child = grandChild))
   }
 }
 
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 a82123368e8b..6e960452cd04 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
@@ -4812,6 +4812,17 @@ object SQLConf {
         "The threshold of window group limit must be -1, 0 or positive 
integer.")
       .createWithDefault(1000)
 
+  val COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD =
+    buildConf("spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .doc("When true, the optimizer collapses two adjacent windows with the 
same partition " +
+        "spec into one when the window with the empty order spec is the child 
(inner) window. " +
+        "This saves a WindowExec pass but can disable the WindowGroupLimit and 
the LocalLimit " +
+        "push-down optimizations for top-k queries.")
+      .version("4.4.0")
+      .booleanConf
+      .createWithDefault(false)
+
   val WINDOW_SEGMENT_TREE_ENABLED =
     buildConf("spark.sql.window.segmentTree.enabled")
       .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
@@ -8852,6 +8863,9 @@ class SQLConf extends Serializable with Logging with 
SqlApiConf {
 
   def coalesceShufflePartitionsEnabled: Boolean = 
getConf(COALESCE_PARTITIONS_ENABLED)
 
+  def collapseWindowWithEmptyOrderSpecInChild: Boolean =
+    getConf(COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD)
+
   def minBatchesToRetain: Int = getConf(MIN_BATCHES_TO_RETAIN)
 
   def maxVersionsToDeletePerMaintenance: Int = 
getConf(MAX_VERSIONS_TO_DELETE_PER_MAINTENANCE)
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala
index 515203da7caf..fb2f0957d74d 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala
@@ -19,9 +19,15 @@ package org.apache.spark.sql.catalyst.optimizer
 
 import org.apache.spark.sql.catalyst.dsl.expressions._
 import org.apache.spark.sql.catalyst.dsl.plans._
+import org.apache.spark.sql.catalyst.expressions.{
+  CurrentRow, RangeFrame, RowFrame, RowNumber, SpecifiedWindowFrame,
+  UnboundedFollowing, UnboundedPreceding}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{
+  AggregateExpression, Complete, Count, First, Sum}
 import org.apache.spark.sql.catalyst.plans.PlanTest
 import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan}
 import org.apache.spark.sql.catalyst.rules.RuleExecutor
+import org.apache.spark.sql.internal.SQLConf
 
 class CollapseWindowSuite extends PlanTest {
   object Optimize extends RuleExecutor[LogicalPlan] {
@@ -168,4 +174,248 @@ class CollapseWindowSuite extends PlanTest {
 
     comparePlans(optimized, correctAnswer)
   }
+
+  test("collapse windows when one has an empty order spec " +
+    "(row_number + count over the whole partition)") {
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has multiple window 
expressions") {
+    // Every window expression of the empty-order window must be 
order-insensitive for the merge.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+    val sm = windowExpr(
+      AggregateExpression(Sum(b), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("sm")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt, sm), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt, sm), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has first() over the 
whole partition") {
+    // `first` is non-deterministic when the order is not determined by the 
query, so evaluating it
+    // under the other window's order spec yields a valid result.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val fr = windowExpr(
+      First(a, ignoreNulls = true).toAggregateExpression(),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("fr")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(fr), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, fr), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("don't collapse windows when the empty-order window has a bounded 
frame") {
+    // The frame `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` is 
order-sensitive: which rows
+    // fall in the frame depends on the ordering, so the window cannot be 
evaluated under the other
+    // window's order spec.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val optimized = Optimize.execute(query.analyze)
+    val correctAnswer = query.analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has a RANGE 
whole-partition frame") {
+    // `RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` covers the 
whole partition just
+    // like `ROWS`, so it also collapses.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RangeFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window is the inner window") {
+    // The empty-order window can also be the child of the ordered window. In 
that case its
+    // expressions are evaluated under the ordered window's order spec, which 
is valid because all
+    // of them are order-insensitive. This direction can disable 
InferWindowGroupLimit, so it is
+    // gated by `spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild`.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+
+    val analyzed = query.analyze
+    val optimized = withSQLConf(
+        SQLConf.COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD.key -> "true") {
+      Optimize.execute(analyzed)
+    }
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(cnt, rk), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("don't collapse the inner empty-order window by default") {
+    // Merging an empty-order child into an ordered parent can disable 
InferWindowGroupLimit for
+    // top-k queries, so it is off by default.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+
+    val optimized = Optimize.execute(query.analyze)
+    val correctAnswer = query.analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows with a Project between them when one has an empty 
order spec") {
+    // The same merge applies when a Project sits between the two windows and 
only passes through
+    // columns that are available below the inner window (SPARK-34565 shape). 
The empty-order
+    // window is the inner window here, so the config must be enabled.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .select($"a", $"b", $"c", $"cnt")
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .select($"a", $"b", $"c", $"cnt", $"rk")
+
+    val analyzed = query.analyze
+    val optimized = withSQLConf(
+        SQLConf.COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD.key -> "true") {
+      Optimize.execute(analyzed)
+    }
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(cnt, rk), partitionSpec1, orderSpec1)
+      .select(a, b, c, $"cnt", $"rk")
+      .analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows with a Project between them, empty order spec as 
parent") {
+    // The empty-order window is the parent here, so this merges by default 
without the config.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .select($"a", $"b", $"c", $"rk")
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .select($"a", $"b", $"c", $"rk", $"cnt")
+
+    val optimized = Optimize.execute(query.analyze)
+    assert(query.analyze.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt), partitionSpec1, orderSpec1)
+      .select(a, b, c, $"rk", $"cnt")
+      .analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferWindowGroupLimitSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferWindowGroupLimitSuite.scala
index 5aa7a27f65fb..5e0f978300cc 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferWindowGroupLimitSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferWindowGroupLimitSuite.scala
@@ -20,9 +20,10 @@ package org.apache.spark.sql.catalyst.optimizer
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.catalyst.dsl.expressions._
 import org.apache.spark.sql.catalyst.dsl.plans._
-import org.apache.spark.sql.catalyst.expressions.{CurrentRow, DenseRank, 
Literal, NthValue, NTile, PercentRank, Rank, RowFrame, RowNumber, 
SpecifiedWindowFrame, UnboundedPreceding}
+import org.apache.spark.sql.catalyst.expressions.{CurrentRow, DenseRank, 
Literal, NthValue, NTile, PercentRank, Rank, RowFrame, RowNumber, 
SpecifiedWindowFrame, UnboundedFollowing, UnboundedPreceding}
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
Complete, Count}
 import org.apache.spark.sql.catalyst.plans.PlanTest
-import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan}
+import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, 
LogicalPlan, WindowGroupLimit}
 import org.apache.spark.sql.catalyst.rules.RuleExecutor
 import org.apache.spark.sql.internal.SQLConf
 
@@ -46,6 +47,17 @@ class InferWindowGroupLimitSuite extends PlanTest {
         LimitPushDownThroughWindow) :: Nil
   }
 
+  private object WithCollapseWindow extends RuleExecutor[LogicalPlan] {
+    val batches =
+      Batch("Insert WindowGroupLimit with CollapseWindow", FixedPoint(10),
+        CollapseWindow,
+        CollapseProject,
+        RemoveNoopOperators,
+        PushDownPredicates,
+        InferWindowGroupLimit,
+        LimitPushDownThroughWindow) :: Nil
+  }
+
   private val testRelation = LocalRelation.fromExternalRows(
     Seq("a".attr.int, "b".attr.int, "c".attr.int),
     1.to(6).map(_ => Row(1, 2, 3)))
@@ -354,4 +366,32 @@ class InferWindowGroupLimitSuite extends PlanTest {
       Optimize.execute(originalQuery.analyze),
       WithoutOptimize.execute(originalQuery.analyze))
   }
+
+  test("SPARK-58757: collapseWindowWithEmptyOrderSpecInChild keeps 
WindowGroupLimit by default") {
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(a :: Nil, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+    val rn = windowExpr(
+      RowNumber(),
+      windowSpec(a :: Nil, c.desc :: Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rn")
+
+    def analyzed: LogicalPlan = testRelation
+      .window(Seq(cnt), a :: Nil, Nil)
+      .window(Seq(rn), a :: Nil, c.desc :: Nil)
+      .where($"rn" <= 2)
+      .analyze
+
+    // By default the config is off, so the empty-order child is not collapsed 
into the ordered
+    // parent and the WindowGroupLimit is preserved.
+    val defaultPlan = WithCollapseWindow.execute(analyzed)
+    assert(defaultPlan.collect { case _: WindowGroupLimit => 1 }.size == 1)
+
+    // With the config on, the empty-order child is collapsed, disabling the 
WindowGroupLimit.
+    withSQLConf(SQLConf.COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD.key -> 
"true") {
+      val enabledPlan = WithCollapseWindow.execute(analyzed)
+      assert(enabledPlan.collect { case _: WindowGroupLimit => 1 }.isEmpty)
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
index 136dc57fdfcf..3530ccbb6ecf 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala
@@ -79,6 +79,28 @@ class DataFrameWindowFunctionsSuite extends 
SharedSparkSession
       parameters = Map("wf_name" -> "row_number", "wf_expr" -> "row_number()"))
   }
 
+  test("SPARK-58757: collapse window with an empty order spec into an ordered 
sibling") {
+    val df = Seq(
+      (0, 0), (0, 2), (0, 4),
+      (1, 1), (1, 3), (1, 5)).toDF("k", "v")
+    val ordered = Window.partitionBy("k").orderBy("v")
+    val unordered = Window.partitionBy("k")
+    val res = df.select(
+      $"k",
+      $"v",
+      row_number().over(ordered).as("rn"),
+      collect_list($"v").over(unordered).as("vs"),
+      first($"v").over(unordered).as("f"))
+    assert(res.queryExecution.optimizedPlan.collect { case w: LogicalWindow => 
w }.size === 1)
+    checkAnswer(res, Seq(
+      Row(0, 0, 1, Array(0, 2, 4), 0),
+      Row(0, 2, 2, Array(0, 2, 4), 0),
+      Row(0, 4, 3, Array(0, 2, 4), 0),
+      Row(1, 1, 1, Array(1, 3, 5), 1),
+      Row(1, 3, 2, Array(1, 3, 5), 1),
+      Row(1, 5, 3, Array(1, 3, 5), 1)))
+  }
+
   test("corr, covar_pop, stddev_pop functions in specific window") {
     withSQLConf(SQLConf.LEGACY_STATISTICAL_AGGREGATE.key -> "true",
       SQLConf.ANSI_ENABLED.key -> "false") {


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

Reply via email to