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 5fb075cd9a [python] Introduce merge_map aggregator function (#8727)
5fb075cd9a is described below

commit 5fb075cd9ad71af49bcefeb6522c25fdfb2be46f
Author: AuroraVoyage <[email protected]>
AuthorDate: Thu Jul 23 21:04:54 2026 +0800

    [python] Introduce merge_map aggregator function (#8727)
---
 .../pypaimon/read/merge_engine_support.py          |  1 +
 .../pypaimon/read/reader/aggregate/aggregators.py  | 96 +++++++++++++++++++++-
 .../pypaimon/tests/test_field_aggregators.py       | 51 ++++++++++++
 3 files changed, 147 insertions(+), 1 deletion(-)

diff --git a/paimon-python/pypaimon/read/merge_engine_support.py 
b/paimon-python/pypaimon/read/merge_engine_support.py
index ff551f9ab5..aab3cf3dd6 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -66,6 +66,7 @@ _AGGREGATION_SUPPORTED_AGG_FUNCS = frozenset([
     "nested_update",
     "collect",
     "merge_map_with_keytime",
+    "merge_map",
 ])
 _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 321a70daca..d973acb8f4 100644
--- a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
+++ b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
@@ -33,7 +33,7 @@ the registry will report them as unsupported so users see a 
clear
 error rather than a silent fallback.
 """
 
-from typing import Any, List, Dict, Optional, Tuple, Union
+from typing import Any, List, Dict, Optional, Tuple, Union, Set
 
 from pypaimon.common.options import CoreOptions
 from pypaimon.common.options.core_options import NestedKeyNullStrategy
@@ -62,6 +62,7 @@ NAME_LISTAGG = "listagg"
 NAME_NESTED_UPDATE = "nested_update"
 NAME_COLLECT = "collect"
 NAME_MERGE_MAP_WITH_KEYTIME = "merge_map_with_keytime"
+NAME_MERGE_MAP = "merge_map"
 
 
 # Base SQL type names treated as numeric for sum/product-style
@@ -851,6 +852,96 @@ class FieldMergeMapWithKeyTimeAgg(FieldAggregator):
         )
 
 
+class FieldMergeMapAgg(FieldAggregator):
+    """
+    Merge map values by combining all key-value pairs.
+
+    When the same key exists in both maps, the value from the input map
+    overwrites the value from the accumulator map.
+    """
+
+    def __init__(self, name: str, field_type: DataType):
+        super().__init__(name, field_type)
+        if not isinstance(field_type, MapType):
+            raise ValueError(
+                "Data type for merge map column must be 'MAP' but was 
'{}'".format(field_type)
+            )
+
+    def agg(self, accumulator: Any, input_field: Any) -> Any:
+        if accumulator is None or input_field is None:
+            return input_field if accumulator is None else accumulator
+
+        result = {}
+
+        self._put_to_map(result, accumulator)
+        self._put_to_map(result, input_field)
+
+        return result
+
+    def retract(self, accumulator: Any, retract_field: Any) -> Any:
+        # it's hard to mark the input is retracted without accumulator
+        if accumulator is None:
+            return None
+
+        # nothing to be retracted
+        if retract_field is None:
+            return accumulator
+
+        if len(retract_field) == 0:
+            return accumulator
+
+        retract_keys = self._get_keys(retract_field)
+        acc = {}
+        self._put_to_map(acc, accumulator)
+
+        result = {
+            key: value
+            for key, value in acc.items()
+            if key not in retract_keys
+        }
+
+        return result
+
+    def _put_to_map(self, maps: Dict[Any, Any], input_field: Any):
+        if isinstance(input_field, dict):
+            maps.update(input_field)
+        elif isinstance(input_field, list):
+            tmp_map = {}
+            for item in input_field:
+                if not isinstance(item, dict):
+                    raise TypeError(
+                        "list element must be dict, got {}".format(type(item))
+                    )
+                tmp_map[item['key']] = item['value']
+
+            maps.update(tmp_map)
+        else:
+            raise TypeError(
+                "input_field must be dict or list[dict], got 
{}".format(type(input_field))
+            )
+
+    def _get_keys(self, retract_field: Any) -> Set[Any]:
+        keys = set()
+
+        if isinstance(retract_field, dict):
+            keys.update(retract_field.keys())
+
+        elif isinstance(retract_field, list):
+            for item in retract_field:
+                if not isinstance(item, dict):
+                    raise TypeError(
+                        "list element must be dict, got {}".format(type(item))
+                    )
+
+                keys.add(item["key"])
+        else:
+            raise TypeError(
+                "retract_field must be dict or list[dict], got 
{}".format(type(retract_field))
+            )
+
+        return keys
+
+
 # ---------------------------------------------------------------------------
 # Registration. Each builder binds an identifier to a factory that
 # optionally validates the column DataType before constructing the
@@ -933,3 +1024,6 @@ register_aggregator(
 register_aggregator(
     NAME_MERGE_MAP_WITH_KEYTIME, 
_build_field_options(FieldMergeMapWithKeyTimeAgg, NAME_MERGE_MAP_WITH_KEYTIME)
 )
+register_aggregator(
+    NAME_MERGE_MAP, _build_no_type_check(FieldMergeMapAgg, NAME_MERGE_MAP)
+)
diff --git a/paimon-python/pypaimon/tests/test_field_aggregators.py 
b/paimon-python/pypaimon/tests/test_field_aggregators.py
index c6a188d90a..72b8767ec8 100644
--- a/paimon-python/pypaimon/tests/test_field_aggregators.py
+++ b/paimon-python/pypaimon/tests/test_field_aggregators.py
@@ -48,6 +48,7 @@ from pypaimon.read.reader.aggregate.aggregators import (
     FieldNestedUpdateAgg,
     FieldCollectAgg,
     FieldMergeMapWithKeyTimeAgg,
+    FieldMergeMapAgg,
 )
 from pypaimon.schema.data_types import AtomicType, DataField, RowType, 
ArrayType, MapType
 from pypaimon.table.row.generic_row import GenericRow
@@ -1920,6 +1921,56 @@ class FieldMergeMapWithKeyTimeAggTest(unittest.TestCase):
             )
 
 
+class FieldMergeMapAggTest(unittest.TestCase):
+
+    def _make(self, options: CoreOptions = None):
+        if options is None:
+            options = CoreOptions(Options.from_none())
+
+        return create_field_aggregator(
+            MapType(True, AtomicType("INT"), AtomicType("STRING")),
+            "field0", "merge_map", options=options
+        )
+
+    def test_field_merge_map_agg(self):
+        agg = self._make()
+        self.assertIsInstance(agg, FieldMergeMapAgg)
+
+        self.assertIsNone(agg.agg(None, None))
+        self.assertEqual(agg.agg({1: "A"}, None), {1: "A"})
+        self.assertEqual(agg.agg(None, {1: "A"}), {1: "A"})
+
+        acc = agg.agg(None, {1: "A"})
+        self.assertEqual(acc, {1: "A"})
+
+        acc = agg.agg(acc, {1: "A", 2: "B"})
+        self.assertEqual(acc, {1: "A", 2: "B"})
+
+        acc = agg.agg(acc, {1: "a", 3: "c"})
+        self.assertEqual(acc, {1: "a", 2: "B", 3: "c"})
+
+    def test_field_merge_map_agg_retract(self):
+        agg = self._make()
+
+        result = agg.retract(
+            {1: "A", 2: "B", 3: "C"},
+            {1: "A", 2: "A"},
+        )
+        self.assertEqual(result, {3: "C"})
+        self.assertEqual(agg.retract(None, {1: "A"}), None)
+        self.assertEqual(agg.retract(result, None), {3: "C"})
+        self.assertEqual(agg.retract(result, {}), {3: "C"})
+
+    def test_field_merge_map_agg_for_pyarrow(self):
+        agg = self._make()
+        acc = agg.agg(None, [{'key': 1, 'value': 'A'}])
+        acc = agg.agg(acc, [{'key': 1, 'value': 'a'}, {'key': 2, 'value': 
'B'}])
+        self.assertEqual(acc, {1: "a", 2: "B"})
+
+        acc = agg.retract(acc, [{'key': 1, 'value': 'A'}, {'key': 3, 'value': 
'C'}])
+        self.assertEqual(acc, {2: "B"})
+
+
 class RegistrationTest(unittest.TestCase):
     """Sanity check that all 10 expected aggregators (the primary-key
     placeholder plus 9 value aggregators) are registered when the

Reply via email to