spark-test commented on code in PR #60090:
URL: https://github.com/apache/spark/pull/60090#discussion_r4483274253
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -3860,6 +3860,25 @@ class Analyzer(
Project(windowOps.output ++ newExpressionsWithWindowFunctions, windowOps)
} // end of addWindow
+ /**
+ * Extracts all nested window expressions and places the HAVING filter
below every Window.
+ */
+ private def addWindowWithHaving(
Review Comment:
**Non-blocking (P2):** This placement runs only when HAVING reaches
`ExtractWindowExpressions` still attached to its Aggregate, which grouping
analytics does only for conditions that need type coercion.
`BaseGroupingSets.resolved` is always false, so the `UnresolvedHaving` case
that calls this method cannot match while the Aggregate still has ROLLUP, CUBE
or GROUPING SETS. `ResolveGroupingAnalytics`, which runs earlier in the batch,
calls the unchanged `tryResolveHavingCondition` instead. When the condition
resolves without type coercion, that method returns `Filter(cond,
constructAggregate(...))` while the window expressions are still in the
aggregate list. Every window layer is then extracted below that Filter,
whatever `spark.sql.legacy.evaluateHavingAfterInnerWindow` is set to. For
example:
```sql
SELECT k, SUM(RANK() OVER (ORDER BY SUM(v) DESC)) OVER () AS rank_sum
FROM VALUES ('drop', 2), ('keep', 1) AS input(k, v)
GROUP BY ROLLUP(k)
HAVING k = 'keep'
```
The windows run over the groups `(NULL, 3)`, `('drop', 2)` and `('keep',
1)`, so the query returns `(keep, 6)`. Evaluating HAVING first gives `(keep,
1)`, which is what the same query with `GROUP BY k` now returns. The existing
`GROUPING_ID() = 1` golden lands below its window only because that condition
needs coercion and is deferred to this method. The migration note and the
config doc state the fix unconditionally, and no test covers this form. A
HAVING condition that reads a SELECT-list window alias also resolves on this
route and is evaluated after every window; that case is outside this gap.
**Recommended change:** In ResolveGroupingAnalytics, when
spark.sql.legacy.evaluateHavingAfterInnerWindow is false, the aggregate list
still contains window expressions, and the resolved HAVING condition reads no
window output, keep the resolved HAVING attached to the constructed Aggregate
as an unresolved HAVING, instead of turning it into a Filter. The nested-window
placement then extracts every window layer first and puts the Filter directly
on the Aggregate. A condition that reads a SELECT-list window alias keeps the
current Filter above every window. Add window-resolution SQL golden tests for
ROLLUP or GROUPING SETS with nested and single-level windows and a HAVING that
needs no coercion, plus one whose HAVING reads a window alias, under both flag
values, and update the migration note and the config doc to cover grouping
analytics.
**Why this works:** The condition is already resolved and remapped to the
constructed Aggregate's output, and that Aggregate's own expressions are
resolved. ExtractWindowExpressions' HAVING case therefore matches it in the
same analyzer batch, and the nested-window placement builds the window layers
over a Filter on the Aggregate. The existing grouping-function and aggregate
rewrites still see a Filter directly over an Aggregate. When extra aggregates
were pushed for the condition, the existing projection that drops them stays on
top. Because this rule resolves HAVING against the Aggregate output, which
includes the SELECT-list window aliases, the remapped condition's references
are checked against the outputs of the window-bearing aggregate expressions: a
condition that reads one keeps the current Filter, since the nested-window
placement would put it below the Window that produces that value and analysis
would fail with a missing attribute.
**Scope:** Route grouping-analytics HAVING that reads no window output
through the change's nested-window placement, add goldens for that route, and
align the documentation.
**Compatibility:** Grouping-analytics HAVING without window functions in the
SELECT list keeps its current plans. A grouping-analytics HAVING that reads a
window alias keeps its Filter above every window, and grouping-analytics
queries with a generator in the SELECT list never reach this rule, so both keep
their current plans.
**Risks:** Single-level window queries over ROLLUP, CUBE or GROUPING SETS
with a HAVING that needs no coercion also change plans and results. HAVING now
runs before their window, which corrects the base-branch behavior. The legacy
flag rolls this back, and the migration note should mention it.
**Constraints:** Gate the new routing on
spark.sql.legacy.evaluateHavingAfterInnerWindow, so that true keeps the current
placement on this route. Keep the HAVING condition resolved against the
constructed Aggregate, including the existing attribute remapping and any
aggregate expressions pushed for the condition. Do not defer a condition that
reads a window output; keep it as a Filter above every window, as today.
**Exclusions:**
- The single-pass resolver already declines HAVING with grouping analytics
and falls back to the fixed-point analyzer, and it stays unchanged.
- Whether a HAVING condition that reads a window alias should be rejected on
this route, as it already is for plain GROUP BY, is a separate decision that
this repair does not make.
**Success:** With the legacy flag false, ROLLUP, CUBE and GROUPING SETS
queries with window functions and a HAVING condition that resolves without
coercion and reads no window output evaluate HAVING below every window layer.
With the legacy flag true, those queries keep the base-branch plans and
results. A grouping-analytics HAVING condition that reads a SELECT-list window
alias keeps its current plan and result under both flag values. The migration
note and the config doc accurately describe grouping-analytics queries.
**Verification:**
- **Regression:** A ROLLUP or GROUPING SETS query with nested window
functions and a HAVING on a grouping key that needs no coercion places the
HAVING filter directly above the Aggregate and returns rank_sum 1 by default.
- **Compatibility:** The same query with
spark.sql.legacy.evaluateHavingAfterInnerWindow set to true keeps the
base-branch plan and result.
- **Behavior:** Grouping-analytics HAVING conditions that use grouping
functions, aggregates absent from the SELECT list, or a subquery still resolve
and keep the right groups when window functions are present.
- **Behavior:** A single-level window over ROLLUP, CUBE or GROUPING SETS
with a HAVING that needs no coercion evaluates HAVING before the window by
default.
- **Behavior:** A ROLLUP or GROUPING SETS query with window functions whose
HAVING reads a SELECT-list window alias without needing coercion keeps the
HAVING filter above every window and returns the same result as before the
change, under both flag values.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -3860,6 +3860,25 @@ class Analyzer(
Project(windowOps.output ++ newExpressionsWithWindowFunctions, windowOps)
} // end of addWindow
+ /**
+ * Extracts all nested window expressions and places the HAVING filter
below every Window.
Review Comment:
**Nit (P3):** With `LEGACY_EVALUATE_HAVING_AFTER_INNER_WINDOW` set to true,
the recursion guard below fails. The method then extracts only the outermost
layer and returns `addWindow` over `Filter(condition, Aggregate(...))`, whose
aggregate list still holds the inner window expressions. Those are later
extracted below the filter, as the new legacy golden shows (`Window [sum] +-
Filter (k = keep) +- Project +- Project +- Window [rank] +- Aggregate`). The
doc states the default-branch guarantee unconditionally; it could cover both
branches:
```suggestion
* Extracts all nested window expressions and places the HAVING filter
below every Window,
* unless LEGACY_EVALUATE_HAVING_AFTER_INNER_WINDOW is true: then only
the outermost layer is
* extracted here, and the inner windows are later extracted below the
HAVING filter.
```
**Verification:**
- **Inspection:** The addWindowWithHaving Scaladoc states that HAVING goes
below every nested window layer only when
spark.sql.legacy.evaluateHavingAfterInnerWindow is false, and that with true
only the outermost layer is extracted and HAVING can end up above the inner
windows.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -4935,19 +4949,47 @@ object ResolveUnresolvedHaving extends
Rule[LogicalPlan] {
*
* Returns None unless the condition can be evaluated below every Window in
the chain.
*/
- private def insertFilterBeforeWindow(filter: Filter): Option[LogicalPlan] =
filter.child match {
- case project: Project =>
- insertFilterBeforeWindow(filter.copy(child = project.child))
+ private def insertFilterBeforeWindow(filter: Filter): Option[LogicalPlan] = {
+ if
(SQLConf.get.getConf(SQLConf.LEGACY_EVALUATE_HAVING_AFTER_INNER_WINDOW)) {
+ insertFilterBeforeOuterWindow(filter)
+ } else {
+ insertFilterBeforeAllWindows(filter, foundWindow = false)
+ }
+ }
+
+ private def insertFilterBeforeAllWindows(
+ filter: Filter,
+ foundWindow: Boolean): Option[LogicalPlan] = filter.child match {
+ case project: Project if
filter.condition.references.subsetOf(project.child.outputSet) =>
+ insertFilterBeforeAllWindows(filter.copy(child = project.child),
foundWindow)
.map(child => project.withNewChildren(Seq(child)))
- case generate: Generate =>
- insertFilterBeforeWindow(filter.copy(child = generate.child))
+ case generate: Generate if
filter.condition.references.subsetOf(generate.child.outputSet) =>
+ insertFilterBeforeAllWindows(filter.copy(child = generate.child),
foundWindow)
.map(child => generate.withNewChildren(Seq(child)))
- case window: Window =>
- insertFilterBeforeWindowChain(filter, window)
+ case window: Window if
filter.condition.references.subsetOf(window.child.outputSet) =>
+ insertFilterBeforeAllWindows(filter.copy(child = window.child),
foundWindow = true)
+ .map(child => window.withNewChildren(Seq(child)))
+ case _ if foundWindow &&
filter.condition.references.subsetOf(filter.child.outputSet) =>
Review Comment:
**Blocking (P1):** Once `foundWindow` is true, this fallback accepts any
stop point, including a lower `Window` of the same chain whose child lacks the
condition's references. That breaks the all-or-none contract in the method doc
above ("Returns None unless the condition can be evaluated below every Window
in the chain").
It is reachable from SQL. In a generator query, HAVING is resolved against
the post-generator `Project`, so it can read a SELECT-list window alias:
```sql
SELECT explode(array(k)) AS g,
RANK() OVER (ORDER BY k) AS r,
COUNT(*) OVER () AS c
FROM VALUES ('a', 1), ('b', 2) AS t(k, v)
GROUP BY k
HAVING r = 1
```
`addWindow` stacks `Window[c]` over `Window[r]`. The walk passes
`Window[c]`, fails the guard at `Window[r]` (`r` is not in the Aggregate
output), and this case returns `Filter(r = 1, Window[r])`. `COUNT(*) OVER ()`
therefore counts only the surviving row, and the query returns `(a, 1, 1)`.
Before this change, and with the legacy flag set to true, the filter stays
above both windows and the query returns `(a, 1, 2)`. Listing `c` before `r`
brings back `c = 2`, so the value now depends on SELECT-list order. Neither the
migration note nor the config doc describes this, and no test covers it.
Suggested fix: return `None` whenever the walk stops at a `Window`, or at a
`Project` or `Generate` that still has a `Window` below it, so the filter is
inserted only once every Window of the chain is above the stop point.
This extends an [existing
discussion](https://github.com/apache/spark/pull/60090#pullrequestreview-5770227938).
**Verification:**
- **Regression:** A generator query whose HAVING references the alias of a
window computed below another window spec keeps the HAVING filter above both
windows and returns the base-branch values, and the same query with the two
window expressions listed in the opposite order returns the same values.
- **Behavior:** Generator queries with nested windows and HAVING on a
grouping key still place the HAVING filter directly above the Aggregate and
return rank_sum 1.
- **Compatibility:** With spark.sql.legacy.evaluateHavingAfterInnerWindow
set to true, generator-query plans and results are unchanged.
- **Inspection:** The default-path walk returns no placement whenever a
Window would remain below the inserted filter, matching the documented contract
that HAVING goes below every Window in the chain or not at all.
--
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]