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 f0143c98b1 [python] Introduce 'nested_partial_update' aggregator 
function (#8578)
f0143c98b1 is described below

commit f0143c98b1ab7efa1a243c51cfbc6a4f05ad60dc
Author: AuroraVoyage <[email protected]>
AuthorDate: Thu Jul 23 21:15:04 2026 +0800

    [python] Introduce 'nested_partial_update' aggregator function (#8578)
---
 .../pypaimon/read/merge_engine_support.py          |   2 +-
 .../pypaimon/read/reader/aggregate/aggregators.py  | 124 +++++++++++++++++++
 .../pypaimon/tests/test_field_aggregators.py       | 135 +++++++++++++++++++++
 3 files changed, 260 insertions(+), 1 deletion(-)

diff --git a/paimon-python/pypaimon/read/merge_engine_support.py 
b/paimon-python/pypaimon/read/merge_engine_support.py
index aab3cf3dd6..321421b892 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -63,7 +63,7 @@ _AGGREGATION_SUPPORTED_AGG_FUNCS = frozenset([
     "sum", "max", "min",
     "bool_or", "bool_and",
     "listagg",
-    "nested_update",
+    "nested_update", "nested_partial_update",
     "collect",
     "merge_map_with_keytime",
     "merge_map",
diff --git a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py 
b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
index d973acb8f4..5c325d99eb 100644
--- a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
+++ b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
@@ -40,6 +40,7 @@ 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, MapType
+from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.row.internal_row import InternalRow
 
 # aggregator input type hints variables
@@ -60,6 +61,7 @@ NAME_BOOL_OR = "bool_or"
 NAME_BOOL_AND = "bool_and"
 NAME_LISTAGG = "listagg"
 NAME_NESTED_UPDATE = "nested_update"
+NAME_NESTED_PARTIAL_UPDATE = "nested_partial_update"
 NAME_COLLECT = "collect"
 NAME_MERGE_MAP_WITH_KEYTIME = "merge_map_with_keytime"
 NAME_MERGE_MAP = "merge_map"
@@ -732,6 +734,125 @@ class FieldNestedUpdateAgg(FieldAggregator):
         )
 
 
+class FieldNestedPartialUpdateAgg(FieldAggregator):
+    """
+    Used to partial update a field which representing a nested table.
+    The data type of nested table field is ARRAY<ROW>
+    """
+    def __init__(
+            self,
+            name: str,
+            field_type: ArrayType,
+            field_name: str,
+            options: CoreOptions,
+    ):
+        field_type = _check_array_row(field_name, field_type)
+        super().__init__(name, field_type)
+
+        nested_type: RowType = field_type.element
+        self.nested_fields = len(nested_type.fields)
+
+        self.nested_key = 
options.field_nested_update_agg_nested_key(field_name)
+        if not self.nested_key:
+            raise ValueError("nested_update_partial requires 'nested-key' to 
be configured.")
+
+        self.key_projection = FieldProjection.from_fields(
+            [nested_type.get_field_index(name) for name in self.nested_key],
+            self.nested_key
+        )
+
+        self.nested_key_null_strategy = (
+            
options.field_nested_update_agg_nested_key_null_strategy(field_name)
+        )
+
+    def agg(self, accumulator: Any, input_field: Any) -> Any:
+        if input_field is None:
+            return accumulator
+
+        rows: List[Record] = []
+        if accumulator is not None:
+            self._add_non_null_rows(accumulator, rows)
+        self._add_non_null_rows(input_field, rows)
+
+        row_map: Dict[Tuple[Any, ...], Record] = {}
+        for row in rows:
+            key = self.key_projection.apply(row)
+            if not self._apply_nested_key_null_strategy(key):
+                continue
+
+            to_update = row_map.get(key)
+            if to_update is None:
+                if isinstance(row, InternalRow):
+                    to_update = GenericRow([None] * self.nested_fields, 
row.fields)
+                elif isinstance(row, dict):
+                    to_update = {}.fromkeys(row.keys())
+                else:
+                    raise TypeError(
+                        "Unsupported row type '{}'. Expected InternalRow or 
dict.".format(
+                            type(row).__name__
+                        )
+                    )
+            self._partial_update(to_update, row)
+            row_map[key] = to_update
+
+        return list(row_map.values())
+
+    def _partial_update(self, to_update: Record, input_row: Record) -> None:
+        if isinstance(to_update, InternalRow) and isinstance(input_row, 
InternalRow):
+            for i in range(self.nested_fields):
+                value = input_row.get_field(i)
+                if value is not None:
+                    to_update.values[i] = value
+        elif isinstance(to_update, dict) and isinstance(input_row, dict):
+            for k, v in input_row.items():
+                if v is not None:
+                    to_update[k] = v
+        else:
+            raise TypeError(
+                "Unsupported row types: to_update={}, input_row={}. "
+                "Expected both to be either InternalRow or dict.".format(
+                    type(to_update).__name__,
+                    type(input_row).__name__,
+                )
+            )
+
+    def _add_non_null_rows(
+            self,
+            array: List[Record],
+            rows: List[Record],
+    ) -> None:
+        """Append non-null rows from array."""
+
+        for row in array:
+            if row is None:
+                continue
+            rows.append(row)
+
+    def _apply_nested_key_null_strategy(self, key: Tuple[Any, ...]) -> bool:
+        """Apply nested-key-null-strategy."""
+
+        if all(v is not None for v in key):
+            return True
+
+        if self.nested_key_null_strategy == NestedKeyNullStrategy.MERGE:
+            return True
+
+        if self.nested_key_null_strategy == NestedKeyNullStrategy.IGNORE:
+            return False
+
+        if self.nested_key_null_strategy == NestedKeyNullStrategy.ERROR:
+            raise ValueError(
+                "Nested key contains null values. "
+                "Primary key fields must not be null."
+            )
+
+        raise ValueError(
+            "Unsupported nested-key-null-strategy '{}'".format(
+                self.nested_key_null_strategy
+            )
+        )
+
+
 class FieldMergeMapWithKeyTimeAgg(FieldAggregator):
     """
     Aggregator for merging MAP values with key and timestamp.
@@ -1018,6 +1139,9 @@ register_aggregator(
 register_aggregator(
     NAME_NESTED_UPDATE, _build_field_options(FieldNestedUpdateAgg, 
NAME_NESTED_UPDATE)
 )
+register_aggregator(
+    NAME_NESTED_PARTIAL_UPDATE, 
_build_field_options(FieldNestedPartialUpdateAgg, NAME_NESTED_PARTIAL_UPDATE)
+)
 register_aggregator(
     NAME_COLLECT, _build_field_options(FieldCollectAgg, NAME_COLLECT)
 )
diff --git a/paimon-python/pypaimon/tests/test_field_aggregators.py 
b/paimon-python/pypaimon/tests/test_field_aggregators.py
index 72b8767ec8..107b160df1 100644
--- a/paimon-python/pypaimon/tests/test_field_aggregators.py
+++ b/paimon-python/pypaimon/tests/test_field_aggregators.py
@@ -46,6 +46,7 @@ from pypaimon.read.reader.aggregate.aggregators import (
     FieldSumAgg,
     FieldListaggAgg,
     FieldNestedUpdateAgg,
+    FieldNestedPartialUpdateAgg,
     FieldCollectAgg,
     FieldMergeMapWithKeyTimeAgg,
     FieldMergeMapAgg,
@@ -1767,6 +1768,140 @@ class FieldNestedUpdateAggTest(unittest.TestCase):
         self.assertCountEqual(accumulator, [self.row(0, 0, "A", 1), ])
 
 
+class FieldNestedPartialUpdateAggTest(unittest.TestCase):
+    IDENTIFIER = "nested_partial_update"
+
+    DEFAULT_FIELDS = [
+        DataField(10, "k", AtomicType("INT")),
+        DataField(20, "v1", AtomicType("INT")),
+        DataField(30, "v2", AtomicType("STRING")),
+    ]
+
+    def _make_data_type(self, fields: List[DataField] = None):
+        if fields is None:
+            fields = self.DEFAULT_FIELDS
+        return ArrayType(
+            True,
+            RowType(True, fields)
+        )
+
+    def _make(self, data_type, options: CoreOptions = None):
+        """Build an aggregator through the public registry path so we also
+        exercise the registered factory (including its type validation).
+        """
+        if options is None:
+            options = CoreOptions(Options.from_none())
+
+        return create_field_aggregator(
+            data_type, "field0", self.IDENTIFIER, 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_nested_partial_update_agg(self):
+        agg = self._make(
+            self._make_data_type(),
+            CoreOptions(
+                Options(
+                    {
+                        "fields.field0.nested-key": "k",
+                    }
+                )
+            ),
+        )
+        self.assertIsInstance(agg, FieldNestedPartialUpdateAgg)
+
+        accumulator = None
+
+        current = self.row(0, 0, None)
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [current])
+
+        current = self.row(0, None, "A")
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [self.row(0, 0, "A")])
+
+        current = self.row(0, 1, "B")
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [self.row(0, 1, "B")])
+
+        current = self.row(1, 2, "C")
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [
+            self.row(0, 1, "B"),
+            self.row(1, 2, "C"),
+        ])
+
+        current = self.row(None, 0, "D")
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [
+            self.row(0, 1, "B"),
+            self.row(1, 2, "C"),
+            self.row(None, 0, "D"),
+        ])
+
+    def 
test_field_nested_partial_update_agg_with_nested_key_null_use_ignore_strategy(self):
+        agg = self._make(
+            self._make_data_type(),
+            CoreOptions(
+                Options(
+                    {
+                        "fields.field0.nested-key": "k",
+                        "fields.field0.nested-key-null-strategy": "IGNORE",
+                    }
+                )
+            ),
+        )
+
+        accumulator = None
+
+        current = self.row(0, 0, None)
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [current])
+
+        current = self.row(None, None, "A_ignore")
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [self.row(0, 0, None)])
+
+        current = self.row(None, None, "FirstInput")
+        accumulator = agg.agg(None, [current])
+        self.assertCountEqual(accumulator, [])
+
+    def 
test_field_nested_partial_update_agg_with_nested_key_null_use_throw_error_strategy(self):
+        agg = self._make(
+            self._make_data_type(),
+            CoreOptions(
+                Options(
+                    {
+                        "fields.field0.nested-key": "k",
+                        "fields.field0.nested-key-null-strategy": "ERROR",
+                    }
+                )
+            ),
+        )
+
+        accumulator = None
+
+        current = self.row(0, 0, None)
+        accumulator = agg.agg(accumulator, [current])
+        self.assertCountEqual(accumulator, [current])
+
+        with self.assertRaisesRegex(
+                ValueError,
+                "Nested key contains null values. Primary key fields must not 
be null.",
+        ):
+            agg.agg(accumulator, [self.row(None, 0, "A")])
+
+        with self.assertRaisesRegex(
+                ValueError,
+                "Nested key contains null values. Primary key fields must not 
be null.",
+        ):
+            agg.agg(None, [self.row(None, None, "FirstInput")])
+
+
 class FieldMergeMapWithKeyTimeAggTest(unittest.TestCase):
 
     DEFAULT_FIELDS = [

Reply via email to