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 f3f1f951db [python] Expand Native update input parity coverage (#10632)
f3f1f951db is described below
commit f3f1f951dbeebbe5b942a12464ed857142d68b63
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 22:59:44 2026 +0800
[python] Expand Native update input parity coverage (#10632)
---
.../tests/native_update_container_casts_test.py | 190 +++++++++++
.../tests/native_update_encoded_inputs_test.py | 378 +++++++++++++++++++++
2 files changed, 568 insertions(+)
diff --git a/paimon-python/pypaimon/tests/native_update_container_casts_test.py
b/paimon-python/pypaimon/tests/native_update_container_casts_test.py
new file mode 100644
index 0000000000..d19a0ff012
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_update_container_casts_test.py
@@ -0,0 +1,190 @@
+# 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.
+
+"""Equivalent Arrow container inputs must work on the existing update APIs."""
+
+from contextlib import ExitStack
+from decimal import Decimal
+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.data_evolution_merge_into import WhenMatched, source_col
+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 = [
+ pytest.param(pa.int32(), pa.int64(), [1, None, 4], [1, None, 4],
id='integer'),
+ pytest.param(pa.string(), pa.int32(), ['1', None, '4'], [1, None, 4],
id='text'),
+ pytest.param(pa.decimal128(8, 3), pa.decimal128(10, 2),
+ [Decimal('1.250'), None, Decimal('-2.500')],
+ [Decimal('1.25'), None, Decimal('-2.50')], id='decimal'),
+ pytest.param(pa.large_binary(), pa.binary(), [b'\xff\0a', None, b''],
[b'\xff\0a', None, b''], id='binary'),
+]
+_OPERATIONS = ['row_id', 'incremental', 'predicate_array',
'predicate_callable', 'upsert', 'merge']
+
+
+def _layout(source_value_type, target_value_type, source_values,
expected_values, shape):
+ source_type = pa.map_(pa.field('source_key', pa.string(), nullable=False),
+ pa.field('source_value', source_value_type))
+ target_type = pa.list_(pa.struct([('label', pa.string()), ('amount',
target_value_type)]))
+ values = [[('a', source_values[0]), ('b', source_values[1])], None, [],
[('z', source_values[2])]]
+ expected = [[{'label': 'a', 'amount': expected_values[0]},
+ {'label': 'b', 'amount': expected_values[1]}], None, [],
+ [{'label': 'z', 'amount': expected_values[2]}]]
+ if shape == 'row':
+ source_type = pa.struct([('items', source_type), ('extra',
pa.int32())])
+ target_type = pa.struct([('items', target_type), ('extra',
pa.int32())])
+ values = [{'items': value, 'extra': i} if i != 1 else None for i,
value in enumerate(values)]
+ expected = [{'items': value, 'extra': i} if i != 1 else None for i,
value in enumerate(expected)]
+ elif shape == 'array':
+ source_type, target_type = pa.list_(source_type), pa.list_(target_type)
+ values = [[values[0], None, []], None, [], [values[3]]]
+ expected = [[expected[0], None, []], None, [], [expected[3]]]
+ elif shape == 'map':
+ source_type, target_type = pa.map_(pa.string(), source_type),
pa.map_(pa.string(), target_type)
+ values = [[('items', values[0]), ('null', None)], None, [], [('last',
values[3])]]
+ expected = [[('items', expected[0]), ('null', None)], None, [],
[('last', expected[3])]]
+ # The slice retains nonzero MAP offsets at one or more nesting levels.
+ source = pa.array([values[0]] + values + [values[3]],
type=source_type).slice(1, 4)
+ return source, target_type, expected
+
+
+def _table(tmp_path, value_type, native):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([('id', pa.int32()), ('value', value_type), ('keep',
pa.int32())])
+ catalog.create_table('db.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('db.t')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': list(range(5)), 'value': [None] * 5,
+ 'keep': list(range(10, 15))},
schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ return table
+
+
+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')]:
+ stack.enter_context(patch.object(cls, method,
side_effect=AssertionError('Python update fallback')))
+
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python MERGE
fallback')))
+ return stack
+
+
+def _apply(table, source, native, stream, operation):
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ update = builder.new_update().with_update_type(['value'])
+ # Include empty chunks and boundaries that differ from the target files.
+ chunks = pa.chunked_array([source.slice(0, 0), source.slice(0, 1),
source.slice(1, 2),
+ source.slice(3, 0), source.slice(3)])
+ ids = pa.array(range(len(source)), type=pa.int32())
+ data = pa.table({'value': chunks, '_ROW_ID': pa.array(range(len(source)),
type=pa.int64())})
+ with _native_only(native):
+ if operation == 'row_id':
+ messages = (update.update_by_arrow_with_row_id(data, 31) if stream
+ else update.update_by_arrow_with_row_id(data))
+ elif operation == 'incremental':
+ writer = update.new_update_by_row_id(31) if stream else
update.new_update_by_row_id()
+ messages = writer.update_columns(data, ['value'])
+ elif operation.startswith('predicate'):
+ kwargs = {}
+ if operation == 'predicate_callable':
+ def assignment(rows):
+ assert rows.column_names == ['id', '_ROW_ID']
+ # Return a different chunk layout from the selected
batches.
+ return chunks.take(rows['id'])
+ kwargs['read_columns'] = ['id']
+ else:
+ assignment = chunks
+ predicate = PredicateBuilder(table.fields).less_than('id',
len(source))
+ messages = (update.update_by_predicate(predicate, {'value':
assignment}, 31, **kwargs) if stream
+ else update.update_by_predicate(predicate, {'value':
assignment}, **kwargs))
+ elif operation == 'upsert':
+ data = pa.table({'id': ids, 'value': chunks})
+ messages = update.upsert_by_arrow_with_key(data, ['id'], 31) if
stream else \
+ update.upsert_by_arrow_with_key(data, ['id'])
+ else:
+ data = pa.table({'id': ids, 'value': chunks})
+ kwargs = {'commit_identifier': 31} if stream else {}
+ messages = update.merge_into(data, on=['id'], when_matched=[
+ WhenMatched.update({'value': source_col('value')})], **kwargs)
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 31) if stream else commit.commit(messages)
+ finally:
+ commit.close()
+
+
+def _check(table, expected):
+ for native in (False, True):
+ view = table.copy({'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower()})
+ builder = view.new_read_builder().with_projection(['id', 'value',
'keep', '_ROW_ID'])
+ reader = builder.new_read()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(reader, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ rows =
reader.to_arrow(builder.new_scan().plan().splits()).sort_by('id')
+ assert rows['value'].to_pylist() == expected + [None] * (5 -
len(expected))
+ assert rows['keep'].to_pylist() == list(range(10, 15))
+ assert rows['_ROW_ID'].to_pylist() == list(range(5))
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', _OPERATIONS)
[email protected]('shape', ['root', 'row', 'array', 'map'])
[email protected]('source_type,target_type,values,expected', _CASES)
+def test_map_inputs_update_array_rows(tmp_path, native, stream, operation,
shape,
+ source_type, target_type, values,
expected):
+ source, target_type, expected = _layout(source_type, target_type, values,
expected, shape)
+ table = _table(tmp_path, target_type, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
[email protected]('shape', ['root', 'row', 'array'])
+def test_invalid_map_child_cannot_change_existing_rows(tmp_path, native,
operation, shape):
+ source, target_type, _ = _layout(pa.int64(), pa.int32(), [1, None, 2 **
40], [1, None, 0], shape)
+ table = _table(tmp_path, target_type, native)
+ before = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises((ValueError, TypeError, OverflowError)):
+ _apply(table, source, native, False, operation)
+ assert table.snapshot_manager().get_latest_snapshot().id == before
+ _check(table, [None] * 4)
diff --git a/paimon-python/pypaimon/tests/native_update_encoded_inputs_test.py
b/paimon-python/pypaimon/tests/native_update_encoded_inputs_test.py
new file mode 100644
index 0000000000..6aa7434896
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_update_encoded_inputs_test.py
@@ -0,0 +1,378 @@
+# 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.
+
+"""Arrow encodings used by existing update APIs must retain their values."""
+
+from decimal import Decimal
+
+import numpy as np
+import pyarrow as pa
+import pytest
+
+from pypaimon.tests.native_update_container_casts_test import (
+ _OPERATIONS, _apply, _check, _native_only, _table,
+)
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _wrap(source, target, expected, shape):
+ if shape == 'row':
+ source = pa.StructArray.from_arrays([source], names=['nested'])
+ target = pa.struct([('nested', target)])
+ expected = [{'nested': value} for value in expected]
+ elif shape == 'array':
+ source = pa.ListArray.from_arrays(pa.array(range(len(source) + 1),
type=pa.int32()), source)
+ target = pa.list_(target)
+ expected = [[value] for value in expected]
+ elif shape == 'map':
+ keys = [str(i) for i in range(len(source))]
+ source = pa.MapArray.from_arrays(pa.array(range(len(source) + 1),
type=pa.int32()),
+ pa.array(keys), source)
+ target = pa.map_(pa.string(), target)
+ expected = [[(key, value)] for key, value in zip(keys, expected)]
+ return source, target, expected
+
+
+def _scalar_case(kind):
+ if kind == 'fixed_binary':
+ source = pa.array([b'old', b'a\0b', None, '中'.encode(), b'xyz',
b'end'], type=pa.binary(3)).slice(1, 4)
+ return source, pa.string(), ['a\0b', None, '中', 'xyz']
+ if kind == 'empty_fixed_binary':
+ return pa.array([b'', None, b'', b''], type=pa.binary(0)),
pa.string(), ['', None, '', '']
+ if kind == 'half_float':
+ values = np.array([0.1, 0.0, -3.25, 2 ** -24], dtype=np.float16)
+ source = pa.array(values, mask=np.array([False, True, False, False]))
+ return source, pa.string(), ['0.0999755859375', None, '-3.25',
'5.960464477539063e-8']
+ coefficient = '1508079088755887087797858531882816082'
+ value = Decimal(coefficient[:-1] + '.' + coefficient[-1])
+ factory = pa.decimal256 if kind.startswith('decimal256') else pa.decimal128
+ precision = 76 if kind.startswith('decimal256_wide') else 38
+ source = pa.array([value, None, value.copy_negate(), Decimal('1.0')],
type=factory(precision, 1))
+ expected = [1.508079088755887e35, None, -1.508079088755887e35, 1.0]
+ target = pa.float64()
+ if kind.endswith('_float'):
+ target = pa.float32()
+ expected = [None if item is None else float(np.float32(item)) for item
in expected]
+ return source, target, expected
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', _OPERATIONS)
[email protected]('shape', ['root', 'row', 'array', 'map'])
[email protected]('kind', ['fixed_binary', 'empty_fixed_binary',
'half_float',
+ 'decimal_double', 'decimal_float',
'decimal256_double', 'decimal256_float',
+ 'decimal256_wide_double',
'decimal256_wide_float'])
+def test_scalar_input_encodings_keep_values(tmp_path, native, stream,
operation, shape, kind):
+ source, target, expected = _wrap(*_scalar_case(kind), shape)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
+def _numeric_union(dense, kind='number'):
+ if dense:
+ left, right = [-1, None, 2], [3, 1, 2 ** 40]
+ else:
+ left = [-1, 0, 2, 0, None, 0]
+ right = [2 ** 40, 1, 2 ** 40, 3, 2 ** 40, 2 ** 40]
+ if kind == 'array':
+ left_type, right_type, target = pa.list_(pa.int32()),
pa.large_list(pa.int64()), pa.list_(pa.int64())
+ left = [None if value is None else [value] for value in left]
+ right = [[value] for value in right]
+ expected = [[1], [2], [3], None]
+ elif kind == 'row':
+ left_type, right_type, target = pa.struct([('n', pa.int32())]),
pa.struct([('n', pa.int64())]), \
+ pa.struct([('n', pa.int64())])
+ left = [None if value is None else {'n': value} for value in left]
+ right = [{'n': value} for value in right]
+ expected = [{'n': 1}, {'n': 2}, {'n': 3}, None]
+ else:
+ left_type, right_type, target = pa.int32(), pa.int64(), pa.int32()
+ expected = [1, 2, 3, None]
+ children = [pa.array(left, type=left_type), pa.array(right,
type=right_type),
+ pa.array(['incompatible inactive child'] * (1 if dense else
6))]
+ ids = pa.array([7, 42, 7, 42, 7, 42], type=pa.int8())
+ kwargs = dict(field_names=['small', 'wide', 'unused'], type_codes=[7, 42,
99])
+ union = (pa.UnionArray.from_dense(ids, pa.array([0, 1, 2, 0, 1, 2],
type=pa.int32()), children, **kwargs)
+ if dense else pa.UnionArray.from_sparse(ids, children, **kwargs))
+ return union.slice(1, 4), target, expected
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
[email protected]('shape', ['root', 'row', 'array', 'map'])
[email protected]('dense', [False, True], ids=['sparse', 'dense'])
[email protected]('kind', ['number', 'array', 'row'])
+def test_union_inputs_select_only_active_values(tmp_path, native, stream,
operation, shape, dense, kind):
+ source, target, expected = _wrap(*_numeric_union(dense, kind), shape)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
[email protected]('shape', ['root', 'row', 'array', 'map'])
[email protected]('dense', [False, True], ids=['sparse', 'dense'])
+def test_dictionary_union_keeps_null_indices_and_active_nulls(tmp_path,
native, stream, operation, shape, dense):
+ source, target, expected = _numeric_union(dense)
+ source = pa.DictionaryArray.from_arrays(pa.array([3, None, 0, 1],
type=pa.int8()), source)
+ expected = [expected[3], None, expected[0], expected[1]]
+ source, target, expected = _wrap(source, target, expected, shape)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
[email protected]('shape,dense', [('row', False), ('row', True),
('fixed_array', True)],
+ ids=['sparse-row', 'dense-row', 'dense-fixed-array'])
[email protected]('encoded', [False, True], ids=['union',
'dictionary_union'])
+def test_union_children_below_null_parents_are_not_converted(
+ tmp_path, native, stream, operation, shape, encoded, dense):
+ ids = pa.array([42, 7, 7, 42], type=pa.int8())
+ if dense:
+ source = pa.UnionArray.from_dense(
+ ids, pa.array([0, 0, 1, 1], type=pa.int32()),
+ [pa.array([1, 2], type=pa.int32()), pa.array(['hidden', 'also
hidden'])], type_codes=[7, 42])
+ else:
+ source = pa.UnionArray.from_sparse(
+ ids, [pa.array([0, 1, 2, 0], type=pa.int32()), pa.array(['hidden']
* 4)], type_codes=[7, 42])
+ if encoded:
+ source = pa.DictionaryArray.from_arrays(pa.array([0, 1, 2, 3],
type=pa.int8()), source)
+ nulls = pa.array([True, False, False, True])
+ if shape == 'row':
+ source = pa.StructArray.from_arrays([source], names=['n'], mask=nulls)
+ target, expected = pa.struct([('n', pa.int32())]), [None, {'n': 1},
{'n': 2}, None]
+ else:
+ source = pa.FixedSizeListArray.from_arrays(source, 1, mask=nulls)
+ target, expected = pa.list_(pa.int32()), [None, [1], [2], None]
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
[email protected]('dense', [False, True], ids=['sparse', 'dense'])
+def test_map_union_values_do_not_fill_missing_row_fields(tmp_path, native,
stream, operation, dense):
+ ids = pa.array([7] * 4, type=pa.int8())
+ children = [pa.array([1, 2, 3, 4], type=pa.int32())]
+ values = (pa.UnionArray.from_dense(ids, pa.array(range(4),
type=pa.int32()), children, type_codes=[7])
+ if dense else pa.UnionArray.from_sparse(ids, children,
type_codes=[7]))
+ source = pa.MapArray.from_arrays(pa.array(range(5), type=pa.int32()),
pa.array(['n'] * 4), values)
+ target = pa.struct([('n', pa.int32()), ('v', pa.string())])
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, [{'n': i, 'v': None} for i in range(1, 5)])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
+def test_union_import_aligns_foreign_children_and_row_siblings(tmp_path,
native, operation):
+ raw = bytearray(b'x' + np.array([1, 2, 3, 4], dtype='<i4').tobytes())
+ buffer = pa.py_buffer(memoryview(raw)[1:])
+ assert buffer.address % 4 != 0
+ numbers = pa.Array.from_buffers(pa.int32(), 4, [None, buffer])
+ union = pa.UnionArray.from_sparse(pa.array([7] * 4, type=pa.int8()),
[numbers], type_codes=[7])
+ source = pa.StructArray.from_arrays([union, numbers], names=['n',
'sibling'])
+ target = pa.struct([('n', pa.int32()), ('sibling', pa.int32())])
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, False, operation)
+ _check(table, [{'n': i, 'sibling': i} for i in range(1, 5)])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
[email protected]('shape', ['nested_sibling', 'nested_child',
'masked_struct', 'masked_fixed_array'])
+def test_sparse_union_import_normalizes_all_parent_coordinates(tmp_path,
native, stream, operation, shape):
+ ids = pa.array([7] * 6, type=pa.int8())
+ numbers = pa.array([None, 1, 2, None, 4, None], type=pa.int32())
+ nested_type = pa.struct([('a', pa.struct([('b', pa.int32())]))])
+ nested = pa.array([{'a': {'b': i}} for i in range(6)], type=nested_type)
+ union = pa.UnionArray.from_sparse(ids, [pa.array(range(6),
type=pa.int32())], type_codes=[7])
+ if shape == 'nested_child':
+ source = pa.UnionArray.from_sparse(ids, [nested], type_codes=[7])
+ target = nested_type
+ expected = nested.slice(1, 4).to_pylist()
+ else:
+ if shape == 'nested_sibling':
+ ordinary, ordinary_type = nested, nested_type
+ elif shape == 'masked_struct':
+ ordinary = pa.StructArray.from_arrays(
+ [numbers], fields=[pa.field('n', pa.int32(), nullable=False)],
mask=numbers.is_null())
+ ordinary_type = pa.struct([('n', pa.int32())])
+ else:
+ ordinary = pa.FixedSizeListArray.from_arrays(
+ numbers, type=pa.list_(pa.field('item', pa.int32(),
nullable=False), 1), mask=numbers.is_null())
+ ordinary_type = pa.list_(pa.int32())
+ source = pa.StructArray.from_arrays([union, ordinary], names=['u',
'ordinary'])
+ target = pa.struct([('u', pa.int32()), ('ordinary', ordinary_type)])
+ expected = [{'u': i, 'ordinary': item} for i, item in
enumerate(ordinary.to_pylist()[1:5], 1)]
+ source = source.slice(1, 4)
+ source.validate(full=True)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, stream, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
[email protected]('precision,scale', [(39, 1), (76, 39)])
+def test_wide_decimal_floating_input_does_not_require_decimal128(tmp_path,
native, operation, precision, scale):
+ coefficient = '1508079088755887087797858531882816082' + '0' * (scale - 1)
+ value = Decimal(coefficient + 'e-' + str(scale))
+ source = pa.array([value, None, value.copy_negate(), Decimal('1.0')],
type=pa.decimal256(precision, scale))
+ table = _table(tmp_path, pa.float64(), native)
+ _apply(table, source, native, False, operation)
+ _check(table, [1.508079088755887e35, None, -1.508079088755887e35, 1.0])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', ['predicate_array',
'predicate_callable', 'merge'])
[email protected]('shape', ['root', 'row', 'array', 'map'])
+def test_union_does_not_relax_safe_assignment_contract(tmp_path, native,
operation, shape):
+ source, target, _ = _wrap(*_numeric_union(True), shape)
+ table = _table(tmp_path, target, native)
+ snapshot = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises((ValueError, TypeError, NotImplementedError)):
+ _apply(table, source, native, False, operation)
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+ _check(table, [None] * 4)
+
+
+def _pair_union():
+ pairs = pa.array([[[1, 2]], [[3, 4]], []],
type=pa.list_(pa.list_(pa.int32())))
+ source = pa.UnionArray.from_dense(
+ pa.array([7, 42, 7, 7], type=pa.int8()), pa.array([0, 0, 1, 2],
type=pa.int32()),
+ [pairs, pa.nulls(1)], field_names=['pairs', 'null'], type_codes=[7,
42])
+ return source, pa.map_(pa.int32(), pa.int64()), [[(1, 2)], None, [(3, 4)],
[]]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
+def test_union_root_map_uses_existing_pair_constructor(tmp_path, native,
operation):
+ source, target, expected = _pair_union()
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, False, operation)
+ _check(table, expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('shape', ['row', 'array', 'map'])
+def test_union_map_pair_constructor_does_not_leak_into_nested_values(tmp_path,
native, shape):
+ source, target, _ = _wrap(*_pair_union(), shape)
+ table = _table(tmp_path, target, native)
+ snapshot = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises((ValueError, TypeError, NotImplementedError)):
+ _apply(table, source, native, False, 'row_id')
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+ _check(table, [None] * 4)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', ['row_id', 'incremental', 'upsert'])
+def test_union_wrapped_dict_preserves_explicit_null_map_values(tmp_path,
native, operation):
+ rows = pa.array([{'a': 1, 'b': None}, {'a': 2, 'b': 3}],
type=pa.struct([('a', pa.int32()), ('b', pa.int32())]))
+ source = pa.UnionArray.from_dense(
+ pa.array([42, 42, 42, 42], type=pa.int8()), pa.array([0, 1, 0, 1],
type=pa.int32()),
+ [rows], field_names=['row'], type_codes=[42])
+ target = pa.map_(pa.string(), pa.int64())
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, False, operation)
+ _check(table, [[('a', 1), ('b', None)], [('a', 2), ('b', 3)]] * 2)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', ['row_id', 'upsert'])
+def test_union_constructor_does_not_retry_safe_casts_per_child(tmp_path,
native, operation):
+ source = pa.UnionArray.from_dense(
+ pa.array([7, 42, 7, 42], type=pa.int8()), pa.array([0, 0, 1, 1],
type=pa.int32()),
+ [pa.array([1.5, 2.5]), pa.array(['3', '4'])], type_codes=[7, 42])
+ table = _table(tmp_path, pa.int32(), native)
+ snapshot = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises((ValueError, TypeError, NotImplementedError)):
+ _apply(table, source, native, False, operation)
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+ _check(table, [None] * 4)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
+def test_invalid_fixed_binary_text_does_not_commit(tmp_path, native,
operation):
+ source = pa.array([b'abc', None, b'\xffab', b'xyz'], type=pa.binary(3))
+ table = _table(tmp_path, pa.string(), native)
+ snapshot = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises((ValueError, TypeError)):
+ _apply(table, source, native, False, operation)
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+ _check(table, [None] * 4)
+
+
+def _decimal_type(width, precision, scale):
+ factory = getattr(pa, 'decimal{}'.format(width), None)
+ if factory is None:
+ pytest.skip('Arrow does not provide this decimal encoding')
+ return factory(precision, scale)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
[email protected]('width,precision,scale', [
+ (32, 9, -128), (64, 18, -90), (128, 38, -2), (256, 60, -40), (256, 76,
-128),
+])
+def test_zero_decimal_inputs_cross_physical_scale_boundaries(tmp_path, native,
operation, width, precision, scale):
+ source_type = _decimal_type(width, precision, scale)
+ source = pa.array([Decimal('0'), None, Decimal('0'), None],
type=source_type)
+ target = pa.decimal128(38, 38)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, False, operation)
+ _check(table, [Decimal('0'), None, Decimal('0'), None])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
[email protected]('width,precision', [(32, 9), (64, 18), (128, 38),
(256, 76)])
+def test_decimal_input_widths_keep_exact_rescaling(tmp_path, native,
operation, width, precision):
+ source_type = _decimal_type(width, precision, 3)
+ source = pa.array([Decimal('1.250'), None, Decimal('-2.500'),
Decimal('3.000')], type=source_type)
+ target = pa.decimal128(10, 2)
+ table = _table(tmp_path, target, native)
+ _apply(table, source, native, False, operation)
+ _check(table, [Decimal('1.25'), None, Decimal('-2.50'), Decimal('3.00')])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_union_update_casts_do_not_relax_append_validation(tmp_path, native):
+ source, target, _ = _numeric_union(True)
+ table = _table(tmp_path, target, native)
+ data = pa.table({'id': pa.array([0, 1, 2, 99], type=pa.int32()), 'value':
source})
+ builder = table.new_batch_write_builder()
+ snapshot = table.snapshot_manager().get_latest_snapshot().id
+ with _native_only(native), pytest.raises((ValueError, TypeError,
NotImplementedError)):
+
builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(data,
['id'])
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+ _check(table, [None] * 4)