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 87aeb13552 [python] Introduce merge_map_with_keytime aggregator 
function (#8734)
87aeb13552 is described below

commit 87aeb135526ebcc92dc0b36f6215670943fc95c5
Author: AuroraVoyage <[email protected]>
AuthorDate: Tue Jul 21 14:06:42 2026 +0800

    [python] Introduce merge_map_with_keytime aggregator function (#8734)
---
 .../pypaimon/common/options/core_options.py        |  10 ++
 .../pypaimon/read/merge_engine_support.py          |   5 +-
 .../pypaimon/read/reader/aggregate/aggregators.py  | 126 ++++++++++++++++-
 .../pypaimon/tests/test_field_aggregators.py       | 155 +++++++++++++++++++++
 4 files changed, 293 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index a6aa5f96b5..36fd975f6d 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -153,6 +153,7 @@ class CoreOptions:
     NESTED_KEY_NULL_STRATEGY = "nested-key-null-strategy"
     NESTED_SEQUENCE_FIELD = "nested-sequence-field"
     COUNT_LIMIT = "count-limit"
+    MERGE_MAP_TS_FIELD = "ts-field"
 
     # Basic options
     AUTO_CREATE: ConfigOption[bool] = (
@@ -1597,6 +1598,15 @@ class CoreOptions:
             .default_value(2147483647)  # Integer.MAX_VALUE
         )
 
+    def field_merge_map_ts_field(self, field_name: str) -> str:
+        return self.options.get(
+            ConfigOptions.key(
+                
f'{CoreOptions.FIELDS_PREFIX}.{field_name}.{CoreOptions.MERGE_MAP_TS_FIELD}'
+            )
+            .string_type()
+            .no_default_value()
+        )
+
     @property
     def query_auth_enabled(self) -> bool:
         return self.options.get(CoreOptions.QUERY_AUTH_ENABLED)
diff --git a/paimon-python/pypaimon/read/merge_engine_support.py 
b/paimon-python/pypaimon/read/merge_engine_support.py
index 89680141e3..ff551f9ab5 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -64,7 +64,8 @@ _AGGREGATION_SUPPORTED_AGG_FUNCS = frozenset([
     "bool_or", "bool_and",
     "listagg",
     "nested_update",
-    "collect"
+    "collect",
+    "merge_map_with_keytime",
 ])
 _FIELDS_PREFIX = "fields."
 _FIELD_SEQUENCE_GROUP_SUFFIX = ".sequence-group"
@@ -211,7 +212,7 @@ def check_supported(table) -> None:
                 "(aggregation.remove-record-on-delete, "
                 "fields.<f>.ignore-retract) "
                 "and other aggregators (product / listagg / collect / "
-                "merge_map* / nested_update* / theta_sketch / "
+                "nested_update* / theta_sketch / "
                 "hll_sketch / roaring_bitmap_*) are not yet supported. "
                 "Open an issue to track support.".format(
                     ", ".join(sorted(unsupported)),
diff --git a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py 
b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
index 060082c1cc..321a70daca 100644
--- a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
+++ b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
@@ -39,7 +39,7 @@ from pypaimon.common.options import CoreOptions
 from pypaimon.common.options.core_options import NestedKeyNullStrategy
 from pypaimon.read.reader.aggregate import register_aggregator
 from pypaimon.read.reader.aggregate.field_aggregator import FieldAggregator
-from pypaimon.schema.data_types import AtomicType, DataType, ArrayType, RowType
+from pypaimon.schema.data_types import AtomicType, DataType, ArrayType, 
RowType, MapType
 from pypaimon.table.row.internal_row import InternalRow
 
 # aggregator input type hints variables
@@ -61,6 +61,7 @@ NAME_BOOL_AND = "bool_and"
 NAME_LISTAGG = "listagg"
 NAME_NESTED_UPDATE = "nested_update"
 NAME_COLLECT = "collect"
+NAME_MERGE_MAP_WITH_KEYTIME = "merge_map_with_keytime"
 
 
 # Base SQL type names treated as numeric for sum/product-style
@@ -730,6 +731,126 @@ class FieldNestedUpdateAgg(FieldAggregator):
         )
 
 
+class FieldMergeMapWithKeyTimeAgg(FieldAggregator):
+    """
+    Aggregator for merging MAP values with key and timestamp.
+
+    The input field type must be MAP with ROW values. Each ROW value must
+    contain a timestamp field, which is used to resolve conflicts when the
+    same key appears multiple times.
+
+    For the same key:
+    - A value with a newer timestamp replaces the existing value.
+    - A value with a null timestamp is ignored.
+    - A null value removes the corresponding key from the map.
+    """
+    def __init__(
+            self,
+            name: str,
+            field_type: DataType,
+            field_name: str,
+            options: CoreOptions,
+    ):
+        super().__init__(name, field_type)
+        if not isinstance(field_type, MapType):
+            raise ValueError(
+                "Data type for field '{}' must be 'MAP' but was 
'{}'".format(field_name, field_type)
+            )
+        if not isinstance(field_type.value, RowType):
+            raise ValueError(
+                "Value type of MAP for field '{}' must be 'ROW' but was 
'{}'".format(field_name, field_type.value)
+            )
+        if len(field_type.value.fields) < 2:
+            raise ValueError(
+                "ROW type for field '{}' must have at least 2 fields, but 
found {}".format(
+                    field_name, len(field_type.value.fields)
+                )
+            )
+
+        self._resolve_ts_field_index(field_type.value, options, field_name)
+
+    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_map = {}
+        self._put_to_map(result_map, accumulator)
+        self._merge_input_map(result_map, input_field)
+
+        return result_map
+
+    def _resolve_ts_field_index(self, row_type: RowType, options: CoreOptions, 
field_name: str) -> None:
+        ts_field_name = options.field_merge_map_ts_field(field_name)
+        if ts_field_name is None:
+            # default to the last field
+            ts_field_index = len(row_type.fields) - 1
+            ts_field_name = row_type.fields[ts_field_index].name
+        else:
+            ts_field_index = row_type.get_field_index(ts_field_name)
+            if ts_field_index < 0:
+                raise ValueError(
+                    "Timestamp field '{}' not found in ROW type for field 
'{}'. Available fields: {}".format(
+                        ts_field_name, field_name, [field.name for field in 
row_type.fields]
+                    )
+                )
+
+        self.ts_field_name = ts_field_name
+        self.ts_field_index = ts_field_index
+
+    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 _merge_input_map(self, result_map: Dict[Any, Any], input_field: Any):
+        input_map = {}
+        self._put_to_map(input_map, input_field)
+
+        for key, new_row in input_map.items():
+            if new_row is None:
+                result_map.pop(key, None)
+                continue
+
+            new_ts = self._get_ts_field(new_row)
+            if new_ts is None:
+                continue
+
+            existing_row = result_map.get(key)
+            if existing_row is None:
+                result_map[key] = new_row
+                continue
+
+            existing_ts = self._get_ts_field(existing_row)
+            if existing_ts is None or new_ts > existing_ts:
+                result_map[key] = new_row
+
+    def _get_ts_field(self, input_row: Any) -> Any:
+        if isinstance(input_row, dict):
+            return input_row.get(self.ts_field_name)
+
+        if isinstance(input_row, InternalRow):
+            return input_row.get_field(self.ts_field_index)
+
+        raise TypeError(
+            "Unsupported row type '{}', expected InternalRow or dict.".format(
+                type(input_row).__name__
+            )
+        )
+
+
 # ---------------------------------------------------------------------------
 # Registration. Each builder binds an identifier to a factory that
 # optionally validates the column DataType before constructing the
@@ -809,3 +930,6 @@ register_aggregator(
 register_aggregator(
     NAME_COLLECT, _build_field_options(FieldCollectAgg, NAME_COLLECT)
 )
+register_aggregator(
+    NAME_MERGE_MAP_WITH_KEYTIME, 
_build_field_options(FieldMergeMapWithKeyTimeAgg, NAME_MERGE_MAP_WITH_KEYTIME)
+)
diff --git a/paimon-python/pypaimon/tests/test_field_aggregators.py 
b/paimon-python/pypaimon/tests/test_field_aggregators.py
index 2f4d07c39d..c6a188d90a 100644
--- a/paimon-python/pypaimon/tests/test_field_aggregators.py
+++ b/paimon-python/pypaimon/tests/test_field_aggregators.py
@@ -47,6 +47,7 @@ from pypaimon.read.reader.aggregate.aggregators import (
     FieldListaggAgg,
     FieldNestedUpdateAgg,
     FieldCollectAgg,
+    FieldMergeMapWithKeyTimeAgg,
 )
 from pypaimon.schema.data_types import AtomicType, DataField, RowType, 
ArrayType, MapType
 from pypaimon.table.row.generic_row import GenericRow
@@ -1765,6 +1766,160 @@ class FieldNestedUpdateAggTest(unittest.TestCase):
         self.assertCountEqual(accumulator, [self.row(0, 0, "A", 1), ])
 
 
+class FieldMergeMapWithKeyTimeAggTest(unittest.TestCase):
+
+    DEFAULT_FIELDS = [
+        DataField(0, "actual_value", AtomicType("STRING")),
+        DataField(1, "dbsync_ts", AtomicType("STRING")),
+    ]
+
+    def _make(self, row_type: RowType = None, options: CoreOptions = None):
+        if options is None:
+            options = CoreOptions(Options.from_none())
+
+        if not row_type:
+            row_type = RowType(True, self.DEFAULT_FIELDS)
+
+        return create_field_aggregator(
+            MapType(True, AtomicType("STRING"), row_type),
+            "field0",
+            "merge_map_with_keytime",
+            options=options,
+        )
+
+    def row(self, *values, fields: List[DataField] = None):
+        if fields is None:
+            fields = self.DEFAULT_FIELDS
+        return GenericRow(list(values), fields)
+
+    def test_field_merge_map_with_key_time_dict_row_default_ts(self):
+        agg = self._make()
+
+        result = agg.agg(
+            None,
+            {"key1": {"actual_value": "A", "dbsync_ts": "100"}},
+        )
+
+        self.assertEqual(
+            result,
+            {"key1": {"actual_value": "A", "dbsync_ts": "100"}},
+        )
+
+        result = agg.agg(
+            result,
+            {
+                "key1": {"actual_value": "A+", "dbsync_ts": "110"},
+                "key2": {"actual_value": "B", "dbsync_ts": "100"}
+            },
+        )
+
+        self.assertEqual(
+            result,
+            {
+                "key1": {"actual_value": "A+", "dbsync_ts": "110"},
+                "key2": {"actual_value": "B", "dbsync_ts": "100"}
+            },
+        )
+
+    def test_field_merge_map_with_key_time_agg_using_default_ts(self):
+        agg = self._make()
+        self.assertIsInstance(agg, FieldMergeMapWithKeyTimeAgg)
+
+        self.assertIsNone(agg.agg(None, None))
+
+        acc = agg.agg(
+            None,
+            {
+                "key1": self.row("A", "17682882903686900100"),
+                "key2": self.row("B", "17682882903686900100"),
+            },
+        )
+        self.assertEqual(
+            acc,
+            {
+                "key1": self.row("A", "17682882903686900100"),
+                "key2": self.row("B", "17682882903686900100"),
+            },
+        )
+
+        # newer timestamp replaces old value
+        acc = agg.agg(
+            acc,
+            {
+                "key1": self.row("A1", "17682882903686900200"),
+                "key3": self.row("C", "17682882903686900200"),
+            },
+        )
+        self.assertEqual(
+            acc,
+            {
+                "key1": self.row("A1", "17682882903686900200"),
+                "key2": self.row("B", "17682882903686900100"),
+                "key3": self.row("C", "17682882903686900200"),
+            },
+        )
+
+        # older timestamp keeps existing value
+        acc = agg.agg(
+            acc,
+            {
+                "key2": self.row("B2", "17682882903686900050"),
+            },
+        )
+        self.assertEqual(
+            acc["key2"],
+            self.row("B", "17682882903686900100"),
+        )
+
+    def test_field_merge_map_with_key_time_agg_using_event_time(self):
+        fields = [
+            DataField(0, "actual_value", AtomicType("STRING")),
+            DataField(1, "other_field", AtomicType("STRING")),
+            DataField(2, "event_time", AtomicType("STRING")),
+            DataField(3, "dbsync_ts", AtomicType("STRING")),
+        ]
+        agg = self._make(
+            row_type=RowType(True, fields),
+            options=CoreOptions(Options(
+                {"fields.field0.ts-field": "event_time"}
+            ))
+        )
+
+        acc = agg.agg(None, {
+            "key1": self.row("A", "other_a", "2026-07-19 10:00:00", 
"2026-07-19 12:00:00")
+        })
+
+        acc = agg.agg(acc, {
+            "key1": self.row("A1", "other_a1", "2026-07-19 11:00:00", 
"2026-07-19 10:00:00")
+        })
+        self.assertEqual(
+            acc["key1"],
+            self.row("A1", "other_a1", "2026-07-19 11:00:00", "2026-07-19 
10:00:00"),
+        )
+
+        acc = agg.agg(acc, {
+            "key1": self.row("A2", "other_a2", "2026-07-19 09:00:00", 
"2026-07-19 13:00:00")
+        })
+        self.assertEqual(
+            acc["key1"],
+            self.row("A1", "other_a1", "2026-07-19 11:00:00", "2026-07-19 
10:00:00"),
+        )
+
+    def test_field_merge_map_with_key_time_agg_retract(self):
+        agg = self._make()
+
+        with self.assertRaises(NotImplementedError):
+            agg.retract(
+                {
+                    "key1": self.row("A", "17682882903686900100"),
+                    "key2": self.row("B", "17682882903686900100"),
+                },
+                {
+                    "key1": self.row("A", "17682882903686900100"),
+                },
+            )
+
+
 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