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 1651043c91 [python] Enable native nested updates, temporal upserts and 
descending sequences (#10242)
1651043c91 is described below

commit 1651043c91c4779729b16f7f3c7724d6b036f622
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 27 22:57:16 2026 +0800

    [python] Enable native nested updates, temporal upserts and descending 
sequences (#10242)
---
 .../pypaimon/tests/native_nested_update_test.py    | 301 +++++++++++++++++++++
 .../pypaimon/tests/native_sequence_test.py         | 121 +++++++++
 .../tests/native_upsert_capabilities_test.py       | 150 ++++++++++
 paimon-python/pypaimon/tests/native_write_test.py  |   2 +-
 paimon-python/pypaimon/write/native_update.py      |  23 +-
 paimon-python/pypaimon/write/native_write.py       |   9 +-
 paimon-python/pypaimon/write/table_update.py       |   2 +-
 .../pypaimon/write/table_upsert_by_key.py          |   3 +-
 8 files changed, 584 insertions(+), 27 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_nested_update_test.py 
b/paimon-python/pypaimon/tests/native_nested_update_test.py
new file mode 100644
index 0000000000..db691e94f6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_nested_update_test.py
@@ -0,0 +1,301 @@
+# 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.
+
+"""Nested Native update dispatch and parity with the Python implementation."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.table_update import TableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+CASES = [
+    (pa.list_(pa.struct([('name', pa.string()), ('amount', pa.int32())])),
+     [[{'name': 'a', 'amount': 1}, None], [], None, [{'name': None, 'amount': 
4}]]),
+    (pa.map_(pa.field('source_key', pa.string(), nullable=False),
+             pa.field('source_value', pa.list_(pa.int32()))),
+     [[('a', [1, None]), ('b', [])], [], None, [('c', None)]]),
+    (pa.struct([('items', pa.list_(pa.int32())), ('raw', pa.binary())]),
+     [{'items': [1, None], 'raw': b'abc'}, {'items': [], 'raw': None}, None,
+      {'items': None, 'raw': b''}]),
+]
+
+
+def _table(tmp_path, data_type, native):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', data_type), 
('untouched', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.table({'id': [0, 1, 2, 3], 'value': [None] * 4,
+                                    'untouched': [10, 11, 12, 13]}, 
schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
+def _native_only(native):
+    stack = ExitStack()
+    if native:
+        for cls, method in [(TableUpdateByRowId, '_load_existing_files_info'),
+                            (TableUpdate, '_build_predicate_update_table'),
+                            (TableUpsertByKey, '_upsert_partition'),
+                            (TableUpsertByKey, '_upsert_row_partition')]:
+            stack.enter_context(patch.object(cls, method, 
side_effect=AssertionError('Python fallback')))
+    return stack
+
+
+def _commit(builder, messages, stream):
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 7) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+
+
+def _check_rows(table, values):
+    for native in (False, True):
+        copy = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                           'read.native.enabled': str(native).lower()})
+        builder = copy.new_read_builder()
+        read = builder.new_read()
+        with ExitStack() as stack:
+            if native:
+                stack.enter_context(patch.object(read, '_create_split_read',
+                                                 
side_effect=AssertionError('Python read fallback')))
+            actual = 
read.to_arrow(builder.new_scan().plan().splits()).sort_by('id')
+        assert actual['value'].to_pylist() == values
+        assert actual['untouched'].to_pylist() == list(range(10, 10 + 
len(values)))
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('data_type,values', CASES)
[email protected]('operation', [
+    'row_id', 'incremental', 'predicate_array', 'predicate_callable', 
'predicate_scalar',
+])
+def test_nested_updates_use_core(tmp_path, native, stream, data_type, values, 
operation):
+    table, _ = _table(tmp_path, data_type, native)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    # Slices retain nonzero offsets in both the top level and nested buffers.
+    array = pa.array([values[0]] + values, type=data_type).slice(1)
+    data = pa.table({'_ROW_ID': [0, 1, 2, 3], 'value': array})
+    with _native_only(native):
+        if operation == 'row_id':
+            messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+                        else update.update_by_arrow_with_row_id(data))
+        elif operation == 'incremental':
+            writer = update.new_update_by_row_id(7) if stream else 
update.new_update_by_row_id()
+            messages = writer.update_columns(data, ['value'])
+        else:
+            predicate = PredicateBuilder(table.fields).greater_or_equal('id', 
0)
+            if operation == 'predicate_callable':
+                def assignment(matched):
+                    assert matched.column_names == ['id', '_ROW_ID']
+                    return pa.array([values[i] for i in 
matched['id'].to_pylist()], type=data_type)
+            elif operation == 'predicate_scalar':
+                assignment = values[0]
+            else:
+                assignment = pa.chunked_array([array.slice(0, 1), 
array.slice(1)])
+            kwargs = {'read_columns': ['id']} if operation == 
'predicate_callable' else {}
+            if stream:
+                messages = update.update_by_predicate(predicate, {'value': 
assignment}, 7, **kwargs)
+            else:
+                messages = update.update_by_predicate(predicate, {'value': 
assignment}, **kwargs)
+    _commit(builder, messages, stream)
+    _check_rows(table, [values[0]] * 4 if operation == 'predicate_scalar' else 
values)
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('data_type,values', CASES)
[email protected]('row_input', [False, True])
+def test_nested_upsert_updates_and_appends(tmp_path, native, stream, 
data_type, values, row_input):
+    table, schema = _table(tmp_path, data_type, native)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    ids = [0, 0, 1, 2, 3, 4]
+    source_values = [None] + values + [values[0]]
+    data = pa.table({'id': ids, 'value': source_values,
+                     'untouched': [99, 99, 99, 99, 99, 14]}, schema=schema)
+    with _native_only(native):
+        if row_input:
+            rows = [GenericRow([row[name] for name in schema.names], 
table.fields)
+                    for row in data.to_pylist()]
+            messages = (update.upsert_by_key(rows, ['id'], 7) if stream
+                        else update.upsert_by_key(rows, ['id']))
+        else:
+            # Column order is unrelated to the schema/updated-column order.
+            data = data.select(['untouched', 'value', 'id'])
+            messages = (update.upsert_by_arrow_with_key(data, ['id'], 7) if 
stream
+                        else update.upsert_by_arrow_with_key(data, ['id']))
+    _commit(builder, messages, stream)
+    _check_rows(table, values + [values[0]])
+
+
[email protected]('native', [False, True])
[email protected]('input_values,target', [
+    (pa.array([{'a': 1.5, 'b': 2.0}, None]), pa.map_(pa.string(), pa.int32())),
+    (pa.array([{'a': 1, 'b': None}, None]), pa.map_(pa.string(), pa.int32())),
+    (pa.array([{}, None]), pa.map_(pa.string(), pa.int32())),
+    (pa.array([[[1.0, 2.5], [3.0, 4.0]], None, []]), pa.map_(pa.int32(), 
pa.int32())),
+    (pa.array([[[1, 2, 3]], None]), pa.map_(pa.int32(), pa.int32())),
+    (pa.array([[1.5, None], [], None]), pa.list_(pa.int32())),
+    (pa.array([{'a': 1, 'b': 1.5}]), pa.struct([('a', pa.string()), ('b', 
pa.int32())])),
+    (pa.array([{'m': {'a': 1, 'b': None}}, None]),
+     pa.struct([('m', pa.map_(pa.string(), pa.int32()))])),
+    (pa.array([{'m': [[1, 2]]}]), pa.struct([('m', pa.map_(pa.int32(), 
pa.int32()))])),
+    (pa.array([[{'a': 1, 'b': None}], None]), pa.list_(pa.map_(pa.string(), 
pa.int32()))),
+    (pa.array([[[[1, 2]]]]), pa.list_(pa.map_(pa.int32(), pa.int32()))),
+    (pa.array([[('a', 1), ('b', 2)], None], type=pa.map_(pa.string(), 
pa.int32())),
+     pa.struct([('a', pa.int32()), ('b', pa.int32())])),
+    (pa.array([[('a', 1.5), ('b', 2.5), ('ignored', float('nan'))], [('a', 
3.5)]],
+              type=pa.map_(pa.string(), pa.float64())),
+     pa.struct([('a', pa.int32()), ('b', pa.int32())])),
+    (pa.array([[('b', 1), ('a', 2)]], type=pa.map_(pa.string(), pa.int32())),
+     pa.struct([('a', pa.int32()), ('b', pa.int32())])),
+    (pa.StructArray.from_arrays([
+        pa.DictionaryArray.from_arrays(pa.array([0, 1], type=pa.int8()),
+                                       pa.array([1, None], type=pa.int32()))], 
names=['a']),
+     pa.map_(pa.string(), pa.int32())),
+])
+def test_nested_row_id_coercion_matches_python(tmp_path, native, input_values, 
target):
+    table, _ = _table(tmp_path, target, native)
+    builder = table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    data = pa.table({'_ROW_ID': list(range(len(input_values))), 'value': 
input_values})
+    before = set(tmp_path.rglob('*.parquet'))
+    try:
+        expected = TableUpdateByRowId._coerce_column(input_values, 
target).to_pylist()
+    except (ValueError, TypeError, pa.ArrowException):
+        with _native_only(native), pytest.raises((ValueError, TypeError, 
pa.ArrowException)):
+            update.update_by_arrow_with_row_id(data)
+        assert set(tmp_path.rglob('*.parquet')) == before
+        expected = [None] * len(input_values)
+    else:
+        with _native_only(native):
+            messages = update.update_by_arrow_with_row_id(data)
+        _commit(builder, messages, False)
+    _check_rows(table, expected + [None] * (4 - len(input_values)))
+
+
[email protected]('native', [False, True])
[email protected]('operation', ['row_id', 'predicate'])
+def test_nested_struct_reordering_uses_operation_cast_rules(tmp_path, native, 
operation):
+    target = pa.struct([('b', pa.int32()), ('a', pa.int32())])
+    values = pa.array([{'a': 1, 'b': 2}])
+    table, _ = _table(tmp_path, target, native)
+    builder = table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    predicate = PredicateBuilder(table.fields).equal('id', 0)
+    before = set(tmp_path.rglob('*.parquet'))
+    with _native_only(native):
+        if operation == 'predicate':
+            # Safe assignment casts reject reordering; row-ID coercion retries
+            # the whole input as dictionaries and can match ROW fields by name.
+            with pytest.raises((ValueError, pa.ArrowException)):
+                update.update_by_predicate(predicate, {'value': values})
+            assert set(tmp_path.rglob('*.parquet')) == before
+            expected = [None] * 4
+        else:
+            messages = update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': 
[0], 'value': values}))
+            _commit(builder, messages, False)
+            expected = [{'b': 2, 'a': 1}, None, None, None]
+    _check_rows(table, expected)
+
+
[email protected]('native', [False, True])
[email protected]('layout', ['list_view', 'large_list_view'])
+def test_view_pairs_update_map(tmp_path, native, layout):
+    if not hasattr(pa, layout):
+        pytest.skip('List view arrays require a newer PyArrow version')
+    target = pa.map_(pa.int32(), pa.int32())
+    table, _ = _table(tmp_path, target, native)
+    values = pa.array([[[99, 99, 99]], [[1, 2], [3, None]], None, []],
+                      type=getattr(pa, layout)(pa.list_(pa.int64()))).slice(1)
+    builder = table.new_batch_write_builder()
+    with _native_only(native):
+        messages = builder.new_update().update_by_arrow_with_row_id(
+            pa.table({'_ROW_ID': [0, 1, 2], 'value': values}))
+    _commit(builder, messages, False)
+    _check_rows(table, [[(1, 2), (3, None)], None, [], None])
+
+
[email protected]('native', [False, True])
[email protected]('data_type,values', CASES)
[email protected]('overlap', [False, True])
+def test_nested_grouped_updates_abort_overlap(tmp_path, native, data_type, 
values, overlap):
+    table, _ = _table(tmp_path, data_type, native)
+    builder = table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    data = pa.table({'_ROW_ID': [0, 1, 2, 3], 'value': pa.array(values, 
type=data_type)})
+    before = set(tmp_path.rglob('*.parquet'))
+    with _native_only(native):
+        if overlap:
+            with pytest.raises(ValueError):
+                update.update_by_arrow_batches_with_row_id(iter([data, data]))
+            assert set(tmp_path.rglob('*.parquet')) == before
+        else:
+            messages = 
update.update_by_arrow_batches_with_row_id(iter([data.slice(0, 0), data]))
+            _commit(builder, messages, False)
+    _check_rows(table, [None] * 4 if overlap else values)
+
+
[email protected]('native', [False, True])
[email protected]('key_type', ['string', 'large_string', 'string_view', 
'binary',
+                                      'large_binary', 'binary_view', 
'fixed_binary'])
+def test_map_field_name_encodings(tmp_path, native, key_type):
+    if key_type == 'fixed_binary':
+        key_type = pa.binary(1)
+    elif hasattr(pa, key_type):
+        key_type = getattr(pa, key_type)()
+    else:
+        pytest.skip('View arrays require a newer PyArrow version')
+    target = pa.struct([('a', pa.int32()), ('b', pa.int32())])
+    table, _ = _table(tmp_path, target, native)
+    values = pa.array([[('a', 1.5), ('b', 2.5), ('x', float('nan'))], [('a', 
3.5)]],
+                      type=pa.map_(key_type, pa.float64()))
+    if not native:
+        try:
+            values.take(pa.array([0], type=pa.int32()))
+        except pa.ArrowNotImplementedError:
+            pytest.skip('This PyArrow version cannot filter MAPs with view 
keys')
+    builder = table.new_batch_write_builder()
+    with _native_only(native):
+        messages = builder.new_update().update_by_arrow_with_row_id(
+            pa.table({'_ROW_ID': [0, 1], 'value': values}))
+    _commit(builder, messages, False)
+    _check_rows(table, [{'a': 1, 'b': 2}, {'a': 3, 'b': None}, None, None])
diff --git a/paimon-python/pypaimon/tests/native_sequence_test.py 
b/paimon-python/pypaimon/tests/native_sequence_test.py
new file mode 100644
index 0000000000..20058bcc12
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_sequence_test.py
@@ -0,0 +1,121 @@
+# 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.
+
+"""Java-compatible user sequence ordering across Python and Native files."""
+
+from contextlib import ExitStack
+from decimal import Decimal
+import datetime
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.write.native_write import NativeTableWrite
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, sequence_type, native, order, engine):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('seq', sequence_type),
+                        ('seq2', pa.int32()), ('value', pa.string())])
+    options = {'bucket': '1', 'sequence.field': 'seq,seq2',
+               'sequence.field.sort-order': order, 'merge-engine': engine,
+               'write.native.enabled': str(native).lower()}
+    if engine == 'aggregation':
+        options['fields.value.aggregate-function'] = 'last_value'
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, primary_keys=['id'], options=options), False)
+    return catalog.get_table('default.t'), schema
+
+
+def _write(table, groups, native, streaming):
+    builder = table.new_stream_write_builder() if streaming else 
table.new_batch_write_builder()
+    commit = builder.new_commit()
+    try:
+        for identifier, group in enumerate(groups, 1):
+            writer = builder.new_write()
+            if native:
+                assert isinstance(writer, NativeTableWrite)
+            try:
+                for chunk in group:
+                    writer.write_arrow(chunk)
+                messages = (writer.prepare_commit(identifier) if streaming
+                            else writer.prepare_commit())
+                if streaming:
+                    commit.commit(messages, identifier)
+                else:
+                    commit.commit(messages)
+                    commit.close()
+                    commit = builder.new_commit()
+                if native:
+                    assert writer._python_writer is None
+            finally:
+                writer.close()
+    finally:
+        commit.close()
+
+
+def _read(table, native, predicate=None, projection=None):
+    copy = table.copy({'read.native.enabled': str(native).lower(),
+                       'scan.native-plan.enabled': str(native).lower()})
+    builder = copy.new_read_builder()
+    if predicate is not None:
+        builder.with_filter(predicate)
+    if projection is not None:
+        builder.with_projection(projection)
+    splits = builder.new_scan().plan().splits()
+    read = builder.new_read()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(read, '_create_split_read',
+                                             
side_effect=AssertionError('Python read fallback')))
+        return read.to_arrow(splits).sort_by('id').to_pylist()
+
+
[email protected]('native', [False, True])
[email protected]('streaming', [False, True])
[email protected]('separate_commits', [False, True])
[email protected]('engine', ['deduplicate', 'partial-update', 
'aggregation'])
[email protected]('sequence_type,high,low', [
+    (pa.int64(), 100, 50), (pa.string(), 'z', 'a'), (pa.binary(), b'z', b'a'),
+    (pa.decimal128(10, 2), Decimal('100.50'), Decimal('50.25')),
+    (pa.timestamp('us'), datetime.datetime(2020, 1, 2), 
datetime.datetime(2020, 1, 1)),
+    (pa.time32('ms'), datetime.time(12, 0), datetime.time(1, 0)),
+])
+def test_descending_sequence_dispatch(
+        tmp_path, native, streaming, separate_commits, engine, sequence_type, 
high, low):
+    table, schema = _table(tmp_path, sequence_type, native, 'descending', 
engine)
+    batches = []
+    for i, value in enumerate([high, low, None]):
+        batches.append(pa.table({'id': [1, 2, 3], 'seq': [value, high, None],
+                                 'seq2': [0, [2, 1, None][i], None],
+                                 'value': [str(i)] * 3}, schema=schema))
+    _write(table, [[batch] for batch in batches] if separate_commits else 
[batches],
+           native, streaming)
+    expected = [{'id': 1, 'value': '1'}, {'id': 2, 'value': '1'}, {'id': 3, 
'value': '2'}]
+    for native_read in (False, True):
+        # A later NULL must not beat non-NULL, the second sequence field breaks
+        # ties, and automatic sequence still resolves entirely equal tuples.
+        assert _read(table, native_read, projection=['id', 'value']) == 
expected
+        predicate = PredicateBuilder(table.fields).equal('value', '1')
+        assert _read(table, native_read, predicate, ['id', 'value']) == 
expected[:2]
diff --git a/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py 
b/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
new file mode 100644
index 0000000000..888edf112e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
@@ -0,0 +1,150 @@
+# 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.
+
+"""Native upserts preserve temporal keys and read only input partitions."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+
[email protected]('native', [False, True])
[email protected]('streaming', [False, True])
[email protected]('row_input', [False, True])
[email protected]('key_type', [
+    pa.time32('ms'), pa.timestamp('ms'), pa.timestamp('us'), 
pa.timestamp('ns'),
+    pa.timestamp('us', 'UTC'), pa.timestamp('ns', 'UTC'),
+])
+def test_temporal_upsert_keys(tmp_path, native, streaming, row_input, 
key_type):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('key', key_type), ('tag', pa.int32()), ('value', 
pa.string())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    key_type = schema.field('key').type
+    # Adjacent storage-unit values must stay distinct, including nanoseconds.
+    keys = pa.array([10, 10, None, 11], type=key_type)
+    seed = pa.table({'key': keys, 'tag': [0, 1, 2, 3], 'value': ['old'] * 4}, 
schema=schema)
+    batch_builder = table.new_batch_write_builder()
+    writer, commit = batch_builder.new_write(), batch_builder.new_commit()
+    try:
+        writer.write_arrow(seed)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    source = pa.table({'key': pa.array([None, 10, 12, 10], type=key_type),
+                       'tag': [99, 99, 4, 99],
+                       'value': ['null update', 'discarded', 'new', 'final']}, 
schema=schema)
+    builder = table.new_stream_write_builder() if streaming else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    with ExitStack() as stack:
+        if native:
+            for method in ('_upsert_partition', '_upsert_row_partition'):
+                stack.enter_context(patch.object(TableUpsertByKey, method,
+                                                 
side_effect=AssertionError('Python upsert fallback')))
+        if row_input:
+            rows = [GenericRow([row[field.name] for field in table.fields], 
table.fields)
+                    for row in source.to_pylist()]
+            messages = (update.upsert_by_key(rows, ['key'], 7) if streaming
+                        else update.upsert_by_key(rows, ['key']))
+        else:
+            source = source.select(['value', 'key', 'tag'])
+            messages = (update.upsert_by_arrow_with_key(source, ['key'], 7) if 
streaming
+                        else update.upsert_by_arrow_with_key(source, ['key']))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 7) if streaming else commit.commit(messages)
+    finally:
+        commit.close()
+    for native_read in (False, True):
+        copy = table.copy({'read.native.enabled': str(native_read).lower(),
+                           'scan.native-plan.enabled': 
str(native_read).lower()})
+        reader = copy.new_read_builder()
+        result = 
reader.new_read().to_arrow(reader.new_scan().plan().splits()).sort_by('tag')
+        assert result['value'].to_pylist() == ['final', 'final', 'null 
update', 'old', 'new']
+        assert result['tag'].to_pylist() == [0, 1, 2, 3, 4]
+        assert result['key'].cast(pa.int32() if pa.types.is_time32(key_type)
+                                  else pa.int64()).to_pylist() == [10, 10, 
None, 11, 12]
+
+
[email protected]('native', [False, True])
+def test_upsert_reads_only_exact_input_partitions(tmp_path, native):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('sub', 
pa.int32()),
+                        ('value', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=['pt', 'sub'], options={
+            'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+            'write.native.enabled': str(native).lower(),
+        }), False)
+    table = catalog.get_table('default.t')
+    data = pa.table({'id': [1] * 5, 'pt': ['a', 'a', 'b', None, None],
+                     'sub': [1, 2, 1, 1, 2], 'value': [10] * 5}, schema=schema)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+
+    def relevant(path):
+        return any(part in str(path) for part in (
+            '/pt=a/sub=1/', '/pt=b/sub=2/', 
'/pt=__DEFAULT_PARTITION__/sub=1/'))
+
+    removed = 0
+    for path in tmp_path.rglob('*.parquet'):
+        if not relevant(path):
+            path.unlink()
+            removed += 1
+    assert removed == 3
+    source = pa.table({'id': [1] * 4, 'pt': ['a', 'b', None, 'a'],
+                       'sub': [1, 2, 1, 1], 'value': [15, 30, 40, 20]}, 
schema=schema)
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(TableUpsertByKey, 
'_upsert_partition',
+                                             
side_effect=AssertionError('Python upsert fallback')))
+        messages = 
builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(
+            source, ['id'])
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    # Read only the partitions intentionally left accessible. The missing
+    # unrelated files prove that matching did not scan their data.
+    read = table.new_read_builder()
+    splits = [split for split in read.new_scan().plan().splits()
+              if 
relevant(table.path_factory().bucket_path(tuple(split.partition.values), 
split.bucket))]
+    assert sorted(read.new_read().to_arrow(splits)['value'].to_pylist()) == 
[20, 30, 40]
diff --git a/paimon-python/pypaimon/tests/native_write_test.py 
b/paimon-python/pypaimon/tests/native_write_test.py
index 56bd5bb8a7..ba853082c0 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -79,7 +79,7 @@ def 
test_sequence_validation_precedes_native_selection(tmp_path, streaming, sequ
 
 @pytest.mark.parametrize('type_,order,supported', [
     (pa.int64(), 'ascending', True),
-    (pa.int64(), 'descending', False),
+    (pa.int64(), 'descending', True),
     (pa.float32(), 'ascending', False),
     (pa.float64(), 'ascending', False),
 ])
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index d4c47367b0..8ecab84802 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -78,18 +78,9 @@ def _native_update_paths_supported(table):
     return True
 
 
-def _native_update_table(table, columns=None):
-    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
-    selected = schema.names if columns is None else columns
-    if any(pa.types.is_nested(schema.field(name).type)
-           for name in selected if name in schema.names):
-        return None
-    return _native_row_id_table(table)
-
-
 def create_native_update(table, commit_user, columns):
     """Use the public core updater for direct and grouped row-ID updates."""
-    native_table = _native_update_table(table, columns)
+    native_table = _native_row_id_table(table)
     if native_table is None:
         return None
     writer = (native_table.new_batch_write_builder()
@@ -102,7 +93,7 @@ def create_native_update(table, commit_user, columns):
 
 def create_native_update_by_row_id(table, commit_user, commit_identifier):
     """Create a core updater sharing one snapshot across incremental calls."""
-    native_table = _native_update_table(table)
+    native_table = _native_row_id_table(table)
     if native_table is None:
         return None
     if commit_identifier == BATCH_COMMIT_IDENTIFIER:
@@ -120,6 +111,7 @@ def _supported_upsert_key_type(data_type):
         pa.types.is_boolean, pa.types.is_integer, pa.types.is_string,
         pa.types.is_large_string, pa.types.is_binary, pa.types.is_large_binary,
         pa.types.is_fixed_size_binary, pa.types.is_date, pa.types.is_decimal,
+        pa.types.is_time, pa.types.is_timestamp,
     ))
 
 
@@ -130,8 +122,7 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
         return None
     fields = table.table_schema.fields
     schema = PyarrowFieldParser.from_paimon_schema(fields)
-    if (any(not _supported_upsert_key_type(schema.field(key).type) for key in 
set(keys + table.partition_keys))
-            or any(pa.types.is_nested(schema.field(name).type) for name in 
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):
         # Missing fields retain their row-object semantics on the fallback
@@ -154,12 +145,8 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
     return NativeTableUpsert(table, writer, keys, data)
 
 
-def create_native_predicate_update(table, commit_user, columns, predicate):
+def create_native_predicate_update(table, commit_user, predicate):
     """Prepare a public core operation before any assignment can run."""
-    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
-    if any(pa.types.is_nested(schema.field(name).type)
-           for name in columns if name in schema.names):
-        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 96f2abb9e8..227a1b905c 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -63,12 +63,9 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False)
     """Return a native writer if the table can use the filesystem write 
path."""
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
     sequence_fields = table.options.sequence_field()
-    if table.is_primary_key_table and sequence_fields:
-        # The native writer currently sorts sequence fields ascending and
-        # does not implement Java's NaN/signed-zero ordering.
-        if (not table.options.sequence_field_sort_order_is_ascending()
-                or any(pa.types.is_floating(schema.field(name).type) for name 
in sequence_fields)):
-            return None
+    if (table.is_primary_key_table and any(
+            pa.types.is_floating(schema.field(name).type) for name in 
sequence_fields)):
+        return None
     if (not native_write_available()
             # Rust does not produce the optional random-access .row sidecars.
             or (table.options.data_evolution_enabled()
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 5c5958cb1e..81e3717ed1 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -306,7 +306,7 @@ class TableUpdate:
             from pypaimon.write.native_update import 
create_native_predicate_update
             try:
                 native = create_native_predicate_update(
-                    self.table, self.commit_user, list(assignments.keys()), 
predicate)
+                    self.table, self.commit_user, predicate)
             except Exception as error:
                 logger.debug('Native predicate update preparation failed: %s', 
error)
             else:
diff --git a/paimon-python/pypaimon/write/table_upsert_by_key.py 
b/paimon-python/pypaimon/write/table_upsert_by_key.py
index 3ffb6ad30e..f3b4064048 100644
--- a/paimon-python/pypaimon/write/table_upsert_by_key.py
+++ b/paimon-python/pypaimon/write/table_upsert_by_key.py
@@ -524,7 +524,8 @@ class TableUpsertByKey:
         if partition_spec:
             predicate_builder = read_builder.new_predicate_builder()
             sub_predicates = [
-                predicate_builder.equal(k, v)
+                (predicate_builder.is_null(k) if v is None
+                 else predicate_builder.equal(k, v))
                 for k, v in partition_spec.items()
             ]
             partition_predicate = 
predicate_builder.and_predicates(sub_predicates)

Reply via email to