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 0592b92168 [Python] Align decimal schema evolution with Java and 
Native (#10531)
0592b92168 is described below

commit 0592b921686bd8a8c3c5aa0cdc0227a50d9b6018
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 13 14:47:44 2026 +0800

    [Python] Align decimal schema evolution with Java and Native (#10531)
---
 paimon-python/pypaimon/casting/decimal_cast.py     |  81 +++++++++++
 paimon-python/pypaimon/casting/numeric_utils.py    | 150 +++++++++++++++++++++
 paimon-python/pypaimon/casting/schema_evolution.py |  21 +--
 .../pypaimon/tests/native_decimal_cast_test.py     | 143 ++++++++++++++++++++
 paimon-python/pypaimon/tests/numeric_utils_test.py |  75 +++++++++++
 .../pypaimon/tests/schema_evolution_cast_test.py   |  94 ++++++++++++-
 .../pypaimon/utils/file_store_path_factory.py      |  41 +-----
 7 files changed, 550 insertions(+), 55 deletions(-)

diff --git a/paimon-python/pypaimon/casting/decimal_cast.py 
b/paimon-python/pypaimon/casting/decimal_cast.py
new file mode 100644
index 0000000000..eb7170dbea
--- /dev/null
+++ b/paimon-python/pypaimon/casting/decimal_cast.py
@@ -0,0 +1,81 @@
+# 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 decimal schema evolution: decimal text, HALF_UP, then precision."""
+
+from decimal import Context, Decimal, InvalidOperation, localcontext, 
ROUND_HALF_UP
+import re
+
+import pyarrow as pa
+
+from pypaimon.casting.numeric_utils import java_double_decimal_text
+
+
+_DECIMAL_TEXT = 
re.compile(r'[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z')
+
+
+def cast_decimal(array, target):
+    """Return None for conversions outside the existing decimal read support.
+
+    FLOAT values from Arrow are already widened to Python double. Java does
+    the same widening before BigDecimal.valueOf, retaining the float's binary
+    rounding. A valid decimal overflowing the target is NULL; invalid text
+    fails the conversion instead of silently becoming NULL.
+    """
+    floating = pa.types.is_floating(array.type)
+    text = pa.types.is_string(array.type)
+    if not pa.types.is_decimal(target) or not (floating or text or 
pa.types.is_decimal(array.type)):
+        return None
+    values = []
+    # The caller's rounding traps and exponent limits must not affect reads.
+    # One extra digit allows rounding to carry, before the precision check.
+    context = Context(prec=target.precision + 1, rounding=ROUND_HALF_UP,
+                      Emin=-999999, Emax=999999, clamp=0, 
traps=[InvalidOperation])
+    with localcontext(context):
+        quantum = Decimal(1).scaleb(-target.scale)
+        for value in array.to_pylist():
+            if value is None:
+                values.append(None)
+                continue
+            if floating:
+                value = java_double_decimal_text(value)
+            if floating or text:
+                if _DECIMAL_TEXT.fullmatch(value) is None:
+                    raise pa.ArrowInvalid('Invalid decimal schema-evolution 
value: %s' % value)
+                try:
+                    value = Decimal(value)
+                except InvalidOperation as error:
+                    raise pa.ArrowInvalid('Invalid decimal schema-evolution 
value: %s' % value) from error
+            # BigDecimal validates the adjusted input scale, then setScale and
+            # BigInteger.TEN.pow check their supported range before precision.
+            # Zero bypasses the power check, but still needs a valid input 
scale.
+            input_scale = -value.as_tuple().exponent
+            if not -(2 ** 31) <= input_scale < 2 ** 31:
+                raise pa.ArrowInvalid('Invalid decimal schema-evolution input 
scale')
+            power = abs(target.scale - input_scale)
+            if not value.is_zero() and (power > 2 ** 31 - 1 or 4 * power // 32 
> (2 ** 31 - 1) // 32 + 1):
+                raise pa.ArrowInvalid("Decimal scale conversion exceeds Java's 
supported range")
+            # Avoid quantizing values whose integer part already overflows.
+            if not value.is_zero() and value.adjusted() + target.scale + 1 > 
target.precision:
+                values.append(None)
+                continue
+            try:
+                rounded = value.quantize(quantum, rounding=ROUND_HALF_UP)
+            except InvalidOperation as error:
+                raise pa.ArrowInvalid('Invalid decimal schema-evolution value: 
%s' % value) from error
+            values.append(rounded if len(rounded.as_tuple().digits) <= 
target.precision else None)
+    return pa.array(values, type=target)
diff --git a/paimon-python/pypaimon/casting/numeric_utils.py 
b/paimon-python/pypaimon/casting/numeric_utils.py
new file mode 100644
index 0000000000..a44ec07cfa
--- /dev/null
+++ b/paimon-python/pypaimon/casting/numeric_utils.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.
+
+"""Java numeric text shared by partition paths and value conversions."""
+
+import math
+import struct
+from decimal import Decimal
+
+
+def java_floating_string(value, single_precision=False) -> str:
+    # Java's JDK 19+ contract selects a shortest round-tripping form with at
+    # least two significant digits before applying its notation thresholds.
+    encoding = '>f' if single_precision else '>d'
+    bits = struct.pack(encoding, value)
+    value = struct.unpack(encoding, bits)[0]
+    if math.isnan(value):
+        return 'NaN'
+    if math.isinf(value):
+        return '-Infinity' if value < 0 else 'Infinity'
+    for precision in range(2, 10 if single_precision else 18):
+        text = format(value, '.{}g'.format(precision))
+        try:
+            rounded = struct.pack(encoding, float(text))
+        except OverflowError:
+            continue
+        if rounded != bits:
+            continue
+        decimal = Decimal(text)
+        if decimal.is_zero() or Decimal('0.001') <= abs(decimal) < 
Decimal('1e7'):
+            text = format(decimal, 'f')
+            if '.' in text:
+                text = text.rstrip('0').rstrip('.')
+            if '.' not in text:
+                text += '.0'
+        else:
+            mantissa, exponent = format(decimal, 'e').split('e')
+            mantissa = mantissa.rstrip('0').rstrip('.') if '.' in mantissa 
else mantissa
+            if '.' not in mantissa:
+                mantissa += '.0'
+            text = '{}E{}'.format(mantissa, int(exponent))
+        return text
+    return str(value)
+
+
+def java_double_decimal_text(value):
+    """Numeric digits of Java 8/11/17 Double.toString for BigDecimal.valueOf.
+
+    The pre-JDK-19 converter has an integral fast path and distinct interval
+    endpoints for its fixed-width and arbitrary-precision digit generators.
+    Use integer arithmetic over IEEE-754 components to preserve those choices.
+    The emitted coefficient/exponent is numeric text, not a display format.
+    """
+    if not math.isfinite(value):
+        return java_floating_string(value)
+    if value == 0:
+        return '0'
+    raw = struct.unpack('>Q', struct.pack('>d', value))[0]
+    sign = '-' if raw >> 63 else ''
+    exponent = ((raw >> 52) & 2047) - 1023
+    mantissa = raw & ((1 << 52) - 1)
+    if exponent == -1023:
+        significant = mantissa.bit_length()
+        normalize = 53 - significant
+        mantissa <<= normalize
+        exponent = -1022 - normalize
+    else:
+        significant = 53
+        mantissa |= 1 << 52
+    trailing = (mantissa & -mantissa).bit_length() - 1
+    width = 53 - trailing
+    tiny = max(0, width - exponent - 1)
+    if -21 <= exponent <= 62 and tiny == 0:
+        integer = mantissa << (exponent - 52) if exponent >= 52 else mantissa 
>> (52 - exponent)
+        discarded = (len(str(1 << max(0, exponent - significant - 1))) - 1
+                     if exponent > significant else 0)
+        factor = 10 ** discarded
+        return sign + str((integer + factor // 2) // factor) + 'e' + 
str(discarded)
+
+    # Approximate log10 from the normalized binary significand. A leading zero
+    # digit corrects an overestimate without changing the generated interval.
+    normalized = mantissa / (1 << 52)
+    decimal_exponent = math.floor(
+        (normalized - 1.5) * 0.289529654 + 0.176091259 + exponent * 
0.301029995663981)
+    negative_power = max(0, -decimal_exponent)
+    positive_power = max(0, decimal_exponent)
+    numerator_power = negative_power + tiny + exponent - width + 1
+    denominator_power = positive_power + tiny
+    margin_power = negative_power + tiny + exponent - significant
+    common = min(numerator_power, denominator_power)
+    numerator_power -= common
+    denominator_power -= common
+    margin_power -= common
+    if width == 1:
+        margin_power -= 1
+    if margin_power < 0:
+        numerator_power -= margin_power
+        denominator_power -= margin_power
+        margin_power = 0
+    numerator = (mantissa >> trailing) * 5 ** negative_power * (1 << 
numerator_power)
+    denominator = 5 ** positive_power * (1 << denominator_power)
+    margin = 5 ** negative_power * (1 << margin_power)
+
+    def five_bits(power):
+        return (5 ** power).bit_length() if 0 < power < 27 else power * 3
+
+    bits = max(width + numerator_power + five_bits(negative_power),
+               denominator_power + 1 + five_bits(positive_power + 1))
+    word = 32 if bits < 32 else 64 if bits < 64 else 0
+
+    def wrap(number):
+        return (number + (1 << (word - 1))) % (1 << word) - (1 << (word - 1)) 
if word else number
+
+    ten_denominator = denominator * 10
+    coefficient, count = 0, 0
+    while True:
+        digit, numerator = divmod(numerator, denominator)
+        numerator *= 10
+        margin = wrap(margin * 10)
+        low = numerator < margin
+        high = (wrap(numerator + margin) > ten_denominator if word else
+                numerator + margin >= ten_denominator)
+        if count and word and margin <= 0:
+            low = high = True
+        if digit == 0 and count == 0 and not high:
+            decimal_exponent -= 1
+        else:
+            coefficient = coefficient * 10 + digit
+            count += 1
+        if count <= 1 and (decimal_exponent < -3 or decimal_exponent >= 8):
+            low = high = False
+        if low or high:
+            difference = wrap(2 * numerator - ten_denominator)
+            if high and (not low or difference > 0 or difference == 0 and 
coefficient % 2):
+                coefficient += 1
+            return sign + str(coefficient) + 'e' + str(decimal_exponent - 
count + 1)
diff --git a/paimon-python/pypaimon/casting/schema_evolution.py 
b/paimon-python/pypaimon/casting/schema_evolution.py
index afa3650627..1116aeaacd 100644
--- a/paimon-python/pypaimon/casting/schema_evolution.py
+++ b/paimon-python/pypaimon/casting/schema_evolution.py
@@ -17,11 +17,11 @@
 
 """Java cast rules shared by old-file readers."""
 
-from decimal import Decimal, localcontext, ROUND_HALF_UP
 import math
 
 import pyarrow as pa
 
+from pypaimon.casting.decimal_cast import cast_decimal
 from pypaimon.casting.temporal_cast import cast_temporal
 from pypaimon.casting.temporal_string import temporal_to_string
 from pypaimon.table.row.generic_row import _parse_type_precision_scale
@@ -122,7 +122,8 @@ def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None,
     epoch seconds, including pre-epoch flooring and Java's millis 
multiplication.
     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.
+    DECIMAL conversion uses Java decimal text and HALF_UP, returning NULL on
+    precision overflow after rounding.
     LTZ conversions use the process local zone, including Java gap/overlap 
rules.
     """
     if pa.types.is_string(target_type):
@@ -137,6 +138,9 @@ def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None,
         return timestamp
     if array.type == target_type:
         return array
+    decimals = cast_decimal(array, target_type)
+    if decimals is not None:
+        return decimals
     if pa.types.is_timestamp(array.type) and target_type == pa.int64():
         return _timestamp_to_bigint(array)
     if (array.type in (pa.int32(), pa.int64())
@@ -145,17 +149,4 @@ def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None,
     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/tests/native_decimal_cast_test.py 
b/paimon-python/pypaimon/tests/native_decimal_cast_test.py
new file mode 100644
index 0000000000..7c1c998438
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_decimal_cast_test.py
@@ -0,0 +1,143 @@
+# 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 decimal golden values through Python and Native REST reads."""
+
+from decimal import Decimal
+import struct
+
+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
+from pypaimon.tests.native_schema_evolution_cast_test import _read, _table, 
_write
+
+rest_catalog = native_plan_rest_test.rest_catalog
+
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_runtime_available(), reason='Rust reader required')]
+
+
+_CASES = [
+    (pa.float64(), 'DECIMAL(38,20)',
+     [struct.unpack('>d', bytes.fromhex('c381e88f38c0c8fd'))[0], None],
+     ['-161304845894623136.00000000000000000000', None]),
+    (pa.float64(), 'DECIMAL(38,6)', [1e23, -1e23, None],
+     ['99999999999999990000000.000000', '-99999999999999990000000.000000', 
None]),
+    (pa.float64(), 'DECIMAL(5,2)', [1.005, -1.005, 0.145, 999.995, 1e30, None],
+     ['1.01', '-1.01', '0.15', None, None, None]),
+    (pa.float32(), 'DECIMAL(5,2)', [1.005, -1.005, 0.145, 999.995, 1e30, None],
+     ['1.00', '-1.00', '0.14', '999.99', None, None]),
+    (pa.float64(), 'DECIMAL(38,20)', [1.2345678901234567, 1.005, 1e30, None],
+     ['1.23456789012345670000', '1.00500000000000000000', None, None]),
+    (pa.float32(), 'DECIMAL(38,20)', [1.2345679, 1.005, 1e30, None],
+     ['1.23456788063049320000', '1.00499999523162840000', None, None]),
+    (pa.string(), 'DECIMAL(5,2)', ['1e-2', '+.005', '2.675', '999.995', 
'1e100', None],
+     ['0.01', '0.01', '2.68', None, None, None]),
+    (pa.decimal128(10, 2), 'DECIMAL(3,2)', [Decimal('9.99'), Decimal('10.00'), 
None],
+     ['9.99', None, None]),
+    (pa.decimal128(10, 2), 'DECIMAL(4,3)', [Decimal('9.99'), 
Decimal('-10.00'), None],
+     ['9.990', None, None]),
+    (pa.decimal128(6, 3), 'DECIMAL(3,2)', [Decimal('9.994'), Decimal('9.995'), 
Decimal('-9.995'), None],
+     ['9.99', None, None, None]),
+]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('source,target,values,expected', _CASES)
+def test_decimal_schema_changes_read_mixed_rest_files(rest_catalog, mode, 
source, target, values, expected):
+    catalog, _ = rest_catalog
+    _table(catalog, mode, source, values)
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType(target))], False)
+    table = catalog.get_table('default.t')
+    target_arrow = 
PyarrowFieldParser.from_paimon_type(table.field_dict['v'].type)
+    expected = [None if value is None else Decimal(value) for value in 
expected]
+    # Read both old and new schema files, with a batch size smaller than the 
input.
+    _write(table, pa.table({'id': [len(values)], 'v': pa.array([expected[0]], 
type=target_arrow)}))
+    table = catalog.get_table('default.t')
+    pb = table.new_read_builder().new_predicate_builder()
+    for native in (False, True):
+        actual = _read(table, native).column('v')
+        assert actual.type == target_arrow
+        assert actual.to_pylist() == expected + [expected[0]]
+        # The converted column remains a residual-filter input when projected 
out.
+        assert _read(table, native, pb.equal('v', expected[0]), 
['id']).column('id').to_pylist() == [
+            i for i, value in enumerate(expected + [expected[0]]) if value == 
expected[0]]
+        assert _read(table, native, pb.is_null('v'), 
['id']).column('id').to_pylist() == [
+            i for i, value in enumerate(expected) if value is None]
+
+
[email protected]('source,target,values,expected', _CASES)
+def 
test_decimal_single_value_stats_do_not_prune_converted_matches(rest_catalog, 
source, target, values, expected):
+    catalog, _ = rest_catalog
+    _table(catalog, 'append', source, values[:1])
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType(target))], False)
+    table = catalog.get_table('default.t')
+    predicate = table.new_read_builder().new_predicate_builder().equal('v', 
Decimal(expected[0]))
+    for native in (False, True):
+        assert _read(table, native, predicate, 
['id']).column('id').to_pylist() == [0]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
+def test_nested_decimal_casts_preserve_containers_and_projection(rest_catalog, 
mode):
+    catalog, _ = rest_catalog
+    schema = pa.schema([
+        ('id', pa.int64()), ('r', pa.struct([('v', pa.float32())])),
+        ('a', pa.list_(pa.float64())), ('m', pa.map_(pa.string(), 
pa.string())),
+    ])
+    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': {'v': 1.2345679}, 'a': [1.005, -1.005, 999.995, None],
+         'm': [('one', '1e-2'), ('two', '+.005'), ('big', '1e100')]},
+        {'id': 1, 'r': None, 'a': None, 'm': None},
+        {'id': 2, 'r': {'v': None}, 'a': [], 'm': []},
+    ], schema=schema))
+    catalog.alter_table('default.t', [
+        SchemaChange.update_column_type(['r', 'v'], 
AtomicType('DECIMAL(38,20)')),
+        SchemaChange.update_column_type(['a', 'element'], 
AtomicType('DECIMAL(5,2)')),
+        SchemaChange.update_column_type(['m', 'value'], 
AtomicType('DECIMAL(5,2)')),
+    ], False)
+    table = catalog.get_table('default.t')
+    for native in (False, True):
+        actual = _read(table, native)
+        assert actual.column('r').to_pylist() == [{'v': 
Decimal('1.23456788063049320000')}, None, {'v': None}]
+        assert actual.column('a').to_pylist() == [[Decimal('1.01'), 
Decimal('-1.01'), None, None], None, []]
+        assert actual.column('m').to_pylist() == [[('one', Decimal('0.01')), 
('two', Decimal('0.01')),
+                                                  ('big', None)], None, []]
+        assert _read(table, native, projection=['id', 
'r.v']).column('r_v').to_pylist() == [
+            Decimal('1.23456788063049320000'), None, None]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
+def test_invalid_decimal_old_file_fails_in_both_readers(rest_catalog, mode):
+    catalog, _ = rest_catalog
+    _table(catalog, mode, pa.string(), [None, '1.2.3'])
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType('DECIMAL(5,2)'))], False)
+    for native in (False, True):
+        with pytest.raises(Exception, match='Invalid decimal schema-evolution 
value'):
+            _read(catalog.get_table('default.t'), native)
diff --git a/paimon-python/pypaimon/tests/numeric_utils_test.py 
b/paimon-python/pypaimon/tests/numeric_utils_test.py
new file mode 100644
index 0000000000..3ef464c0fa
--- /dev/null
+++ b/paimon-python/pypaimon/tests/numeric_utils_test.py
@@ -0,0 +1,75 @@
+# 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.
+
+"""Golden numeric digits from Java 8, 11 and 17 Double.toString."""
+
+from decimal import Decimal
+import struct
+
+import pytest
+
+from pypaimon.casting.numeric_utils import java_double_decimal_text
+
+
[email protected]('bits,expected', [
+    (0xc381e88f38c0c8fd, '-1.61304845894623136E17'),
+    (0x44b52d02c7e14af6, '9.999999999999999E22'),
+    (0x0000000000000001, '4.9E-324'),
+    (0x0010000000000000, '2.2250738585072014E-308'),
+    (0x7fefffffffffffff, '1.7976931348623157E308'),
+    (0x0000000000000002, '1.0E-323'),
+    (0x000fffffffffffff, '2.225073858507201E-308'),
+    (0x0010000000000001, '2.225073858507202E-308'),
+    (0x016fffffffffffff, '9.332636185032188E-302'),
+    (0x0170000000000000, '9.332636185032189E-302'),
+    (0x0170000000000001, '9.33263618503219E-302'),
+    (0x39afffffffffffff, '7.888609052210117E-31'),
+    (0x39b0000000000000, '7.888609052210118E-31'),
+    (0x39b0000000000001, '7.88860905221012E-31'),
+    (0x3befffffffffffff, '5.4210108624275216E-20'),
+    (0x3bf0000000000000, '5.421010862427522E-20'),
+    (0x3bf0000000000001, '5.421010862427523E-20'),
+    (0x3c9fffffffffffff, '1.1102230246251564E-16'),
+    (0x3ca0000000000000, '1.1102230246251565E-16'),
+    (0x3ca0000000000001, '1.1102230246251568E-16'),
+    (0x3e9fffffffffffff, '4.7683715820312495E-7'),
+    (0x3ea0000000000000, '4.76837158203125E-7'),
+    (0x3ea0000000000001, '4.768371582031251E-7'),
+    (0x3fbfffffffffffff, '0.12499999999999999'),
+    (0x3fc0000000000000, '0.125'),
+    (0x3fc0000000000001, '0.12500000000000003'),
+    (0x3fefffffffffffff, '0.9999999999999999'),
+    (0x3ff0000000000000, '1.0'),
+    (0x3ff0000000000001, '1.0000000000000002'),
+    (0x3fffffffffffffff, '1.9999999999999998'),
+    (0x4000000000000000, '2.0'),
+    (0x4000000000000001, '2.0000000000000004'),
+    (0x41efffffffffffff, '4.2949672959999995E9'),
+    (0x41f0000000000000, '4.294967296E9'),
+    (0x41f0000000000001, '4.294967296000001E9'),
+    (0x432fffffffffffff, '4.5035996273704955E15'),
+    (0x4330000000000000, '4.503599627370496E15'),
+    (0x4330000000000001, '4.503599627370497E15'),
+    (0x433fffffffffffff, '9.007199254740991E15'),
+    (0x4340000000000000, '9.007199254740992E15'),
+    (0x4340000000000001, '9.007199254740994E15'),
+    (0x434fffffffffffff, '1.8014398509481982E16'),
+    (0x4350000000000000, '1.8014398509481984E16'),
+])
+def test_double_decimal_digits_match_supported_jvms(bits, expected):
+    value = struct.unpack('>d', struct.pack('>Q', bits))[0]
+    assert Decimal(java_double_decimal_text(value)) == Decimal(expected)
diff --git a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
index f3280ec9f8..363986c77d 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
@@ -15,8 +15,9 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from decimal import Decimal
+from decimal import Decimal, DefaultContext, Inexact, localcontext, Rounded
 import os
+import struct
 import time
 from types import SimpleNamespace
 
@@ -27,6 +28,97 @@ from pypaimon.casting.schema_evolution import 
cast_array_for_schema_evolution
 from pypaimon.schema.data_types import AtomicType
 
 
[email protected]('chunked', [False, True])
+def test_decimal_evolution_uses_supported_jvm_floating_decimal_digits(chunked):
+    # Java 8/11/17 retain exact digits in this integral fast path. JDK 19+
+    # shortest text changes the integer by four, before scale is applied.
+    value = struct.unpack('>d', bytes.fromhex('c381e88f38c0c8fd'))[0]
+    source = pa.array([None, value, -value, 1e23, None]).slice(1, 4)
+    if chunked:
+        source = pa.chunked_array([source.slice(0, 1), source.slice(1)])
+    result = cast_array_for_schema_evolution(source, pa.decimal128(38, 20))
+    assert result.to_pylist() == [
+        Decimal('-161304845894623136.00000000000000000000'),
+        Decimal('161304845894623136.00000000000000000000'), None, None,
+    ]
+    # For the power-of-ten boundary, the old converter excludes the upper
+    # interval endpoint and preserves 9.999999999999999E22, rather than 1E23.
+    result = cast_array_for_schema_evolution(pa.array([1e23]), 
pa.decimal128(38, 6))
+    assert result.to_pylist() == [Decimal('99999999999999990000000.000000')]
+
+
[email protected]('chunked', [False, True])
[email protected]('source_type,values,precision,scale,expected', [
+    (pa.float64(), [1.005, -1.005, 2.675, -2.675, 0.145, 999.995, 1e30, None], 
5, 2,
+     ['1.01', '-1.01', '2.68', '-2.68', '0.15', None, None, None]),
+    (pa.float32(), [1.005, -1.005, 2.675, 0.145, 999.995, 1e30, None], 5, 2,
+     ['1.00', '-1.00', '2.67', '0.14', '999.99', None, None]),
+    (pa.float64(), [1.005, 1.2345678901234567, 2.2250738585072014e-308, None], 
38, 20,
+     ['1.00500000000000000000', '1.23456789012345670000', 
'0.00000000000000000000', None]),
+    (pa.float32(), [1.005, 1.2345679, None], 38, 20,
+     ['1.00499999523162840000', '1.23456788063049320000', None]),
+    (pa.string(), ['1.005', '-1.005', '+.005', '2.675', '1e-2', '999.995', 
'1e100', None], 5, 2,
+     ['1.01', '-1.01', '0.01', '2.68', '0.01', None, None, None]),
+    (pa.decimal128(10, 2), [Decimal('9.99'), Decimal('10.00'), 
Decimal('-10.00'), None], 3, 2,
+     ['9.99', None, None, None]),
+    (pa.decimal128(10, 2), [Decimal('9.99'), Decimal('10.00'), 
Decimal('-10.00'), None], 4, 3,
+     ['9.990', None, None, None]),
+    (pa.decimal128(6, 3), [Decimal('9.994'), Decimal('9.995'), 
Decimal('-9.995'), None], 3, 2,
+     ['9.99', None, None, None]),
+])
+def test_decimal_evolution_matches_java_rounding_and_precision(
+        chunked, source_type, values, precision, scale, expected):
+    # Golden values from CastExecutors: FLOAT is promoted to double before
+    # BigDecimal.valueOf; overflow is NULL after HALF_UP, never wrapping.
+    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, 0), 
source.slice(2)])
+    target = pa.decimal128(precision, scale)
+    actual = cast_array_for_schema_evolution(source, target)
+    assert actual.type == target
+    assert actual.to_pylist() == [None if value is None else Decimal(value) 
for value in expected]
+
+
[email protected]('text', [
+    '', ' 1.2 ', '1_2', '1.2.3', '1e', 'Infinity', '0e-2147483648',
+    '0.1e-2147483647', '1e2147483647', '1e-2147483647', 
'1e9999999999999999999',
+])
+def test_decimal_evolution_rejects_invalid_text(text):
+    with pytest.raises(pa.ArrowInvalid):
+        cast_array_for_schema_evolution(pa.array([None, text]), 
pa.decimal128(5, 2))
+
+
+def test_decimal_evolution_handles_long_mantissas_and_java_zero_exponents():
+    values = ['1.005' + '0' * 200, '0.' + '0' * 200 + '1',
+              '0e2147483648', '0.0e2147483648', '0e2147483647']
+    result = cast_array_for_schema_evolution(pa.array(values), 
pa.decimal128(5, 2))
+    assert result.to_pylist() == [Decimal('1.01')] + [Decimal('0.00')] * 4
+
+
[email protected]('source_type,values', [
+    (pa.float64(), [1.005, 10000, None]),
+    (pa.string(), ['1.005', '10000', None]),
+    (pa.decimal128(10, 3), [Decimal('1.005'), Decimal('10000'), None]),
+])
+def 
test_decimal_evolution_is_independent_of_caller_decimal_context(source_type, 
values):
+    source = pa.array(values, type=source_type)
+    with localcontext() as context:
+        context.prec = 2
+        context.Emax = 2
+        context.traps[Inexact] = True
+        context.traps[Rounded] = True
+        result = cast_array_for_schema_evolution(source, pa.decimal128(10, 2))
+    assert result.to_pylist() == [Decimal('1.01'), Decimal('10000.00'), None]
+
+
+def 
test_decimal_evolution_is_independent_of_default_decimal_context(monkeypatch):
+    monkeypatch.setattr(DefaultContext, 'Emax', 2)
+    monkeypatch.setattr(DefaultContext, 'Emin', -2)
+    monkeypatch.setattr(DefaultContext, 'clamp', 1)
+    result = cast_array_for_schema_evolution(pa.array(['1.005', '10000', 
None]), pa.decimal128(10, 2))
+    assert result.to_pylist() == [Decimal('1.01'), Decimal('10000.00'), None]
+
+
 @pytest.fixture
 def local_zone():
     if not hasattr(time, 'tzset'):
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py 
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 8da0b11a72..c1c956e560 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -15,12 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import math
-import struct
 from datetime import date, timezone
-from decimal import Decimal
 from typing import List, Optional, Tuple
 
+from pypaimon.casting.numeric_utils import java_floating_string
 from pypaimon.casting.row_to_string import cast_value_to_string, 
_format_timestamp, _is_unsupported
 from pypaimon.common.external_path_provider import ExternalPathProvider
 from pypaimon.utils.path import resolve_path, to_file_io_path
@@ -82,41 +80,6 @@ def canonical_data_file_path(table, partition, bucket, 
file_name):
     return path
 
 
-def _floating_partition_string(value, single_precision: bool) -> str:
-    # Java's JDK 19+ contract selects a shortest round-tripping form with at
-    # least two significant digits before applying its notation thresholds.
-    encoding = '>f' if single_precision else '>d'
-    bits = struct.pack(encoding, value)
-    value = struct.unpack(encoding, bits)[0]
-    if math.isnan(value):
-        return 'NaN'
-    if math.isinf(value):
-        return '-Infinity' if value < 0 else 'Infinity'
-    for precision in range(2, 10 if single_precision else 18):
-        text = format(value, '.{}g'.format(precision))
-        try:
-            rounded = struct.pack(encoding, float(text))
-        except OverflowError:
-            continue
-        if rounded != bits:
-            continue
-        decimal = Decimal(text)
-        if decimal.is_zero() or Decimal('0.001') <= abs(decimal) < 
Decimal('1e7'):
-            text = format(decimal, 'f')
-            if '.' in text:
-                text = text.rstrip('0').rstrip('.')
-            if '.' not in text:
-                text += '.0'
-        else:
-            mantissa, exponent = format(decimal, 'e').split('e')
-            mantissa = mantissa.rstrip('0').rstrip('.') if '.' in mantissa 
else mantissa
-            if '.' not in mantissa:
-                mantissa += '.0'
-            text = '{}E{}'.format(mantissa, int(exponent))
-        return text
-    return str(value)
-
-
 class FileStorePathFactory:
     MANIFEST_PATH = "manifest"
     MANIFEST_PREFIX = "manifest-"
@@ -211,7 +174,7 @@ class FileStorePathFactory:
             if _is_null_or_whitespace_only(value):
                 text = self.default_part_value
             elif type_name in ('FLOAT', 'REAL', 'DOUBLE'):
-                text = _floating_partition_string(value, type_name != 'DOUBLE')
+                text = java_floating_string(value, type_name != 'DOUBLE')
             elif self.legacy_partition_name and type_name == 'DATE':
                 text = str((value - date(1970, 1, 1)).days)
             elif self.legacy_partition_name and 
type_name.startswith('TIMESTAMP'):

Reply via email to