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 c9f93bd2fa [python] Align temporal string schema evolution with Java
and Native (#10519)
c9f93bd2fa is described below
commit c9f93bd2fa0599a61e632ae0dce17b180ca07936
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 13 07:21:12 2026 +0800
[python] Align temporal string schema evolution with Java and Native
(#10519)
---
paimon-python/pypaimon/casting/row_to_string.py | 37 +-----
paimon-python/pypaimon/casting/schema_evolution.py | 5 +
paimon-python/pypaimon/casting/temporal_string.py | 143 +++++++++++++++++++++
.../pypaimon/read/reader/data_file_batch_reader.py | 2 +-
.../tests/native_schema_evolution_cast_test.py | 118 ++++++++++++++++-
.../pypaimon/tests/schema_evolution_cast_test.py | 51 ++++++++
6 files changed, 318 insertions(+), 38 deletions(-)
diff --git a/paimon-python/pypaimon/casting/row_to_string.py
b/paimon-python/pypaimon/casting/row_to_string.py
index e775ca457c..ddfa6f9e93 100644
--- a/paimon-python/pypaimon/casting/row_to_string.py
+++ b/paimon-python/pypaimon/casting/row_to_string.py
@@ -38,6 +38,7 @@ it would have emitted anyway:
from typing import Any, Optional
+from pypaimon.casting.temporal_string import format_time, format_timestamp
from pypaimon.table.row.generic_row import _parse_type_precision_scale
_UNSUPPORTED_TYPES = ("FLOAT", "REAL", "DOUBLE", "TIMESTAMP_LTZ",
@@ -107,39 +108,9 @@ def _is_unsupported(data_type) -> bool:
def _format_timestamp(value, precision: int) -> str:
- """Format as ``yyyy-MM-dd HH:mm:ss[.fraction]``.
-
- The fraction is padded to nine digits and then stripped of trailing zeros
- down to ``precision`` digits, as ``DateTimeUtils.formatTimestamp`` does.
- Python datetimes are microsecond resolution, so the last three digits of a
- TIMESTAMP(7..9) value are always zero.
- """
- text = "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
- value.year, value.month, value.day,
- value.hour, value.minute, value.second)
- fraction = "{:09d}".format(value.microsecond * 1000)
- while len(fraction) > precision and fraction.endswith("0"):
- fraction = fraction[:-1]
- return (text + "." + fraction) if fraction else text
+ return format_timestamp(value, precision)
def _format_time(value, precision: int) -> str:
- """Format as ``HH:mm:ss[.fraction]``.
-
- Digit by digit off the millisecond part, exactly as
- ``DateTimeUtils.formatTimestampMillis`` does: it stops only once nothing
- but zeros is left, so a truncated non zero tail keeps the zero before it
- (``.10`` for 101 ms at precision 2, not ``.1``).
- """
- text = "{:02d}:{:02d}:{:02d}".format(value.hour, value.minute,
value.second)
- if precision <= 0:
- return text
- millis = value.microsecond // 1000
- digits = []
- while precision > 0:
- digits.append(str(millis // 100))
- millis = millis % 100 * 10
- if millis == 0:
- break
- precision -= 1
- return text + "." + "".join(digits)
+ millis = ((value.hour * 60 + value.minute) * 60 + value.second) * 1000 +
value.microsecond // 1000
+ return format_time(millis, precision)
diff --git a/paimon-python/pypaimon/casting/schema_evolution.py
b/paimon-python/pypaimon/casting/schema_evolution.py
index b01cc99d7e..3aabc54371 100644
--- a/paimon-python/pypaimon/casting/schema_evolution.py
+++ b/paimon-python/pypaimon/casting/schema_evolution.py
@@ -22,6 +22,7 @@ import math
import pyarrow as pa
+from pypaimon.casting.temporal_string import temporal_to_string
from pypaimon.table.row.generic_row import _parse_type_precision_scale
@@ -122,6 +123,10 @@ def cast_array_for_schema_evolution(array, target_type,
source_paimon_type=None,
and saturate to int/long before byte/short narrowing.
DECIMAL scale reduction uses HALF_UP and returns NULL on precision
overflow.
"""
+ if pa.types.is_string(target_type):
+ strings = temporal_to_string(array, source_paimon_type,
target_paimon_type)
+ if strings is not None:
+ return strings
timestamp = _truncate_timestamp(array, target_type, source_paimon_type,
target_paimon_type)
if timestamp is not None:
return timestamp
diff --git a/paimon-python/pypaimon/casting/temporal_string.py
b/paimon-python/pypaimon/casting/temporal_string.py
new file mode 100644
index 0000000000..5cf6e5034c
--- /dev/null
+++ b/paimon-python/pypaimon/casting/temporal_string.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 DateTimeUtils text shared by schema evolution and row rendering."""
+
+import time
+
+import pyarrow as pa
+
+from pypaimon.table.row.generic_row import _parse_type_precision_scale
+
+
+def _epoch_day_to_date(days):
+ # March-based Gregorian eras, with floor division also for pre-epoch days.
+ days += 719468
+ era, day_of_era = divmod(days, 146097)
+ year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 -
day_of_era // 146096) // 365
+ year = year_of_era + era * 400
+ day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 -
year_of_era // 100)
+ march_month = (5 * day_of_year + 2) // 153
+ day = day_of_year - (153 * march_month + 2) // 5 + 1
+ month = march_month + (3 if march_month < 10 else -9)
+ return year + (month <= 2), month, day
+
+
+def _local_offset(seconds):
+ # Avoid converting the date itself through datetime's 1..9999 year range.
+ local = time.localtime(seconds)
+ offset = getattr(local, 'tm_gmtoff', None)
+ if offset is not None:
+ return offset
+ return -(time.altzone if local.tm_isdst > 0 else time.timezone)
+
+
+def format_timestamp(value, precision, nanos=None):
+ """Keep significant fraction digits, with declared precision as a
minimum."""
+ return format_timestamp_parts(value.year, value.month, value.day,
value.hour, value.minute,
+ value.second, value.microsecond * 1000 if
nanos is None else nanos, precision)
+
+
+def format_timestamp_parts(year, month, day, hour, minute, second, nanos,
precision):
+ # DateTimeUtils.int4 writes four individual digits, even for years outside
+ # 0000..9999. Preserve Java's signed remainder rather than Python's modulo.
+ digits = []
+ for divisor in (1000, 100, 10, 1):
+ part = abs(year) // divisor
+ part = -(part % 10) if year < 0 else part % 10
+ digits.append(chr(ord('0') + part))
+ text = "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
+ ''.join(digits), month, day, hour, minute, second)
+ fraction = "{:09d}".format(nanos)
+ while len(fraction) > precision and fraction.endswith("0"):
+ fraction = fraction[:-1]
+ return text + "." + fraction if fraction else text
+
+
+def format_time(millis, precision):
+ """Java TIME has millisecond storage, independent of declared precision."""
+ if millis < 0:
+ millis %= 86400000
+ seconds, fraction = divmod(millis, 1000)
+ hours, seconds = divmod(seconds, 3600)
+ minutes, seconds = divmod(seconds, 60)
+ text = "{:02d}:{:02d}:{:02d}".format(hours % 100, minutes, seconds)
+ if precision <= 0:
+ return text
+ digits = []
+ while precision > 0:
+ digits.append(str(fraction // 100))
+ fraction = fraction % 100 * 10
+ if fraction == 0:
+ break
+ precision -= 1
+ return text + "." + "".join(digits)
+
+
+def temporal_to_string(array, source_type=None, target_type=None):
+ """Return None for non-temporal input; preserve NULLs, slices and nanos.
+
+ LTZ uses the process local zone, as Java TimeZone.getDefault() does.
+ Character length is applied after formatting, using the target Paimon type.
+ """
+ timestamp = pa.types.is_timestamp(array.type)
+ if not timestamp and not pa.types.is_time(array.type):
+ return None
+ unit = array.type.unit
+ precision = (_parse_type_precision_scale(source_type)[0] if source_type is
not None
+ else {'s': 0, 'ms': 3, 'us': 6, 'ns': 9}[unit])
+ if timestamp:
+ raw = array.cast(pa.int64()).to_pylist()
+ ticks = {'s': 1, 'ms': 1000, 'us': 1000000, 'ns': 1000000000}[unit]
+ source_name = str(source_type).upper() if source_type is not None else
''
+ local_zone = (array.type.tz is not None if source_type is None else
+ source_name.startswith('TIMESTAMP_LTZ') or 'WITH LOCAL
TIME ZONE' in source_name)
+ else:
+ raw = array.cast(pa.time32('ms'),
safe=False).cast(pa.int32()).to_pylist()
+ name = str(target_type).upper() if target_type is not None else 'STRING'
+ length = (_parse_type_precision_scale(target_type)[0] or 1) if
name.startswith(('CHAR', 'VARCHAR')) else None
+ values = []
+ for value in raw:
+ if value is None:
+ values.append(None)
+ continue
+ if timestamp:
+ millis = value * 1000 if unit == 's' else value // (ticks // 1000)
+ # Arrow seconds may exceed Java's signed millisecond storage.
+ millis = (millis + (1 << 63)) % (1 << 64) - (1 << 63)
+ seconds = millis // 1000
+ if local_zone:
+ millis += _local_offset(seconds) * 1000
+ millis = (millis + (1 << 63)) % (1 << 64) - (1 << 63)
+ seconds = millis // 1000
+ sub_milli = value % (ticks // 1000) * (1000000000 // ticks) if
ticks > 1000 else 0
+ nanos = millis % 1000 * 1000000 + sub_milli
+ day, second_of_day = divmod(seconds, 86400)
+ day = (day + (1 << 31)) % (1 << 32) - (1 << 31)
+ year, month, day = _epoch_day_to_date(day)
+ hour, second_of_day = divmod(second_of_day, 3600)
+ minute, second = divmod(second_of_day, 60)
+ text = format_timestamp_parts(year, month, day, hour, minute,
second,
+ nanos, precision)
+ else:
+ text = format_time(value, precision)
+ if length is not None:
+ text = text[:length]
+ if name.startswith('CHAR'):
+ text = text.ljust(length)
+ values.append(text)
+ return pa.array(values, type=pa.string())
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 dcd8c0a967..e9d8066a39 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -60,7 +60,7 @@ def _to_string_values(array, data_type) -> list:
"""Render *array* as a list of per-row strings (None for NULL rows)."""
if isinstance(data_type, (RowType, ArrayType, MapType)):
return _constructed_to_string_array(array, data_type).to_pylist()
- return array.cast(pa.string(), safe=False).to_pylist()
+ return cast_array_for_schema_evolution(array, pa.string(), data_type,
AtomicType('STRING')).to_pylist()
def _constructed_to_string_array(array, file_type):
diff --git a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
index f910737817..881983b779 100644
--- a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
@@ -18,6 +18,7 @@
"""Read existing Python schema changes through Native using REST tables."""
from contextlib import ExitStack
+from datetime import datetime, timezone
from decimal import Decimal
from unittest.mock import patch
@@ -26,7 +27,7 @@ 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.data_types import AtomicType, DataField, MapType,
PyarrowFieldParser, RowType
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.tests import native_plan_rest_test
@@ -48,15 +49,18 @@ def _write(table, data):
commit.close()
-def _table(catalog, mode, arrow_type, values):
+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)])
- catalog.create_table('default.t', Schema.from_pyarrow_schema(
- schema, primary_keys=['id'] if mode == 'pk' else [], options=options),
False)
+ 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)}))
@@ -81,6 +85,66 @@ def _read(table, native, predicate=None, projection=None):
return result.sort_by([('id', 'ascending')])
+_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]),
+]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('source_type,values,expected', _TEMPORAL_STRING_CASES)
[email protected]('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
+
+
[email protected]('mode', ['append', 'de'])
[email protected]('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)
+
+
[email protected]('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]),
@@ -335,3 +399,49 @@ def
test_nested_epoch_seconds_evolution_and_projection(rest_catalog, mode, integ
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}']),
+]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('target_name', ['STRING', 'CHAR(8)'])
[email protected]('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
+
+
[email protected]('mode', ['append', 'de'])
[email protected]('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
diff --git a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
index 97d3ecc3a4..1b52f967d8 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
@@ -112,3 +112,54 @@ 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
+
+
[email protected]('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'),
+])
[email protected]('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]
+
+
[email protected]('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]
+
+
[email protected]('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]