|
11 | 11 | import sys |
12 | 12 | from collections import Counter |
13 | 13 | from decimal import Decimal |
14 | | -from functools import cached_property |
| 14 | +from functools import cached_property, reduce |
15 | 15 | from logging import getLogger |
16 | 16 | from types import ModuleType |
17 | 17 | from typing import ( |
|
54 | 54 | create_join_type, |
55 | 55 | ) |
56 | 56 | from snowflake.snowpark._internal.analyzer.analyzer_utils import unquote_if_quoted |
| 57 | +from snowflake.snowpark._internal.analyzer.binary_expression import ( |
| 58 | + And, |
| 59 | +) |
57 | 60 | from snowflake.snowpark._internal.analyzer.expression import ( |
58 | 61 | Attribute, |
59 | 62 | Expression, |
@@ -689,7 +692,14 @@ def __init__( |
689 | 692 |
|
690 | 693 | self._statement_params = None |
691 | 694 | 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. |
692 | 698 | 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 = [] |
693 | 703 |
|
694 | 704 | # Whether all columns are VARIANT data type, |
695 | 705 | # which support querying nested fields via dot notations |
@@ -2115,27 +2125,24 @@ def filter( |
2115 | 2125 |
|
2116 | 2126 | # In snowpark_connect_compatible mode, we need to handle |
2117 | 2127 | # 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. |
2118 | 2133 | if ( |
2119 | 2134 | context._is_snowpark_connect_compatible_mode |
2120 | 2135 | 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 |
2122 | 2137 | ): |
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 | + ) |
2139 | 2146 | else: |
2140 | 2147 | if self._select_statement: |
2141 | 2148 | return self._with_plan( |
@@ -2331,27 +2338,24 @@ def sort( |
2331 | 2338 |
|
2332 | 2339 | # In snowpark_connect_compatible mode, we need to handle |
2333 | 2340 | # 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. |
2334 | 2345 | if ( |
2335 | 2346 | context._is_snowpark_connect_compatible_mode |
2336 | 2347 | 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 |
2338 | 2349 | ): |
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 | + ) |
2355 | 2359 | else: |
2356 | 2360 | df = ( |
2357 | 2361 | self._with_plan(self._select_statement.sort(sort_exprs)) |
@@ -3063,24 +3067,15 @@ def limit( |
3063 | 3067 | and self._ops_after_agg is not None |
3064 | 3068 | and "limit" not in self._ops_after_agg |
3065 | 3069 | ): |
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, |
3068 | 3078 | ) |
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 |
3084 | 3079 | else: |
3085 | 3080 | if self._select_statement: |
3086 | 3081 | return self._with_plan( |
@@ -6835,6 +6830,83 @@ def dtypes(self) -> List[Tuple[str, str]]: |
6835 | 6830 | ] |
6836 | 6831 | return dtypes |
6837 | 6832 |
|
| 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 | + |
6838 | 6910 | def _with_plan(self, plan, _ast_stmt=None) -> "DataFrame": |
6839 | 6911 | """ |
6840 | 6912 | :param proto.Bind ast_stmt: The AST statement protobuf corresponding to this value. |
|
0 commit comments