blob: cff237b5be0d788b4d54f2cf5acf67d8c04aaefa [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 tempfile
from decimal import Decimal
import pyarrow as pa
import pytest
from pypaimon.read.reader.format_row_reader import FormatRowReader
from pypaimon.schema.data_types import (
ArrayType, AtomicType, DataField, MapType, RowType
)
from pypaimon.write.writer.format_row_writer import FormatRowWriter
class SimpleFileIO:
"""Minimal FileIO for testing."""
def get_file_size(self, path):
return os.path.getsize(path)
def new_input_stream(self, path):
return open(path, 'rb')
def _write_row_file(path, fields, data_table):
with open(path, 'wb') as f:
writer = FormatRowWriter(f, fields)
writer.write_table(data_table)
writer.close()
def _read_row_file(path, fields, read_field_names=None, row_indices=None):
file_io = SimpleFileIO()
if read_field_names is None:
read_field_names = [f.name for f in fields]
reader = FormatRowReader(file_io, path, read_field_names, fields, None,
row_indices=row_indices)
batches = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
batches.append(batch)
reader.close()
if not batches:
return pa.table({f.name: [] for f in fields})
return pa.Table.from_batches(batches)
class TestFormatRowReaderWriter:
def test_basic_int_string(self):
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING")),
]
data = pa.table({
"id": pa.array([1, 2, 3], type=pa.int32()),
"name": pa.array(["alice", "bob", "charlie"], type=pa.string()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("id").to_pylist() == [1, 2, 3]
assert result.column("name").to_pylist() == ["alice", "bob", "charlie"]
finally:
os.unlink(path)
def test_all_primitive_types(self):
fields = [
DataField(0, "bool_col", AtomicType("BOOLEAN")),
DataField(1, "tinyint_col", AtomicType("TINYINT")),
DataField(2, "smallint_col", AtomicType("SMALLINT")),
DataField(3, "int_col", AtomicType("INT")),
DataField(4, "bigint_col", AtomicType("BIGINT")),
DataField(5, "float_col", AtomicType("FLOAT")),
DataField(6, "double_col", AtomicType("DOUBLE")),
DataField(7, "string_col", AtomicType("STRING")),
DataField(8, "binary_col", AtomicType("BYTES")),
]
data = pa.table({
"bool_col": pa.array([True, False], type=pa.bool_()),
"tinyint_col": pa.array([1, -1], type=pa.int8()),
"smallint_col": pa.array([100, -100], type=pa.int16()),
"int_col": pa.array([1000, -1000], type=pa.int32()),
"bigint_col": pa.array([100000, -100000], type=pa.int64()),
"float_col": pa.array([1.5, -2.5], type=pa.float32()),
"double_col": pa.array([3.14, -2.71], type=pa.float64()),
"string_col": pa.array(["hello", "world"], type=pa.string()),
"binary_col": pa.array([b"\x01\x02", b"\x03\x04"], type=pa.binary()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("bool_col").to_pylist() == [True, False]
assert result.column("tinyint_col").to_pylist() == [1, -1]
assert result.column("smallint_col").to_pylist() == [100, -100]
assert result.column("int_col").to_pylist() == [1000, -1000]
assert result.column("bigint_col").to_pylist() == [100000, -100000]
assert result.column("float_col").to_pylist()[0] == pytest.approx(1.5)
assert result.column("float_col").to_pylist()[1] == pytest.approx(-2.5)
assert result.column("double_col").to_pylist() == [pytest.approx(3.14), pytest.approx(-2.71)]
assert result.column("string_col").to_pylist() == ["hello", "world"]
assert result.column("binary_col").to_pylist() == [b"\x01\x02", b"\x03\x04"]
finally:
os.unlink(path)
def test_nulls(self):
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING")),
]
data = pa.table({
"id": pa.array([1, None, 3], type=pa.int32()),
"name": pa.array([None, "bob", None], type=pa.string()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("id").to_pylist() == [1, None, 3]
assert result.column("name").to_pylist() == [None, "bob", None]
finally:
os.unlink(path)
def test_decimal(self):
fields = [
DataField(0, "d1", AtomicType("DECIMAL(10, 2)")),
DataField(1, "d2", AtomicType("DECIMAL(20, 5)")),
]
data = pa.table({
"d1": pa.array([Decimal("123.45"), Decimal("-67.89")], type=pa.decimal128(10, 2)),
"d2": pa.array([Decimal("12345.67890"), Decimal("-99999.12345")], type=pa.decimal128(20, 5)),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("d1").to_pylist() == [Decimal("123.45"), Decimal("-67.89")]
assert result.column("d2").to_pylist() == [Decimal("12345.67890"), Decimal("-99999.12345")]
finally:
os.unlink(path)
def test_timestamp(self):
fields = [
DataField(0, "ts_millis", AtomicType("TIMESTAMP(3)")),
DataField(1, "ts_micros", AtomicType("TIMESTAMP(6)")),
]
data = pa.table({
"ts_millis": pa.array([1000, 2000], type=pa.timestamp('ms')),
"ts_micros": pa.array([1000000, 2000000], type=pa.timestamp('us')),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.num_rows == 2
finally:
os.unlink(path)
def test_array_type(self):
element_type = AtomicType("INT")
fields = [
DataField(0, "arr", ArrayType(True, element_type)),
]
data = pa.table({
"arr": pa.array([[1, 2, 3], [4, 5]], type=pa.list_(pa.int32())),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("arr").to_pylist() == [[1, 2, 3], [4, 5]]
finally:
os.unlink(path)
def test_map_type(self):
fields = [
DataField(0, "m", MapType(True, AtomicType("STRING"), AtomicType("INT"))),
]
data = pa.table({
"m": pa.array(
[[("a", 1), ("b", 2)], [("c", 3)]],
type=pa.map_(pa.string(), pa.int32())
),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
result_maps = result.column("m").to_pylist()
assert len(result_maps) == 2
assert len(result_maps[0]) == 2
assert len(result_maps[1]) == 1
finally:
os.unlink(path)
def test_nested_row(self):
inner_type = RowType(True, [
DataField(0, "x", AtomicType("INT")),
DataField(1, "y", AtomicType("STRING")),
])
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "nested", inner_type),
]
data = pa.table({
"id": pa.array([1, 2], type=pa.int32()),
"nested": pa.array(
[{"x": 10, "y": "a"}, {"x": 20, "y": "b"}],
type=pa.struct([
pa.field("x", pa.int32()),
pa.field("y", pa.string()),
])
),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.column("id").to_pylist() == [1, 2]
nested = result.column("nested").to_pylist()
assert nested[0] == {"x": 10, "y": "a"}
assert nested[1] == {"x": 20, "y": "b"}
finally:
os.unlink(path)
def test_multi_block(self):
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "data", AtomicType("STRING")),
]
num_rows = 5000
ids = list(range(num_rows))
strings = [f"value_{i}" for i in range(num_rows)]
data = pa.table({
"id": pa.array(ids, type=pa.int32()),
"data": pa.array(strings, type=pa.string()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
with open(path, 'wb') as f:
writer = FormatRowWriter(f, fields, block_size=4096)
writer.write_table(data)
writer.close()
result = _read_row_file(path, fields)
assert result.num_rows == num_rows
assert result.column("id").to_pylist() == ids
assert result.column("data").to_pylist() == strings
finally:
os.unlink(path)
def test_empty_file(self):
fields = [
DataField(0, "id", AtomicType("INT")),
]
data = pa.table({
"id": pa.array([], type=pa.int32()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.num_rows == 0
finally:
os.unlink(path)
def test_column_projection(self):
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING")),
DataField(2, "value", AtomicType("DOUBLE")),
]
data = pa.table({
"id": pa.array([1, 2, 3], type=pa.int32()),
"name": pa.array(["a", "b", "c"], type=pa.string()),
"value": pa.array([1.1, 2.2, 3.3], type=pa.float64()),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields, read_field_names=["id", "value"])
assert result.num_columns == 2
assert result.column("id").to_pylist() == [1, 2, 3]
assert result.column("value").to_pylist() == [pytest.approx(1.1), pytest.approx(2.2), pytest.approx(3.3)]
finally:
os.unlink(path)
def test_date_and_time(self):
fields = [
DataField(0, "d", AtomicType("DATE")),
DataField(1, "t", AtomicType("TIME")),
]
data = pa.table({
"d": pa.array([18000, 19000], type=pa.date32()),
"t": pa.array([3600000, 7200000], type=pa.time32('ms')),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
assert result.num_rows == 2
finally:
os.unlink(path)
def test_variant_type(self):
fields = [
DataField(0, "v", AtomicType("VARIANT")),
]
data = pa.table({
"v": pa.array(
[{"value": b"\x01\x02", "metadata": b"\x03\x04"},
{"value": b"\x05", "metadata": b"\x06\x07\x08"}],
type=pa.struct([
pa.field("value", pa.binary(), nullable=False),
pa.field("metadata", pa.binary(), nullable=False),
])
),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
result = _read_row_file(path, fields)
variants = result.column("v").to_pylist()
assert variants[0]["value"] == b"\x01\x02"
assert variants[0]["metadata"] == b"\x03\x04"
assert variants[1]["value"] == b"\x05"
assert variants[1]["metadata"] == b"\x06\x07\x08"
finally:
os.unlink(path)
def test_row_indices_random_access(self):
"""Test reading specific rows by index (O(1) row-number lookup)."""
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("VARCHAR")),
]
data = pa.table({
"id": pa.array(list(range(100)), type=pa.int32()),
"name": pa.array([f"row_{i}" for i in range(100)]),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
_write_row_file(path, fields, data)
# Read specific rows: 0, 5, 50, 99
result = _read_row_file(path, fields, row_indices=[0, 5, 50, 99])
assert result.num_rows == 4
assert result.column("id").to_pylist() == [0, 5, 50, 99]
assert result.column("name").to_pylist() == [
"row_0", "row_5", "row_50", "row_99"
]
# Read single row
result = _read_row_file(path, fields, row_indices=[42])
assert result.num_rows == 1
assert result.column("id").to_pylist() == [42]
# Read empty indices
result = _read_row_file(path, fields, row_indices=[])
assert result.num_rows == 0
finally:
os.unlink(path)
def test_row_indices_multi_block(self):
"""Test row_indices across multiple blocks."""
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "value", AtomicType("VARCHAR")),
]
# Write enough data to create multiple blocks (small block size)
n_rows = 500
data = pa.table({
"id": pa.array(list(range(n_rows)), type=pa.int32()),
"value": pa.array([f"val_{i}" * 10 for i in range(n_rows)]),
})
with tempfile.NamedTemporaryFile(suffix=".row", delete=False) as tmp:
path = tmp.name
try:
with open(path, 'wb') as f:
writer = FormatRowWriter(f, fields, block_size=1024)
writer.write_table(data)
writer.close()
# Read rows from different blocks
indices = [0, 100, 200, 300, 499]
result = _read_row_file(path, fields, row_indices=indices)
assert result.num_rows == 5
assert result.column("id").to_pylist() == indices
finally:
os.unlink(path)
def test_data_evolution_row_id_read(self):
"""Test Data Evolution scenario: partial-column write then row-id based read.
Simulates the Data Evolution pattern where:
1. First commit writes columns (f0, f1)
2. Second commit writes column (f2) with first_row_id=0
3. Read merges by row ID to reconstruct full rows
"""
import shutil
from pypaimon import CatalogFactory, Schema
tempdir = tempfile.mkdtemp()
try:
warehouse = os.path.join(tempdir, 'warehouse')
catalog = CatalogFactory.create({'warehouse': warehouse})
catalog.create_database('default', True)
pa_schema = pa.schema([
('f0', pa.int32()),
('f1', pa.string()),
('f2', pa.string()),
])
schema = Schema.from_pyarrow_schema(
pa_schema,
options={
'file.format': 'row',
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
})
catalog.create_table('default.de_row_id_test', schema, False)
table = catalog.get_table('default.de_row_id_test')
# First commit: write (f0, f1)
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write().with_write_type(['f0', 'f1'])
table_commit = write_builder.new_commit()
data1 = pa.table({
'f0': pa.array([1, 2, 3, 4, 5], type=pa.int32()),
'f1': pa.array(['a1', 'a2', 'a3', 'a4', 'a5']),
})
table_write.write_arrow(data1)
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
# Second commit: write (f2) with first_row_id = 0
table_write = write_builder.new_write().with_write_type(['f2'])
table_commit = write_builder.new_commit()
data2 = pa.table({
'f2': pa.array(['b1', 'b2', 'b3', 'b4', 'b5']),
})
table_write.write_arrow(data2)
cmts = table_write.prepare_commit()
cmts[0].new_files[0].first_row_id = 0
table_commit.commit(cmts)
table_write.close()
table_commit.close()
# Read full table - should merge partial columns by row ID
table = catalog.get_table('default.de_row_id_test')
read_builder = table.new_read_builder()
splits = read_builder.new_scan().plan().splits()
result = read_builder.new_read().to_arrow(splits)
assert result.num_rows == 5
result_sorted = result.sort_by('f0')
assert result_sorted.column('f0').to_pylist() == [1, 2, 3, 4, 5]
assert result_sorted.column('f1').to_pylist() == [
'a1', 'a2', 'a3', 'a4', 'a5'
]
assert result_sorted.column('f2').to_pylist() == [
'b1', 'b2', 'b3', 'b4', 'b5'
]
finally:
shutil.rmtree(tempdir, ignore_errors=True)