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 d257057b28 [Python] Align schema evolution casts and pruning with Java
and Native (#10502)
d257057b28 is described below
commit d257057b2852380cbe5f763f0edcb0c14a259fd8
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 17:47:57 2026 +0800
[Python] Align schema evolution casts and pruning with Java and Native
(#10502)
---
paimon-python/pypaimon/casting/schema_evolution.py | 109 +++++++++++
.../pypaimon/manifest/simple_stats_evolutions.py | 49 ++++-
.../pypaimon/read/reader/data_file_batch_reader.py | 34 +---
.../pypaimon/read/scanner/file_scanner.py | 10 +-
.../tests/manifest/simple_stats_evolutions_test.py | 42 ++++
.../tests/native_schema_evolution_cast_test.py | 216 +++++++++++++++++++++
.../pypaimon/tests/schema_evolution_cast_test.py | 56 ++++++
7 files changed, 480 insertions(+), 36 deletions(-)
diff --git a/paimon-python/pypaimon/casting/schema_evolution.py
b/paimon-python/pypaimon/casting/schema_evolution.py
new file mode 100644
index 0000000000..62793ec79c
--- /dev/null
+++ b/paimon-python/pypaimon/casting/schema_evolution.py
@@ -0,0 +1,109 @@
+# 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 cast rules shared by old-file readers."""
+
+from decimal import Decimal, localcontext, ROUND_HALF_UP
+import math
+
+import pyarrow as pa
+
+from pypaimon.table.row.generic_row import _parse_type_precision_scale
+
+
+def _narrow_integer(value, bits):
+ half = 1 << (bits - 1)
+ return (value + half) % (half * 2) - half
+
+
+def _numeric_to_integer(array, target_type):
+ bits = target_type.bit_width
+ floating = pa.types.is_floating(array.type)
+ intermediate_bits = 64 if bits == 64 else 32
+ lower = -(1 << (intermediate_bits - 1))
+ upper = (1 << (intermediate_bits - 1)) - 1
+ values = []
+ for value in array.to_pylist():
+ if value is None:
+ values.append(None)
+ continue
+ # Number.byteValue/shortValue first convert floats to int; Java's
+ # float-to-int/long conversion saturates before the final narrowing.
+ if floating:
+ # Java's floating-point narrowing maps NaN to zero.
+ if math.isnan(value):
+ value = 0
+ else:
+ value = lower if value <= lower else upper if value >= upper
else int(value)
+ else:
+ # BigDecimal.longValue truncates towards zero and retains low bits.
+ value = int(value)
+ values.append(_narrow_integer(value, bits))
+ return pa.array(values, type=target_type)
+
+
+def _truncate_timestamp(array, target_type, source_paimon_type,
target_paimon_type):
+ if (not pa.types.is_timestamp(array.type) or not
pa.types.is_timestamp(target_type)
+ or array.type.tz != target_type.tz
+ or source_paimon_type is None or target_paimon_type is None):
+ return None
+ source_precision = _parse_type_precision_scale(source_paimon_type)[0]
+ target_precision = _parse_type_precision_scale(target_paimon_type)[0]
+ if source_precision <= target_precision:
+ return None
+ nanos_per_tick = {'s': 1000000000, 'ms': 1000000, 'us': 1000, 'ns': 1}
+ quantum = 10 ** (9 - target_precision)
+ values = [None if value is None else
+ (value * nanos_per_tick[array.type.unit] // quantum * quantum)
// nanos_per_tick[target_type.unit]
+ for value in array.cast(pa.int64()).to_pylist()]
+ # Flooring a nanosecond boundary can exceed Arrow's int64 storage even
+ # though Java's timestamp representation can hold it. Match Native's
+ # lenient overflow handling when rebuilding the Arrow result.
+ values = [value if value is None or -(1 << 63) <= value < (1 << 63) else
None for value in values]
+ return pa.array(values, type=pa.int64()).cast(target_type)
+
+
+def cast_array_for_schema_evolution(array, target_type,
source_paimon_type=None, target_paimon_type=None):
+ """Materialize existing type changes using Java numeric and timestamp
casts.
+
+ Declared Paimon types are needed for precision changes within one Arrow
+ timestamp unit. Numeric narrowing preserves low bits, while floating-point
+ values truncate and saturate to int/long before byte/short narrowing.
+ DECIMAL scale reduction uses HALF_UP and returns NULL on precision
overflow.
+ """
+ timestamp = _truncate_timestamp(array, target_type, source_paimon_type,
target_paimon_type)
+ if timestamp is not None:
+ return timestamp
+ if array.type == target_type:
+ return array
+ if (pa.types.is_signed_integer(target_type)
+ and (pa.types.is_floating(array.type) or
pa.types.is_decimal(array.type))):
+ return _numeric_to_integer(array, target_type)
+ if (pa.types.is_decimal(array.type) and pa.types.is_decimal(target_type)
+ and array.type.scale > target_type.scale):
+ values = []
+ with localcontext() as context:
+ context.prec = max(array.type.precision, target_type.precision) + 1
+ quantum = Decimal(1).scaleb(-target_type.scale)
+ for value in array.to_pylist():
+ if value is None:
+ values.append(None)
+ continue
+ rounded = value.quantize(quantum, rounding=ROUND_HALF_UP)
+ values.append(rounded if len(rounded.as_tuple().digits) <=
target_type.precision else None)
+ return pa.array(values, type=target_type)
+ return array.cast(target_type, safe=False)
diff --git a/paimon-python/pypaimon/manifest/simple_stats_evolutions.py
b/paimon-python/pypaimon/manifest/simple_stats_evolutions.py
index 0035852c1f..5f59e402c2 100644
--- a/paimon-python/pypaimon/manifest/simple_stats_evolutions.py
+++ b/paimon-python/pypaimon/manifest/simple_stats_evolutions.py
@@ -17,8 +17,10 @@
from typing import Callable, Dict, List, Optional
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.manifest.simple_stats_evolution import SimpleStatsEvolution
-from pypaimon.schema.data_types import DataField
+from pypaimon.schema.data_types import AtomicType, DataField
class SimpleStatsEvolutions:
@@ -47,6 +49,51 @@ class SimpleStatsEvolutions:
self.evolutions[data_schema_id] = evolution
return evolution
+ def filter_unsafe_filter(self, data_schema_id: int,
+ predicate: Optional[Predicate]) ->
Optional[Predicate]:
+ """Keep evolution-safe conjuncts, like Java's filterUnsafeFilter.
+
+ Integer narrowing can wrap and precision changes can truncate values.
+ Comparing current literals to those files' original bounds is unsafe.
+ An OR must stay intact: dropping one of its branches could lose rows.
+ """
+ if predicate is None or data_schema_id == self.table_schema_id:
+ return predicate
+ source_fields = {field.id: field for field in
self.schema_fields(data_schema_id)}
+
+ def safe(part):
+ if part.method in ('and', 'or'):
+ return all(safe(child) for child in part.literals)
+ if part.index is None or not 0 <= part.index <
len(self.table_fields):
+ return False
+ target = self.table_fields[part.index]
+ source = source_fields.get(target.id)
+ if source is None:
+ # Missing fields remain NULL in evolved stats.
+ return True
+ source_type, target_type = source.type, target.type
+ if isinstance(source_type, AtomicType) and isinstance(target_type,
AtomicType):
+ if source_type.type == target_type.type:
+ return True
+ widths = {'TINYINT': 8, 'SMALLINT': 16, 'INT': 32, 'BIGINT':
64}
+ source_width = widths.get(source_type.type)
+ target_width = widths.get(target_type.type)
+ if source_width and target_width and source_width <
target_width:
+ # Java also rejects literals that cannot be represented in
+ # the old type. Stats values need no conversion in Python.
+ bound = 1 << (source_width - 1)
+ return all(value is not None and -bound <= value < bound
+ for value in (part.literals or []))
+ return False
+ return source_type == target_type
+
+ def conjuncts(part):
+ if part.method == 'and':
+ return [child for item in part.literals for child in
conjuncts(item)]
+ return [part]
+
+ return PredicateBuilder.and_predicates([part for part in
conjuncts(predicate) if safe(part)])
+
@staticmethod
def _create_index_cast_mapping(table_fields: List[DataField],
data_fields: List[DataField]) -> Dict[str,
Optional[List[int]]]:
diff --git a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
index b50842299f..dcd8c0a967 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -15,13 +15,13 @@
# specific language governing permissions and limitations
# under the License.
-from decimal import Decimal, localcontext, ROUND_HALF_UP
from typing import List, Optional, Tuple
import pyarrow as pa
import pyarrow.compute as pc
from pyarrow import RecordBatch
+from pypaimon.casting.schema_evolution import cast_array_for_schema_evolution
from pypaimon.common.file_io import FileIO
from pypaimon.utils.arrow_utils import zero_column_batch
from pypaimon.data.map_shared_shredding import (
@@ -44,32 +44,6 @@ def _is_character_string_type(data_type) -> bool:
return t == 'STRING' or t.startswith('VARCHAR') or t.startswith('CHAR')
-def cast_array_for_schema_evolution(array, target_type):
- """Cast old-file values using Java's DECIMAL rounding and overflow rules.
-
- PyArrow's unsafe DECIMAL scale reduction truncates and can retain values
- outside the target precision. Java rounds HALF_UP and returns NULL for
- values that do not fit after rounding. Other conversions retain the
- existing unsafe-cast behavior (such as DOUBLE -> INT truncation).
- """
- if (pa.types.is_decimal(array.type) and pa.types.is_decimal(target_type)
- and array.type.scale > target_type.scale):
- values = []
- with localcontext() as context:
- # Rounding a maximal value can carry into one extra digit.
- context.prec = max(array.type.precision, target_type.precision) + 1
- quantum = Decimal(1).scaleb(-target_type.scale)
- for value in array.to_pylist():
- if value is None:
- values.append(None)
- continue
- rounded = value.quantize(quantum, rounding=ROUND_HALF_UP)
- values.append(rounded if len(rounded.as_tuple().digits)
- <= target_type.precision else None)
- return pa.array(values, type=target_type)
- return array.cast(target_type, safe=False)
-
-
def _unslice(array):
"""Re-materialize a sliced array so offsets/buffers start at zero.
@@ -335,9 +309,7 @@ class DataFileBatchReader(RecordBatchReader):
return _constructed_to_string_array(array, file_type)
# Leaf / non-nested: cast to the target type when it differs.
target_pa_type = PyarrowFieldParser.from_paimon_type(target_type)
- if array.type != target_pa_type:
- return cast_array_for_schema_evolution(array, target_pa_type)
- return array
+ return cast_array_for_schema_evolution(array, target_pa_type,
file_type, target_type)
def read_arrow_batch(self, start_idx=None, end_idx=None) ->
Optional[RecordBatch]:
if isinstance(self.format_reader, FormatBlobReader):
@@ -423,7 +395,7 @@ class DataFileBatchReader(RecordBatchReader):
common (non-evolution) path zero-copy.
DECIMAL scale reductions use Java's HALF_UP and overflow-to-NULL
- semantics; other conversions retain the existing unsafe Arrow cast.
+ semantics; numeric narrowing and timestamp precision also follow Java
casts.
Evolution legality is the writer's concern (``DataTypeCasts``).
"""
out_arrays = []
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index a7eb538520..7a60208a41 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -890,6 +890,8 @@ class FileScanner:
stats.buckets_after_pruning.add((partition_key, entry.bucket))
# Get SimpleStatsEvolution for this schema
evolution =
self.simple_stats_evolutions.get_or_create(entry.file.schema_id)
+ stats_predicate = self.simple_stats_evolutions.filter_unsafe_filter(
+ entry.file.schema_id, self.predicate_for_stats)
# Apply evolution to stats
if self.table.is_primary_key_table:
@@ -903,7 +905,7 @@ class FileScanner:
return False
# Java enables value filtering only when batch scans exclude L0.
# With L0 present, pruning an update can expose an older value.
- if self.skip_level0 and self.predicate_for_stats:
+ if self.skip_level0 and stats_predicate:
if entry.file.value_stats_cols is None and
entry.file.write_cols is not None:
stats_fields = entry.file.write_cols
else:
@@ -913,7 +915,7 @@ class FileScanner:
entry.file.row_count,
stats_fields
)
- if not self.predicate_for_stats.test_by_simple_stats(
+ if not stats_predicate.test_by_simple_stats(
evolved_stats,
entry.file.row_count
):
@@ -922,7 +924,7 @@ class FileScanner:
stats.entries_after_stats += 1
return True
else:
- if not self.predicate or self.predicate_for_stats is None:
+ if not self.predicate or stats_predicate is None:
if stats is not None:
stats.entries_after_stats += 1
return True
@@ -938,7 +940,7 @@ class FileScanner:
entry.file.row_count,
stats_fields
)
- kept = self.predicate_for_stats.test_by_simple_stats(
+ kept = stats_predicate.test_by_simple_stats(
evolved_stats,
entry.file.row_count
)
diff --git
a/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
b/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
index 9f134a96cf..8831bd0deb 100644
--- a/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
+++ b/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
@@ -43,6 +43,48 @@ class SimpleStatsEvolutionsTest(unittest.TestCase):
evolution = evolutions.get_or_create(0)
self.assertIsNone(evolution.index_mapping)
+ def test_unsafe_cast_filters_keep_safe_conjuncts_and_preserve_or(self):
+ source = self._make_fields([(0, 'old_name', 'BIGINT'), (1, 'id',
'INT')])
+ target = self._make_fields([(1, 'id', 'INT'), (0, 'new_name',
'TINYINT')])
+ schemas = {0: source, 1: target}
+ evolutions = SimpleStatsEvolutions(lambda sid: schemas[sid], 1)
+ narrowed = Predicate('equal', 1, 'new_name', [-128])
+ unchanged = Predicate('equal', 0, 'id', [1])
+ self.assertIsNone(evolutions.filter_unsafe_filter(0, narrowed))
+ conjunction = Predicate('and', None, None, [narrowed, unchanged])
+ self.assertEqual(evolutions.filter_unsafe_filter(0, conjunction),
unchanged)
+ disjunction = Predicate('or', None, None, [narrowed, unchanged])
+ self.assertIsNone(evolutions.filter_unsafe_filter(0, disjunction))
+ self.assertIs(evolutions.filter_unsafe_filter(1, narrowed), narrowed)
+
+ def test_integer_widening_only_keeps_representable_literals(self):
+ source = self._make_fields([(0, 'v', 'TINYINT')])
+ target = self._make_fields([(0, 'v', 'BIGINT')])
+ schemas = {0: source, 1: target}
+ evolutions = SimpleStatsEvolutions(lambda sid: schemas[sid], 1)
+ for value in (-128, 127):
+ predicate = Predicate('equal', 0, 'v', [value])
+ self.assertEqual(evolutions.filter_unsafe_filter(0, predicate),
predicate)
+ for value in (-129, 128):
+ self.assertIsNone(evolutions.filter_unsafe_filter(0,
Predicate('equal', 0, 'v', [value])))
+ for method in ('in', 'notIn'):
+ self.assertIsNone(evolutions.filter_unsafe_filter(0,
Predicate(method, 0, 'v', [1, None])))
+ predicate = Predicate('isNull', 0, 'v')
+ self.assertEqual(evolutions.filter_unsafe_filter(0, predicate),
predicate)
+
+ def test_timestamp_precision_and_decimal_casts_disable_stats_filter(self):
+ for old_type, new_type in [('TIMESTAMP(3)', 'TIMESTAMP(1)'),
+ ('TIMESTAMP_LTZ(3)', 'TIMESTAMP_LTZ(1)'),
+ ('DECIMAL(10,2)', 'DECIMAL(10,1)'),
+ ('DOUBLE', 'INT')]:
+ source = self._make_fields([(0, 'v', old_type)])
+ target = self._make_fields([(0, 'v', new_type)])
+ schemas = {0: source, 1: target}
+ evolutions = SimpleStatsEvolutions(lambda sid: schemas[sid], 1)
+ for method in ('equal', 'notEqual', 'isNull', 'isNotNull'):
+ with self.subTest(old_type=old_type, new_type=new_type,
method=method):
+ self.assertIsNone(evolutions.filter_unsafe_filter(0,
Predicate(method, 0, 'v', [1])))
+
def test_predicate_with_incomplete_projected_stats(self):
fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'INT')])
evolution = SimpleStatsEvolutions(lambda _: fields, 0).get_or_create(0)
diff --git a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
new file mode 100644
index 0000000000..4f6d7b6400
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
@@ -0,0 +1,216 @@
+# 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.
+
+"""Read existing Python schema changes through Native using REST tables."""
+
+from contextlib import ExitStack
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests import native_plan_rest_test
+
+rest_catalog = native_plan_rest_test.rest_catalog
+
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+ not native_runtime_available(), reason='Rust reader required')]
+
+
+def _write(table, data):
+ 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 _table(catalog, mode, arrow_type, values):
+ options = {'file.format': 'parquet', 'read.batch-size': '2'}
+ if mode == 'pk':
+ options['bucket'] = '1'
+ elif mode == 'de':
+ options.update({'data-evolution.enabled': 'true',
'row-tracking.enabled': 'true'})
+ schema = pa.schema([('id', pa.int64()), ('v', arrow_type)])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'] if mode == 'pk' else [], options=options),
False)
+ table = catalog.get_table('default.t')
+ _write(table, pa.table({'id': list(range(len(values))), 'v':
pa.array(values, type=arrow_type)}))
+
+
+def _read(table, native, predicate=None, projection=None):
+ table = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ builder = table.new_read_builder()
+ if predicate is not None:
+ builder.with_filter(predicate)
+ if projection is not None:
+ builder.with_projection(projection)
+ scan = builder.new_scan()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Native plan fell back')))
+
stack.enter_context(patch('pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('Native read
fell back')))
+ plan = scan.plan()
+ result = builder.new_read().to_arrow(plan.splits())
+ return result.sort_by([('id', 'ascending')])
+
+
+_NUMERIC_CASES = [
+ (pa.int64(), 'TINYINT', [128, -129, 2 ** 63 - 1, -(2 ** 63), None], [-128,
127, -1, 0, None]),
+ (pa.int64(), 'SMALLINT', [32768, -32769, 2 ** 63 - 1, None], [-32768,
32767, -1, None]),
+ (pa.int64(), 'INT', [2 ** 31, -(2 ** 31) - 1, 2 ** 63 - 1, None],
+ [-(2 ** 31), 2 ** 31 - 1, -1, None]),
+ (pa.float64(), 'TINYINT', [128.9, -129.9, 1e30, -1e30, None], [-128, 127,
-1, 0, None]),
+ (pa.float64(), 'INT', [42.9, -42.9, 1e30, -1e30, None],
+ [42, -42, 2 ** 31 - 1, -(2 ** 31), None]),
+ (pa.float64(), 'BIGINT', [42.9, -42.9, 1e30, -1e30, None],
+ [42, -42, 2 ** 63 - 1, -(2 ** 63), None]),
+ (pa.decimal128(38, 2), 'BIGINT',
+ [Decimal('42.99'), Decimal('-42.99'), Decimal('9223372036854775808.00'),
None],
+ [42, -42, -(2 ** 63), None]),
+ (pa.decimal128(10, 2), 'TINYINT', [Decimal('128.99'), Decimal('-129.99'),
None], [-128, 127, None]),
+]
+
+
[email protected]('source_type,target_name,values,expected',
_NUMERIC_CASES)
+def test_single_value_numeric_stats_do_not_prune_converted_match(
+ rest_catalog, source_type, target_name, values, expected):
+ catalog, _ = rest_catalog
+ _table(catalog, 'append', source_type, values[:1])
+ catalog.alter_table('default.t', [SchemaChange.update_column_type('v',
AtomicType(target_name))], False)
+ table = catalog.get_table('default.t')
+ predicate = table.new_read_builder().new_predicate_builder().equal('v',
expected[0])
+ for native in (False, True):
+ assert _read(table, native, predicate,
['id']).column('id').to_pylist() == [0]
+
+
[email protected]('zone', [None, 'UTC'])
+def
test_single_value_timestamp_stats_do_not_prune_truncated_match(rest_catalog,
zone):
+ catalog, _ = rest_catalog
+ _table(catalog, 'append', pa.timestamp('ms', zone), [123])
+ target_name = 'TIMESTAMP_LTZ(1)' if zone else 'TIMESTAMP(1)'
+ catalog.alter_table('default.t', [SchemaChange.update_column_type('v',
AtomicType(target_name))], False)
+ table = catalog.get_table('default.t')
+ literal = pa.scalar(100, type=pa.timestamp('ms', zone)).as_py()
+ predicate = table.new_read_builder().new_predicate_builder().equal('v',
literal)
+ for native in (False, True):
+ assert _read(table, native, predicate,
['id']).column('id').to_pylist() == [0]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('source_type,target_name,values,expected',
_NUMERIC_CASES,
+ ids=['long-byte', 'long-short', 'long-int',
'double-byte', 'double-int',
+ 'double-long', 'decimal-long', 'decimal-byte'])
+def test_numeric_narrowing_reads_old_and_new_rest_files(
+ rest_catalog, mode, source_type, target_name, values, expected):
+ catalog, _ = rest_catalog
+ _table(catalog, mode, source_type, values)
+ catalog.alter_table('default.t', [SchemaChange.update_column_type('v',
AtomicType(target_name))], False)
+ table = catalog.get_table('default.t')
+ for native in (False, True):
+ assert _read(table, native).column('v').to_pylist() == expected
+ target_type = PyarrowFieldParser.from_paimon_type(AtomicType(target_name))
+ _write(table, pa.table({'id': [len(values)], 'v': pa.array([7],
type=target_type)}))
+ table = catalog.get_table('default.t')
+ predicate = table.new_read_builder().new_predicate_builder().equal('v',
expected[0])
+ for native in (False, True):
+ assert _read(table, native).column('v').to_pylist() == expected + [7]
+ matches = {native: _read(table, native, predicate,
['id']).column('id').to_pylist()
+ for native in (False, True)}
+ assert matches == {False: [0], True: [0]}
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('precision', [0, 1, 2, 4, 5, 7, 8])
[email protected]('zone', [None, 'UTC'])
+def test_timestamp_declared_precision_reads_old_rest_files(rest_catalog, mode,
precision, zone):
+ catalog, _ = rest_catalog
+ raw = [123456789, -1, -123456789, 1000000000, None]
+ _table(catalog, mode, pa.timestamp('ns', zone), raw)
+ type_name = 'TIMESTAMP_LTZ' if zone else 'TIMESTAMP'
+ catalog.alter_table('default.t', [SchemaChange.update_column_type(
+ 'v', AtomicType('%s(%d)' % (type_name, precision)))], False)
+ table = catalog.get_table('default.t')
+ target_type =
PyarrowFieldParser.from_paimon_type(table.field_dict['v'].type)
+ ticks_per_second = {'s': 1, 'ms': 1000, 'us': 1000000, 'ns':
1000000000}[target_type.unit]
+ quantum = 10 ** (9 - precision)
+ expected = [None if value is None else (value // quantum * quantum) //
(1000000000 // ticks_per_second)
+ for value in raw]
+ for native in (False, True):
+ result = _read(table, native).column('v')
+ assert result.type == target_type
+ assert result.cast(pa.int64()).to_pylist() == expected
+ if precision <= 6:
+ literal = pa.scalar(expected[0],
type=pa.int64()).cast(target_type).as_py()
+ predicate =
table.new_read_builder().new_predicate_builder().equal('v', literal)
+ assert _read(table, native, predicate,
['id']).column('id').to_pylist() == [0]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
+def
test_nested_narrowing_preserves_null_containers_and_leaf_projection(rest_catalog,
mode):
+ catalog, _ = rest_catalog
+ schema = pa.schema([
+ ('id', pa.int64()),
+ ('r', pa.struct([('n', pa.float64()), ('t', pa.timestamp('ns'))])),
+ ('a', pa.list_(pa.int64())),
+ ('m', pa.map_(pa.string(), pa.timestamp('ns'))),
+ ])
+ options = {'file.format': 'parquet', 'read.batch-size': '1'}
+ if mode == 'pk':
+ options['bucket'] = '1'
+ elif mode == 'de':
+ options.update({'data-evolution.enabled': 'true',
'row-tracking.enabled': 'true'})
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'] if mode == 'pk' else [], options=options),
False)
+ _write(catalog.get_table('default.t'), pa.Table.from_pylist([
+ {'id': 0, 'r': {'n': 128.9, 't': 123456789}, 'a': [128, None, -129],
+ 'm': [('one', 123456789), ('two', -1)]},
+ {'id': 1, 'r': None, 'a': None, 'm': None},
+ {'id': 2, 'r': {'n': -129.9, 't': -1}, 'a': [], 'm': []},
+ ], schema=schema))
+ catalog.alter_table('default.t', [
+ SchemaChange.update_column_type(['r', 'n'], AtomicType('TINYINT')),
+ SchemaChange.update_column_type(['r', 't'],
AtomicType('TIMESTAMP(1)')),
+ SchemaChange.update_column_type(['a', 'element'],
AtomicType('TINYINT')),
+ SchemaChange.update_column_type(['m', 'value'],
AtomicType('TIMESTAMP(1)')),
+ ], False)
+ table = catalog.get_table('default.t')
+ expected_a = [[-128, None, 127], None, []]
+ expected_m = pa.array([[('one', 100), ('two', -100)], None, []],
+ type=pa.map_(pa.string(),
pa.timestamp('ms'))).to_pylist()
+ for native in (False, True):
+ actual = _read(table, native)
+ assert actual.column('a').to_pylist() == expected_a
+ assert actual.column('m').to_pylist() == expected_m
+ # ROW field ids belong to the catalog; assert values independently of
them.
+ assert actual.column('r').flatten()[0].to_pylist() == [-128, None, 127]
+ assert actual.column('r').flatten()[1].cast(pa.int64()).to_pylist() ==
[100, None, -100]
+ leaves = _read(table, native, projection=['id', 'r.n', 'r.t'])
+ assert leaves.column('r_n').to_pylist() == [-128, None, 127]
+ assert leaves.column('r_t').cast(pa.int64()).to_pylist() == [100,
None, -100]
diff --git a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
new file mode 100644
index 0000000000..1b6e4f817b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
@@ -0,0 +1,56 @@
+# 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.
+
+from decimal import Decimal
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.casting.schema_evolution import cast_array_for_schema_evolution
+from pypaimon.schema.data_types import AtomicType
+
+
[email protected]('source_type,values,target_type,expected', [
+ (pa.float32(), [128.9, -129.9, 1e30, -1e30, None], pa.int8(), [-128, 127,
-1, 0, None]),
+ (pa.float64(), [32768.9, -32769.9, 1e30, -1e30, None], pa.int16(),
[-32768, 32767, -1, 0, None]),
+ (pa.float64(), [42.9, -42.9, 1e30, -1e30, None], pa.int32(),
+ [42, -42, 2 ** 31 - 1, -(2 ** 31), None]),
+ (pa.float64(), [42.9, -42.9, 1e30, -1e30, None], pa.int64(),
+ [42, -42, 2 ** 63 - 1, -(2 ** 63), None]),
+ (pa.decimal128(38, 2),
+ [Decimal('999999999999999999999999999999999999.99'), Decimal('-42.99'),
None],
+ pa.int64(), [-5527149226598858753, -42, None]),
+])
[email protected]('chunked', [False, True])
+def test_java_numeric_narrowing_of_sliced_and_chunked_arrays(source_type,
values, target_type, expected, chunked):
+ source = pa.array([None] + values + [None], type=source_type).slice(1,
len(values))
+ if chunked:
+ source = pa.chunked_array([source.slice(0, 2), source.slice(2)])
+ output = cast_array_for_schema_evolution(source, target_type)
+ assert output.type == target_type
+ assert output.to_pylist() == expected
+
+
[email protected]('zone', [None, 'UTC'])
[email protected]('precision', [7, 8])
+def test_timestamp_floor_overflow_returns_null(zone, precision):
+ source = pa.array([-(2 ** 63), -(2 ** 63) + 1000, -1, None],
type=pa.timestamp('ns', zone))
+ name = 'TIMESTAMP_LTZ' if zone else 'TIMESTAMP'
+ output = cast_array_for_schema_evolution(
+ source, source.type, AtomicType(name + '(9)'), AtomicType('%s(%d)' %
(name, precision)))
+ quantum = 10 ** (9 - precision)
+ assert output.cast(pa.int64()).to_pylist() == [None, (-(2 ** 63) + 1000)
// quantum * quantum, -quantum, None]