blob: 7bbee3d84ab81c8822b0740ad1ab2379560963a2 [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 os
import shutil
import tempfile
import unittest
import pandas
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import Move, SchemaChange
from pypaimon.schema.schema_manager import SchemaManager
from pypaimon.schema.table_schema import TableSchema
class SchemaEvolutionReadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tempdir = tempfile.mkdtemp()
cls.warehouse = os.path.join(cls.tempdir, 'warehouse')
cls.catalog = CatalogFactory.create({
'warehouse': cls.warehouse
})
cls.catalog.create_database('default', False)
cls.pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('behavior', pa.string()),
('dt', pa.string())
])
cls.raw_data = {
'user_id': [1, 2, 3, 4, 5],
'item_id': [1001, 1002, 1003, 1004, 1005],
'behavior': ['a', 'b', 'c', None, 'e'],
'dt': ['p1', 'p1', 'p1', 'p1', 'p2'],
}
cls.expected = pa.Table.from_pydict(cls.raw_data, schema=cls.pa_schema)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tempdir, ignore_errors=True)
@pytest.mark.python_plan
def test_schema_evolution(self):
# schema 0
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('dt', pa.string())
])
schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'])
self.catalog.create_table('default.test_sample', schema, False)
table1 = self.catalog.get_table('default.test_sample')
write_builder = table1.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data1 = {
'user_id': [1, 2, 3, 4],
'item_id': [1001, 1002, 1003, 1004],
'dt': ['p1', 'p1', 'p2', 'p1'],
}
pa_table = pa.Table.from_pydict(data1, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# schema 1 add behavior column
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('dt', pa.string()),
('behavior', pa.string())
])
schema2 = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'])
self.catalog.create_table('default.test_schema_evolution', schema2, False)
table2 = self.catalog.get_table('default.test_schema_evolution')
table2.table_schema.id = 1
write_builder = table2.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data2 = {
'user_id': [5, 6, 7, 8],
'item_id': [1005, 1006, 1007, 1008],
'dt': ['p2', 'p1', 'p2', 'p2'],
'behavior': ['e', 'f', 'g', 'h'],
}
pa_table = pa.Table.from_pydict(data2, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# write schema-0 and schema-1 to table2
schema_manager = SchemaManager(table2.file_io, table2.table_path)
schema_manager.commit(TableSchema.from_schema(schema_id=0, schema=schema))
schema_manager.commit(TableSchema.from_schema(schema_id=1, schema=schema2))
splits = self._scan_table(table1.new_read_builder())
read_builder = table2.new_read_builder()
splits2 = self._scan_table(read_builder)
splits.extend(splits2)
table_read = read_builder.new_read()
actual = table_read.to_arrow(splits)
expected = pa.Table.from_pydict({
'user_id': [1, 2, 4, 3, 5, 7, 8, 6],
'item_id': [1001, 1002, 1004, 1003, 1005, 1007, 1008, 1006],
'dt': ["p1", "p1", "p1", "p2", "p2", "p2", "p2", "p1"],
'behavior': [None, None, None, None, "e", "g", "h", "f"],
}, schema=pa_schema)
self.assertEqual(expected, actual)
@pytest.mark.python_plan
def test_schema_evolution_type(self):
# schema 0
pa_schema = pa.schema([
('user_id', pa.int64()),
('time', pa.timestamp('s')),
('dt', pa.string())
])
schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'])
self.catalog.create_table('default.schema_evolution_type', schema, False)
table1 = self.catalog.get_table('default.schema_evolution_type')
write_builder = table1.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data1 = {
'user_id': [1, 2, 3, 4],
'time': [pandas.Timestamp("2025-01-01 00:00:00"), pandas.Timestamp("2025-01-02 00:02:00"),
pandas.Timestamp("2025-01-03 00:03:00"), pandas.Timestamp("2025-01-04 00:04:00")],
'dt': ['p1', 'p1', 'p2', 'p1'],
}
pa_table = pa.Table.from_pydict(data1, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# schema 1 add behavior column
pa_schema = pa.schema([
('user_id', pa.int8()),
('time', pa.timestamp('ms')),
('dt', pa.string()),
('behavior', pa.string())
])
schema2 = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'])
self.catalog.create_table('default.schema_evolution_type2', schema2, False)
table2 = self.catalog.get_table('default.schema_evolution_type2')
table2.table_schema.id = 1
write_builder = table2.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data2 = {
'user_id': [5, 6, 7, 8],
'time': [pandas.Timestamp("2025-01-05 00:05:00"), pandas.Timestamp("2025-01-06 00:06:00"),
pandas.Timestamp("2025-01-07 00:07:00"), pandas.Timestamp("2025-01-08 00:08:00")],
'dt': ['p2', 'p1', 'p2', 'p2'],
'behavior': ['e', 'f', 'g', 'h'],
}
pa_table = pa.Table.from_pydict(data2, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# write schema-0 and schema-1 to table2
schema_manager = SchemaManager(table2.file_io, table2.table_path)
schema_manager.commit(TableSchema.from_schema(schema_id=0, schema=schema))
schema_manager.commit(TableSchema.from_schema(schema_id=1, schema=schema2))
splits = self._scan_table(table1.new_read_builder())
read_builder = table2.new_read_builder()
splits2 = self._scan_table(read_builder)
splits.extend(splits2)
table_read = read_builder.new_read()
actual = table_read.to_arrow(splits)
expected = pa.Table.from_pydict({
'user_id': [1, 2, 4, 3, 5, 7, 8, 6],
'time': [pandas.Timestamp("2025-01-01 00:00:00"), pandas.Timestamp("2025-01-02 00:02:00"),
pandas.Timestamp("2025-01-04 00:04:00"), pandas.Timestamp("2025-01-03 00:03:00"),
pandas.Timestamp("2025-01-05 00:05:00"), pandas.Timestamp("2025-01-07 00:07:00"),
pandas.Timestamp("2025-01-08 00:08:00"), pandas.Timestamp("2025-01-06 00:06:00"), ],
'dt': ["p1", "p1", "p1", "p2", "p2", "p2", "p2", "p1"],
'behavior': [None, None, None, None, "e", "g", "h", "f"],
}, schema=pa_schema)
self.assertEqual(expected, actual)
def test_schema_evolution_type_promotion_unpartitioned(self):
# End-to-end via public API only (create -> write -> alter column type
# -> write -> read). A non-partitioned table whose read needs no column
# reordering takes the reader fast path that skips partition padding and
# index remapping. The file written before the type change keeps its old
# physical type, so it must be aligned to the promoted type to
# concatenate with the file written after; otherwise the read crashes
# with an Arrow schema mismatch. This is not specific to INT -> BIGINT:
# it applies to any type change of an existing column -- integer/float
# widening, DECIMAL precision/scale changes, and cross-type changes.
import decimal
# Each case: (name, old arrow type, new arrow type, new Paimon type,
# value written to the old-schema file, that same value as it should
# read back under the new type, value written to the new-schema file).
# The old write value and its expected read form differ for cross-type
# changes, where the old file is materialized under the new type.
cases = [
("smallint_to_int", pa.int16(), pa.int32(), 'INT',
[10, 20], [10, 20], [30, 40]),
("int_to_bigint", pa.int32(), pa.int64(), 'BIGINT',
[10, 20], [10, 20], [30, 40]),
("float_to_double", pa.float32(), pa.float64(), 'DOUBLE',
[1.5, 2.5], [1.5, 2.5], [3.5, 4.5]),
("decimal_precision_up",
pa.decimal128(10, 2), pa.decimal128(20, 2), 'DECIMAL(20, 2)',
[decimal.Decimal('1.23'), decimal.Decimal('4.56')],
[decimal.Decimal('1.23'), decimal.Decimal('4.56')],
[decimal.Decimal('7.89'), decimal.Decimal('0.12')]),
("decimal_scale_up",
pa.decimal128(10, 2), pa.decimal128(10, 4), 'DECIMAL(10, 4)',
[decimal.Decimal('1.23'), decimal.Decimal('4.56')],
[decimal.Decimal('1.2300'), decimal.Decimal('4.5600')],
[decimal.Decimal('7.8901'), decimal.Decimal('0.1234')]),
("int_to_string", pa.int32(), pa.string(), 'STRING',
[10, 20], ['10', '20'], ['a', 'b']),
# Lossy cross-type change: DOUBLE -> INT truncates (matches Java
# CastExecutors), so 1.2/2.8 read back as 1/2.
("double_to_int", pa.float64(), pa.int32(), 'INT',
[1.2, 2.8], [1, 2], [3, 4]),
# Lossy DECIMAL scale-down: (10,4) -> (10,2) truncates the extra
# scale rather than raising.
("decimal_scale_down",
pa.decimal128(10, 4), pa.decimal128(10, 2), 'DECIMAL(10, 2)',
[decimal.Decimal('1.2345'), decimal.Decimal('4.5678')],
[decimal.Decimal('1.23'), decimal.Decimal('4.56')],
[decimal.Decimal('7.89'), decimal.Decimal('0.12')]),
]
for (name, old_type, new_type, new_type_str,
write_vals, old_read_vals, new_vals) in cases:
with self.subTest(case=name):
table_name = f'default.promo_{name}'
old_schema = pa.schema([('k', pa.int64()), ('v', old_type)])
self.catalog.create_table(
table_name, Schema.from_pyarrow_schema(old_schema), False)
# Write under the original schema (file stamped schema_id 0).
table = self.catalog.get_table(table_name)
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(pa.Table.from_pydict(
{'k': [1, 2], 'v': write_vals}, schema=old_schema))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# Widen column v through the catalog (new schema_id 1).
self.catalog.alter_table(
table_name,
[SchemaChange.update_column_type(
'v', AtomicType(new_type_str))],
False)
# Write under the promoted schema (file stamped schema_id 1).
table = self.catalog.get_table(table_name)
new_schema = pa.schema([('k', pa.int64()), ('v', new_type)])
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(pa.Table.from_pydict(
{'k': [3, 4], 'v': new_vals}, schema=new_schema))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# Plain full-table read spanning both schema versions.
read_builder = table.new_read_builder()
actual = read_builder.new_read().to_arrow(
self._scan_table(read_builder))
expected = pa.Table.from_pydict(
{'k': [1, 2, 3, 4], 'v': old_read_vals + new_vals},
schema=new_schema)
self.assertEqual(expected, actual)
def test_schema_evolution_type_lossy_old_file_only(self):
# Reading ONLY old-schema files after a lossy type change (no
# newer-schema file in the splits). The output type must equal the
# current read schema regardless of which files the read spans, and the
# conversion must truncate to match Java CastExecutors rather than
# raise. (A previous fix that relied on pyarrow's safe cast crashed
# here on lossy evolutions.)
import decimal
cases = [
("scale_down",
pa.decimal128(10, 4), pa.decimal128(10, 2), 'DECIMAL(10, 2)',
[decimal.Decimal('1.2345'), decimal.Decimal('4.5678')],
[decimal.Decimal('1.23'), decimal.Decimal('4.56')]),
("double_to_int", pa.float64(), pa.int32(), 'INT',
[1.2, 2.8], [1, 2]),
]
for name, old_type, new_type, new_type_str, write_vals, read_vals \
in cases:
with self.subTest(case=name):
table_name = f'default.lossy_old_only_{name}'
old_schema = pa.schema([('k', pa.int64()), ('v', old_type)])
self.catalog.create_table(
table_name, Schema.from_pyarrow_schema(old_schema), False)
# Write under the original schema, then change the type. No
# write happens afterwards, so the read sees only this file.
table = self.catalog.get_table(table_name)
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(pa.Table.from_pydict(
{'k': [1, 2], 'v': write_vals}, schema=old_schema))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
self.catalog.alter_table(
table_name,
[SchemaChange.update_column_type(
'v', AtomicType(new_type_str))],
False)
table = self.catalog.get_table(table_name)
new_schema = pa.schema([('k', pa.int64()), ('v', new_type)])
read_builder = table.new_read_builder()
actual = read_builder.new_read().to_arrow(
self._scan_table(read_builder))
expected = pa.Table.from_pydict(
{'k': [1, 2], 'v': read_vals}, schema=new_schema)
self.assertEqual(expected, actual)
def test_schema_evolution_with_scan_filter(self):
# schema 0
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('dt', pa.string())
])
options = {'metadata.stats-mode': 'full'}
schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'], options=options)
self.catalog.create_table('default.test_schema_evolution1', schema, False)
table1 = self.catalog.get_table('default.test_schema_evolution1')
write_builder = table1.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data1 = {
'user_id': [1, 2, 3, 4],
'item_id': [1001, 1002, 1003, 1004],
'dt': ['p1', 'p1', 'p2', 'p1'],
}
pa_table = pa.Table.from_pydict(data1, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# schema 1 add behavior column
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('behavior', pa.string()),
('dt', pa.string())
])
schema2 = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'])
self.catalog.create_table('default.test_schema_evolution2', schema2, False)
table2 = self.catalog.get_table('default.test_schema_evolution2')
table2.table_schema.id = 1
write_builder = table2.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data2 = {
'user_id': [5, 6, 7, 8],
'item_id': [1005, 1006, 1007, 1008],
'behavior': ['e', 'f', 'g', 'h'],
'dt': ['p2', 'p1', 'p2', 'p2'],
}
pa_table = pa.Table.from_pydict(data2, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# write schema-0 and schema-1 to table2
schema_manager = SchemaManager(table2.file_io, table2.table_path)
schema_manager.commit(TableSchema.from_schema(schema_id=0, schema=schema))
schema_manager.commit(TableSchema.from_schema(schema_id=1, schema=schema2))
# scan filter for schema evolution
latest_snapshot = table1.new_read_builder().new_scan().file_scanner.snapshot_manager.get_latest_snapshot()
table2.table_path = table1.table_path
new_read_buidler = table2.new_read_builder()
predicate_builder = new_read_buidler.new_predicate_builder()
predicate = predicate_builder.less_than('user_id', 3)
new_scan = new_read_buidler.with_filter(predicate).new_scan()
manifest_files = new_scan.file_scanner.manifest_list_manager.read_all(latest_snapshot)
entries = new_scan.file_scanner.read_manifest_entries(manifest_files)
self.assertEqual(1, len(entries)) # verify scan filter success for schema evolution
@pytest.mark.python_plan
def test_schema_evolution_with_read_filter(self):
# schema 0
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('dt', pa.string())
])
options = {'metadata.stats-mode': 'full'}
schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'], options=options)
self.catalog.create_table('default.test_schema_evolution_with_filter', schema, False)
table1 = self.catalog.get_table('default.test_schema_evolution_with_filter')
write_builder = table1.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data1 = {
'user_id': [1, 2, 3, 4],
'item_id': [1001, 1002, 1003, 1004],
'dt': ['p1', 'p1', 'p2', 'p1'],
}
pa_table = pa.Table.from_pydict(data1, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# schema 1 add behavior column
pa_schema = pa.schema([
('user_id', pa.int64()),
('item_id', pa.int64()),
('dt', pa.string()),
('behavior', pa.string())
])
schema2 = Schema.from_pyarrow_schema(pa_schema, partition_keys=['dt'], options=options)
self.catalog.create_table('default.test_schema_evolution_with_filter2', schema2, False)
table2 = self.catalog.get_table('default.test_schema_evolution_with_filter2')
table2.table_schema.id = 1
write_builder = table2.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data2 = {
'user_id': [5, 6, 7, 8],
'item_id': [1005, 1006, 1007, 1008],
'dt': ['p2', 'p1', 'p2', 'p2'],
'behavior': ['e', 'f', 'g', 'h'],
}
pa_table = pa.Table.from_pydict(data2, schema=pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# write schema-0 and schema-1 to table2
schema_manager = SchemaManager(table2.file_io, table2.table_path)
schema_manager.file_io.delete_quietly(table2.table_path + "/schema/schema-0")
schema_manager.commit(TableSchema.from_schema(schema_id=0, schema=schema))
schema_manager.commit(TableSchema.from_schema(schema_id=1, schema=schema2))
# behavior or user_id filter
splits = self._scan_table(table1.new_read_builder())
read_builder = table2.new_read_builder()
predicate_builder = read_builder.new_predicate_builder()
ne_predicate = predicate_builder.equal('behavior', "g")
lt_predicate = predicate_builder.less_than('user_id', 6)
and_predicate = predicate_builder.or_predicates([ne_predicate, lt_predicate])
splits2 = self._scan_table(read_builder.with_filter(and_predicate))
for split in splits2:
for file in split.files:
file.schema_id = 1
splits.extend(splits2)
table_read = read_builder.new_read()
actual = table_read.to_arrow(splits)
expected = pa.Table.from_pydict({
'user_id': [1, 2, 4, 3, 5, 7],
'item_id': [1001, 1002, 1004, 1003, 1005, 1007],
'dt': ["p1", "p1", "p1", "p2", "p2", "p2"],
'behavior': [None, None, None, None, "e", "g"],
}, schema=pa_schema)
self.assertEqual(expected, actual)
# behavior and user_id filter
splits = self._scan_table(table1.new_read_builder())
read_builder = table2.new_read_builder()
predicate_builder = read_builder.new_predicate_builder()
ne_predicate = predicate_builder.equal('behavior', "g")
lt_predicate = predicate_builder.less_than('user_id', 8)
and_predicate = predicate_builder.and_predicates([ne_predicate, lt_predicate])
splits2 = self._scan_table(read_builder.with_filter(and_predicate))
for split in splits2:
for file in split.files:
file.schema_id = 1
splits.extend(splits2)
table_read = read_builder.new_read()
actual = table_read.to_arrow(splits)
expected = pa.Table.from_pydict({
'user_id': [1, 2, 4, 3, 7],
'item_id': [1001, 1002, 1004, 1003, 1007],
'dt': ["p1", "p1", "p1", "p2", "p2"],
'behavior': [None, None, None, None, "g"],
}, schema=pa_schema)
self.assertEqual(expected, actual)
# user_id filter
splits = self._scan_table(table1.new_read_builder())
read_builder = table2.new_read_builder()
predicate_builder = read_builder.new_predicate_builder()
predicate = predicate_builder.less_than('user_id', 6)
splits2 = self._scan_table(read_builder.with_filter(predicate))
self.assertEqual(1, len(splits2))
for split in splits2:
for file in split.files:
file.schema_id = 1
splits.extend(splits2)
table_read = read_builder.new_read()
actual = table_read.to_arrow(splits)
expected = pa.Table.from_pydict({
'user_id': [1, 2, 4, 3, 5],
'item_id': [1001, 1002, 1004, 1003, 1005],
'dt': ["p1", "p1", "p1", "p2", "p2"],
'behavior': [None, None, None, None, "e"],
}, schema=pa_schema)
self.assertEqual(expected, actual)
# ------------------------------------------------------------------
# Public-API end-to-end evolution cases (create -> write -> alter_table
# -> write -> read), the范式 promoted by
# test_schema_evolution_type_promotion_unpartitioned. Unlike the older
# white-box cases above (manual schema-N files + file.schema_id), these
# drive evolution purely through catalog.alter_table.
# ------------------------------------------------------------------
def _write(self, table, pa_table):
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
def _read_rows(self, table, sort_key='k'):
read_builder = table.new_read_builder()
rows = read_builder.new_read().to_arrow(
self._scan_table(read_builder)).to_pylist()
return sorted(rows, key=lambda r: (r[sort_key] is None, r[sort_key]))
def _read_arrow(self, table, projection=None):
read_builder = table.new_read_builder()
if projection is not None:
read_builder = read_builder.with_projection(projection)
return read_builder.new_read().to_arrow(self._scan_table(read_builder))
def test_evolution_drop_column_then_read(self):
# A5: a column dropped after old data was written must disappear from
# the read; the old file's value for it is discarded, not surfaced.
name = 'default.evo_drop'
s0 = pa.schema([('k', pa.int64()), ('v', pa.string()), ('w', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'v': ['a', 'b'], 'w': ['p', 'q']}, schema=s0))
self.catalog.alter_table(name, [SchemaChange.drop_column('v')], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('w', pa.string())])
self._write(table, pa.Table.from_pydict({'k': [3], 'w': ['r']}, schema=s1))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'w': 'p'}, {'k': 2, 'w': 'q'}, {'k': 3, 'w': 'r'}])
def test_evolution_rename_column_then_read(self):
# A6: after rename the old file (written under the old name) and the
# new file (new name) must read back as the SAME logical column,
# matched by field id, not by name.
name = 'default.evo_rename'
s0 = pa.schema([('k', pa.int64()), ('v', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'v': ['a', 'b']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.rename_column('v', 'renamed')], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('renamed', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [3], 'renamed': ['c']}, schema=s1))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'renamed': 'a'},
{'k': 2, 'renamed': 'b'},
{'k': 3, 'renamed': 'c'}])
def test_evolution_column_position_then_read(self):
# A7: moving a column to FIRST must reorder the read schema; the old
# file (original order) must be remapped so values stay with their
# column.
name = 'default.evo_position'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string()), ('b', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1], 'a': ['a1'], 'b': ['b1']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.update_column_position(Move.first('b'))], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('b', pa.string()), ('k', pa.int64()), ('a', pa.string())])
self._write(table, pa.Table.from_pydict(
{'b': ['b2'], 'k': [2], 'a': ['a2']}, schema=s1))
arrow = self._read_arrow(table)
self.assertEqual(arrow.column_names, ['b', 'k', 'a'])
self.assertEqual(self._read_rows(table), [
{'b': 'b1', 'k': 1, 'a': 'a1'},
{'b': 'b2', 'k': 2, 'a': 'a2'}])
def test_evolution_multi_version_chain(self):
# A8: chain schema versions (add + type promotion), writing one batch
# per version then reading across all of them. Rename across versions
# is covered on its own by A6; this case isolates the add+promotion
# chain.
name = 'default.evo_chain'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string()), ('n', pa.int32())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1], 'a': ['a1'], 'n': [10]}, schema=s0))
# v1: add column b
self.catalog.alter_table(
name, [SchemaChange.add_column('b', AtomicType('STRING'))], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('a', pa.string()),
('n', pa.int32()), ('b', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [2], 'a': ['a2'], 'n': [20], 'b': ['b2']}, schema=s1))
# v2: promote n int32 -> int64
self.catalog.alter_table(
name, [SchemaChange.update_column_type('n', AtomicType('BIGINT'))],
False)
table = self.catalog.get_table(name)
s2 = pa.schema([('k', pa.int64()), ('a', pa.string()),
('n', pa.int64()), ('b', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [3], 'a': ['a3'], 'n': [30], 'b': ['b3']}, schema=s2))
# v3: add column d
self.catalog.alter_table(
name, [SchemaChange.add_column('d', AtomicType('STRING'))], False)
table = self.catalog.get_table(name)
s3 = pa.schema([('k', pa.int64()), ('a', pa.string()),
('n', pa.int64()), ('b', pa.string()), ('d', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [4], 'a': ['a4'], 'n': [40], 'b': ['b4'], 'd': ['d4']},
schema=s3))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'a': 'a1', 'n': 10, 'b': None, 'd': None},
{'k': 2, 'a': 'a2', 'n': 20, 'b': 'b2', 'd': None},
{'k': 3, 'a': 'a3', 'n': 30, 'b': 'b3', 'd': None},
{'k': 4, 'a': 'a4', 'n': 40, 'b': 'b4', 'd': 'd4'}])
def test_evolution_projection_after_add_column(self):
# A10: projecting a subset that includes the newly added column must
# return NULL for that column on rows from old files.
name = 'default.evo_projection'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'a': ['a1', 'a2']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.add_column('b', AtomicType('STRING'))], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('a', pa.string()), ('b', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [3], 'a': ['a3'], 'b': ['b3']}, schema=s1))
rows = sorted(
self._read_arrow(table, projection=['k', 'b']).to_pylist(),
key=lambda r: r['k'])
self.assertEqual(rows, [
{'k': 1, 'b': None}, {'k': 2, 'b': None}, {'k': 3, 'b': 'b3'}])
def test_evolution_nullability_then_read(self):
# A11: relaxing NOT NULL -> nullable must let later NULLs read back
# while the old (non-null) file still reads correctly.
name = 'default.evo_nullability'
s0 = pa.schema([('k', pa.int64()),
pa.field('v', pa.string(), nullable=False)])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'v': ['a', 'b']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.update_column_nullability('v', True)], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('v', pa.string())])
self._write(table, pa.Table.from_pydict({'k': [3], 'v': [None]}, schema=s1))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}, {'k': 3, 'v': None}])
def test_evolution_drop_then_readd_same_name(self):
# A13 (name-vs-field-id isolation): dropping v then adding a new v
# (fresh field id) must NOT revive the old file's v. Because columns
# are aligned by field id, the dropped v's id is gone and the re-added
# v's new id is absent from old files, so old rows read NULL. Covers
# same-type and changed-type re-add.
for label, readd_paimon, readd_arrow, new_val in [
('same_type', 'STRING', pa.string(), ['new3']),
('changed_type', 'INT', pa.int32(), [99]),
]:
with self.subTest(case=label):
name = 'default.evo_drop_readd_{}'.format(label)
s0 = pa.schema([('k', pa.int64()), ('v', pa.string())])
self.catalog.create_table(
name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'v': ['old1', 'old2']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.drop_column('v')], False)
self.catalog.alter_table(
name, [SchemaChange.add_column('v', AtomicType(readd_paimon))],
False)
table = self.catalog.get_table(name)
s2 = pa.schema([('k', pa.int64()), ('v', readd_arrow)])
self._write(table, pa.Table.from_pydict(
{'k': [3], 'v': new_val}, schema=s2))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'v': None},
{'k': 2, 'v': None},
{'k': 3, 'v': new_val[0]}])
def test_evolution_rename_then_add_same_name(self):
# A14 (name-vs-field-id isolation): rename a->b, then add a new a
# (fresh field id). Aligned by field id, reading old files b (old id)
# carries the old a-data and the new a (new id) reads NULL -- a
# name-based alignment would instead feed the old physical column 'a'
# to the new a and leave b empty.
name = 'default.evo_rename_readd'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1, 2], 'a': ['old1', 'old2']}, schema=s0))
self.catalog.alter_table(
name, [SchemaChange.rename_column('a', 'b')], False)
self.catalog.alter_table(
name, [SchemaChange.add_column('a', AtomicType('STRING'))], False)
table = self.catalog.get_table(name)
s2 = pa.schema([('k', pa.int64()), ('b', pa.string()), ('a', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [3], 'b': ['B3'], 'a': ['A3']}, schema=s2))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'b': 'old1', 'a': None},
{'k': 2, 'b': 'old2', 'a': None},
{'k': 3, 'b': 'B3', 'a': 'A3'}])
def test_evolution_column_swap_direct_rejected(self):
# A15a: a one-shot name swap is rejected -- renaming a->b while b
# still exists collides on the existing name. (Constraint check, this
# behaviour is correct.)
name = 'default.evo_swap_direct'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string()), ('b', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
# alter_table wraps the underlying ColumnAlreadyExistException in a
# RuntimeError (filesystem_catalog catch-all), so match on that.
with self.assertRaises(RuntimeError) as cm:
self.catalog.alter_table(name, [
SchemaChange.rename_column('a', 'b'),
SchemaChange.rename_column('b', 'a'),
], False)
self.assertIn('already exists', str(cm.exception))
def test_evolution_column_swap_via_temp_name(self):
# A15b (name-vs-field-id isolation): a 3-step swap via a temp name
# keeps field ids stable, so on read the old data follows the id, not
# the name -- old 'a1' (id of a) ends up under column b, old 'b1'
# under column a. A name-based alignment would drop/misalign both.
name = 'default.evo_swap_temp'
s0 = pa.schema([('k', pa.int64()), ('a', pa.string()), ('b', pa.string())])
self.catalog.create_table(name, Schema.from_pyarrow_schema(s0), False)
table = self.catalog.get_table(name)
self._write(table, pa.Table.from_pydict(
{'k': [1], 'a': ['a1'], 'b': ['b1']}, schema=s0))
self.catalog.alter_table(name, [
SchemaChange.rename_column('a', '__tmp'),
SchemaChange.rename_column('b', 'a'),
SchemaChange.rename_column('__tmp', 'b'),
], False)
table = self.catalog.get_table(name)
s1 = pa.schema([('k', pa.int64()), ('b', pa.string()), ('a', pa.string())])
self._write(table, pa.Table.from_pydict(
{'k': [2], 'b': ['B2'], 'a': ['A2']}, schema=s1))
self.assertEqual(self._read_rows(table), [
{'k': 1, 'b': 'a1', 'a': 'b1'},
{'k': 2, 'b': 'B2', 'a': 'A2'}])
def _write_test_table(self, table):
write_builder = table.new_batch_write_builder()
# first write
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data1 = {
'user_id': [1, 2, 3, 4],
'item_id': [1001, 1002, 1003, 1004],
'behavior': ['a', 'b', 'c', None],
'dt': ['p1', 'p1', 'p2', 'p1'],
}
pa_table = pa.Table.from_pydict(data1, schema=self.pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# second write
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
data2 = {
'user_id': [5, 6, 7, 8],
'item_id': [1005, 1006, 1007, 1008],
'behavior': ['e', 'f', 'g', 'h'],
'dt': ['p2', 'p1', 'p2', 'p2'],
}
pa_table = pa.Table.from_pydict(data2, schema=self.pa_schema)
table_write.write_arrow(pa_table)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
def _scan_table(self, read_builder):
splits = read_builder.new_scan().plan().splits()
return splits