blob: 992f456b272c8ec1489ce4031f84cee73666b56d [file]
# 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.
import builtins
from decimal import Decimal, DefaultContext, Inexact, localcontext, Rounded
import os
import struct
import time
from types import SimpleNamespace
import pyarrow as pa
import pytest
from pypaimon.schema.arrow_schema import read_arrow_schema
from pypaimon.execution.python.casting.schema_evolution import cast_array_for_schema_evolution
from pypaimon.schema.data_types import ArrayType, AtomicType, DataField, MapType, PyarrowFieldParser, RowType
@pytest.mark.parametrize('chunked', [False, True])
def test_schema_helpers_do_not_load_native_execution(monkeypatch, chunked):
original_import = builtins.__import__
def python_import(name, *args, **kwargs):
if name.startswith(('pypaimon.execution.native', 'pypaimon_rust')):
raise AssertionError('Python schema evolution imported Native execution: ' + name)
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', python_import)
values = pa.array([1.9, -2.5, None])
if chunked:
values = pa.chunked_array([values.slice(0, 1), values.slice(1)])
result = cast_array_for_schema_evolution(values, pa.int32(), AtomicType('DOUBLE'), AtomicType('INT'))
assert result.to_pylist() == [1, -2, None]
fields = [DataField(1, 'rows', ArrayType(False, RowType(True, [
DataField(2, 'id', AtomicType('INT', False)),
])), description='nested schema')]
schema = read_arrow_schema(fields)
assert schema.field('rows').nullable
assert schema.field('rows').metadata is None
assert schema.field('rows').type.value_field.nullable
assert schema.field('rows').type.value_type.field('id').nullable
def test_read_schema_preserves_runtime_nulls_without_relaxing_write_constraints():
from pypaimon.read.table_read import TableRead
decimal = AtomicType('DECIMAL(3,2)', False)
row = RowType(False, [DataField(2, 'amount', decimal)])
fields = [DataField(1, 'rows', ArrayType(False, row)),
DataField(3, 'amounts', MapType(False, AtomicType('STRING', False), row)),
DataField(4, 'variant', AtomicType('VARIANT', False)),
DataField(5, 'row_keys', MapType(False, row, ArrayType(False, decimal)))]
schema = read_arrow_schema(fields)
rows, amounts, variant = schema.field('rows'), schema.field('amounts'), schema.field('variant')
assert rows.nullable and rows.type.value_field.nullable
assert rows.type.value_type.field('amount').nullable
assert amounts.nullable and not amounts.type.key_field.nullable
assert amounts.type.item_field.nullable and amounts.type.item_type.field('amount').nullable
row_keys = schema.field('row_keys').type
assert not row_keys.key_field.nullable and row_keys.key_type.field('amount').nullable
assert row_keys.item_field.nullable and row_keys.item_type.value_field.nullable
# These VARIANT children describe its physical representation, not logical
# ROW fields. Nullable read values do not relax the required binary buffers.
assert variant.nullable and not any(field.nullable for field in variant.type)
values = [[{'amount': None}], None, []]
rows_array = pa.array(values, type=rows.type)
rows_array.validate(full=True)
batch = pa.record_batch([rows_array], names=['rows'])
aligned = TableRead._try_to_pad_batch_by_schema(batch, pa.schema([rows]))
assert aligned.schema.equals(pa.schema([rows]))
assert aligned.column(0).to_pylist() == values
maps = [[('a', {'amount': None}), ('b', None)], None, []]
pa.array(maps, type=amounts.type).validate(full=True)
write_schema = PyarrowFieldParser.from_paimon_schema(fields)
assert not write_schema.field('rows').nullable
assert not write_schema.field('rows').type.value_field.nullable
assert not write_schema.field('rows').type.value_type.field('amount').nullable
assert not write_schema.field('amounts').type.item_field.nullable
@pytest.mark.parametrize('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')]
@pytest.mark.parametrize('chunked', [False, True])
@pytest.mark.parametrize('source_type', [pa.int8(), pa.int16(), pa.int32(), pa.int64()])
def test_integer_decimal_evolution_applies_precision_per_value(chunked, source_type):
# Java NumericPrimitiveToDecimalCastRule accepts a narrow target even when
# the source type's full range would overflow it. Only overflowing rows
# become NULL; PyArrow's integer cast instead rejects the whole target type.
values = pa.array([None, 1, -1, 99, -99, 100, -100, None], type=source_type)
source = values.slice(1, 7)
if chunked:
source = pa.chunked_array([source.slice(0, 2), source.slice(2, 0), source.slice(2)])
actual = cast_array_for_schema_evolution(source, pa.decimal128(4, 2))
assert actual.to_pylist() == [
Decimal('1.00'), Decimal('-1.00'), Decimal('99.00'), Decimal('-99.00'),
None, None, None,
]
@pytest.mark.parametrize('chunked', [False, True])
@pytest.mark.parametrize('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]
@pytest.mark.parametrize('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
@pytest.mark.parametrize('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'):
pytest.skip('Changing the process local zone requires tzset')
original = os.environ.get('TZ')
def set_zone(name):
os.environ['TZ'] = name
time.tzset()
yield set_zone
if original is None:
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = original
time.tzset()
@pytest.mark.parametrize('source_type,target_type,expected', [
(pa.timestamp('ms', 'UTC'), pa.timestamp('ms'), [28800000, 28799999, None]),
(pa.timestamp('ms'), pa.timestamp('ms', 'UTC'), [-28800000, -28800001, None]),
(pa.date32(), pa.timestamp('ms', 'UTC'), [-28800000, -115200000, None]),
])
def test_temporal_cast_uses_java_process_local_zone(local_zone, source_type, target_type, expected):
local_zone('Asia/Shanghai')
source = pa.array([0, -1, None], type=source_type)
result = cast_array_for_schema_evolution(source, target_type)
assert result.cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('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]),
])
@pytest.mark.parametrize('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
@pytest.mark.parametrize('zone', [None, 'UTC'])
@pytest.mark.parametrize('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]
@pytest.mark.parametrize('unit,ticks', [('s', 1), ('ms', 1000), ('us', 1000000), ('ns', 1000000000)])
@pytest.mark.parametrize('zone', [None, 'UTC'])
@pytest.mark.parametrize('chunked', [False, True])
def test_timestamp_to_bigint_uses_epoch_seconds_flooring(unit, ticks, zone, chunked):
fraction = 0 if unit == 's' else 1
values = [ticks + fraction, -1, -ticks - fraction, 0,
(2 ** 31 + 2) * ticks, -(2 ** 31 + 2) * ticks, None]
expected = [1, -1, -1 if unit == 's' else -2, 0, 2 ** 31 + 2, -(2 ** 31 + 2), None]
source = pa.array([None] + values + [None], type=pa.timestamp(unit, zone)).slice(1, len(values))
if chunked:
source = pa.chunked_array([source.slice(0, 2), source.slice(2, 0), source.slice(2)])
result = cast_array_for_schema_evolution(source, pa.int64())
assert result.type == pa.int64()
assert result.to_pylist() == expected
@pytest.mark.parametrize('unit,ticks', [('s', 1), ('ms', 1000), ('us', 1000000), ('ns', 1000000000)])
@pytest.mark.parametrize('chunked', [False, True])
@pytest.mark.parametrize('source_type', [pa.int32(), pa.int64()])
def test_integer_to_timestamp_uses_epoch_seconds_and_java_millis_wrapping(unit, ticks, chunked, source_type):
values = [42, -42, 1700000000, None]
expected = [42 * ticks, -42 * ticks, 1700000000 * ticks, None]
if source_type == pa.int64():
values.extend([2 ** 63 - 1, -(2 ** 63), 2 ** 63 - 2])
expected.extend([-ticks, 0, -2 * ticks])
else:
values.extend([2 ** 31 - 1, -(2 ** 31), 0])
expected.extend([(2 ** 31 - 1) * ticks, -(2 ** 31) * ticks, 0])
source = pa.array([None] + values + [None], type=source_type).slice(1, len(values))
if chunked:
source = pa.chunked_array([source.slice(0, 1), source.slice(1)])
result = cast_array_for_schema_evolution(source, pa.timestamp(unit))
assert result.type == pa.timestamp(unit)
assert result.cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('zone,expected', [
(None, [9223372036854775, -9223372036854776, None]),
('UTC', [-53757322505225, 53757322505224, None]),
])
def test_timestamp_to_bigint_matches_java_extreme_millis_round_trip(zone, expected):
# Golden outputs from Java CastExecutors, including LTZ's int epoch day.
source = pa.array([2 ** 63 - 1, -(2 ** 63), None], type=pa.timestamp('ms', zone))
assert cast_array_for_schema_evolution(source, pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('unit,expected', [
('s', [9223372036854775, -9223372036854775, None]),
('ms', [9223372036854775000, -9223372036854775000, None]),
('us', [None, None, None]),
('ns', [None, None, None]),
])
def test_bigint_to_timestamp_keeps_lenient_arrow_storage_overflow(unit, expected):
source = pa.array([9223372036854775, -9223372036854775, None], type=pa.int64())
result = cast_array_for_schema_evolution(source, pa.timestamp(unit))
assert result.cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('precision,whole,nonzero', [
(0, '1970-01-01 00:00:00', '1970-01-01 00:00:00.123456789'),
(1, '1970-01-01 00:00:00.0', '1970-01-01 00:00:00.123456789'),
(3, '1970-01-01 00:00:00.000', '1970-01-01 00:00:00.123456789'),
(6, '1970-01-01 00:00:00.000000', '1970-01-01 00:00:00.123456789'),
(9, '1970-01-01 00:00:00.000000000', '1970-01-01 00:00:00.123456789'),
])
@pytest.mark.parametrize('chunked', [False, True])
def test_java_timestamp_string_preserves_nanos_and_declared_precision(precision, whole, nonzero, chunked):
source = pa.array([None, 0, 123456789, -1, None, 0], type=pa.timestamp('ns')).slice(1, 4)
if chunked:
source = pa.chunked_array([source.slice(0, 2), source.slice(2)])
result = cast_array_for_schema_evolution(
source, pa.string(), AtomicType('TIMESTAMP(%d)' % precision), AtomicType('STRING'))
assert result.to_pylist() == [whole, nonzero, '1969-12-31 23:59:59.999999999', None]
@pytest.mark.parametrize('precision,expected', [
(0, ['00:00:00', '00:00:00', '00:00:00', '23:59:59']),
(1, ['00:00:00.0', '00:00:00.1', '00:00:00.1', '23:59:59.9']),
(2, ['00:00:00.0', '00:00:00.1', '00:00:00.10', '23:59:59.99']),
(3, ['00:00:00.0', '00:00:00.1', '00:00:00.101', '23:59:59.999']),
(9, ['00:00:00.0', '00:00:00.1', '00:00:00.101', '23:59:59.999']),
])
def test_java_time_string_fraction_and_negative_millis(precision, expected):
source = pa.array([None, 0, 100, 101, -1, None, 0], type=pa.time32('ms')).slice(1, 5)
result = cast_array_for_schema_evolution(
source, pa.string(), AtomicType('TIME(%d)' % precision), AtomicType('STRING'))
assert result.to_pylist() == expected + [None]
@pytest.mark.parametrize('raw,expected', [
(253402300800000, '0000-01-01 00:00:00.000'), # year 10000: Java int4
(-62167219200000, '0000-01-01 00:00:00.000'), # year 0
(-62198755200000, '000/-01-01 00:00:00.000'), # year -1: signed remainder
(2 ** 63 - 1, '/+-.-04-06 07:12:55.807'), # signed epoch-day narrowing
(-(2 ** 63), '5471-09-26 16:47:04.192'),
])
def test_timestamp_string_keeps_arrow_dates_outside_python_datetime_range(raw, expected):
source = pa.array([raw, None], type=pa.timestamp('ms'))
assert cast_array_for_schema_evolution(
source, pa.string(), AtomicType('TIMESTAMP(3)'), AtomicType('STRING')).to_pylist() == [expected, None]
def test_timestamp_seconds_wrapping_keeps_wrapped_millisecond_fraction():
source = pa.array([9223372036854776, None], type=pa.timestamp('s'))
assert cast_array_for_schema_evolution(
source, pa.string(), AtomicType('TIMESTAMP(3)'), AtomicType('STRING')).to_pylist() == [
'5471-09-26 16:47:04.384', None]
_JAVA_ZONE_CASTS = [
('America/New_York', [
[18000000, 17999900, 1710055800100, 1730611800100, 1710070200100, 1730633400100,
-53757322487462200, 53757322519624100],
[-18000000, -18000100, 1710019800100, 1730583000100, 1710041400100, 1730597400100,
9223372036840375800, 9223372036837013800],
[-1, -1, 19791, 20029, 19792, 20030,
-622191233, 622191233],
[68400000, 68399999, 77400123, 77400123, 12600123, 5400123,
11575807, 42662192],
]),
('Asia/Shanghai', [
[-28800000, -28800100, 1710009000100, 1730568600100, 1710027000100, 1730586600100,
-53757322534367200, 53757322476424100],
[28800000, 28799900, 1710066600100, 1730626200100, 1710084600100, 1730644200100,
-9223372036825975900, -9223372036825632900],
[0, 0, 19792, 20030, 19792, 20030,
-622191233, 622191234],
[28800000, 28799999, 37800123, 34200123, 55800123, 52200123,
54775807, 3167192],
]),
]
@pytest.mark.parametrize('zone,expected', _JAVA_ZONE_CASTS)
@pytest.mark.parametrize('operation', range(4))
@pytest.mark.parametrize('chunked', [False, True])
def test_temporal_zone_casts_match_java_boundaries(local_zone, zone, expected, operation, chunked):
local_zone(zone)
from_type, target, from_name, target_name = [
(pa.timestamp('ms'), pa.timestamp('ms', 'UTC'), 'TIMESTAMP(9)', 'TIMESTAMP_LTZ(1)'),
(pa.timestamp('ms', 'UTC'), pa.timestamp('ms'), 'TIMESTAMP_LTZ(9)', 'TIMESTAMP(1)'),
(pa.timestamp('ms', 'UTC'), pa.date32(), 'TIMESTAMP_LTZ(9)', 'DATE'),
(pa.timestamp('ms', 'UTC'), pa.time32('ms'), 'TIMESTAMP_LTZ(9)', 'TIME(1)'),
][operation]
values = [0, -1, 1710037800123, 1730597400123, 1710055800123, 1730615400123,
9223372036854775807, -9223372036854775808]
source = pa.array([None] + values + [None, None], type=from_type).slice(1, 9)
if chunked:
source = pa.chunked_array([source.slice(0, 1), source.slice(1, 3), source.slice(4)])
result = cast_array_for_schema_evolution(source, target, AtomicType(from_name), AtomicType(target_name))
if operation in (2, 3):
result = result.cast(pa.int32())
else:
result = result.cast(pa.int64())
assert result.to_pylist() == expected[operation] + [None]
@pytest.mark.parametrize('zone,expected', [
('Asia/Shanghai', [-28800000, -115200000, 1717171200000, 1325174400000, None]),
('America/New_York', [18000000, -68400000, 1717214400000, 1325221200000, None]),
('Pacific/Apia', [39600000, -46800000, 1717153200000, 1325239200000, None]),
])
def test_date_to_ltz_resolves_java_midnight(local_zone, zone, expected):
local_zone(zone)
source = pa.array([0, -1, 19875, 15338, None], type=pa.date32())
assert cast_array_for_schema_evolution(source, pa.timestamp('ms', 'UTC')).cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('value', [-(2 ** 31), 2 ** 31 - 1])
def test_date_to_ltz_rejects_invalid_java_julian_date(local_zone, value):
local_zone('UTC')
with pytest.raises(ValueError, match='Invalid Java DATE'):
cast_array_for_schema_evolution(pa.array([value], type=pa.date32()), pa.timestamp('ms', 'UTC'))
@pytest.mark.parametrize('precision,target,expected', [
(6, pa.timestamp('us', 'UTC'), [1710055800123456, 17999999999, None]),
(1, pa.timestamp('ms', 'UTC'), [1710055800100, 17999900, None]),
])
def test_cross_zone_cast_preserves_nanos_and_declared_precision(local_zone, precision, target, expected):
local_zone('America/New_York')
source = pa.array([1710037800123456789, -1, None], type=pa.timestamp('ns'))
result = cast_array_for_schema_evolution(
source, target, AtomicType('TIMESTAMP(9)'), AtomicType('TIMESTAMP_LTZ(%d)' % precision))
assert result.cast(pa.int64()).to_pylist() == expected
def test_bigint_to_ltz_keeps_java_epoch_seconds_and_long_wrapping(local_zone):
local_zone('Asia/Shanghai')
source = pa.array([0, -1, 2 ** 63 - 1, -(2 ** 63), None], type=pa.int64())
assert cast_array_for_schema_evolution(source, pa.timestamp('ms', 'UTC')).cast(pa.int64()).to_pylist() == [
28800000, 28799000, 28799000, 28800000, None]
def test_ltz_semantics_follow_declared_paimon_type(local_zone):
local_zone('Asia/Shanghai')
source = pa.array([0, -1, None], type=pa.timestamp('ms'))
result = cast_array_for_schema_evolution(
source, pa.timestamp('ms'), AtomicType('TIMESTAMP_LTZ(3)'), AtomicType('TIMESTAMP(3)'))
assert result.cast(pa.int64()).to_pylist() == [28800000, 28799999, None]
@pytest.mark.parametrize('zone,values,expected', [
('Asia/Shanghai', [-377705023201000, -377705023200000, None],
[-377705052344000, -377705052343000, None]),
('America/New_York', [253402207199000, 253402207200000, None],
[253402225199000, 253402225200000, None]),
])
def test_local_wall_time_can_cross_date_library_bounds(local_zone, zone, values, expected):
local_zone(zone)
source = pa.array(values, type=pa.timestamp('ms'))
result = cast_array_for_schema_evolution(source, pa.timestamp('ms', 'UTC'))
assert result.cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('platform', ['standard_offset', 'range_error'])
@pytest.mark.parametrize('target,expected', [
(pa.timestamp('ms'), [
-18000000, 9223372036840375807, 9223372021198375807, 9223372036830600000,
9223372036837013808, 253402282800000, None]),
(pa.time32('ms'), [68400000, 11575807, 7975807, 1800000, 42662192, 68400000, None]),
(pa.date32(), [-1, -622191233, -622191414, -622191233, 622191233, 2932896, None]),
(pa.string(), [
'1969-12-31 19:00:00.000', '/+-.-04-06 03:12:55.807', '/+---10-08 02:12:55.807',
'/+-.-04-06 00:30:00.000', '/+-.-04-06 02:16:53.808', '9999-12-31 19:00:00.000', None]),
])
def test_wide_ltz_casts_keep_java_dst_when_platform_localtime_cannot(
local_zone, monkeypatch, platform, target, expected):
local_zone('America/New_York')
libc_localtime = time.localtime
def limited_localtime(seconds=None):
if seconds is None:
return libc_localtime()
if seconds > 253402207200:
if platform == 'standard_offset':
# Some libc implementations lose the far-future DST rule.
return SimpleNamespace(tm_gmtoff=-18000, tm_isdst=0)
raise OverflowError('Platform cannot convert this future year')
if seconds < -377705023201 and platform == 'range_error':
raise OverflowError('Platform cannot convert this ancient year')
return libc_localtime(seconds)
monkeypatch.setattr(time, 'localtime', limited_localtime)
values = [0, 9223372036854775807, 9223372021216375807, 9223372036845000000,
-9223372036854775808, 253402300800000, None]
source = pa.array([None] + values + [None], type=pa.timestamp('ms', 'UTC')).slice(1, len(values))
source = pa.chunked_array([source.slice(0, 3), source.slice(3)])
result = cast_array_for_schema_evolution(source, target)
if not pa.types.is_string(target):
result = result.cast(pa.int64() if pa.types.is_timestamp(target) else pa.int32())
# Golden values from real Java CastExecutors, including a local midnight
# whose DATE changes if a standard offset replaces the correct DST offset.
assert result.to_pylist() == expected