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 5209d3a1e0 [python] Use native upsert for rows with different supplied 
fields (#10414)
5209d3a1e0 is described below

commit 5209d3a1e09b41342dc814acad1d48f4425bbcc6
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 19:37:28 2026 +0800

    [python] Use native upsert for rows with different supplied fields (#10414)
---
 .../tests/native_upsert_row_shapes_test.py         | 184 +++++++++++++++++++++
 paimon-python/pypaimon/write/native_update.py      |  41 +++--
 2 files changed, 212 insertions(+), 13 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_upsert_row_shapes_test.py 
b/paimon-python/pypaimon/tests/native_upsert_row_shapes_test.py
new file mode 100644
index 0000000000..7afbf2222f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_upsert_row_shapes_test.py
@@ -0,0 +1,184 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Keep named-row presence through Native upsert matching and appending."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.native_write import native_write_available
+
+
[email protected](params=[False, pytest.param(True, marks=[
+    pytest.mark.native_plan,
+    pytest.mark.skipif(not native_write_available(), reason='native writer 
required'),
+])], ids=['python', 'native'])
+def native(request):
+    return request.param
+
+
[email protected](params=[False, True], ids=['batch', 'stream'])
+def stream(request):
+    return request.param
+
+
[email protected]
+def table_factory(tmp_path, native):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+
+    def create(partitioned=False):
+        fields = [('id', pa.int32()), ('value', pa.int32()), ('score', 
pa.int32())]
+        if partitioned:
+            fields.append(('p', pa.int32()))
+        schema = pa.schema(fields)
+        catalog.create_table('default.t', Schema.from_pyarrow_schema(
+            schema, partition_keys=['p'] if partitioned else None, options={
+                'data-evolution.enabled': 'true', 'row-tracking.enabled': 
'true',
+                'deletion-vectors.enabled': 'true',
+                'write.native.enabled': str(native).lower(),
+                'scan.native-plan.enabled': str(native).lower(),
+                'read.native.enabled': str(native).lower(),
+            }), False)
+        return catalog.get_table('default.t'), schema
+
+    return create
+
+
+def _seed(table, schema, rows):
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _upsert(table, native, stream, rows, columns=('value',)):
+    by_name = {field.name: field for field in table.fields}
+    rows = [GenericRow(list(row.values()), [by_name[name] for name in row]) 
for row in rows]
+    builder = (table.new_stream_write_builder() if stream else 
table.new_batch_write_builder())
+    update = builder.new_update().with_update_type(list(columns))
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch(
+                
'pypaimon.write.table_upsert_by_key.TableUpsertByKey._upsert_row_partition',
+                side_effect=AssertionError('named-row upsert fell back to 
Python')))
+            stack.enter_context(patch(
+                
'pypaimon.write.table_upsert_by_key.TableUpsertByKey._build_key_to_row_ids_map',
+                side_effect=AssertionError('upsert keys were matched in 
Python')))
+        messages = (update.upsert_by_key(rows, ['id'], 101) if stream
+                    else update.upsert_by_key(rows, ['id']))
+    commit = builder.new_commit()
+    try:
+        if stream:
+            commit.commit(messages, 101)
+        else:
+            commit.commit(messages)
+    finally:
+        commit.close()
+    return messages
+
+
+def _rows(table):
+    builder = table.new_read_builder()
+    plan = builder.new_scan().plan()
+    return sorted(builder.new_read().to_arrow(plan.splits()).to_pylist(),
+                  key=lambda row: (row['id'], row.get('p', 0)))
+
+
+def _append_shapes(messages):
+    return {tuple(file.write_cols) for message in messages for file in 
message.new_files
+            if file.write_cols and 'id' in file.write_cols}
+
+
+def test_upsert_deduplicates_across_shapes_before_validating_columns(
+        native, stream, table_factory):
+    table, schema = table_factory()
+    _seed(table, schema, [
+        {'id': 1, 'value': 10, 'score': 100},
+        {'id': 1, 'value': 11, 'score': 101},
+        {'id': 2, 'value': 20, 'score': 200},
+    ])
+    messages = _upsert(table, native, stream, [
+        {'id': 1, 'score': 999}, {'id': 4, 'value': 40},
+        {'value': 111, 'id': 1}, {'id': 4, 'score': 400},
+        {'id': 2, 'value': 222, 'score': 999},
+    ])
+    assert _rows(table) == [
+        {'id': 1, 'value': 111, 'score': 100},
+        {'id': 1, 'value': 111, 'score': 101},
+        {'id': 2, 'value': 222, 'score': 200},
+        {'id': 4, 'value': None, 'score': 400},
+    ]
+    assert _append_shapes(messages) == {('id', 'score')}
+
+
+def test_upsert_distinguishes_explicit_null_from_an_absent_field(native, 
stream, table_factory):
+    table, schema = table_factory()
+    _seed(table, schema, [{'id': 1, 'value': 10, 'score': 100}])
+    messages = _upsert(table, native, stream, [
+        {'id': 1, 'value': None}, {'id': 2, 'score': 200},
+    ])
+    assert _rows(table) == [
+        {'id': 1, 'value': None, 'score': 100},
+        {'id': 2, 'value': None, 'score': 200},
+    ]
+    assert _append_shapes(messages) == {('id', 'score')}
+
+
+def test_upsert_allows_different_append_shapes_in_different_partitions(native, 
stream, table_factory):
+    table, schema = table_factory(partitioned=True)
+    _seed(table, schema, [
+        {'id': 1, 'value': 10, 'score': 100, 'p': 1},
+        {'id': 1, 'value': 20, 'score': 200, 'p': 2},
+    ])
+    messages = _upsert(table, native, stream, [
+        {'id': 1, 'value': 111, 'p': 1}, {'id': 4, 'value': 40, 'p': 1},
+        {'id': 4, 'score': 400, 'p': 2}, {'id': 1, 'value': 222, 'score': 999, 
'p': 2},
+        {'id': 4, 'value': 41, 'p': 1},
+    ])
+    assert _rows(table) == [
+        {'id': 1, 'value': 111, 'score': 100, 'p': 1},
+        {'id': 1, 'value': 222, 'score': 200, 'p': 2},
+        {'id': 4, 'value': 41, 'score': None, 'p': 1},
+        {'id': 4, 'value': None, 'score': 400, 'p': 2},
+    ]
+    assert _append_shapes(messages) == {('id', 'value', 'p'), ('id', 'score', 
'p')}
+
+
[email protected]('input_rows,message', [
+    ([{'id': 1, 'value': 111}, {'id': 2, 'score': 222}], 'value'),
+    ([{'id': 4, 'value': 40}, {'id': 5, 'score': 500}], 'same field set'),
+])
+def test_upsert_rejects_missing_winner_columns_and_inconsistent_appends(
+        native, stream, table_factory, input_rows, message):
+    table, schema = table_factory()
+    original = [{'id': 1, 'value': 10, 'score': 100}, {'id': 2, 'value': 20, 
'score': 200}]
+    _seed(table, schema, original)
+    before = table.snapshot_manager().get_latest_snapshot().id
+    with pytest.raises(ValueError, match=message):
+        _upsert(table, native, stream, input_rows)
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert _rows(table) == original
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 96b93109cb..9b19e49b1f 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -136,6 +136,31 @@ def _native_update_columns_supported(table, columns):
                    and field.name not in inline for field in table.fields)
 
 
+def _upsert_row_batches(rows, fields, schema):
+    """Encode consecutive row shapes without padding absent fields with NULL.
+
+    Keep source order, including duplicates across shapes. Key matching,
+    last-write-wins and matched/append column validation belong to Rust core.
+    """
+    batches = []
+    start = 0
+    while start < len(rows):
+        names = set(rows[start])
+        if not names <= set(schema.names):
+            raise ValueError('upsert row fields must be in the table schema')
+        end = start + 1
+        while end < len(rows) and set(rows[end]) == names:
+            end += 1
+        selected = [field for field in fields if field.name in names]
+        batch_schema = pa.schema([schema.field(field.name) for field in 
selected])
+        batches.append(pa.RecordBatch.from_pydict({
+            field.name: [value_for_arrow(row[field.name], field) for row in 
rows[start:end]]
+            for field in selected
+        }, schema=batch_schema))
+        start = end
+    return batches
+
+
 def create_native_upsert(table, commit_user, data, keys, columns):
     """Prepare one core upsert from Arrow columns or named row values."""
     if table.options.video_frame_fields() or not 
_native_update_columns_supported(table, columns):
@@ -150,20 +175,10 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
     if any(not _supported_upsert_key_type(schema.field(key).type) for key in 
set(keys + table.partition_keys)):
         return None
     if not isinstance(data, pa.Table):
-        # Keep absent fields absent. Heterogeneous row field sets still need
-        # the row-aware updater to distinguish absence from an explicit NULL.
         if not columns or not data:
             return None
-        names = set(data[0])
-        if not names <= set(schema.names) or any(set(values) != names for 
values in data):
-            return None
-        fields = [field for field in fields if field.name in names]
-        schema = pa.schema([schema.field(field.name) for field in fields])
-        data = pa.Table.from_pydict({
-            field.name: [value_for_arrow(values[field.name], field) for values 
in data]
-            for field in fields
-        }, schema=schema)
-    if (len(data.column_names) != len(set(data.column_names))
+        data = _upsert_row_batches(data, fields, schema)
+    elif (len(data.column_names) != len(set(data.column_names))
             or not set(data.column_names) <= set(schema.names)
             or any(data.schema.field(name).type != schema.field(name).type
                    for name in data.column_names)):
@@ -295,7 +310,7 @@ class NativeTableUpdateByRowId(TableUpdateByRowId):
 
 
 class NativeTableUpsert:
-    """Submit full Arrow rows to the core Rust upsert writer."""
+    """Submit Arrow tables or row-shape batches to the core Rust upsert 
writer."""
 
     def __init__(self, table, writer, keys, data):
         self.table = table

Reply via email to