This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 7b84e3fa54f [FLINK-40191][python] Add aggregation support to PyFlink
DataFrame API (#28937)
7b84e3fa54f is described below
commit 7b84e3fa54f08a9ad401231ab4bceb6e308ac44e
Author: Liu Liu <[email protected]>
AuthorDate: Fri Aug 14 14:28:10 2026 +0800
[FLINK-40191][python] Add aggregation support to PyFlink DataFrame API
(#28937)
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 13 ++
flink-python/pyflink/dataframe/__init__.py | 3 +-
flink-python/pyflink/dataframe/dataframe.py | 161 ++++++++++++++++++++-
.../pyflink/dataframe/tests/test_dataframe.py | 112 ++++++++++++++
4 files changed, 286 insertions(+), 3 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 2caa7503eba..19ebb0ad9c0 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -54,6 +54,19 @@ Transformations
DataFrame.filter
DataFrame.__getitem__
+Aggregations
+------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ DataFrame.group_by
+ DataFrame.agg
+ GroupedDataFrame
+ GroupedDataFrame.agg
+
Results
-------
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 8ad43bcbbe2..5412c50adf9 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -44,11 +44,12 @@ from pyflink.dataframe.context import (
get_table_environment,
set_table_environment,
)
-from pyflink.dataframe.dataframe import DataFrame, col, lit
+from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
from pyflink.dataframe.datatype import DataType
__all__ = [
"DataFrame",
+ "GroupedDataFrame",
"DataType",
"col",
"lit",
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 66c2020ee4d..4d386a1580c 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -16,7 +16,7 @@
# limitations under the License.
################################################################################
-from typing import Any, Callable, List, Optional, Tuple, Union, overload
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union, overload
from pyflink.common import Row
from pyflink.dataframe.datatype import DataType
@@ -31,7 +31,7 @@ from pyflink.table.table import Table
from pyflink.table.types import DataTypes as TableDataTypes
from pyflink.util.api_stability_decorators import PublicEvolving
-__all__ = ["DataFrame", "col", "lit"]
+__all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
@PublicEvolving()
@@ -116,6 +116,8 @@ class DataFrame:
def __init__(self, table: Table):
self._table = table
+ # ======================== Core Operations ========================
+
@PublicEvolving()
def filter(
self,
@@ -271,6 +273,87 @@ class DataFrame:
return DataFrame(self._table.select(*expressions))
+ # ======================== Aggregation ========================
+
+ @PublicEvolving()
+ def group_by(self, *columns: Union[str, Expression]) -> "GroupedDataFrame":
+ """
+ Group rows by one or more columns for aggregation.
+
+ String column names are converted to column expressions. Grouping keys
are retained in
+ their supplied order and are included first in the result of
+ :meth:`GroupedDataFrame.agg`.
+
+ :param columns: Column names or expressions used as grouping keys.
+ :return: A grouped DataFrame that can be aggregated.
+ :raises TypeError: If a grouping key is not a string or expression.
+ :raises ValueError: If no grouping keys are provided.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... ("engineering", 10),
+ ... ("engineering", 20),
+ ... ("sales", 5),
+ ... ], schema=["department", "amount"])
+ >>> totals = df.group_by("department").agg(
+ ... pf.col("amount").sum.alias("total_amount"),
+ ... row_count=pf.col("amount").count,
+ ... )
+ >>> # totals schema: [department: STRING, total_amount: BIGINT,
+ >>> # row_count: BIGINT NOT NULL]
+
+ .. versionadded:: 2.4.0
+ """
+ if not columns:
+ raise ValueError("group_by() requires at least one grouping key")
+
+ grouping_keys: List[Expression] = []
+ for column in columns:
+ if isinstance(column, str):
+ grouping_keys.append(table_col(column))
+ elif isinstance(column, Expression):
+ grouping_keys.append(column)
+ else:
+ raise TypeError(
+ "group_by() grouping keys must be strings or expressions"
+ )
+ return GroupedDataFrame(self, grouping_keys)
+
+ @PublicEvolving()
+ def agg(self, *aggs: Expression, **named_aggs: Expression) -> "DataFrame":
+ """
+ Aggregate all rows in this DataFrame.
+
+ Positional aggregation expressions are followed by named aggregations
in the result.
+ Each named aggregation is aliased to its keyword name.
+
+ :param aggs: Aggregation expressions.
+ :param named_aggs: Aggregation expressions keyed by their result
column names.
+ :return: A DataFrame containing the global aggregation results.
+ :raises TypeError: If an aggregation is not an expression.
+ :raises ValueError: If no aggregations are provided.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... (1, 10), (2, 20)
+ ... ], schema=["order_id", "amount"])
+ >>> summary = df.agg(
+ ... pf.col("order_id").count.alias("order_count"),
+ ... total_amount=pf.col("amount").sum,
+ ... )
+ >>> # summary schema: [order_count: BIGINT NOT NULL, total_amount:
BIGINT]
+
+ .. versionadded:: 2.4.0
+ """
+ aggregations = _normalize_aggregations(aggs, named_aggs)
+ return DataFrame(self._table.group_by().select(*aggregations))
+
+ # ======================== Special Methods ========================
+
@overload
def __getitem__(self, key: str) -> Expression:
...
@@ -329,6 +412,8 @@ class DataFrame:
return self.filter(key)
raise TypeError("key must be a string, list, tuple, or Expression")
+ # ======================== Conversion ========================
+
@PublicEvolving()
def collect(self) -> List[Row]:
"""
@@ -348,3 +433,75 @@ class DataFrame:
"""
with self._table.execute().collect() as rows:
return list(rows)
+
+
+@PublicEvolving()
+class GroupedDataFrame:
+ """
+ A DataFrame grouped by one or more keys and ready for aggregation.
+
+ Instances are created by :meth:`DataFrame.group_by`.
+
+ .. versionadded:: 2.4.0
+ """
+
+ def __init__(self, dataframe: DataFrame, grouping_keys: List[Expression]):
+ self._dataframe = dataframe
+ self._grouping_keys = grouping_keys
+
+ @PublicEvolving()
+ def agg(self, *aggs: Expression, **named_aggs: Expression) -> DataFrame:
+ """
+ Aggregate the rows in each group.
+
+ Grouping keys are included first in their supplied order, followed by
positional
+ aggregation expressions and then named aggregations. Each named
aggregation is aliased to
+ its keyword name.
+
+ :param aggs: Aggregation expressions.
+ :param named_aggs: Aggregation expressions keyed by their result
column names.
+ :return: A DataFrame containing the grouping keys and aggregation
results.
+ :raises TypeError: If an aggregation is not an expression.
+ :raises ValueError: If no aggregations are provided.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... ("engineering", 10),
+ ... ("engineering", 20),
+ ... ("sales", 5),
+ ... ], schema=["department", "amount"])
+ >>> totals = df.group_by("department").agg(
+ ... pf.col("amount").sum.alias("total_amount"),
+ ... row_count=pf.col("amount").count,
+ ... )
+ >>> # totals schema: [department: STRING, total_amount: BIGINT,
+ >>> # row_count: BIGINT NOT NULL]
+
+ .. versionadded:: 2.4.0
+ """
+ aggregations = _normalize_aggregations(aggs, named_aggs)
+ grouped_table = self._dataframe._table.group_by(*self._grouping_keys)
+ return DataFrame(grouped_table.select(*self._grouping_keys,
*aggregations))
+
+
+# ======================== Internal Helpers ========================
+
+
+def _normalize_aggregations(
+ aggs: Tuple[Expression, ...], named_aggs: Dict[str, Expression]
+) -> List[Expression]:
+ if not aggs and not named_aggs:
+ raise ValueError("agg() requires at least one aggregation")
+
+ aggregations: List[Expression] = []
+ for aggregation in aggs:
+ if not isinstance(aggregation, Expression):
+ raise TypeError("agg() aggregations must be expressions")
+ aggregations.append(aggregation)
+ for name, aggregation in named_aggs.items():
+ if not isinstance(aggregation, Expression):
+ raise TypeError("agg() aggregations must be expressions")
+ aggregations.append(aggregation.alias(name))
+ return aggregations
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index 98714e9fbb6..8f5b7620cab 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -468,6 +468,98 @@ class DataFrameLiteralTests(PyFlinkDataFrameUTTestCase):
pf.lit(1, object())
+class DataFrameAggregationTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [
+ ("engineering", "east", 10),
+ ("engineering", "west", 20),
+ ("sales", "east", 5),
+ ],
+ schema=["department", "region", "amount"],
+ )
+
+ def test_global_aggregation_preserves_positional_and_named_order(self):
+ result = self.dataframe.agg(
+ pf.col("amount").sum.alias("total_amount"),
+ row_count=pf.col("amount").count,
+ )
+
+ self.assert_dataframe_schema(
+ result,
+ ["total_amount", "row_count"],
+ [TableDataTypes.BIGINT(), TableDataTypes.BIGINT().not_null()],
+ )
+
+ def test_grouped_aggregation_emits_string_and_expression_keys_first(self):
+ grouped = self.dataframe.group_by("department", pf.col("region"))
+
+ self.assertIsInstance(grouped, pf.GroupedDataFrame)
+ result = grouped.agg(
+ pf.col("amount").sum.alias("total_amount"),
+ row_count=pf.col("amount").count,
+ )
+
+ self.assert_dataframe_schema(
+ result,
+ ["department", "region", "total_amount", "row_count"],
+ [
+ TableDataTypes.STRING(),
+ TableDataTypes.STRING(),
+ TableDataTypes.BIGINT(),
+ TableDataTypes.BIGINT().not_null(),
+ ],
+ )
+
+ def test_group_by_requires_grouping_key(self):
+ with self.assertRaisesRegex(ValueError, "requires at least one
grouping key"):
+ self.dataframe.group_by()
+
+ def test_group_by_rejects_unsupported_key_type(self):
+ with self.assertRaisesRegex(
+ TypeError, "grouping keys must be strings or expressions"
+ ):
+ self.dataframe.group_by(42)
+
+ def test_global_aggregation_requires_aggregation(self):
+ with self.assertRaisesRegex(ValueError, "requires at least one
aggregation"):
+ self.dataframe.agg()
+
+ def test_global_aggregation_rejects_unsupported_positional_type(self):
+ with self.assertRaisesRegex(TypeError, "aggregations must be
expressions"):
+ self.dataframe.agg(42)
+
+ def test_global_aggregation_rejects_unsupported_named_type(self):
+ with self.assertRaisesRegex(TypeError, "aggregations must be
expressions"):
+ self.dataframe.agg(total=42)
+
+ def test_grouped_aggregation_requires_aggregation(self):
+ grouped = self.dataframe.group_by("department")
+ with self.assertRaisesRegex(ValueError, "requires at least one
aggregation"):
+ grouped.agg()
+
+ def test_grouped_aggregation_rejects_unsupported_positional_type(self):
+ grouped = self.dataframe.group_by("department")
+ with self.assertRaisesRegex(TypeError, "aggregations must be
expressions"):
+ grouped.agg(42)
+
+ def test_grouped_aggregation_rejects_unsupported_named_type(self):
+ grouped = self.dataframe.group_by("department")
+ with self.assertRaisesRegex(TypeError, "aggregations must be
expressions"):
+ grouped.agg(total=42)
+
+ def test_global_aggregation_delegates_expression_legality_to_planner(self):
+ with self.assertRaisesRegex(Py4JJavaError, "ValidationException"):
+ self.dataframe.agg(pf.col("amount"))
+
+ def test_grouped_aggregation_delegates_ambiguous_output_to_planner(self):
+ with self.assertRaisesRegex(Py4JJavaError, "ValidationException"):
+ self.dataframe.group_by("department").agg(
+ department=pf.col("amount").sum
+ )
+
+
class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
def test_from_records(self):
dataframe = pf.from_records(
@@ -570,6 +662,26 @@ class DataFrameBatchITTests(PyFlinkITTestCase):
self.assertEqual(result.collect(), [Row(2, "Bob")])
+ def test_grouped_aggregation_with_batch_table_environment(self):
+ pf.set_table_environment(self.t_env)
+
+ result = pf.from_records(
+ [
+ ("engineering", 10),
+ ("engineering", 20),
+ ("sales", 5),
+ ],
+ schema=["department", "amount"],
+ ).group_by("department").agg(
+ total_amount=pf.col("amount").sum,
+ row_count=pf.col("amount").count,
+ )
+
+ self.assertCountEqual(
+ result.collect(),
+ [Row("engineering", 30, 2), Row("sales", 5, 1)],
+ )
+
if __name__ == "__main__":
unittest.main()