blob: 58bde17a7d0727d551b4160ddb39c02af53ed9a2 [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.
"""Read existing Python schema changes through Native using REST tables."""
from contextlib import ExitStack
from datetime import datetime, timezone
from decimal import Decimal
import os
from unittest.mock import patch
from urllib.parse import unquote, urlparse
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.schema.data_types import AtomicType, DataField, MapType, PyarrowFieldParser, RowType
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, paimon_type=None):
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)])
table_schema = Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if mode == 'pk' else [], options=options)
if paimon_type is not None:
table_schema.fields[1].type = paimon_type
catalog.create_table('default.t', table_schema, 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(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Native plan fell back')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._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')])
def test_large_utf8_old_parquet_reads_as_integer_without_native_fallback(rest_catalog):
catalog, _ = rest_catalog
_table(catalog, 'append', pa.string(), ['1.9', '-2.5', None])
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', AtomicType('INT'))], False)
table = catalog.get_table('default.t')
# Plan once before rewriting the disposable old file. Preserve its logical
# STRING schema while storing the supported LargeUtf8 Arrow representation.
planning_table = table.copy({'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'})
splits = planning_table.new_read_builder().new_scan().plan().splits()
assert splits
for split in splits:
for meta in split.files:
path = meta.file_path
if path.startswith('file:'):
path = unquote(urlparse(path).path)
physical = pq.read_table(path)
index = physical.schema.get_field_index('v')
physical = physical.set_column(index, 'v', physical.column('v').cast(pa.large_string()))
pq.write_table(physical, path)
meta.file_size = os.stat(path).st_size
assert pq.read_schema(path).field('v').type == pa.large_string()
reader = table.copy({'read.native.enabled': 'true'}).new_read_builder().new_read()
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Native read fell back')
):
result = reader.to_arrow(splits).sort_by([('id', 'ascending')])
assert result.column('v').to_pylist() == [1, -2, None]
_TEMPORAL_STRING_CASES = [
(pa.time32('ms'), [0, 100, 101, 12345678, None],
['00:00:00.0', '00:00:00.1', '00:00:00.101', '03:25:45.678', None]),
(pa.timestamp('ms'), [0, 100, -1, 12345678, None],
['1970-01-01 00:00:00.000', '1970-01-01 00:00:00.100',
'1969-12-31 23:59:59.999', '1970-01-01 03:25:45.678', None]),
(pa.timestamp('ns'), [0, 123456789, -1, 1000000000, None],
['1970-01-01 00:00:00.000000000', '1970-01-01 00:00:00.123456789',
'1969-12-31 23:59:59.999999999', '1970-01-01 00:00:01.000000000', None]),
]
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('source_type,values,expected', _TEMPORAL_STRING_CASES)
@pytest.mark.parametrize('target_name', ['STRING', 'VARCHAR(12)', 'CHAR(35)'])
def test_temporal_character_strings_read_old_rest_files(
rest_catalog, mode, source_type, values, expected, target_name):
_check_temporal_string_reads(rest_catalog, mode, source_type, values, expected, target_name)
def _check_temporal_string_reads(rest_catalog, mode, source_type, values, expected, target_name):
catalog, _ = rest_catalog
_table(catalog, mode, source_type, values,
AtomicType('TIME(3)') if pa.types.is_time(source_type) else None)
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', AtomicType(target_name))], False)
table = catalog.get_table('default.t')
if target_name == 'VARCHAR(12)':
expected = [value[:12] if value is not None else None for value in expected]
elif target_name == 'CHAR(35)':
expected = [value.ljust(35) if value is not None else None for value in expected]
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
matches = [index for index, value in enumerate(expected) if value == expected[0]]
assert _read(table, native, predicate, ['id']).column('id').to_pylist() == matches
@pytest.mark.parametrize('mode', ['append', 'de'])
@pytest.mark.parametrize('target_name', ['STRING', 'VARCHAR(12)', 'CHAR(35)'])
def test_wide_timestamp_strings_read_old_rest_files(rest_catalog, mode, target_name):
# The existing Python PK writer materializes datetimes while merging rows,
# so it cannot create this input; append and DE retain the raw Arrow ticks.
_check_temporal_string_reads(rest_catalog, mode, pa.timestamp('ms'),
[253402300800000, -62167219200000, 2 ** 63 - 1, -(2 ** 63), None],
['0000-01-01 00:00:00.000', '0000-01-01 00:00:00.000',
'/+-.-04-06 07:12:55.807', '5471-09-26 16:47:04.192', None], target_name)
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
def test_ltz_string_uses_local_zone_without_zone_suffix(rest_catalog, mode):
catalog, _ = rest_catalog
# Check a winter and a summer instant: the system zone may observe DST.
instants = [datetime(2024, 1, 1, tzinfo=timezone.utc), datetime(2024, 7, 1, tzinfo=timezone.utc)]
expected = [value.astimezone().strftime('%Y-%m-%d %H:%M:%S') + '.000' for value in instants] + [None]
_table(catalog, mode, pa.timestamp('ms', 'UTC'), instants + [None])
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', AtomicType('STRING'))], False)
for native in (False, True):
assert _read(catalog.get_table('default.t'), native).column('v').to_pylist() == expected
_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]),
]
@pytest.mark.parametrize('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]
@pytest.mark.parametrize('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]
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('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]}
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('precision', [0, 1, 2, 4, 5, 7, 8])
@pytest.mark.parametrize('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]
@pytest.mark.parametrize('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]
_TEMPORAL_CASES = [
(pa.timestamp('ms'), 'BIGINT', [42001, -1, -42001, 2147483650000, None],
[42, -1, -43, 2147483650, None]),
(pa.timestamp('us'), 'BIGINT', [42000001, -1, -42000001, 2147483650000000, None],
[42, -1, -43, 2147483650, None]),
(pa.timestamp('ns'), 'BIGINT', [42000000001, -1, -42000000001, 2147483650000000000, None],
[42, -1, -43, 2147483650, None]),
(pa.timestamp('ms', 'UTC'), 'BIGINT', [42001, -1, -42001, 2147483650000, None],
[42, -1, -43, 2147483650, None]),
(pa.timestamp('us', 'UTC'), 'BIGINT', [42000001, -1, -42000001, 2147483650000000, None],
[42, -1, -43, 2147483650, None]),
(pa.timestamp('ns', 'UTC'), 'BIGINT', [42000000001, -1, -42000000001, 2147483650000000000, None],
[42, -1, -43, 2147483650, None]),
(pa.int32(), 'TIMESTAMP(0)', [42, -42, 2147483647, -2147483648, None],
[42, -42, 2147483647, -2147483648, None]),
(pa.int32(), 'TIMESTAMP(1)', [42, -42, 2147483647, -2147483648, None],
[42000, -42000, 2147483647000, -2147483648000, None]),
(pa.int32(), 'TIMESTAMP(4)', [42, -42, 2147483647, -2147483648, None],
[42000000, -42000000, 2147483647000000, -2147483648000000, None]),
(pa.int32(), 'TIMESTAMP(7)', [42, -42, 2147483647, -2147483648, None],
[42000000000, -42000000000, 2147483647000000000, -2147483648000000000, None]),
(pa.int64(), 'TIMESTAMP(0)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 63), None],
[42, -42, 2147483650, -1, 0, None]),
(pa.int64(), 'TIMESTAMP(1)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 63), None],
[42000, -42000, 2147483650000, -1000, 0, None]),
(pa.int64(), 'TIMESTAMP(4)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 63), None],
[42000000, -42000000, 2147483650000000, -1000000, 0, None]),
(pa.int64(), 'TIMESTAMP(7)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 63), None],
[42000000000, -42000000000, 2147483650000000000, -1000000000, 0, None]),
]
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('source_type,target_name,values,expected', _TEMPORAL_CASES)
def test_epoch_seconds_evolution_reads_old_and_new_rest_files(
rest_catalog, mode, source_type, target_name, values, expected):
catalog, _ = rest_catalog
_table(catalog, mode, source_type, values)
target = AtomicType(target_name)
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', target)], False)
table = catalog.get_table('default.t')
target_type = PyarrowFieldParser.from_paimon_type(target)
for native in (False, True):
result = _read(table, native).column('v')
assert result.type == target_type
assert result.cast(pa.int64()).to_pylist() == expected
# Both schema versions must materialize the current type in one read.
_write(table, pa.table({'id': [len(values)], 'v': pa.array([expected[0]], type=target_type)}))
table = catalog.get_table('default.t')
first = pa.scalar(expected[0], type=target_type).as_py()
pb = table.new_read_builder().new_predicate_builder()
for native in (False, True):
assert _read(table, native).column('v').cast(pa.int64()).to_pylist() == expected + [expected[0]]
assert _read(table, native, pb.equal('v', first), ['id']).column('id').to_pylist() == [0, len(values)]
assert _read(table, native, pb.is_null('v'), ['id']).column('id').to_pylist() == [len(values) - 1]
@pytest.mark.parametrize('source_type,target_name,values,expected', _TEMPORAL_CASES)
def test_single_value_epoch_stats_do_not_prune_converted_match(
rest_catalog, source_type, target_name, values, expected):
catalog, _ = rest_catalog
_table(catalog, 'append', source_type, values[:1])
target = AtomicType(target_name)
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', target)], False)
table = catalog.get_table('default.t')
literal = pa.scalar(expected[0], type=PyarrowFieldParser.from_paimon_type(target)).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]
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('integer_type', [pa.int32(), pa.int64()])
def test_nested_epoch_seconds_evolution_and_projection(rest_catalog, mode, integer_type):
catalog, _ = rest_catalog
schema = pa.schema([
('id', pa.int64()),
('r', pa.struct([('t', pa.timestamp('us')), ('s', integer_type)])),
('a', pa.list_(pa.timestamp('ns'))),
('m', pa.map_(pa.string(), integer_type)),
])
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)
boundary = 2 ** 63 - 1 if integer_type == pa.int64() else -(2 ** 31)
expected_boundary = -1000000 if integer_type == pa.int64() else -2147483648000000
_write(catalog.get_table('default.t'), pa.Table.from_pylist([
{'id': 0, 'r': {'t': 42000001, 's': 42}, 'a': [42000000001, None, -1],
'm': [('positive', 42), ('negative', -42)]},
{'id': 1, 'r': None, 'a': None, 'm': None},
{'id': 2, 'r': {'t': -1, 's': boundary}, 'a': [], 'm': []},
], schema=schema))
catalog.alter_table('default.t', [
SchemaChange.update_column_type(['r', 't'], AtomicType('BIGINT')),
SchemaChange.update_column_type(['r', 's'], AtomicType('TIMESTAMP(6)')),
SchemaChange.update_column_type(['a', 'element'], AtomicType('BIGINT')),
SchemaChange.update_column_type(['m', 'value'], AtomicType('TIMESTAMP(3)')),
], False)
table = catalog.get_table('default.t')
expected_map = pa.array([[('positive', 42000), ('negative', -42000)], None, []],
type=pa.map_(pa.string(), pa.timestamp('ms'))).to_pylist()
for native in (False, True):
actual = _read(table, native)
assert actual.column('r').flatten()[0].to_pylist() == [42, None, -1]
assert actual.column('r').flatten()[1].cast(pa.int64()).to_pylist() == [42000000, None, expected_boundary]
assert actual.column('a').to_pylist() == [[42, None, -1], None, []]
assert actual.column('m').to_pylist() == expected_map
leaves = _read(table, native, projection=['id', 'r.t', 'r.s'])
assert leaves.column('r_t').to_pylist() == [42, None, -1]
assert leaves.column('r_s').cast(pa.int64()).to_pylist() == [42000000, None, expected_boundary]
selected = _read(table, native, projection=[
'id', "m['positive']", "m['negative']", "m['missing']"])
assert selected.column('m_positive').cast(pa.int64()).to_pylist() == [42000, None, None]
assert selected.column('m_negative').cast(pa.int64()).to_pylist() == [-42000, None, None]
assert selected.column('m_missing').null_count == 3
_NESTED_TEMPORAL_STRING_CASES = [
(pa.list_(pa.timestamp('ns')), None, [[0, -1, None], None, [], [1000000000]],
['[1970-01-01 00:00:00.000000000, 1969-12-31 23:59:59.999999999, null]', None,
'[]', '[1970-01-01 00:00:01.000000000]']),
(pa.map_(pa.string(), pa.time32('ms')), MapType(True, AtomicType('STRING'), AtomicType('TIME(3)')),
[[('x', 0), ('y', 101), ('n', None)], None, [], [('p', 100)]],
['{x -> 00:00:00.0, y -> 00:00:00.101, n -> null}', None, '{}', '{p -> 00:00:00.1}']),
(pa.struct([('t', pa.time32('ms')), ('ts', pa.timestamp('ns'))]),
RowType(True, [DataField(2, 't', AtomicType('TIME(3)')), DataField(3, 'ts', AtomicType('TIMESTAMP(9)'))]),
[{'t': 0, 'ts': -1}, None, {'t': None, 'ts': None}, {'t': 100, 'ts': 0}],
['{00:00:00.0, 1969-12-31 23:59:59.999999999}', None, '{null, null}',
'{00:00:00.1, 1970-01-01 00:00:00.000000000}']),
]
@pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@pytest.mark.parametrize('target_name', ['STRING', 'CHAR(8)'])
@pytest.mark.parametrize('arrow_type,paimon_type,values,expected', _NESTED_TEMPORAL_STRING_CASES)
def test_constructed_string_evolution_uses_temporal_child_casts(
rest_catalog, mode, target_name, arrow_type, paimon_type, values, expected):
catalog, _ = rest_catalog
_table(catalog, mode, arrow_type, values, paimon_type)
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', AtomicType(target_name))], False)
table = catalog.get_table('default.t')
# Java's constructed cast does not trim/pad the overall result. Its scalar
# children use STRING, retaining their own declared temporal precision.
for native in (False, True):
assert _read(table, native).column('v').to_pylist() == expected
@pytest.mark.parametrize('mode', ['append', 'de'])
@pytest.mark.parametrize('target_name', ['STRING', 'VARCHAR(12)', 'CHAR(35)'])
def test_wide_ltz_string_does_not_fail_a_mixed_batch(rest_catalog, mode, target_name):
catalog, _ = rest_catalog
values = [0, 2 ** 63 - 1, None, -(2 ** 63), 2 ** 63 - 1 - 181 * 86400000]
_table(catalog, mode, pa.timestamp('ms', 'UTC'), values)
catalog.alter_table('default.t', [SchemaChange.update_column_type('v', AtomicType(target_name))], False)
table = catalog.get_table('default.t')
# The Python executor supports the complete signed millisecond range. Core
# unit tests check its matching Java strings for UTC, Shanghai and DST zones.
expected = _read(table, False).column('v').to_pylist()
assert expected[2] is None
assert all(value is not None for i, value in enumerate(expected) if i != 2)
assert _read(table, True).column('v').to_pylist() == expected