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 e711c1c277 [python] Extend Native upsert keys and preserve decimal 
index predicates (#10635)
e711c1c277 is described below

commit e711c1c277cca259df4be7e64afb1ce9991192ea
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 08:47:04 2026 +0800

    [python] Extend Native upsert keys and preserve decimal index predicates 
(#10635)
---
 .../pypaimon/globalindex/global_index_evaluator.py |  26 ++-
 .../pypaimon/tests/global_index_evaluator_test.py  |  32 ++++
 .../tests/native_decimal_predicate_test.py         | 203 +++++++++++++++++++++
 .../tests/native_upsert_key_normalization_test.py  | 176 +++++++++++++++++-
 paimon-python/pypaimon/write/native_update.py      |  14 +-
 5 files changed, 445 insertions(+), 6 deletions(-)

diff --git a/paimon-python/pypaimon/globalindex/global_index_evaluator.py 
b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
index fe786e604b..7bb5e4eb65 100644
--- a/paimon-python/pypaimon/globalindex/global_index_evaluator.py
+++ b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
@@ -18,14 +18,16 @@
 """Global index evaluator for filtering data using global indexes."""
 
 import threading
+import struct
 from collections import deque
 from concurrent.futures import Future
 from typing import Callable, Collection, Dict, FrozenSet, List, NamedTuple, 
Optional
 
-from pypaimon.globalindex.global_index_reader import GlobalIndexReader, 
FieldRef
+from pypaimon.globalindex.global_index_reader import GlobalIndexReader, 
FieldRef, _completed_future
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.globalindex.key_serializer import create_serializer
 from pypaimon.common.predicate import Predicate
-from pypaimon.schema.data_types import DataField
+from pypaimon.schema.data_types import AtomicType, DataField
 
 
 class GlobalIndexEvaluation(NamedTuple):
@@ -82,6 +84,9 @@ class GlobalIndexEvaluator:
             f.set_result(None)
             return f
 
+        if not self._decimal_literals_supported(field, predicate):
+            return _completed_future(None)
+
         field_id = field.id
         readers = self._index_readers_cache.get(field_id)
         if readers is None:
@@ -121,6 +126,23 @@ class GlobalIndexEvaluator:
 
         return all_done
 
+    @staticmethod
+    def _decimal_literals_supported(field: DataField, predicate: Predicate) -> 
bool:
+        if not (isinstance(field.type, AtomicType)
+                and field.type.type.upper().startswith(('DECIMAL', 
'NUMERIC'))):
+            return True
+        # Like Native, decline a physical index lookup if conversion would
+        # round a comparison literal. The original predicate must then filter
+        # the data; a rounded bitmap lookup can silently discard matching rows.
+        serializer = create_serializer(field.type)
+        compare = serializer.create_comparator()
+        try:
+            return all(literal is None or compare(
+                serializer.deserialize(serializer.serialize(literal)), 
literal) == 0
+                for literal in (predicate.literals or []))
+        except (ArithmeticError, ValueError, struct.error):
+            return False
+
     def _combine_reader_results(
         self, reader_futures: List[Future], field_id: int,
     ) -> Optional[GlobalIndexEvaluation]:
diff --git a/paimon-python/pypaimon/tests/global_index_evaluator_test.py 
b/paimon-python/pypaimon/tests/global_index_evaluator_test.py
index d979275ec0..8c3daf6bd8 100644
--- a/paimon-python/pypaimon/tests/global_index_evaluator_test.py
+++ b/paimon-python/pypaimon/tests/global_index_evaluator_test.py
@@ -18,6 +18,7 @@
 import threading
 import time
 import unittest
+from decimal import Decimal, localcontext
 from concurrent.futures import ThreadPoolExecutor
 
 from pypaimon.common.predicate import Predicate
@@ -69,6 +70,37 @@ def _result_of(*row_ids):
 
 class GlobalIndexEvaluatorTest(unittest.TestCase):
 
+    def test_decimal_literals_cannot_round_to_an_index_key(self):
+        for precision in [10, 38]:
+            field = DataField(0, 'amount', AtomicType('DECIMAL(%d,2)' % 
precision))
+            evaluator = GlobalIndexEvaluator([field], lambda field: 
self.fail('Unsafe index lookup'))
+            try:
+                for method, values in [('equal', ['1.235']), ('notEqual', 
['-1.235']),
+                                       ('in', ['1.23', '1.235']), ('notIn', 
['1.235']),
+                                       ('between', ['1.23', '1.235'])]:
+                    literals = [Decimal(value) for value in values]
+                    predicate = Predicate(method, 0, 'amount', literals)
+                    with localcontext() as context:
+                        context.prec = 3
+                        self.assertIsNone(evaluator.evaluate(predicate))
+                    self.assertEqual(predicate.literals, literals)
+            finally:
+                evaluator.close()
+
+    def test_exact_decimal_literals_still_use_the_index(self):
+        field = DataField(0, 'amount', AtomicType('DECIMAL(10,2)'))
+        expected = _result_of(1, 3)
+        evaluator = GlobalIndexEvaluator([field], lambda field: 
[StubGlobalIndexReader(expected)])
+        try:
+            for value in ['1.2300', '-1.23', '0E+100']:
+                predicate = Predicate('equal', 0, 'amount', [Decimal(value)])
+                with localcontext() as context:
+                    context.prec = 3
+                    self.assertIs(evaluator.evaluate(predicate), expected)
+            self.assertIsNone(evaluator.evaluate(Predicate('equal', 0, 
'amount', [Decimal('1E+37')])))
+        finally:
+            evaluator.close()
+
     def test_single_field_sequential(self):
         fields = _make_fields()
         expected = GlobalIndexResult.from_range(Range(1, 3))
diff --git a/paimon-python/pypaimon/tests/native_decimal_predicate_test.py 
b/paimon-python/pypaimon/tests/native_decimal_predicate_test.py
new file mode 100644
index 0000000000..691fa783ce
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_decimal_predicate_test.py
@@ -0,0 +1,203 @@
+# 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.
+
+"""DECIMAL predicates compare values without rounding literals to column 
scale."""
+
+from contextlib import ExitStack
+from decimal import Decimal, localcontext
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate import Predicate
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.write.table_update import TableUpdate
+
+
+pytestmark = pytest.mark.native_plan
+
+_VALUES = [Decimal(value) for value in ['-1.24', '-1.23', '0.00', '1.23', 
'1.24']] + [None]
+_PREDICATES = [
+    ('equal', ['1.235'], []),
+    ('notEqual', ['1.235'], [0, 1, 2, 3, 4]),
+    ('lessThan', ['1.235'], [0, 1, 2, 3]),
+    ('lessOrEqual', ['1.235'], [0, 1, 2, 3]),
+    ('greaterThan', ['1.235'], [4]),
+    ('greaterOrEqual', ['1.235'], [4]),
+    ('in', ['1.235', '1.240', None], [4]),
+    ('notIn', ['1.235', '1.240'], [0, 1, 2, 3]),
+    ('notIn', ['1.235', None], []),
+    ('between', ['-1.235', '1.235'], [1, 2, 3]),
+    ('notBetween', ['-1.235', '1.235'], [0, 4]),
+    ('lessThan', ['123456789'], [0, 1, 2, 3, 4]),
+    ('equal', ['1E+37'], []),
+    ('greaterThan', ['1E-38'], [3, 4]),
+    ('lessThan', ['0E+100'], [0, 1]),
+    ('lessThan', ['-1.23500000000000000000000000000000000000000'], [0]),
+]
+
+
+def _predicate(method, literals):
+    return Predicate(method, 1, 'amount', [Decimal(value) if value is not None 
else None for value in literals])
+
+
+def _table(tmp_path, native=True, evolution=False, indexed=None, precision=10):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('amount', 
pa.decimal128(precision, 2)), ('value', pa.int32())])
+    evolution = evolution or indexed is not None
+    options = {'write.native.enabled': str(native).lower(),
+               'row-tracking.enabled': str(evolution).lower(),
+               'deletion-vectors.enabled': str(evolution).lower(),
+               'data-evolution.enabled': str(evolution).lower()}
+    if indexed is not None:
+        options.update({'global-index.enabled': 'true', 
'global-index.search-mode': 'full'})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options=options), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        # Separate files make incorrect min/max pruning observable too.
+        for start in range(0, len(_VALUES), 2):
+            writer.write_arrow(pa.table({'id': [start, start + 1], 'amount': 
_VALUES[start:start + 2],
+                                        'value': [0, 0]}, schema=schema))
+            commit.commit(writer.prepare_commit())
+            writer.close()
+            commit.close()
+            if start + 2 < len(_VALUES):
+                writer, commit = builder.new_write(), builder.new_commit()
+    finally:
+        writer.close()
+        commit.close()
+    if indexed is not None:
+        messages = GlobalIndexBuilder(table, 'amount', 
index_type=indexed).build()
+        assert messages
+        commit = table.new_batch_write_builder().new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+    return table
+
+
+def _read(table, predicate, native_plan, native_read, projection=('id',)):
+    table = table.copy({'scan.native-plan.enabled': str(native_plan).lower(),
+                        'read.native.enabled': str(native_read).lower()})
+    builder = table.new_read_builder().with_projection(list(projection))
+    if predicate is not None:
+        builder.with_filter(predicate)
+    plan = builder.new_scan().plan()
+    if native_plan:
+        assert plan.stats().native_planned
+    reader = builder.new_read()
+    with ExitStack() as stack:
+        if native_read:
+            stack.enter_context(patch.object(reader, '_native_fallback',
+                                             
side_effect=AssertionError('Python read fallback')))
+            stack.enter_context(patch.object(reader, '_create_split_read',
+                                             
side_effect=AssertionError('Python split reader')))
+        rows = reader.to_arrow(plan.splits(), parallelism=1).to_pylist()
+    return sorted(rows, key=lambda row: row['id'])
+
+
[email protected]('native_plan,native_read', [(False, False), (True, 
False), (False, True), (True, True)])
[email protected]('indexed', [None, 'btree', 'bitmap'])
[email protected]('precision', [10, 38], ids=['compact', 'wide'])
+def test_decimal_predicates_preserve_literal_value_in_scan_and_read(
+        tmp_path, native_plan, native_read, indexed, precision):
+    table = _table(tmp_path, indexed=indexed, precision=precision)
+    # Literal construction and conversion must not depend on Decimal context.
+    with localcontext() as context:
+        context.prec = 3
+        for method, literals, expected in _PREDICATES:
+            assert _read(table, _predicate(method, literals), native_plan, 
native_read) == [
+                {'id': i} for i in expected], (method, literals)
+        fine = _predicate('lessThan', ['1.235'])
+        exact = Predicate('equal', 1, 'amount', [Decimal('1.24')])
+        for method, expected in [('and', []), ('or', list(range(5)))]:
+            combined = Predicate(method, None, None, [fine, exact])
+            assert _read(table, combined, native_plan, native_read) == [{'id': 
i} for i in expected]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['literal', 'callable', 'delete'])
+def 
test_decimal_predicates_select_the_same_rows_for_updates_and_deletes(tmp_path, 
native, stream, operation):
+    table = _table(tmp_path, native=native, evolution=True)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    predicate = _predicate('between', ['-1.235', '1.235'])
+    with ExitStack() as stack:
+        if native:
+            for method in ['_build_predicate_update_table', 
'_matched_delete_row_ids']:
+                stack.enter_context(patch.object(TableUpdate, method,
+                                                 
side_effect=AssertionError('Python matching fallback')))
+        if operation == 'delete':
+            messages = update.delete_by_predicate(predicate, 19) if stream 
else update.delete_by_predicate(predicate)
+        else:
+            if operation == 'callable':
+                def assignment(rows):
+                    assert rows.column_names == ['id', '_ROW_ID']
+                    return pa.array([i + 10 for i in rows['id'].to_pylist()], 
type=pa.int32())
+                kwargs = {'read_columns': ['id']}
+            else:
+                assignment, kwargs = 42, {}
+            messages = (update.update_by_predicate(predicate, {'value': 
assignment}, 19, **kwargs) if stream
+                        else update.update_by_predicate(predicate, {'value': 
assignment}, **kwargs))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 19) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    ids = [0, 4, 5] if operation == 'delete' else list(range(6))
+    expected = [{'id': i, 'amount': _VALUES[i], 'value': (
+        (i + 10 if operation == 'callable' else 42) if i in (1, 2, 3) else 0)} 
for i in ids]
+    for read_native in [False, True]:
+        assert _read(table, None, read_native, read_native, ('id', 'amount', 
'value')) == expected
+
+
[email protected]('engine', ['deduplicate', 'partial-update'])
[email protected]('native_plan,native_read', [(False, False), (True, 
False), (False, True), (True, True)])
+def test_decimal_filter_observes_merged_primary_key_values(tmp_path, engine, 
native_plan, native_read):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('amount', pa.decimal128(10, 2)), 
('value', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
primary_keys=['id'], options={
+        'bucket': '1', 'merge-engine': engine, 'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    batches = [
+        pa.table({'id': range(6), 'amount': _VALUES, 'value': [0] * 6}, 
schema=schema),
+        # Filtering either file before merging can resurrect id=3's obsolete
+        # value or discard id=4's new value on the other side of 1.235.
+        pa.table({'id': [3, 4], 'amount': [Decimal('1.24'), Decimal('1.23')], 
'value': [42, 43]}, schema=schema),
+    ]
+    for batch in batches:
+        builder = table.new_batch_write_builder()
+        writer, commit = builder.new_write(), builder.new_commit()
+        try:
+            writer.write_arrow(batch)
+            commit.commit(writer.prepare_commit())
+        finally:
+            writer.close()
+            commit.close()
+    for method, literals, expected in _PREDICATES:
+        expected = sorted(4 if i == 3 else 3 if i == 4 else i for i in 
expected)
+        rows = _read(table, _predicate(method, literals), native_plan, 
native_read, ('id', 'value'))
+        assert rows == [{'id': i, 'value': 42 if i == 3 else 43 if i == 4 else 
0} for i in expected]
diff --git 
a/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py 
b/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py
index 004c653269..50bcde062e 100644
--- a/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py
+++ b/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py
@@ -18,6 +18,10 @@
 """Logical upsert keys must not depend on Arrow widths or dictionaries."""
 
 import datetime
+import mmap
+import os
+import struct
+import sys
 from contextlib import ExitStack
 from decimal import Decimal
 from unittest.mock import patch
@@ -37,6 +41,12 @@ _KEYS = [
     (pa.int16(), pa.uint32(), 1, 2),
     (pa.int32(), pa.int64(), 1, 2),
     (pa.int64(), pa.uint64(), 1, 2),
+    (pa.float32(), pa.float64(), 1.5, -2.25),
+    (pa.float64(), pa.float32(), 1.5, -2.25),
+    (pa.float64(), pa.dictionary(pa.int8(), pa.float32()), 1.5, -2.25),
+    (pa.binary(), pa.large_binary(), b'\xff\0', b'other'),
+    (pa.large_binary(), pa.binary(), b'\xff\0', b'other'),
+    (pa.binary(), pa.dictionary(pa.int8(), pa.large_binary()), b'\xff\0', 
b'other'),
     (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), 
Decimal('-2.50')),
     (pa.date32(), pa.date64(), datetime.date(1969, 12, 31), 
datetime.date(1970, 1, 2)),
     (pa.time32('ms'), pa.time64('us'), datetime.time(1, 2, 3, 400000), 
datetime.time(4, 5, 6)),
@@ -52,6 +62,32 @@ _KEYS = [
     (pa.binary(), pa.dictionary(pa.int8(), pa.binary()), b'key', b'other'),
 ]
 
+_NUMERIC_KEYS = [
+    (pa.int32(), pa.float64(), 1, 1.0, 2),
+    (pa.int64(), pa.float64(), 2 ** 53 + 2, float(2 ** 53 + 2), 2 ** 53 + 4),
+    (pa.float32(), pa.int64(), 1.0, 1, 2.0),
+    (pa.float64(), pa.int64(), float(2 ** 53 + 2), 2 ** 53 + 2, float(2 ** 53 
+ 4)),
+    (pa.decimal128(10, 2), pa.int64(), Decimal('1.00'), 1, Decimal('2.00')),
+    (pa.int32(), pa.decimal128(15, 4), 1, Decimal('1.0000'), 2),
+    (pa.decimal128(10, 2), pa.float64(), Decimal('1.50'), 1.5, 
Decimal('2.25')),
+    (pa.float64(), pa.decimal128(10, 2), 1.5, Decimal('1.50'), 2.25),
+    (pa.decimal128(38, 38), pa.float64(), Decimal('0'), 0.0, Decimal('1E-38')),
+    (pa.float32(), pa.decimal256(40, 4), 1.5, Decimal('1.5000'), 2.25),
+    (pa.int64(), pa.dictionary(pa.int8(), pa.float64()), 1, 1.0, 2),
+    (pa.float64(), pa.dictionary(pa.int8(), pa.decimal128(10, 2)), 1.5, 
Decimal('1.50'), 2.25),
+    (pa.float64(), pa.decimal256(76, 55), 0.1, Decimal.from_float(0.1), 0.25),
+    (pa.float64(), pa.decimal256(76, 70), 2.0 ** -70, Decimal.from_float(2.0 
** -70), 2.0 ** -69),
+    (pa.float64(), pa.decimal256(76, -20), 1e20, Decimal('1E+20'), 2e20),
+    (pa.float32(), pa.uint32(), float(2 ** 24 + 2), 2 ** 24 + 2, float(2 ** 24 
+ 4)),
+    (pa.float64(), pa.uint64(), float(2 ** 64 - 2048), 2 ** 64 - 2048, 1.0),
+    (pa.decimal128(38, 38), pa.int64(), Decimal('0'), 0, Decimal('1E-38')),
+]
+
+for _width in (32, 64):
+    _factory = getattr(pa, 'decimal{}'.format(_width), None)
+    if _factory is not None:
+        _NUMERIC_KEYS.append((pa.float64(), _factory(9, 2), 1.5, 
Decimal('1.50'), 2.25))
+
 
 def _array(values, data_type):
     if pa.types.is_dictionary(data_type):
@@ -128,9 +164,98 @@ def 
test_upsert_normalizes_match_keys_without_changing_row_identity(
 
 @pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
 @pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
-def 
test_partition_key_dictionaries_and_integer_widths_share_one_identity(tmp_path, 
native, stream):
-    table, _ = _table(tmp_path, native, pa.int32(), 1, 2, partitioned=True)
-    source = pa.table({'key': pa.array([1, 2, 1], type=pa.int64()),
[email protected]('key_type,input_type,key,input_key,other', 
_NUMERIC_KEYS)
+def test_numeric_keys_match_by_exact_value_across_arrow_types(
+        tmp_path, native, stream, key_type, input_type, key, input_key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    chunks = [pa.table({'key': _array([value], input_type), 'value': 
pa.array([number], type=pa.int32())})
+              for value, number in [(input_key, 1), (None, 9), (input_key, 
42)]]
+    _upsert(table, pa.concat_tables(chunks), native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0, 9]
+        assert [{k: v for k, v in row.items() if k != 'value'} for row in 
rows] == [
+            {k: v for k, v in row.items() if k != 'value'} for row in before]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,input_type,key,invalid,other', [
+    (pa.int32(), pa.float64(), 1, 1.00000001, 2),
+    (pa.int64(), pa.float64(), 2 ** 63 - 1, float(2 ** 63), 2),
+    (pa.float64(), pa.int64(), float(2 ** 53), 2 ** 53 + 1, 2.0),
+    (pa.decimal128(10, 2), pa.float64(), Decimal('0.10'), 0.1, 
Decimal('0.20')),
+    (pa.float64(), pa.decimal128(10, 2), 0.1, Decimal('0.10'), 0.2),
+])
+def test_numeric_key_conversion_cannot_round_into_an_existing_row(
+        tmp_path, native, key_type, input_type, key, invalid, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _array([invalid], input_type), 'value': 
pa.array([42], type=pa.int32())})
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert rows == before
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,input_type,key,input_key,other', 
_NUMERIC_KEYS[:8])
+def test_mixed_numeric_upsert_validates_original_unmatched_rows_before_updates(
+        tmp_path, native, key_type, input_type, key, input_key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _array([input_key, input_key + 4], input_type),
+                       'value': pa.array([42, 99], type=pa.int32())})
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert rows == before
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('key_type,input_type,key,input_key,other', 
_NUMERIC_KEYS[:3] + _NUMERIC_KEYS[4:8])
+def test_full_row_numeric_upsert_preserves_row_identity_and_payload_casts(
+        tmp_path, native, stream, key_type, input_type, key, input_key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _array([input_key], input_type), 'pt': 
['updated'],
+                       'value': pa.array([42], type=pa.int32()), 'keep': 
pa.array([99], type=pa.int32())})
+    _upsert(table, source, native, stream, columns=None)
+    for rows in _rows(table):
+        by_id = {row['_ROW_ID']: row for row in rows}
+        for original in before:
+            expected = dict(original)
+            if original['key'] == key:
+                expected.update(pt='updated', value=42, keep=99)
+            assert by_id[original['_ROW_ID']] == expected
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_exact_match_does_not_relax_updated_key_assignment_range(tmp_path, 
native):
+    key = float(2 ** 53 + 2)
+    table, _ = _table(tmp_path, native, pa.float64(), key, float(2 ** 53 + 4))
+    before = next(_rows(table))
+    source = pa.table({'key': pa.array([2 ** 53 + 2], type=pa.int64()), 'pt': 
['updated'],
+                       'value': pa.array([42], type=pa.int32()), 'keep': 
pa.array([99], type=pa.int32())})
+    # Matching only needs an exact numeric identity. Updating the key payload
+    # still uses the ordinary integer-to-double assignment range (<= 2^53).
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, False, columns=None)
+    for rows in _rows(table):
+        assert rows == before
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('key_type,input_type,key,other', [
+    (pa.int32(), pa.int64(), 1, 2),
+    (pa.float64(), pa.float32(), 1.5, -2.25),
+    (pa.binary(), pa.large_binary(), b'key', b'other'),
+])
+def test_partition_dictionaries_and_equivalent_key_layouts_share_one_identity(
+        tmp_path, native, stream, key_type, input_type, key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other, partitioned=True)
+    source = pa.table({'key': _array([key, other, key], input_type),
                        'pt': _array(['a', 'b', 'a'], pa.dictionary(pa.int8(), 
pa.string())),
                        'value': pa.array([1, 7, 42], type=pa.int32())})
     before = next(_rows(table))
@@ -143,6 +268,10 @@ def 
test_partition_key_dictionaries_and_integer_widths_share_one_identity(tmp_pa
 @pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
 @pytest.mark.parametrize('key_type,input_type,key,other,new_key', [
     (pa.int32(), pa.int64(), 1, 2, 3),
+    (pa.float32(), pa.float64(), 1.5, -2.25, 3.5),
+    (pa.float64(), pa.float32(), 1.5, -2.25, 3.5),
+    (pa.binary(), pa.large_binary(), b'key', b'other', b'new'),
+    (pa.large_binary(), pa.binary(), b'key', b'other', b'new'),
     (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.00'), 
Decimal('2.00'), Decimal('3.00')),
     (pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
      datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 3)),
@@ -161,6 +290,7 @@ def test_new_rows_retain_original_key_write_type_validation(
 @pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
 @pytest.mark.parametrize('key_type,input_type,key,other,invalid', [
     (pa.int8(), pa.int64(), 1, 2, 256),
+    (pa.float32(), pa.float64(), 1.0, 2.0, 1.00000001),
     (pa.int64(), pa.uint64(), 1, 2, 2 ** 63),
     (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), 
Decimal('2.50'), Decimal('1.2501')),
     (pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
@@ -230,3 +360,43 @@ def 
test_decimal_zero_and_null_keys_rescale_across_the_full_scale_range(tmp_path
     _upsert(table, source, native, stream)
     for rows in _rows(table):
         assert [row['value'] for row in rows] == [42, 42, 0, 9]
+
+
[email protected](os.name != 'posix' or sys.maxsize <= 2 ** 32, 
reason='Requires 64-bit POSIX mmap')
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_large_binary_key_slice_after_two_gib_matches_visible_bytes(tmp_path, 
native):
+    table, _ = _table(tmp_path, native, pa.binary(), b'ab', b'other')
+    before = next(_rows(table))
+    start = 2 ** 31 + 17
+    # Reserve address space without allocating or reading a 2 GiB prefix.
+    memory = mmap.mmap(-1, start + 2)
+    memory[start:start + 2] = b'ab'
+    keys = pa.Array.from_buffers(pa.large_binary(), 1, [
+        None, pa.py_buffer(struct.pack('<qq', start, start + 2)), 
pa.py_buffer(memory)])
+    source = pa.table({'key': keys, 'value': pa.array([42], type=pa.int32())})
+    _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0, 0]
+        assert [{k: v for k, v in row.items() if k != 'value'} for row in 
rows] == [
+            {k: v for k, v in row.items() if k != 'value'} for row in before]
+
+
[email protected](os.name != 'posix' or sys.maxsize <= 2 ** 32, 
reason='Requires 64-bit POSIX mmap')
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('mixed', [False, True], ids=['all_null', 'mixed'])
+def test_binary_match_keys_ignore_storage_owned_by_null_values(tmp_path, 
native, mixed):
+    table, _ = _table(tmp_path, native, pa.binary(), b'ab', b'other')
+    before = next(_rows(table))
+    start = 2 ** 31 + 17
+    memory = mmap.mmap(-1, start + 2)
+    memory[start:start + 2] = b'ab'
+    offsets = [0, start, start + 2] if mixed else [0, start]
+    keys = pa.Array.from_buffers(pa.large_binary(), len(offsets) - 1, [
+        pa.py_buffer(b'\x02'), pa.array(offsets, 
type=pa.int64()).buffers()[1], pa.py_buffer(memory)],
+        null_count=1)
+    source = pa.table({'key': keys, 'value': pa.array([9, 42] if mixed else 
[9], type=pa.int32())})
+    _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == ([42, 42, 0, 9] if mixed else 
[0, 0, 0, 9])
+        assert [{k: v for k, v in row.items() if k != 'value'} for row in 
rows] == [
+            {k: v for k, v in row.items() if k != 'value'} for row in before]
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index dbf0b50d17..abd129958e 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -169,9 +169,21 @@ def _native_upsert_key_type_compatible(source, target):
     source = _dictionary_value_type(source)
     if pa.types.is_null(source):
         return True
-    for family in (pa.types.is_integer, pa.types.is_decimal, pa.types.is_date, 
pa.types.is_time):
+
+    def numeric(data_type):
+        return (pa.types.is_integer(data_type) or 
pa.types.is_floating(data_type)
+                or pa.types.is_decimal(data_type))
+    if numeric(source) and numeric(target):
+        return True
+    for family in (pa.types.is_date, pa.types.is_time):
         if family(source) and family(target):
             return True
+
+    def binary(data_type):
+        return (pa.types.is_binary(data_type) or 
pa.types.is_large_binary(data_type)
+                or pa.types.is_fixed_size_binary(data_type))
+    if binary(source) and binary(target):
+        return True
     if pa.types.is_timestamp(source) and pa.types.is_timestamp(target):
         return (source.tz is None) == (target.tz is None)
     return arrow_schemas_compatible(

Reply via email to