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 3f4e1a3f43f [FLINK-40436][python] Add UDAF support to the DataFrame 
API (#29431)
3f4e1a3f43f is described below

commit 3f4e1a3f43f762ddf9bb7ad7ff2abef850798e74
Author: Victor Wang <[email protected]>
AuthorDate: Fri Oct 16 13:16:31 2026 +0800

    [FLINK-40436][python] Add UDAF support to the DataFrame API (#29431)
---
 .../docs/reference/pyflink.dataframe/udf.rst       |  11 +
 flink-python/pyflink/dataframe/__init__.py         |   2 +
 flink-python/pyflink/dataframe/sql.py              |  15 +-
 flink-python/pyflink/dataframe/tests/test_udaf.py  | 473 +++++++++++++++++++++
 flink-python/pyflink/dataframe/udaf.py             | 318 ++++++++++++++
 flink-python/pyflink/dataframe/udf.py              |  14 +-
 .../pyflink/fn_execution/tests/test_coders.py      |  26 ++
 .../pyflink/table/tests/test_pandas_udaf.py        |  19 +
 flink-python/pyflink/table/utils.py                |   7 +-
 9 files changed, 870 insertions(+), 15 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/udf.rst 
b/flink-python/docs/reference/pyflink.dataframe/udf.rst
index 798297eea33..7618b887cb7 100644
--- a/flink-python/docs/reference/pyflink.dataframe/udf.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/udf.rst
@@ -45,6 +45,16 @@ DataFrame table UDFs support synchronous functions, callable 
classes and instanc
 declaration forms, type inference, lifecycle, and examples, and
 :meth:`~pyflink.dataframe.DataFrame.flat_map` for row input and output 
semantics.
 
+Aggregate Functions
+===================
+
+Use :func:`pyflink.dataframe.udaf` to declare an aggregate for
+:meth:`~pyflink.dataframe.DataFrame.agg`,
+:meth:`~pyflink.dataframe.GroupedDataFrame.agg`, or 
:func:`pyflink.dataframe.sql`.
+General UDAFs use the Table API's ``AggregateFunction`` accumulator protocol.
+Pandas UDAFs also accept ordinary functions and callable classes or instances.
+See :func:`pyflink.dataframe.udaf` for type declarations, execution modes, and 
examples.
+
 API Reference
 =============
 
@@ -55,3 +65,4 @@ API Reference
 
     udf
     udtf
+    udaf
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
index 5c0b3af3941..561f2ea318f 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -86,6 +86,7 @@ from pyflink.dataframe.over_window import following, preceding
 from pyflink.dataframe.sql import sql
 from pyflink.dataframe.udf import udf
 from pyflink.dataframe.udtf import udtf
+from pyflink.dataframe.udaf import udaf
 
 __all__ = [
     "CloseableIterator",
@@ -103,6 +104,7 @@ __all__ = [
     "lit",
     "udf",
     "udtf",
+    "udaf",
     "from_arrow",
     "from_dict",
     "from_pandas",
diff --git a/flink-python/pyflink/dataframe/sql.py 
b/flink-python/pyflink/dataframe/sql.py
index 9a9e11e2134..46d66cc60de 100644
--- a/flink-python/pyflink/dataframe/sql.py
+++ b/flink-python/pyflink/dataframe/sql.py
@@ -27,6 +27,7 @@ from pyflink.dataframe.context import 
get_or_create_table_environment
 from pyflink.dataframe.dataframe import DataFrame
 from pyflink.dataframe.udf import _DataFrameUDFWrapper
 from pyflink.dataframe.udtf import _DataFrameUDTFWrapper
+from pyflink.dataframe.udaf import _DataFrameUDAFWrapper
 from pyflink.java_gateway import get_gateway
 from pyflink.table import Table, TableEnvironment
 from pyflink.table.expression import Expression
@@ -41,9 +42,9 @@ _LOG = logging.getLogger(__name__)
 # Scalar UDFs use the declared return type of :func:`pyflink.dataframe.udf`
 # so that its result type-checks as a binding; at runtime a binding must be an 
actual
 # UDF object, which is what _BINDABLE_TYPES enforces.
-_FunctionBinding = Union[_DataFrameUDFWrapper, _DataFrameUDTFWrapper]
+_FunctionBinding = Union[_DataFrameUDFWrapper, _DataFrameUDTFWrapper, 
_DataFrameUDAFWrapper]
 _Binding = Union[DataFrame, Callable[..., Expression], _DataFrameUDTFWrapper]
-_FUNCTION_TYPES = (_DataFrameUDFWrapper, _DataFrameUDTFWrapper)
+_FUNCTION_TYPES = (_DataFrameUDFWrapper, _DataFrameUDTFWrapper, 
_DataFrameUDAFWrapper)
 _BINDABLE_TYPES = (DataFrame, *_FUNCTION_TYPES)
 
 
@@ -55,7 +56,8 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
     The query must be a single statement that returns a result, such as SELECT 
or
     VALUES (no INSERT / DDL; use :meth:`TableEnvironment.execute_sql` for 
those).
     The referenced DataFrames are registered as temporary views and the 
referenced
-    UDFs (created with :func:`~pyflink.dataframe.udf` or 
:func:`~pyflink.dataframe.udtf`)
+    UDFs (created with :func:`~pyflink.dataframe.udf`, 
:func:`~pyflink.dataframe.udtf`,
+    or :func:`~pyflink.dataframe.udaf`)
     as temporary system functions for the duration of the call, and both are 
dropped
     afterwards. The result can be further transformed with the DataFrame API.
 
@@ -95,8 +97,8 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
                         auto-bound candidates belong to different 
TableEnvironments
                         when there are no explicit bindings.
     :raises TypeError: If an explicit binding is neither a :class:`DataFrame` 
nor a
-                       UDF created with :func:`~pyflink.dataframe.udf` or
-                       :func:`~pyflink.dataframe.udtf`.
+                       UDF created with :func:`~pyflink.dataframe.udf`,
+                       :func:`~pyflink.dataframe.udtf`, or 
:func:`~pyflink.dataframe.udaf`.
 
     Example::
 
@@ -148,7 +150,8 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
         if not isinstance(value, _BINDABLE_TYPES):
             raise TypeError(
                 f"sql() binding '{name}' must be a DataFrame or a UDF created 
with "
-                f"pyflink.dataframe.udf or pyflink.dataframe.udtf, got 
{type(value).__name__}"
+                "pyflink.dataframe.udf, pyflink.dataframe.udtf or 
pyflink.dataframe.udaf, "
+                f"got {type(value).__name__}"
             )
     explicit_frames = _get_dataframes(bindings)
     explicit_udfs = _get_udfs(bindings)
diff --git a/flink-python/pyflink/dataframe/tests/test_udaf.py 
b/flink-python/pyflink/dataframe/tests/test_udaf.py
new file mode 100644
index 00000000000..86f51b65571
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_udaf.py
@@ -0,0 +1,473 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+import datetime
+import functools
+import os
+import unittest
+from typing import TypedDict
+
+import cloudpickle
+import pandas as pd
+import pyflink.dataframe as pf
+from pyflink.common import Row, Types
+from pyflink.table import DataTypes, ListView, MapView, Schema
+from pyflink.table.expressions import col, lit
+from pyflink.table.udf import AggregateFunction, ScalarFunction, udaf as 
table_udaf
+from pyflink.table.window import Session
+from pyflink.testing.test_case_utils import (
+    PyFlinkBatchTableTestCase, PyFlinkDataFrameUTTestCase, 
PyFlinkStreamDataFrameTestCase,
+)
+from pyflink.util.exceptions import TableException
+
+
+class Sum(AggregateFunction):
+    def create_accumulator(self):
+        return [0]
+
+    def accumulate(self, accumulator, value):
+        if value is not None:
+            accumulator[0] += value
+
+    def retract(self, accumulator, value):
+        if value is not None:
+            accumulator[0] -= value
+
+    def merge(self, accumulator, accumulators):
+        for other in accumulators:
+            accumulator[0] += other[0]
+
+    def get_value(self, accumulator) -> int:
+        return accumulator[0]
+
+    def get_accumulator_type(self):
+        return DataTypes.ARRAY(DataTypes.BIGINT())
+
+
+class Statistics(TypedDict):
+    total: int
+    count: int
+
+
+def _aggregate(declaration, *columns):
+    adapter = 
cloudpickle.loads(cloudpickle.dumps(declaration._table_udf_wrapper._func))
+    adapter.open(None)
+    try:
+        accumulator = adapter.create_accumulator()
+        adapter.accumulate(accumulator, *columns)
+        return adapter.get_value(accumulator)
+    finally:
+        adapter.close()
+
+
+class DataFrameUDAFDeclarationTests(unittest.TestCase):
+    def test_declaration_forms(self):
+        for declaration in (pf.udaf(Sum), pf.udaf()(Sum), 
pf.udaf(return_dtype=int)(Sum),
+                            pf.udaf(Sum())):
+            self.assertEqual(declaration.return_dtype, 
pf.DataType.int64().not_null())
+            self.assertEqual(declaration.__name__, "Sum")
+
+    def test_general_requires_aggregate_function(self):
+        class CallableSum:
+            def __call__(self, values) -> int:
+                return sum(values)
+
+        for func in (sum, CallableSum, CallableSum()):
+            with self.subTest(func=func), self.assertRaisesRegex(TypeError, 
"AggregateFunction"):
+                pf.udaf(func, return_dtype=int, accumulator_type=list[int])
+
+    def test_type_precedence(self):
+        class TypedSum(Sum):
+            def get_result_type(self):
+                return DataTypes.INT()
+
+        declaration = pf.udaf(TypedSum)
+        self.assertEqual(declaration.return_dtype, pf.DataType.int32())
+        self.assertEqual(declaration.accumulator_type, 
pf.DataType.list(pf.DataType.int64()))
+        declaration = pf.udaf(TypedSum, return_dtype=float, 
accumulator_type=list[float])
+        self.assertEqual(declaration.return_dtype, 
pf.DataType.float64().not_null())
+        self.assertEqual(declaration.accumulator_type,
+                         
pf.DataType.list(pf.DataType.float64().not_null()).not_null())
+
+        class ExplicitSum(Sum):
+            def get_result_type(self):
+                raise AssertionError("Explicit return_dtype must take 
precedence")
+
+            def get_accumulator_type(self):
+                raise AssertionError("Explicit accumulator_type must take 
precedence")
+
+        self.assertEqual(pf.udaf(ExplicitSum, return_dtype=int,
+                                 accumulator_type=list[int]).return_dtype,
+                         pf.DataType.int64().not_null())
+
+    def test_return_annotation_is_resolved_independently(self):
+        def total(values):
+            return values.sum()
+
+        total.__annotations__ = {"values": "UnknownInput", "return": int}
+        self.assertEqual(pf.udaf(total, func_type="pandas").return_dtype,
+                         pf.DataType.int64().not_null())
+        total.__annotations__["return"] = "UnknownOutput"
+        self.assertEqual(pf.udaf(total, return_dtype=int, 
func_type="pandas").return_dtype,
+                         pf.DataType.int64().not_null())
+
+    def test_missing_and_invalid_types(self):
+        class UntypedSum(Sum):
+            get_accumulator_type = AggregateFunction.get_accumulator_type
+
+            def create_accumulator(self) -> list[int]:
+                raise AssertionError("Type resolution must not execute 
create_accumulator")
+
+        with self.assertRaisesRegex(TypeError, "accumulator_type"):
+            pf.udaf(UntypedSum)
+        with self.assertRaisesRegex(TypeError, "return_dtype"):
+            pf.udaf(lambda values: values.sum(), func_type="pandas")
+        with self.assertRaisesRegex(TypeError, "MAP"):
+            pf.udaf(lambda values: {}, return_dtype=dict[str, int], 
func_type="pandas")
+        with self.assertRaises(TypeError):
+            pf.udaf(Sum, return_dtype=object())
+
+        class InvalidType(Sum):
+            def get_result_type(self):
+                return int
+
+        with self.assertRaisesRegex(TypeError, "get_result_type"):
+            pf.udaf(InvalidType)
+
+    def test_invalid_functions_and_async_methods(self):
+        class Scalar(ScalarFunction):
+            def eval(self, value):
+                return value
+
+        class NeedsArgument(Sum):
+            def __init__(self, argument):
+                self.argument = argument
+
+        for func in (42, Scalar, Scalar(), NeedsArgument):
+            with self.subTest(func=func), self.assertRaises(TypeError):
+                pf.udaf(func, return_dtype=int, func_type="pandas")
+        with self.assertRaises(ValueError):
+            pf.udaf(Sum, func_type="arrow")
+        with self.assertRaises(TypeError):
+            pf.udaf(Sum, deterministic=1)
+
+        async def coroutine(values):
+            return 1
+
+        async def generator(values):
+            yield 1
+
+        @functools.wraps(generator)
+        def wrapped(values):
+            return generator(values)
+
+        for func in (coroutine, generator, wrapped):
+            with self.subTest(func=func), self.assertRaisesRegex(TypeError, 
"async"):
+                pf.udaf(func, return_dtype=int, func_type="pandas")
+        for method in ("accumulate", "get_value", "create_accumulator", 
"open", "merge"):
+            cls = type("AsyncAggregate", (Sum,), {method: coroutine})
+            with self.subTest(method=method), 
self.assertRaisesRegex(TypeError, "async"):
+                pf.udaf(cls)
+
+    def test_names_and_determinism(self):
+        for name in (None, "", "my_sum"):
+            self.assertEqual(pf.udaf(Sum, name=name).__name__, name or "Sum")
+
+        class RandomSum(Sum):
+            def is_deterministic(self):
+                return False
+
+        with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
+            pf.udaf(RandomSum)
+        adapter = pf.udaf(RandomSum, 
deterministic=False)._table_udf_wrapper._func
+        self.assertFalse(adapter.is_deterministic())
+
+    def test_client_constructor_and_worker_lifecycle(self):
+        events = []
+
+        class LifecycleSum(Sum):
+            def __init__(self):
+                events.append("init")
+
+            def open(self, context):
+                events.append(("open", context))
+
+            def create_accumulator(self):
+                events.append("accumulator")
+                return super().create_accumulator()
+
+            def close(self):
+                events.append("close")
+                raise RuntimeError("close failure")
+
+        declaration = pf.udaf(LifecycleSum)
+        adapter = declaration._table_udf_wrapper._func
+        self.assertEqual(events, ["init"])
+        adapter.open("worker")
+        accumulator = adapter.create_accumulator()
+        adapter.accumulate(accumulator, 5)
+        adapter.retract(accumulator, 2)
+        adapter.merge(accumulator, [[7], [1]])
+        self.assertEqual(accumulator, [11])
+        self.assertEqual(adapter.get_value(accumulator), 11)
+        with self.assertRaisesRegex(RuntimeError, "close failure"):
+            adapter.close()
+        self.assertEqual(events, ["init", ("open", "worker"), "accumulator", 
"close"])
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapter.get_value(accumulator)
+        adapter.close()
+
+    def test_callable_classes_construct_on_worker(self):
+        events = []
+
+        class Total:
+            def __init__(self, offset=0):
+                events.append("init")
+                self.offset = offset
+
+            def __call__(self, values) -> int:
+                return int(values.sum()) + self.offset
+
+        declaration = pf.udaf(Total, func_type="pandas")
+        adapter = declaration._table_udf_wrapper._func
+        self.assertEqual(events, [])
+        adapter.open(None)
+        try:
+            acc = adapter.create_accumulator()
+            adapter.accumulate(acc, pd.Series([1, 2]))
+            self.assertEqual(adapter.get_value(acc), 3)
+        finally:
+            adapter.close()
+        self.assertEqual(events, ["init"])
+        for func in (Total(2), functools.partial(lambda offset, values: 
values.sum() + offset, 2)):
+            self.assertEqual(_aggregate(pf.udaf(func, return_dtype=int, 
func_type="pandas"),
+                                        pd.Series([1, 2])), 5)
+
+    def test_struct_normalization_and_user_failures(self):
+        dtype = pf.DataType.struct({
+            "nested": pf.DataType.list(pf.DataType.struct({"value": 
pf.DataType.int64()})),
+            "missing": pf.DataType.string(),
+        })
+
+        class Nested(Sum):
+            def get_value(self, accumulator):
+                return {"nested": [{"value": accumulator[0]}]}
+
+        for func_type, expected in (
+            ("general", Row(nested=[Row(value=3)], missing=None)),
+            ("pandas", ([(3,)], None)),
+        ):
+            with self.subTest(func_type=func_type):
+                self.assertEqual(_aggregate(pf.udaf(
+                    Nested, return_dtype=dtype, func_type=func_type), 3), 
expected)
+
+        def fail(values) -> int:
+            raise RuntimeError("user failure")
+
+        with self.assertRaisesRegex(RuntimeError, "user failure"):
+            _aggregate(pf.udaf(fail, func_type="pandas"), pd.Series([1]))
+
+
+class DataFrameUDAFPlanningTests(PyFlinkDataFrameUTTestCase):
+    def test_sql_type_declarations(self):
+        class SqlSum(Sum):
+            def get_result_type(self):
+                return "BIGINT"
+
+            def get_accumulator_type(self):
+                return "ARRAY<BIGINT>"
+
+        for declaration in (pf.udaf(SqlSum), pf.udaf(
+                Sum, return_dtype="BIGINT", accumulator_type="ARRAY<BIGINT>")):
+            self.assertEqual(declaration.return_dtype, pf.DataType.int64())
+            self.assertEqual(declaration.accumulator_type, 
pf.DataType.list(pf.DataType.int64()))
+
+
+class DataFrameUDAFBatchTests(PyFlinkBatchTableTestCase):
+    def setUp(self):
+        previous = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous)
+        pf.set_table_environment(self.t_env)
+
+    def test_pandas_callables_classes_and_multiple_columns(self):
+        client_pid = os.getpid()
+
+        class Total:
+            def __init__(self):
+                if os.getpid() == client_pid:
+                    raise AssertionError("Callable class must be constructed 
on the worker")
+
+            def __call__(self, values, weights) -> int:
+                return int((values * weights).sum())
+
+        class WeightedSum(Sum):
+            def __init__(self):
+                self.pid = os.getpid()
+
+            def open(self, context):
+                if self.pid != client_pid:
+                    raise AssertionError("AggregateFunction must be 
constructed on the client")
+                self.opened = True
+
+            def accumulate(self, accumulator, values, weights):
+                if not self.opened:
+                    raise AssertionError("open must be called")
+                accumulator[0] += int((values * weights).sum())
+
+        source = pf.from_records([("a", 1, 2), ("a", 3, 4), ("b", 2, 3)],
+                                 schema=["category", "value", "weight"])
+        weighted = pf.udaf(Total, func_type="pandas")
+        result = source.group_by("category").agg(
+            total=weighted(pf.col("value"), pf.col("weight")))
+        self.assertEqual(result.columns, ["category", "total"])
+        self.assertEqual(result.schema.get_field_data_types(),
+                         [DataTypes.STRING(), DataTypes.BIGINT().not_null()])
+        second = source.group_by("category").agg(
+            total=pf.udaf(WeightedSum, func_type="pandas")(pf.col("value"), 
pf.col("weight")))
+        self.assertCountEqual(result.union_all(second).collect(),
+                              [Row("a", 14), Row("b", 6)] * 2)
+
+    def test_sql_bindings_and_struct_fields(self):
+        @pf.udaf(func_type="pandas")
+        def stats(values) -> Statistics:
+            return {"count": len(values), "total": int(values.sum())}
+
+        source = pf.from_dict({"value": [1, 2, 3]})
+        direct = source.agg(stats(pf.col("value")).alias("summary"))
+        explicit = pf.sql("SELECT s(`value`) AS summary FROM src", 
auto_bind=False,
+                          src=source, s=stats)
+        automatic = pf.sql("SELECT stats(`value`) AS summary FROM source")
+        for name in ("stats", "s"):
+            self.assertNotIn(name, self.t_env.list_user_defined_functions())
+        combined = direct.union_all(explicit).union_all(automatic)
+        projected = combined.select(pf.col("summary").get("total"),
+                                    pf.col("summary").get("count"))
+        self.assertCountEqual(projected.collect(), [Row(6, 3)] * 3)
+
+    def test_pandas_nested_struct_results(self):
+        def summarize(values):
+            return {"nested": {
+                "sum": {"label": "total", "value": int(values.sum())},
+            }}
+
+        class Summary(AggregateFunction):
+            def create_accumulator(self):
+                return [None]
+
+            def accumulate(self, accumulator, values):
+                accumulator[0] = summarize(values)
+
+            def get_value(self, accumulator):
+                return accumulator[0]
+
+        dtype = "ROW<nested MAP<STRING, ROW<value BIGINT, label STRING>>>"
+        function = pf.udaf(summarize, return_dtype=dtype, func_type="pandas")
+        aggregate = pf.udaf(Summary, return_dtype=dtype, func_type="pandas")
+        source = pf.from_dict({"value": [1, 2, 3]})
+        result = source.agg(function(pf.col("value")), 
aggregate(pf.col("value")))
+        expected = Row({"sum": Row(6, "total")})
+        self.assertEqual(result.collect(), [Row(expected, expected)])
+
+    def test_empty_nulls_and_table_udaf_compatibility(self):
+        def total(values) -> int:
+            return int(values.sum())
+
+        source = pf.from_dict({"value": [1, None, 3]})
+        declaration = pf.udaf(total, func_type="pandas")
+        native = table_udaf(total, result_type=DataTypes.BIGINT(), 
func_type="pandas")
+        
self.assertEqual(source.agg(total=declaration(pf.col("value"))).collect(), 
[Row(4)])
+        empty = source.filter(pf.col("value") < 0)
+        
self.assertEqual(empty.agg(total=declaration(pf.col("value"))).collect(),
+                         empty.agg(total=native(pf.col("value"))).collect())
+        with self.assertRaisesRegex(TableException, "non-Pandas UDAFs"):
+            
source.agg(total=pf.udaf(Sum)(pf.col("value"))).to_table().explain()
+
+
+class DataFrameUDAFStreamTests(PyFlinkStreamDataFrameTestCase):
+    def test_grouped_global_and_retracting_inputs(self):
+        total = pf.udaf(Sum)
+        source = pf.from_records([("a", 1), ("a", 2), ("b", 3), ("b", None)],
+                                 schema=["category", "value"])
+        grouped = source.group_by("category").agg(total=total(pf.col("value")))
+        self.assertEqual(self._materialize(grouped, key=["category"]), [("a", 
3), ("b", 3)])
+        
self.assertEqual(self._materialize(source.agg(total=total(pf.col("value"))), 
key=[]),
+                         [(6,)])
+        intermediate = 
source.group_by("category").agg(subtotal=pf.col("value").sum)
+        result = intermediate.agg(total=total(pf.col("subtotal")))
+        self.assertEqual(self._materialize(result, key=[]), [(6,)])
+        with self.assertRaisesRegex(TableException, "Pandas UDAFs"):
+            source.agg(total=pf.udaf(lambda values: values.sum(), 
return_dtype=int,
+                                     
func_type="pandas")(pf.col("value"))).to_table().explain()
+
+    def test_general_sql_and_empty_input(self):
+        total = pf.udaf(Sum())
+        source = pf.from_dict({"value": [1, 2, 3]})
+        result = pf.sql("SELECT total(`value`) AS total_value FROM source",
+                        auto_bind=False, source=source, total=total)
+        self.assertEqual(self._materialize(result, key=[]), [(6,)])
+        empty = source.filter(pf.col("value") < 0)
+        native = table_udaf(Sum(), result_type=DataTypes.BIGINT())
+        self.assertEqual(self._materialize(empty.agg(total(pf.col("value"))), 
key=[]),
+                         self._materialize(empty.agg(native(pf.col("value"))), 
key=[]))
+
+    def test_dataview_accumulator_is_preserved(self):
+        class DistinctCount(AggregateFunction):
+            def create_accumulator(self):
+                return Row(MapView(), ListView())
+
+            def accumulate(self, accumulator, value):
+                if value not in accumulator[0]:
+                    accumulator[0][value] = True
+                    accumulator[1].add(value)
+
+            def get_value(self, accumulator) -> int:
+                return len(list(accumulator[1].get()))
+
+            def get_accumulator_type(self):
+                return DataTypes.ROW([
+                    DataTypes.FIELD("seen", 
DataTypes.MAP_VIEW(DataTypes.STRING(),
+                                                               
DataTypes.BOOLEAN())),
+                    DataTypes.FIELD("values", 
DataTypes.LIST_VIEW(DataTypes.STRING())),
+                ])
+
+        source = pf.from_records([("a", "x"), ("a", "x"), ("a", "y"), ("b", 
"x")],
+                                 schema=["category", "value"])
+        result = 
source.group_by("category").agg(count=pf.udaf(DistinctCount)(pf.col("value")))
+        self.assertEqual(self._materialize(result, key=["category"]), [("a", 
2), ("b", 1)])
+
+    def test_session_window_merges_accumulators(self):
+        data = self.env.from_collection([
+            Row("a", 1, datetime.datetime(2020, 1, 1, 0, 0, 1)),
+            Row("a", 3, datetime.datetime(2020, 1, 1, 0, 0, 3)),
+            Row("a", 2, datetime.datetime(2020, 1, 1, 0, 0, 2)),
+        ], type_info=Types.ROW_NAMED(["category", "value", "ts"],
+                                     [Types.STRING(), Types.INT(), 
Types.SQL_TIMESTAMP()]))
+        schema = Schema.new_builder().column("category", DataTypes.STRING()) \
+            .column("value", DataTypes.INT()) \
+            .column("ts", 
DataTypes.TIMESTAMP(3).bridged_to("java.sql.Timestamp")) \
+            .watermark("ts", "ts - INTERVAL '10' SECOND").build()
+        table = self.t_env.from_data_stream(data, schema)
+        total = pf.udaf(Sum)
+        result = 
table.window(Session.with_gap(lit(2).seconds).on(col("ts")).alias("w")) \
+            .group_by(col("category"), col("w")) \
+            .select(col("category"), total(col("value")).alias("total"))
+        self.assertEqual(pf.from_table(result).collect(), [Row("a", 6)])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/udaf.py 
b/flink-python/pyflink/dataframe/udaf.py
new file mode 100644
index 00000000000..dd82b267f6f
--- /dev/null
+++ b/flink-python/pyflink/dataframe/udaf.py
@@ -0,0 +1,318 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+"""User-defined aggregate functions for the DataFrame API."""
+
+import inspect
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional, Tuple, Type, Union, cast, overload
+
+from pyflink.dataframe.datatype import DataType
+from pyflink.dataframe.udf import (
+    _DataTypeLike,
+    _UDFDeclarationContext,
+    _convert_to_dtype,
+    _create_declaration_context,
+    _create_result_normalizer,
+    _infer_return_dtype,
+    _resolve_udf,
+    _validate_determinism_agreement,
+    _validate_zero_argument_class,
+)
+from pyflink.table.expression import Expression
+from pyflink.table.types import DataType as TableDataType, MapType
+from pyflink.table.udf import (
+    AggregateFunction,
+    DelegatingPandasAggregateFunction,
+    UserDefinedAggregateFunctionWrapper,
+    UserDefinedFunction,
+    udaf as table_udaf,
+)
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["udaf"]
+
+_UDAFInput = Union[Callable[..., Any], AggregateFunction, Type]
+
+
+@dataclass(frozen=True)
+class _DataFrameUDAFWrapper:
+    _func: _UDAFInput
+    return_dtype: DataType
+    accumulator_type: Optional[DataType]
+    _func_type: str
+    _deterministic: bool
+    _name: Optional[str]
+    _cached_table_udf_wrapper: Optional[UserDefinedAggregateFunctionWrapper] = 
field(
+        default=None, init=False, repr=False, compare=False)
+
+    @property
+    def __name__(self) -> str:
+        return self._table_udf_wrapper._name
+
+    def __call__(self, *args: Any) -> Expression:
+        return self._table_udf_wrapper(*args)
+
+    @property
+    def _table_udf_wrapper(self) -> UserDefinedAggregateFunctionWrapper:
+        if self._cached_table_udf_wrapper is None:
+            wrapper = table_udaf(
+                _DataFrameAggregateFunctionAdapter(
+                    self._func, self.return_dtype, self._deterministic, 
self._func_type),
+                result_type=self.return_dtype._to_table_data_type(),
+                accumulator_type=(self.accumulator_type._to_table_data_type()
+                                  if self.accumulator_type is not None else 
None),
+                func_type=self._func_type,
+                deterministic=self._deterministic,
+                name=self._name,
+            )
+            object.__setattr__(self, "_cached_table_udf_wrapper", wrapper)
+        return cast(UserDefinedAggregateFunctionWrapper, 
self._cached_table_udf_wrapper)
+
+
+@overload
+def udaf(
+    func: _UDAFInput, *, return_dtype: Optional[_DataTypeLike] = ...,
+    accumulator_type: Optional[_DataTypeLike] = ..., func_type: str = ...,
+    deterministic: bool = ..., name: Optional[str] = ...,
+) -> _DataFrameUDAFWrapper:
+    ...
+
+
+@overload
+def udaf(
+    func: None = ..., *, return_dtype: Optional[_DataTypeLike] = ...,
+    accumulator_type: Optional[_DataTypeLike] = ..., func_type: str = ...,
+    deterministic: bool = ..., name: Optional[str] = ...,
+) -> Callable[[_UDAFInput], _DataFrameUDAFWrapper]:
+    ...
+
+
+@PublicEvolving()
+def udaf(
+    func: Optional[_UDAFInput] = None,
+    *,
+    return_dtype: Optional[_DataTypeLike] = None,
+    accumulator_type: Optional[_DataTypeLike] = None,
+    func_type: str = "general",
+    deterministic: bool = True,
+    name: Optional[str] = None,
+) -> Union[_DataFrameUDAFWrapper, Callable[[_UDAFInput], 
_DataFrameUDAFWrapper]]:
+    """
+    Declare a function that aggregates multiple rows into one result.
+
+    General UDAFs require an :class:`~pyflink.table.udf.AggregateFunction` 
instance
+    or class. Pandas UDAFs also accept functions and callable instances or 
classes;
+    each argument contains a group or window's column values as pandas data.
+    Calling the declaration with column expressions produces an aggregation 
expression
+    for ``agg()``. Declarations can also be bound in 
:func:`pyflink.dataframe.sql`.
+
+    ``AggregateFunction`` classes are constructed on the client. Plain 
callable classes
+    are constructed on workers. Classes must have a zero-argument constructor;
+    provided instances are serialized with the job. ``AggregateFunction.open`` 
and
+    ``AggregateFunction.close`` run on workers.
+
+    Explicit types take precedence over ``get_result_type()`` and
+    ``get_accumulator_type()``. Without a result type, the return annotation of
+    ``get_value()`` or the callable is used. Accumulator types are not 
inferred.
+    Aggregation results follow the nested value conversion rules of DataFrame 
scalar UDFs.
+
+    Ordinary grouped and global aggregations support general UDAFs in 
streaming mode
+    and pandas UDAFs in batch mode. Window aggregations follow Table API 
support.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> from pyflink.table import EnvironmentSettings, TableEnvironment
+        >>> from pyflink.table.udf import AggregateFunction
+        >>> pf.set_table_environment(TableEnvironment.create(
+        ...     EnvironmentSettings.in_streaming_mode()))
+        >>> class Sum(AggregateFunction):
+        ...     def create_accumulator(self):
+        ...         return [0]
+        ...     def accumulate(self, accumulator, value):
+        ...         accumulator[0] += value
+        ...     def get_value(self, accumulator) -> int:
+        ...         return accumulator[0]
+        >>> total = pf.udaf(Sum, accumulator_type=list[int])
+        >>> df = pf.from_dict({"category": ["a", "a", "b"], "value": [1, 2, 
3]})
+        >>> result = df.group_by("category").agg(total=total(pf.col("value")))
+        >>> result.columns
+        ['category', 'total']
+
+    A pandas callable can be used in batch aggregations::
+
+        >>> pf.set_table_environment(TableEnvironment.create(
+        ...     EnvironmentSettings.in_batch_mode()))
+        >>> df = pf.from_dict({"category": ["a", "a", "b"], "value": [1, 2, 
3]})
+        >>> @pf.udaf(return_dtype=float, func_type="pandas")
+        ... def mean(values):
+        ...     return values.mean()
+        >>> result = df.group_by("category").agg(mean=mean(pf.col("value")))
+        >>> result = pf.sql(
+        ...     "SELECT category, avg_value(`value`) AS mean FROM src GROUP BY 
category",
+        ...     auto_bind=False, src=df, avg_value=mean)
+
+    :param func: AggregateFunction instance/class, or a pandas callable 
instance/class/function.
+    :param return_dtype: Result type as a DataFrame DataType, Python type, or 
SQL type string.
+    :param accumulator_type: General UDAF state type, in the same formats as 
``return_dtype``.
+                             Omit when ``get_accumulator_type()`` supplies it. 
Pandas UDAFs
+                             use the accumulator type managed by the Table API.
+    :param func_type: ``"general"`` (default) or ``"pandas"``.
+    :param deterministic: Must agree with 
``AggregateFunction.is_deterministic()``.
+    :param name: Optional function name. None or an empty string uses the 
callable's name.
+    :return: A reusable aggregate declaration, or a decorator when ``func`` is 
omitted.
+
+    .. versionadded:: 2.4.0
+    """
+    if func_type not in ("general", "pandas"):
+        raise ValueError("func_type must be 'general' or 'pandas'.")
+    if not isinstance(deterministic, bool):
+        raise TypeError("deterministic must be a bool.")
+
+    def decorator(f: _UDAFInput) -> _DataFrameUDAFWrapper:
+        actual_func, context = _resolve_udaf(f, func_type)
+        result = _resolve_aggregate_type(actual_func, return_dtype, 
"get_result_type")
+        if result is None:
+            result = _infer_return_dtype(context, None, name or "UDAF")
+        if func_type == "pandas" and isinstance(result._to_table_data_type(), 
MapType):
+            raise TypeError("Pandas UDAFs do not support MAP results.")
+        accumulator = _resolve_aggregate_type(
+            actual_func, accumulator_type, "get_accumulator_type"
+        ) if func_type == "general" or accumulator_type is not None else None
+        if func_type == "general" and accumulator is None:
+            raise TypeError("Specify accumulator_type or implement 
get_accumulator_type().")
+        if isinstance(actual_func, AggregateFunction):
+            _validate_determinism_agreement(deterministic, 
actual_func.is_deterministic())
+        return _DataFrameUDAFWrapper(
+            actual_func, result, accumulator, func_type, deterministic, name)
+
+    return decorator if func is None else decorator(func)
+
+
+def _resolve_udaf(func: _UDAFInput, func_type: str) -> Tuple[_UDAFInput, 
_UDFDeclarationContext]:
+    if inspect.isclass(func) and issubclass(func, AggregateFunction):
+        _validate_zero_argument_class(func)
+        func = func()
+        if not isinstance(func, AggregateFunction):
+            raise TypeError(
+                "An AggregateFunction class must construct an 
AggregateFunction instance.")
+    if isinstance(func, AggregateFunction):
+        for method_name in (
+            "create_accumulator", "accumulate", "get_value", "retract", 
"merge",
+            "open", "close", "get_result_type", "get_accumulator_type", 
"is_deterministic",
+        ):
+            _validate_sync_aggregate_method(getattr(func, method_name))
+        context = _create_declaration_context(func.get_value, 
partial_source=func.get_value)
+    else:
+        if func_type == "general":
+            raise TypeError("General UDAFs require an AggregateFunction 
instance or class.")
+        if isinstance(func, UserDefinedFunction) or (
+            inspect.isclass(func) and issubclass(func, UserDefinedFunction)
+        ):
+            raise TypeError("Pandas UDAFs require an AggregateFunction or a 
Python callable.")
+        context = _resolve_udf(func).declaration_context
+        _validate_sync_aggregate_method(context.annotation_target)
+    return func, context
+
+
+def _validate_sync_aggregate_method(func: Callable[..., Any]) -> None:
+    if not callable(func):
+        raise TypeError("UDAF methods must be callable.")
+    for candidate in (func, inspect.unwrap(func)):
+        if inspect.iscoroutinefunction(candidate) or 
inspect.isasyncgenfunction(candidate):
+            raise TypeError("UDAFs must be synchronous; async functions are 
not supported.")
+
+
+def _resolve_aggregate_type(
+    func: _UDAFInput, explicit_type: Optional[_DataTypeLike], method_name: str,
+) -> Optional[DataType]:
+    if explicit_type is not None:
+        return _convert_to_dtype(explicit_type)
+    if isinstance(func, AggregateFunction):
+        method = getattr(func, method_name)
+        if getattr(method, "__func__", None) is not getattr(AggregateFunction, 
method_name):
+            declared_type = method()
+            if isinstance(declared_type, TableDataType):
+                return DataType(declared_type)
+            if isinstance(declared_type, str):
+                return _convert_to_dtype(declared_type)
+            raise TypeError(f"{method_name}() must return a Table DataType or 
SQL type string.")
+    return None
+
+
+class _DataFrameAggregateFunctionAdapter(AggregateFunction):
+    def __init__(
+        self, func: _UDAFInput, return_dtype: DataType, deterministic: bool, 
func_type: str,
+    ) -> None:
+        self._func = func
+        self._return_dtype = return_dtype
+        self._deterministic = deterministic
+        self._func_type = func_type
+        self._active_func: Optional[AggregateFunction] = None
+        self._normalizer: Optional[Callable[[Any], Any]] = None
+        self.__name__ = getattr(func, "__name__", type(func).__name__)
+
+    def open(self, function_context: Any) -> None:
+        if isinstance(self._func, AggregateFunction):
+            active_func = self._func
+        else:
+            invoke = self._func() if inspect.isclass(self._func) else 
self._func
+            if not callable(invoke):
+                raise TypeError("UDAF class must construct a callable 
instance.")
+            active_func = DelegatingPandasAggregateFunction(invoke)
+        # Arrow requires tuples or dictionaries for structs, including nested 
ones.
+        normalizer = _create_result_normalizer(
+            self._return_dtype._to_table_data_type(), 
row_as_tuple=self._func_type == "pandas")
+        active_func.open(function_context)
+        self._active_func = active_func
+        self._normalizer = normalizer
+
+    def close(self) -> None:
+        try:
+            if self._active_func is not None:
+                self._active_func.close()
+        finally:
+            self._active_func = None
+            self._normalizer = None
+
+    def is_deterministic(self) -> bool:
+        if isinstance(self._func, AggregateFunction):
+            return self._func.is_deterministic()
+        return self._deterministic
+
+    def _active(self) -> AggregateFunction:
+        if self._active_func is None:
+            raise RuntimeError("UDAF was invoked before open().")
+        return self._active_func
+
+    def create_accumulator(self) -> Any:
+        return self._active().create_accumulator()
+
+    def accumulate(self, accumulator: Any, *args: Any) -> None:
+        self._active().accumulate(accumulator, *args)
+
+    def retract(self, accumulator: Any, *args: Any) -> None:
+        self._active().retract(accumulator, *args)
+
+    def merge(self, accumulator: Any, accumulators: Any) -> None:
+        self._active().merge(accumulator, accumulators)
+
+    def get_value(self, accumulator: Any) -> Any:
+        result: Any = self._active().get_value(accumulator)
+        return self._normalizer(result) if self._normalizer is not None else 
result
diff --git a/flink-python/pyflink/dataframe/udf.py 
b/flink-python/pyflink/dataframe/udf.py
index df0b92da8d3..2d036454edb 100644
--- a/flink-python/pyflink/dataframe/udf.py
+++ b/flink-python/pyflink/dataframe/udf.py
@@ -1555,12 +1555,13 @@ def _wrap_map_arrow(func, input_type: RowType, 
output_type: RowType):
 
 
 def _create_result_normalizer(
-    data_type: Any, *, require_row_fields: bool = False,
+    data_type: Any, *, require_row_fields: bool = False, row_as_tuple: bool = 
False,
 ) -> Optional[Callable[[Any], Any]]:
     if isinstance(data_type, RowType):
         field_names = data_type.field_names()
         field_normalizers = tuple(
-            _create_result_normalizer(field.data_type, 
require_row_fields=require_row_fields)
+            _create_result_normalizer(
+                field.data_type, require_row_fields=require_row_fields, 
row_as_tuple=row_as_tuple)
             for field in data_type
         )
 
@@ -1577,6 +1578,8 @@ def _create_result_normalizer(
                     field_values, field_normalizers
                 )
             ]
+            if row_as_tuple:
+                return tuple(normalized_fields)
             row = Row(*normalized_fields)
             row.set_field_names(field_names)
             if isinstance(value, Row):
@@ -1586,7 +1589,8 @@ def _create_result_normalizer(
         return normalize_row
     if isinstance(data_type, ArrayType):
         element_normalizer = _create_result_normalizer(
-            data_type.element_type, require_row_fields=require_row_fields)
+            data_type.element_type, require_row_fields=require_row_fields,
+            row_as_tuple=row_as_tuple)
         if element_normalizer is None:
 
             def normalize_leaf_array(value: Any) -> Any:
@@ -1602,9 +1606,9 @@ def _create_result_normalizer(
         return normalize_array
     if isinstance(data_type, MapType):
         key_normalizer = _create_result_normalizer(
-            data_type.key_type, require_row_fields=require_row_fields)
+            data_type.key_type, require_row_fields=require_row_fields, 
row_as_tuple=row_as_tuple)
         value_normalizer = _create_result_normalizer(
-            data_type.value_type, require_row_fields=require_row_fields)
+            data_type.value_type, require_row_fields=require_row_fields, 
row_as_tuple=row_as_tuple)
 
         def normalize_map(value: Any) -> Any:
             if value is None:
diff --git a/flink-python/pyflink/fn_execution/tests/test_coders.py 
b/flink-python/pyflink/fn_execution/tests/test_coders.py
index 3bbc8cf859f..50a8c0690b1 100644
--- a/flink-python/pyflink/fn_execution/tests/test_coders.py
+++ b/flink-python/pyflink/fn_execution/tests/test_coders.py
@@ -36,6 +36,32 @@ from pyflink.testing.test_case_utils import PyFlinkTestCase
 
 
 class ArrowSchemaTests(unittest.TestCase):
+    def test_pandas_row_preserves_field_nullability(self):
+        import pandas as pd
+        from pyflink.table import DataTypes
+        from pyflink.table.types import create_arrow_schema
+        from pyflink.table.utils import pandas_to_arrow
+
+        row_type = DataTypes.ROW([
+            DataTypes.FIELD("total", DataTypes.BIGINT().not_null()),
+            DataTypes.FIELD("label", DataTypes.STRING()),
+        ]).not_null()
+        schema = create_arrow_schema(["summary"], [row_type])
+        values = [{"total": 6, "label": None}, {"total": 9, "label": "ok"}]
+        dataframe = pd.DataFrame(values)
+        for size in (0, len(values)):
+            with self.subTest(size=size):
+                batch = pandas_to_arrow(schema, pytz.UTC, [row_type], 
[dataframe.iloc[:size]])
+                with pa.BufferOutputStream() as output:
+                    with pa.ipc.new_stream(output, schema) as writer:
+                        writer.write_batch(batch)
+                    decoded = 
pa.ipc.open_stream(output.getvalue()).read_next_batch()
+                self.assertEqual(decoded.schema, schema)
+                
self.assertFalse(decoded.column(0).type.field("total").nullable)
+                self.assertTrue(decoded.column(0).type.field("label").nullable)
+                self.assertEqual(decoded.to_pylist(),
+                                 [{"summary": value} for value in 
values[:size]])
+
     def test_pandas_positional_rows_require_matching_arity(self):
         import pandas as pd
         from pyflink.common import Row
diff --git a/flink-python/pyflink/table/tests/test_pandas_udaf.py 
b/flink-python/pyflink/table/tests/test_pandas_udaf.py
index 09e17dd1fca..e9bca487db0 100644
--- a/flink-python/pyflink/table/tests/test_pandas_udaf.py
+++ b/flink-python/pyflink/table/tests/test_pandas_udaf.py
@@ -125,6 +125,25 @@ class BatchPandasUDAFITTests(PyFlinkBatchTableTestCase):
         actual = source_sink_utils.results()
         self.assert_equals(actual, ["+I[5]"])
 
+    def test_row_result_with_non_nullable_fields(self):
+        source = self.t_env.from_elements(
+            [(1,), (2,), (3,)],
+            DataTypes.ROW([DataTypes.FIELD("value", DataTypes.BIGINT())]))
+
+        @udaf(result_type=DataTypes.ROW([
+            DataTypes.FIELD("total", DataTypes.BIGINT().not_null()),
+            DataTypes.FIELD("count", DataTypes.BIGINT().not_null()),
+            DataTypes.FIELD("label", DataTypes.STRING()),
+        ]).not_null(), func_type="pandas")
+        def stats(values):
+            return int(values.sum()), len(values), None
+
+        summary = source.select(stats(source.value).alias("summary"))
+        result = summary.select(col("summary").get("total"), 
col("summary").get("count"),
+                                col("summary").get("label"))
+        with result.execute().collect() as rows:
+            self.assertEqual(list(rows), [Row(6, 3, None)])
+
     def test_group_aggregate_with_aux_group(self):
         t = self.t_env.from_elements(
             [(1, 2, 3), (3, 2, 3), (2, 1, 3), (1, 5, 4), (1, 8, 6), (2, 3, 4)],
diff --git a/flink-python/pyflink/table/utils.py 
b/flink-python/pyflink/table/utils.py
index 7001dc9dc9a..f2467e41802 100644
--- a/flink-python/pyflink/table/utils.py
+++ b/flink-python/pyflink/table/utils.py
@@ -217,10 +217,9 @@ def pandas_to_arrow(schema, timezone, field_types, series):
         field_type = field_types[i]
         schema_type = schema.types[i]
         if type(s) == pd.DataFrame:
-            array_names = [(create_array(s[s.columns[j]], field.type), 
field.name)
-                           for j, field in enumerate(schema_type)]
-            struct_arrays, struct_names = zip(*array_names)
-            arrays.append(pa.StructArray.from_arrays(struct_arrays, 
struct_names))
+            struct_arrays = [create_array(s[s.columns[j]], field.type)
+                             for j, field in enumerate(schema_type)]
+            arrays.append(pa.StructArray.from_arrays(struct_arrays, 
fields=list(schema_type)))
         else:
             arrays.append(create_array(
                 tz_convert_to_internal(s, field_type, timezone), schema_type))

Reply via email to