Skip to content

Commit 7c853e1

Browse files
authored
SNOW-3266495: Restore SQL generation fix for HAVING with LIMIT/ORDER BY (#4163)
1 parent 78cbed8 commit 7c853e1

3 files changed

Lines changed: 350 additions & 54 deletions

File tree

src/snowflake/snowpark/dataframe.py

Lines changed: 124 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import sys
1212
from collections import Counter
1313
from decimal import Decimal
14-
from functools import cached_property
14+
from functools import cached_property, reduce
1515
from logging import getLogger
1616
from types import ModuleType
1717
from typing import (
@@ -54,6 +54,9 @@
5454
create_join_type,
5555
)
5656
from snowflake.snowpark._internal.analyzer.analyzer_utils import unquote_if_quoted
57+
from snowflake.snowpark._internal.analyzer.binary_expression import (
58+
And,
59+
)
5760
from snowflake.snowpark._internal.analyzer.expression import (
5861
Attribute,
5962
Expression,
@@ -689,7 +692,14 @@ def __init__(
689692

690693
self._statement_params = None
691694
self.is_cached: bool = is_cached #: Whether the dataframe is cached.
695+
# Internal state variables used to construct flattened GROUP BY clauses in the correct order
696+
# in SCOS compatibility mode.
697+
# See comments on `_build_post_agg_df` for details.
692698
self._ops_after_agg = None
699+
self._agg_base_plan = None
700+
self._agg_base_select_statement = None
701+
self._pending_havings = []
702+
self._pending_order_bys = []
693703

694704
# Whether all columns are VARIANT data type,
695705
# which support querying nested fields via dot notations
@@ -2115,27 +2125,24 @@ def filter(
21152125

21162126
# In snowpark_connect_compatible mode, we need to handle
21172127
# the filtering for dataframe after aggregation without nesting using HAVING.
2128+
# We defer the HAVING expression and rebuild the plan from the
2129+
# aggregate base so that SQL clauses are emitted in the correct order
2130+
# (HAVING -> ORDER BY -> LIMIT) regardless of the user's call order.
2131+
# If there is a LIMIT earlier in the expression tree, then we must produce a new
2132+
# sub-query from this filter to ensure correctness.
21182133
if (
21192134
context._is_snowpark_connect_compatible_mode
21202135
and self._ops_after_agg is not None
2121-
and "filter" not in self._ops_after_agg
2136+
and "limit" not in self._ops_after_agg
21222137
):
2123-
having_plan = Filter(filter_col_expr, self._plan, is_having=True)
2124-
if self._select_statement:
2125-
df = self._with_plan(
2126-
self._session._analyzer.create_select_statement(
2127-
from_=self._session._analyzer.create_select_snowflake_plan(
2128-
having_plan, analyzer=self._session._analyzer
2129-
),
2130-
analyzer=self._session._analyzer,
2131-
),
2132-
_ast_stmt=stmt,
2133-
)
2134-
else:
2135-
df = self._with_plan(having_plan, _ast_stmt=stmt)
2136-
df._ops_after_agg = self._ops_after_agg.copy()
2137-
df._ops_after_agg.add("filter")
2138-
return df
2138+
new_ops = self._ops_after_agg.copy()
2139+
new_ops.add("filter")
2140+
return self._build_post_agg_df(
2141+
ops_after_agg=new_ops,
2142+
pending_havings=self._pending_havings + [filter_col_expr],
2143+
pending_order_bys=self._pending_order_bys,
2144+
_ast_stmt=stmt,
2145+
)
21392146
else:
21402147
if self._select_statement:
21412148
return self._with_plan(
@@ -2331,27 +2338,24 @@ def sort(
23312338

23322339
# In snowpark_connect_compatible mode, we need to handle
23332340
# the sorting for dataframe after aggregation without nesting.
2341+
# We defer the ORDER BY expressions and rebuild the plan from
2342+
# the aggregate base in correct SQL clause order.
2343+
# If there is a LIMIT earlier in the expression tree, then we must produce a new
2344+
# sub-query from this filter to ensure correctness.
23342345
if (
23352346
context._is_snowpark_connect_compatible_mode
23362347
and self._ops_after_agg is not None
2337-
and "sort" not in self._ops_after_agg
2348+
and "limit" not in self._ops_after_agg
23382349
):
2339-
sort_plan = Sort(sort_exprs, self._plan, is_order_by_append=True)
2340-
if self._select_statement:
2341-
df = self._with_plan(
2342-
self._session._analyzer.create_select_statement(
2343-
from_=self._session._analyzer.create_select_snowflake_plan(
2344-
sort_plan, analyzer=self._session._analyzer
2345-
),
2346-
analyzer=self._session._analyzer,
2347-
),
2348-
_ast_stmt=stmt,
2349-
)
2350-
else:
2351-
df = self._with_plan(sort_plan, _ast_stmt=stmt)
2352-
df._ops_after_agg = self._ops_after_agg.copy()
2353-
df._ops_after_agg.add("sort")
2354-
return df
2350+
new_ops = self._ops_after_agg.copy()
2351+
new_ops.add("sort")
2352+
return self._build_post_agg_df(
2353+
ops_after_agg=new_ops,
2354+
pending_havings=self._pending_havings,
2355+
# New ordering clauses must be placed before previously-declared ones
2356+
pending_order_bys=sort_exprs + self._pending_order_bys,
2357+
_ast_stmt=stmt,
2358+
)
23552359
else:
23562360
df = (
23572361
self._with_plan(self._select_statement.sort(sort_exprs))
@@ -3063,24 +3067,15 @@ def limit(
30633067
and self._ops_after_agg is not None
30643068
and "limit" not in self._ops_after_agg
30653069
):
3066-
limit_plan = Limit(
3067-
Literal(n), Literal(offset), self._plan, is_limit_append=True
3070+
new_ops = self._ops_after_agg.copy()
3071+
new_ops.add("limit")
3072+
return self._build_post_agg_df(
3073+
ops_after_agg=new_ops,
3074+
pending_havings=self._pending_havings,
3075+
pending_order_bys=self._pending_order_bys,
3076+
limit_parameters=(n, offset),
3077+
_ast_stmt=stmt,
30683078
)
3069-
if self._select_statement:
3070-
df = self._with_plan(
3071-
self._session._analyzer.create_select_statement(
3072-
from_=self._session._analyzer.create_select_snowflake_plan(
3073-
limit_plan, analyzer=self._session._analyzer
3074-
),
3075-
analyzer=self._session._analyzer,
3076-
),
3077-
_ast_stmt=stmt,
3078-
)
3079-
else:
3080-
df = self._with_plan(limit_plan, _ast_stmt=stmt)
3081-
df._ops_after_agg = self._ops_after_agg.copy()
3082-
df._ops_after_agg.add("limit")
3083-
return df
30843079
else:
30853080
if self._select_statement:
30863081
return self._with_plan(
@@ -6835,6 +6830,83 @@ def dtypes(self) -> List[Tuple[str, str]]:
68356830
]
68366831
return dtypes
68376832

6833+
def _build_post_agg_df(
6834+
self,
6835+
ops_after_agg: set[str],
6836+
pending_havings: list[Expression],
6837+
pending_order_bys: list[Expression],
6838+
limit_parameters: Optional[tuple[int, int]] = None,
6839+
_ast_stmt=None,
6840+
) -> "DataFrame":
6841+
"""
6842+
When constructing group by aggregation queries in SCOS compatibility mode, we must ensure that
6843+
filter (HAVING), sorting (ORDER BY), and LIMIT clauses are emitted in the correct order, regardless of
6844+
the order in which the user specified those operations. For example:
6845+
6846+
df.groupBy("dept").agg(
6847+
count("*").alias("headcount"),
6848+
avg("salary").alias("avg_salary"),
6849+
)
6850+
.orderBy(col("avg_salary").desc())
6851+
.filter(col("headcount") > 1)
6852+
.limit(2)
6853+
6854+
Even though `orderBy` is the first operation, we must re-order the `filter` to be first because
6855+
SQL syntax requires HAVING, ORDER BY, and LIMIT clauses to appear in that specific order.
6856+
We use `_agg_base_plan` and `_agg_base_select_statement` to re-construct SQL with this constraint.
6857+
6858+
Note that LIMIT itself does not commute with ORDER BY and FILTER, so if another FILTER or
6859+
ORDER BY appears after a LIMIT, we must generate a new sub-query. This invariant is enforced
6860+
when chaining new filter/order by operations.
6861+
6862+
This method should only be called in SCOS compatibility mode (context._is_snowpark_connect_compatible_mode).
6863+
"""
6864+
current = self._agg_base_plan
6865+
# We must call `resolve`` on each expression node to ensure compatibility with CTE optimization, which assumes
6866+
# that a LogicalPlan's child node is always a SnowflakePlan. While this introduces some overhead, it does
6867+
# not affect the correctness of the output value.
6868+
# Skipping the `resolve`` call and instead including raw Expression nodes will cause an error in error handling in
6869+
# `wrap_exception`, which assumes all children are either instances of Selectable or SnowflakePlan.
6870+
# See test_df_aggregate.py::test_group_by_sort_by_nonexistent for an example that triggered this failure.
6871+
resolve = self._session._analyzer.resolve
6872+
6873+
if len(pending_havings) > 0:
6874+
current = resolve(
6875+
Filter(
6876+
reduce(
6877+
lambda acc, expr: And(acc, expr),
6878+
pending_havings,
6879+
),
6880+
current,
6881+
is_having=True,
6882+
)
6883+
)
6884+
if len(pending_order_bys) > 0:
6885+
current = resolve(Sort(pending_order_bys, current, is_order_by_append=True))
6886+
if limit_parameters is not None:
6887+
n, offset = limit_parameters
6888+
current = resolve(
6889+
Limit(Literal(n), Literal(offset), current, is_limit_append=True)
6890+
)
6891+
6892+
if self._agg_base_select_statement is not None:
6893+
new_plan = self._session._analyzer.create_select_statement(
6894+
from_=self._session._analyzer.create_select_snowflake_plan(
6895+
current, analyzer=self._session._analyzer
6896+
),
6897+
analyzer=self._session._analyzer,
6898+
)
6899+
else:
6900+
new_plan = current
6901+
6902+
df = self._with_plan(new_plan, _ast_stmt=_ast_stmt)
6903+
df._ops_after_agg = ops_after_agg
6904+
df._agg_base_plan = self._agg_base_plan
6905+
df._agg_base_select_statement = self._agg_base_select_statement
6906+
df._pending_havings = pending_havings
6907+
df._pending_order_bys = pending_order_bys
6908+
return df
6909+
68386910
def _with_plan(self, plan, _ast_stmt=None) -> "DataFrame":
68396911
"""
68406912
:param proto.Bind ast_stmt: The AST statement protobuf corresponding to this value.

src/snowflake/snowpark/relational_grouped_dataframe.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ def agg(
338338
# if no grouping exprs, there is already a LIMIT 1 in the query
339339
# see aggregate_statement in analyzer_utils.py
340340
df._ops_after_agg = set() if self._grouping_exprs else {"limit"}
341+
df._agg_base_plan = df._plan
342+
df._agg_base_select_statement = df._select_statement
341343

342344
if _emit_ast:
343345
df._ast_id = stmt.uid
@@ -531,6 +533,8 @@ def end_partition(
531533
# if no grouping exprs, there is already a LIMIT 1 in the query
532534
# see aggregate_statement in analyzer_utils.py
533535
df._ops_after_agg = set() if self._grouping_exprs else {"limit"}
536+
df._agg_base_plan = df._plan
537+
df._agg_base_select_statement = df._select_statement
534538

535539
if _emit_ast:
536540
stmt = working_dataframe._session._ast_batch.bind()
@@ -766,6 +770,8 @@ def count(self, _emit_ast: bool = True, **kwargs) -> DataFrame:
766770
# if no grouping exprs, there is already a LIMIT 1 in the query
767771
# see aggregate_statement in analyzer_utils.py
768772
df._ops_after_agg = set() if self._grouping_exprs else {"limit"}
773+
df._agg_base_plan = df._plan
774+
df._agg_base_select_statement = df._select_statement
769775

770776
# TODO: count seems similar to mean, min, .... Can we unify implementation here?
771777
if _emit_ast:
@@ -815,6 +821,8 @@ def _function(
815821
# if no grouping exprs, there is already a LIMIT 1 in the query
816822
# see aggregate_statement in analyzer_utils.py
817823
df._ops_after_agg = set() if self._grouping_exprs else {"limit"}
824+
df._agg_base_plan = df._plan
825+
df._agg_base_select_statement = df._select_statement
818826

819827
if _emit_ast:
820828
stmt = self._dataframe._session._ast_batch.bind()
@@ -909,6 +917,8 @@ def ai_agg(
909917
# if no grouping exprs, there is already a LIMIT 1 in the query
910918
# see aggregate_statement in analyzer_utils.py
911919
df._ops_after_agg = set() if self._grouping_exprs else {"limit"}
920+
df._agg_base_plan = df._plan
921+
df._agg_base_select_statement = df._select_statement
912922

913923
if _emit_ast:
914924
stmt = self._dataframe._session._ast_batch.bind()

0 commit comments

Comments
 (0)