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 d1675a7a64 [python] Support nested payloads in local MERGE INTO 
(#10094)
d1675a7a64 is described below

commit d1675a7a64089fa24504ed53ca67a0e579198337
Author: chaoyang <[email protected]>
AuthorDate: Thu Sep 24 14:38:52 2026 +0800

    [python] Support nested payloads in local MERGE INTO (#10094)
---
 .../pypaimon/table/data_evolution_merge_into.py    |  30 +++++-
 .../pypaimon/tests/table_merge_into_test.py        | 102 +++++++++++++++++++++
 2 files changed, 129 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py 
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index ac556b6675..7439823d08 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -486,7 +486,8 @@ def _build_matched_update_table(
 
     target_renamed = _rename_with_prefix(target, "t.")
     source_renamed = _rename_with_prefix(source_table, "s.")
-    joined = target_renamed.join(
+    joined = _join_with_row_indices(
+        target_renamed,
         source_renamed,
         keys=["t.{}".format(c) for c in ctx.target_on_cols],
         right_keys=["s.{}".format(c) for c in ctx.source_on_cols],
@@ -525,7 +526,8 @@ def _build_matched_delete_table(
 
     target_renamed = _rename_with_prefix(target, "t.")
     source_renamed = _rename_with_prefix(source_table, "s.")
-    joined = target_renamed.join(
+    joined = _join_with_row_indices(
+        target_renamed,
         source_renamed,
         keys=["t.{}".format(c) for c in ctx.target_on_cols],
         right_keys=["s.{}".format(c) for c in ctx.source_on_cols],
@@ -564,7 +566,8 @@ def _build_not_matched_insert_table(
             unmatched = source_renamed
         else:
             target_renamed = _rename_with_prefix(target, "t.")
-            unmatched = source_renamed.join(
+            unmatched = _join_with_row_indices(
+                source_renamed,
                 target_renamed,
                 keys=["s.{}".format(c) for c in ctx.source_on_cols],
                 right_keys=["t.{}".format(c) for c in ctx.target_on_cols],
@@ -577,6 +580,27 @@ def _build_not_matched_insert_table(
     return transform(unmatched)
 
 
+def _join_with_row_indices(left, right, keys, right_keys, join_type):
+    """Keep nested payloads out of Arrow's join, then gather matched rows."""
+    key_names = ["key_%d" % index for index in range(len(keys))]
+    left_keys = left.select(keys).rename_columns(key_names).append_column(
+        "left_index", pa.array(range(left.num_rows), type=pa.int64()))
+    right_keys_table = right.select(right_keys).rename_columns(key_names)
+    if join_type == "inner":
+        right_keys_table = right_keys_table.append_column(
+            "right_index", pa.array(range(right.num_rows), type=pa.int64()))
+    indices = left_keys.join(right_keys_table, keys=key_names, 
join_type=join_type)
+    result = left.take(indices["left_index"])
+    if join_type == "inner":
+        # Match Table.join's coalesced-key schema: keep only the left keys.
+        payload = right.select([
+            name for name in right.column_names if name not in right_keys
+        ]).take(indices["right_index"])
+        for field, column in zip(payload.schema, payload.columns):
+            result = result.append_column(field, column)
+    return result
+
+
 def _prepare_commit_messages(
     table,
     update_table: Optional[pa.Table],
diff --git a/paimon-python/pypaimon/tests/table_merge_into_test.py 
b/paimon-python/pypaimon/tests/table_merge_into_test.py
index 811de74d90..3f117c4893 100644
--- a/paimon-python/pypaimon/tests/table_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/table_merge_into_test.py
@@ -623,6 +623,108 @@ class TableMergeIntoTest(BatchModeMixin, 
DataEvolutionTestBase, unittest.TestCas
             self._read_projected_sorted(target, ["id", "name", "payload"]),
         )
 
+    def test_table_merge_into_nested_payloads(self):
+        cases = [
+            (pa.list_(pa.float32(), 2), [1., 2.], [3., 4.], [5., 6.]),
+            (pa.list_(pa.int64()), [1, None], [], None),
+            (pa.map_(pa.string(), pa.string()), [("old", "value")], [], 
[("new", None)]),
+            (pa.struct([("label", pa.string()), ("count", pa.int64())]),
+             {"label": "old", "count": None}, None, {"label": None, "count": 
2}),
+        ]
+        for payload_type, before, updated, inserted in cases:
+            with self.subTest(payload_type=payload_type):
+                schema = pa.schema([("id", pa.int32()), ("payload", 
payload_type)])
+                target = self._create_table(pa_schema=schema, options=dict(
+                    self.table_options, **{
+                        "file.format": "parquet", "vector.file.format": 
"parquet",
+                        "deletion-vectors.enabled": "true",
+                    }))
+                self._write_arrow(target, pa.Table.from_pylist([
+                    {"id": 1, "payload": before}, {"id": 2, "payload": before},
+                ], schema=schema))
+                # Keep the source chunked, with a nonzero slice offset.
+                source = pa.concat_tables([
+                    pa.Table.from_pylist([
+                        {"id": 0, "payload": before}, {"id": 2, "payload": 
updated},
+                    ], schema=schema).slice(1),
+                    pa.Table.from_pylist([{"id": 3, "payload": inserted}], 
schema=schema),
+                ])
+
+                self._merge_and_commit(
+                    target, source, on=["id"],
+                    when_matched=[WhenMatched.update("*")],
+                    when_not_matched=[WhenNotMatched(insert="*")])
+
+                self.assertEqual({"id": [1, 2, 3], "payload": [before, 
updated, inserted]},
+                                 self._read_sorted(target))
+                # A delete must also accept unused nested source columns.
+                self._merge_and_commit(
+                    target, source.slice(0, 1), on=["id"],
+                    when_matched=[WhenMatched.delete()])
+                self.assertEqual({"id": [1, 3], "payload": [before, inserted]},
+                                 self._read_sorted(target))
+
+    @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+    def test_nested_merge_keeps_mapped_composite_keys_and_null_semantics(self):
+        schema = pa.schema([
+            ("id", pa.int32()), ("group", pa.string()),
+            ("payload", pa.list_(pa.int32())), ("previous", 
pa.list_(pa.int32())),
+        ])
+        target = self._create_table(pa_schema=schema, options=dict(
+            self.table_options, **{"file.format": "parquet"}))
+        self._write_arrow(target, pa.Table.from_pylist([
+            {"id": 1, "group": "a", "payload": [10], "previous": []},
+            {"id": 1, "group": "b", "payload": [20], "previous": []},
+            {"id": None, "group": "a", "payload": [30], "previous": []},
+        ], schema=schema))
+        source_schema = pa.schema([
+            ("source_id", pa.int32()), ("source_group", pa.string()),
+            ("payload", pa.list_(pa.int32())),
+        ])
+        source = pa.Table.from_pylist([
+            {"source_id": 1, "source_group": "a", "payload": [11]},
+            {"source_id": 1, "source_group": "b", "payload": [21]},
+            {"source_id": None, "source_group": "a", "payload": [31]},
+            {"source_id": 2, "source_group": "a", "payload": [40]},
+        ], schema=source_schema)
+
+        self._merge_and_commit(
+            target, source, on={"id": "source_id", "group": "source_group"},
+            when_matched=[WhenMatched.update({
+                "payload": source_col("payload"), "previous": 
target_col("payload"),
+            }, condition="s.source_id = 1 AND t.group = 'a'")],
+            when_not_matched=[WhenNotMatched(insert={"payload": 
source_col("payload")})])
+
+        self.assertCountEqual([
+            {"id": 1, "group": "a", "payload": [11], "previous": [10]},
+            {"id": 1, "group": "b", "payload": [20], "previous": []},
+            {"id": None, "group": "a", "payload": [30], "previous": []},
+            {"id": None, "group": "a", "payload": [31], "previous": None},
+            {"id": 2, "group": "a", "payload": [40], "previous": None},
+        ], self._read_all(target).to_pylist())
+
+    def test_nested_merge_rejects_duplicate_matches_before_commit(self):
+        schema = pa.schema([("id", pa.int32()), ("payload", 
pa.list_(pa.int32()))])
+        target = self._create_table(pa_schema=schema, options=dict(
+            self.table_options, **{"file.format": "parquet"}))
+        self._write_arrow(target, pa.Table.from_pylist([
+            {"id": 1, "payload": [10]},
+        ], schema=schema))
+        snapshot_id = target.snapshot_manager().get_latest_snapshot().id
+        source = pa.Table.from_pylist([
+            {"id": 1, "payload": [11]}, {"id": 1, "payload": [12]},
+            {"id": 2, "payload": [20]},
+        ], schema=schema)
+
+        with self.assertRaisesRegex(ValueError, "multiple source rows"):
+            self._merge_and_commit(
+                target, source, on=["id"],
+                when_matched=[WhenMatched.update("*")],
+                when_not_matched=[WhenNotMatched(insert="*")])
+
+        self.assertEqual(snapshot_id, 
target.snapshot_manager().get_latest_snapshot().id)
+        self.assertEqual({"id": [1], "payload": [[10]]}, 
self._read_sorted(target))
+
     def test_table_merge_into_inserts_null_for_unspecified_blob_column(self):
         blob_schema = pa.schema([
             ("id", pa.int32()),

Reply via email to