This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 35ca2c0425 [python] Introduce collect aggregator function. (#8737)
35ca2c0425 is described below
commit 35ca2c0425de91a949562e83c70c5ea5b41c44be
Author: AuroraVoyage <[email protected]>
AuthorDate: Mon Jul 20 11:28:18 2026 +0800
[python] Introduce collect aggregator function. (#8737)
---
.../pypaimon/read/merge_engine_support.py | 1 +
.../pypaimon/read/reader/aggregate/aggregators.py | 69 +++++++
.../pypaimon/tests/test_aggregation_e2e.py | 14 +-
.../pypaimon/tests/test_field_aggregators.py | 209 ++++++++++++++++++++-
4 files changed, 286 insertions(+), 7 deletions(-)
diff --git a/paimon-python/pypaimon/read/merge_engine_support.py
b/paimon-python/pypaimon/read/merge_engine_support.py
index cefcee2e67..89680141e3 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -64,6 +64,7 @@ _AGGREGATION_SUPPORTED_AGG_FUNCS = frozenset([
"bool_or", "bool_and",
"listagg",
"nested_update",
+ "collect"
])
_FIELDS_PREFIX = "fields."
_FIELD_SEQUENCE_GROUP_SUFFIX = ".sequence-group"
diff --git a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
index 11b182edaf..060082c1cc 100644
--- a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
+++ b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
@@ -60,6 +60,7 @@ NAME_BOOL_OR = "bool_or"
NAME_BOOL_AND = "bool_and"
NAME_LISTAGG = "listagg"
NAME_NESTED_UPDATE = "nested_update"
+NAME_COLLECT = "collect"
# Base SQL type names treated as numeric for sum/product-style
@@ -455,6 +456,71 @@ class FieldListaggAgg(FieldAggregator):
return self.delimiter.join(result)
+class FieldCollectAgg(FieldAggregator):
+
+ def __init__(
+ self,
+ name: str,
+ field_type: ArrayType,
+ field_name: str,
+ options: CoreOptions,
+ ):
+ super().__init__(name, field_type)
+
+ if not isinstance(field_type, ArrayType):
+ raise ValueError(
+ "Data type for collect column must be 'Array' but was
'{}'.".format(field_type)
+ )
+
+ self.distinct = options.field_collect_distinct(field_name)
+
+ def agg_reversed(self, accumulator: Any, input_field: Any) -> Any:
+ # we don't need to actually do the reverse here for this agg
+ # because accumulator has been distinct, just let accumulator be
accumulator will speed up
+ # distinct process
+ return self.agg(accumulator, input_field)
+
+ def agg(self, accumulator: Any, input_field: Any) -> Any:
+ if accumulator is None and input_field is None:
+ return None
+
+ if (accumulator is None or input_field is None) and not self.distinct:
+ return input_field if accumulator is None else accumulator
+
+ if self.distinct:
+ result = []
+ self._collect(result, accumulator)
+ self._collect(result, input_field)
+
+ return result
+ else:
+ return list(accumulator) + list(input_field)
+
+ def retract(self, accumulator: Any, retract_field: Any) -> Any:
+ if accumulator is None:
+ return None
+
+ if retract_field is None:
+ return accumulator
+
+ if len(retract_field) == 0:
+ return accumulator
+
+ result = list(accumulator)
+ for element in retract_field:
+ if element in result:
+ result.remove(element)
+
+ return result
+
+ def _collect(self, result: List[Any], data: List[Any]):
+ if data is None:
+ return
+ for element in data:
+ if element not in result:
+ result.append(element)
+
+
class FieldNestedUpdateAgg(FieldAggregator):
"""
Used to update a field which representing a nested table.
@@ -740,3 +806,6 @@ register_aggregator(
register_aggregator(
NAME_NESTED_UPDATE, _build_field_options(FieldNestedUpdateAgg,
NAME_NESTED_UPDATE)
)
+register_aggregator(
+ NAME_COLLECT, _build_field_options(FieldCollectAgg, NAME_COLLECT)
+)
diff --git a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
index 0d557799e9..b70fe97d51 100644
--- a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
+++ b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
@@ -294,20 +294,22 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
)
def test_out_of_scope_field_aggregator_rejected(self):
- # collect is one of the aggregator identifiers this engine
- # doesn't support yet. The guard must reject the config rather
+ # hll_sketch is one of the aggregator identifiers this engine
+ # doesn't support yet. collect is now supported by the Python
+ # aggregation engine, so use hll_sketch instead.
+ # The guard must reject the config rather
# than let the per-field factory build a (silently wrong)
# fallback.
self._create_and_expect_unsupported(
- 'agg_reject_collect',
- {'fields.label.aggregate-function': 'collect'},
+ 'agg_reject_hll_sketch',
+ {'fields.label.aggregate-function': 'hll_sketch'},
'fields.label.aggregate-function',
)
def test_out_of_scope_default_aggregator_rejected(self):
self._create_and_expect_unsupported(
- 'agg_reject_default_collect',
- {'fields.default-aggregate-function': 'product'},
+ 'agg_reject_default_hll_sketch',
+ {'fields.default-aggregate-function': 'hll_sketch'},
'fields.default-aggregate-function',
)
diff --git a/paimon-python/pypaimon/tests/test_field_aggregators.py
b/paimon-python/pypaimon/tests/test_field_aggregators.py
index 9c9d5876fe..2f4d07c39d 100644
--- a/paimon-python/pypaimon/tests/test_field_aggregators.py
+++ b/paimon-python/pypaimon/tests/test_field_aggregators.py
@@ -46,8 +46,9 @@ from pypaimon.read.reader.aggregate.aggregators import (
FieldSumAgg,
FieldListaggAgg,
FieldNestedUpdateAgg,
+ FieldCollectAgg,
)
-from pypaimon.schema.data_types import AtomicType, DataField, RowType,
ArrayType
+from pypaimon.schema.data_types import AtomicType, DataField, RowType,
ArrayType, MapType
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.row.internal_row import InternalRow
@@ -550,6 +551,212 @@ class FieldListaggAggTest(unittest.TestCase):
)
+class FieldCollectAggTest(unittest.TestCase):
+
+ def _make(self, distinct, element_type=None):
+ if element_type is None:
+ element_type = AtomicType("INT")
+
+ options = CoreOptions(Options({"fields.field0.distinct": distinct}))
+
+ return create_field_aggregator(
+ ArrayType(True, element_type),
+ "field0",
+ "collect",
+ options=options,
+ )
+
+ def row(self, *values, fields: List[DataField]):
+ return GenericRow(list(values), fields)
+
+ def test_field_collect_agg_with_distinct(self):
+ agg = self._make(distinct=True)
+ self.assertIsInstance(agg, FieldCollectAgg)
+
+ self.assertIsNone(agg.agg(None, None))
+
+ result = agg.agg(None, [1, 1, 2])
+ self.assertEqual(result, [1, 2])
+
+ result = agg.agg([1, 1, 2], [2, 3])
+ self.assertEqual(result, [1, 2, 3])
+
+ def test_field_collect_agg_without_distinct(self):
+ agg = self._make(distinct=False)
+
+ self.assertIsNone(agg.agg(None, None))
+
+ result = agg.agg(None, [1, 1, 2])
+ self.assertEqual(result, [1, 1, 2])
+
+ result = agg.agg([1, 1, 2], [2, 3])
+ self.assertEqual(result, [1, 1, 2, 2, 3])
+
+ def test_field_collect_agg_retract(self):
+ agg = self._make(distinct=True)
+
+ result = agg.retract([1, 2, 3], [1])
+ self.assertEqual(result, [2, 3])
+ self.assertIsNone(agg.retract(None, [1]))
+ self.assertEqual(agg.retract([1, 2], None), [1, 2])
+
+ def test_field_collect_agg_retract_duplicate_elements(self):
+ # primitive type
+ agg = self._make(distinct=True)
+
+ self.assertEqual(
+ agg.retract([1, 1, 2, 2, 3], [1, 2, 3]),
+ [1, 2],
+ )
+
+ # row type
+ fields = [
+ DataField(0, "id", AtomicType("INT")),
+ DataField(1, "name", AtomicType("STRING")),
+ ]
+ agg = self._make(
+ distinct=True,
+ element_type=RowType(True, fields),
+ )
+
+ self.assertEqual(
+ agg.retract(
+ [
+ self.row(1, "A", fields=fields),
+ self.row(1, "A", fields=fields),
+ self.row(1, "B", fields=fields),
+ self.row(2, "B", fields=fields),
+ ],
+ [
+ self.row(1, "A", fields=fields),
+ self.row(2, "B", fields=fields),
+ ],
+ ),
+ [
+ self.row(1, "A", fields=fields),
+ self.row(1, "B", fields=fields),
+ ],
+ )
+
+ # array type
+ agg = self._make(
+ distinct=True,
+ element_type=ArrayType(True, AtomicType("INT")),
+ )
+
+ self.assertEqual(
+ agg.retract(
+ [[1, 1], [1, 1], [1, 2], [2, 1]],
+ [[1, 1], [1, 2]],
+ ),
+ [[1, 1], [2, 1]],
+ )
+
+ # map type
+ agg = self._make(
+ distinct=True,
+ element_type=MapType(True, AtomicType("INT"),
AtomicType("STRING")),
+ )
+
+ self.assertEqual(
+ agg.retract(
+ [{1: "A"}, {1: "A"}, {1: "A", 2: "B"}, {1: "C"}],
+ [{1: "A"}, {2: "B", 1: "A"}],
+ ),
+ [{1: "A"}, {1: "C"}],
+ )
+
+ def test_field_collect_agg_with_row_type(self):
+ fields = [
+ DataField(0, "id", AtomicType("INT")),
+ DataField(1, "name", AtomicType("STRING")),
+ ]
+ agg = self._make(
+ distinct=True,
+ element_type=RowType(True, fields),
+ )
+
+ input1 = [
+ self.row(1, "A", fields=fields),
+ self.row(1, "B", fields=fields),
+ ]
+
+ result = agg.agg(None, input1)
+ self.assertEqual(result, input1)
+
+ input2 = [
+ self.row(1, "A", fields=fields),
+ self.row(2, "A", fields=fields),
+ ]
+
+ result = agg.agg(input1, input2)
+ self.assertEqual(result, [
+ self.row(1, "A", fields=fields),
+ self.row(1, "B", fields=fields),
+ self.row(2, "A", fields=fields),
+ ])
+
+ # retract
+ result = agg.retract(
+ [
+ self.row(1, "A", fields=fields),
+ self.row(1, "B", fields=fields),
+ self.row(2, "B", fields=fields),
+ ],
+ [
+ self.row(1, "A", fields=fields),
+ self.row(2, "B", fields=fields),
+ ],
+ )
+ self.assertEqual(result, [self.row(1, "B", fields=fields)])
+
+ def test_field_collect_agg_with_array_type(self):
+ agg = self._make(
+ distinct=True,
+ element_type=ArrayType(True, AtomicType("INT")),
+ )
+
+ input1 = [[1, 1], [1, 2]]
+ acc = agg.agg(None, input1)
+ self.assertEqual(acc, input1)
+
+ input2 = [[1, 1], [1, 2], [2, 1]]
+ acc = agg.agg(acc, input2)
+ self.assertEqual(acc, [[1, 1], [1, 2], [2, 1]])
+
+ # retract
+ acc = agg.retract(
+ [[1, 1], [1, 2], [2, 1]],
+ [[1, 1], [1, 2]],
+ )
+ self.assertEqual(acc, [[2, 1]])
+
+ def test_field_collect_agg_with_map_type(self):
+ agg = self._make(
+ distinct=True,
+ element_type=MapType(
+ True,
+ AtomicType("INT"),
+ AtomicType("STRING"),
+ ),
+ )
+
+ input1 = [{1: "A"}, {1: "A", 2: "B"}]
+ acc = agg.agg(None, input1)
+ self.assertEqual(acc, input1)
+
+ input2 = [{1: "A"}, {2: "B", 1: "A"}, {1: "C"}]
+ acc = agg.agg(acc, input2)
+ self.assertEqual(acc, [{1: "A"}, {1: "A", 2: "B"}, {1: "C"}])
+
+ # retract
+ acc = agg.retract(
+ [{1: "A"}, {1: "A", 2: "B"}, {1: "C"}],
+ [{1: "A"}, {2: "B", 1: "A"}],
+ )
+ self.assertEqual(acc, [{1: "C"}])
+
+
class FieldNestedUpdateAggTest(unittest.TestCase):
IDENTIFIER = "nested_update"