blob: dd28d5d0d6aaca55cccd10facc0e6e3ab9c2012e [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.
################################################################################
"""Tests for the public pypaimon.daft read_paimon() / write_paimon() API."""
from __future__ import annotations
import datetime
import decimal
import time
import pyarrow as pa
import pytest
pypaimon = pytest.importorskip("pypaimon")
daft = pytest.importorskip("daft")
from daft import col
from pypaimon.daft import explain_paimon_scan, read_paimon, write_paimon
from pypaimon.daft.daft_paimon import _timestamp_scan_option
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import Move, SchemaChange
@pytest.fixture
def catalog_options(tmp_path):
options = {"warehouse": str(tmp_path)}
catalog = pypaimon.CatalogFactory.create(options)
catalog.create_database("test_db", ignore_if_exists=True)
return options
def _create_table(
catalog_options,
table_name: str,
pa_schema: pa.Schema,
*,
partition_keys: list[str] | None = None,
options: dict[str, str] | None = None,
):
identifier = f"test_db.{table_name}"
catalog = pypaimon.CatalogFactory.create(catalog_options)
schema = pypaimon.Schema.from_pyarrow_schema(
pa_schema,
partition_keys=partition_keys,
options=options,
)
catalog.create_table(identifier, schema, ignore_if_exists=False)
return identifier, catalog.get_table(identifier)
def _write_arrow(table, data: pa.Table) -> None:
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
try:
table_write.write_arrow(data)
table_commit.commit(table_write.prepare_commit())
finally:
table_write.close()
table_commit.close()
def _create_and_populate_table(
catalog_options,
table_name: str,
data: pa.Table,
*,
partition_keys: list[str] | None = None,
options: dict[str, str] | None = None,
) -> str:
identifier, table = _create_table(
catalog_options,
table_name,
data.schema,
partition_keys=partition_keys,
options=options,
)
_write_arrow(table, data)
return identifier
def test_read_paimon_basic(catalog_options):
data = pa.table(
{
"id": pa.array([1, 2, 3], pa.int64()),
"name": pa.array(["alice", "bob", "carol"], pa.string()),
"value": pa.array([10, 20, 30], pa.int64()),
}
)
identifier = _create_and_populate_table(catalog_options, "read_basic", data)
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
assert result == {
"id": [1, 2, 3],
"name": ["alice", "bob", "carol"],
"value": [10, 20, 30],
}
def test_read_paimon_data_evolution_merges_column_fragments(catalog_options):
pa_schema = pa.schema([
("id", pa.int32()),
("name", pa.string()),
("score", pa.float64()),
])
identifier, table = _create_table(
catalog_options,
"read_data_evolution",
pa_schema,
options={
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"file.format": "parquet",
},
)
write_builder = table.new_batch_write_builder()
id_name_write = write_builder.new_write().with_write_type(["id", "name"])
score_write = write_builder.new_write().with_write_type(["score"])
table_commit = write_builder.new_commit()
try:
id_name_write.write_arrow(pa.table({
"id": pa.array([1, 2, 3], pa.int32()),
"name": pa.array(["a", "b", "c"], pa.string()),
}))
score_write.write_arrow(pa.table({
"score": pa.array([1.1, 2.2, 3.3], pa.float64()),
}))
commit_messages = id_name_write.prepare_commit() + score_write.prepare_commit()
# Both files are column fragments for the same logical row range.
for message in commit_messages:
for data_file in message.new_files:
data_file.first_row_id = 0
table_commit.commit(commit_messages)
finally:
id_name_write.close()
score_write.close()
table_commit.close()
splits = table.new_read_builder().new_scan().plan().splits()
assert len(splits) == 1
assert splits[0].raw_convertible is False
result = read_paimon(identifier, catalog_options).to_pydict()
assert result == {
"id": [1, 2, 3],
"name": ["a", "b", "c"],
"score": [1.1, 2.2, 3.3],
}
def test_read_paimon_renamed_column_from_old_schema_file(catalog_options):
old_schema = pa.schema([
("id", pa.int64()),
("value", pa.string()),
])
identifier, table = _create_table(
catalog_options,
"read_renamed_column",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
old_schema_id = table.table_schema.id
_write_arrow(
table,
pa.table({"id": [1, 2], "value": ["a", "b"]}, schema=old_schema),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[SchemaChange.rename_column("value", "renamed")],
ignore_if_not_exists=False,
)
table = catalog.get_table(identifier)
splits = table.new_read_builder().new_scan().plan().splits()
assert all(split.raw_convertible for split in splits)
assert table.table_schema.id != old_schema_id
assert {
data_file.schema_id
for split in splits
for data_file in split.files
} == {old_schema_id}
projected_result = (
read_paimon(identifier, catalog_options)
.select("id")
.sort("id")
.to_pydict()
)
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
assert projected_result == {"id": [1, 2]}
assert result == {
"id": [1, 2],
"renamed": ["a", "b"],
}
def test_read_paimon_renamed_nested_field_from_old_schema_file(catalog_options):
old_schema = pa.schema([
("id", pa.int64()),
("payload", pa.struct([
("value", pa.string()),
("count", pa.int32()),
])),
])
identifier, table = _create_table(
catalog_options,
"read_renamed_nested_field",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
_write_arrow(
table,
pa.Table.from_pylist(
[
{"id": 1, "payload": {"value": "a", "count": 10}},
{"id": 2, "payload": {"value": "b", "count": 20}},
],
schema=old_schema,
),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[SchemaChange.rename_column(["payload", "value"], "renamed")],
ignore_if_not_exists=False,
)
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
assert explain.pypaimon_fallback_split_count == 1
assert explain.native_parquet_split_count == 0
assert result == {
"id": [1, 2],
"payload": [
{"renamed": "a", "count": 10},
{"renamed": "b", "count": 20},
],
}
def test_read_paimon_drop_readd_same_column_isolates_field_ids(
catalog_options,
):
old_schema = pa.schema([
("id", pa.int64()),
("value", pa.string()),
])
identifier, table = _create_table(
catalog_options,
"read_drop_readd_column",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
old_value_field = table.fields[1]
_write_arrow(
table,
pa.table({"id": [1, 2], "value": ["a", "b"]}, schema=old_schema),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[SchemaChange.drop_column("value")],
ignore_if_not_exists=False,
)
catalog.alter_table(
identifier,
[SchemaChange.add_column("value", AtomicType("STRING"))],
ignore_if_not_exists=False,
)
table = catalog.get_table(identifier)
new_value_field = table.fields[1]
assert new_value_field.name == old_value_field.name
assert new_value_field.type == old_value_field.type
assert new_value_field.id != old_value_field.id
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
assert explain.pypaimon_fallback_split_count == 1
assert explain.native_parquet_split_count == 0
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
assert result == {
"id": [1, 2],
"value": [None, None],
}
def test_native_read_handles_name_aligned_schema_evolution(catalog_options):
old_schema = pa.schema([
pa.field("id", pa.int32(), nullable=False),
pa.field("value", pa.string()),
])
identifier, table = _create_table(
catalog_options,
"read_native_schema_evolution",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
_write_arrow(
table,
pa.table({"id": [1, 2], "value": ["a", "b"]}, schema=old_schema),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[
SchemaChange.add_column("added", AtomicType("STRING")),
SchemaChange.update_column_position(Move.first("added")),
SchemaChange.update_column_nullability("id", True),
SchemaChange.update_column_type("id", AtomicType("BIGINT")),
SchemaChange.drop_column("value"),
],
ignore_if_not_exists=False,
)
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
filtered_result = (
read_paimon(identifier, catalog_options)
.where(col("id") == 2)
.select("id")
.to_pydict()
)
assert explain.native_parquet_split_count == 1
assert explain.pypaimon_fallback_split_count == 0
assert explain.fallback_reasons == {}
assert result == {
"added": [None, None],
"id": [1, 2],
}
assert filtered_result == {"id": [2]}
def test_native_read_handles_nested_type_widening(catalog_options):
old_schema = pa.schema([
("id", pa.int64()),
("payload", pa.struct([
("value", pa.int32()),
("label", pa.string()),
])),
])
identifier, table = _create_table(
catalog_options,
"read_nested_type_widening",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
_write_arrow(
table,
pa.Table.from_pylist(
[
{"id": 1, "payload": {"value": 10, "label": "a"}},
{"id": 2, "payload": {"value": 20, "label": "b"}},
],
schema=old_schema,
),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[SchemaChange.update_column_type(["payload", "value"], AtomicType("BIGINT"))],
ignore_if_not_exists=False,
)
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
result = read_paimon(identifier, catalog_options).sort("id").to_arrow()
assert explain.native_parquet_split_count == 1
assert explain.pypaimon_fallback_split_count == 0
assert explain.fallback_reasons == {}
assert result.schema.field("payload").type.field("value").type == pa.int64()
assert result.to_pydict() == {
"id": [1, 2],
"payload": [
{"value": 10, "label": "a"},
{"value": 20, "label": "b"},
],
}
@pytest.mark.parametrize(
(
"case_name",
"old_type",
"pypaimon_type",
"daft_type",
"new_paimon_type",
"values",
"expected",
),
[
pytest.param(
"decimal_scale_down",
pa.decimal128(10, 4),
pa.decimal128(10, 2),
pa.decimal128(10, 2),
AtomicType("DECIMAL(10, 2)"),
[decimal.Decimal("1.2355"), decimal.Decimal("-4.5678")],
[decimal.Decimal("1.23"), decimal.Decimal("-4.56")],
id="decimal-scale-down",
),
pytest.param(
"timestamp_to_time",
pa.timestamp("ms"),
pa.time32("ms"),
pa.time64("us"),
AtomicType("TIME(3)"),
[
datetime.datetime(2025, 1, 2, 3, 4, 5, 678000),
datetime.datetime(1999, 12, 31, 23, 59, 58, 987000),
],
[
datetime.time(3, 4, 5, 678000),
datetime.time(23, 59, 58, 987000),
],
id="timestamp-to-time",
),
],
)
def test_read_paimon_falls_back_for_native_cast_semantic_mismatch(
catalog_options,
case_name,
old_type,
pypaimon_type,
daft_type,
new_paimon_type,
values,
expected,
):
old_schema = pa.schema([("id", pa.int64()), ("value", old_type)])
identifier, table = _create_table(
catalog_options,
f"read_{case_name}",
old_schema,
options={"bucket": "-1", "file.format": "parquet"},
)
_write_arrow(
table,
pa.table({"id": [1, 2], "value": values}, schema=old_schema),
)
catalog = pypaimon.CatalogFactory.create(catalog_options)
catalog.alter_table(
identifier,
[SchemaChange.update_column_type("value", new_paimon_type)],
ignore_if_not_exists=False,
)
table = catalog.get_table(identifier)
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
daft_result = read_paimon(identifier, catalog_options).sort("id").to_arrow()
read_builder = table.new_read_builder()
pypaimon_result = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits()
).sort_by([("id", "ascending")])
expected_result = pa.table(
{"id": [1, 2], "value": expected},
schema=pa.schema([("id", pa.int64()), ("value", pypaimon_type)]),
)
assert explain.pypaimon_fallback_split_count == 1
assert explain.native_parquet_split_count == 0
assert daft_result.schema == pa.schema(
[("id", pa.int64()), ("value", daft_type)]
)
assert daft_result.to_pydict() == pypaimon_result.to_pydict()
assert pypaimon_result.equals(expected_result)
def test_read_paimon_time_uses_pypaimon_fallback(catalog_options):
schema = pa.schema([("id", pa.int64()), ("value", pa.time32("ms"))])
values = [
datetime.time(3, 4, 5, 678000),
datetime.time(23, 59, 58, 987000),
]
identifier, table = _create_table(
catalog_options,
"read_time",
schema,
options={"bucket": "-1", "file.format": "parquet"},
)
_write_arrow(
table,
pa.table({"id": [1, 2], "value": values}, schema=schema),
)
explain = explain_paimon_scan(identifier, catalog_options, verbose=True)
daft_result = read_paimon(identifier, catalog_options).sort("id").to_arrow()
read_builder = table.new_read_builder()
pypaimon_result = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits()
).sort_by([("id", "ascending")])
assert explain.pypaimon_fallback_split_count == 1
assert explain.native_parquet_split_count == 0
assert daft_result.schema == pa.schema(
[("id", pa.int64()), ("value", pa.time64("us"))]
)
assert pypaimon_result.schema == schema
assert daft_result.to_pydict() == pypaimon_result.to_pydict()
def test_read_paimon_projection(catalog_options):
data = pa.table(
{
"id": pa.array([1, 2], pa.int64()),
"name": pa.array(["alice", "bob"], pa.string()),
"value": pa.array([10, 20], pa.int64()),
}
)
identifier = _create_and_populate_table(catalog_options, "read_projection", data)
result = read_paimon(identifier, catalog_options).select("id", "name").sort("id").to_pydict()
assert result == {
"id": [1, 2],
"name": ["alice", "bob"],
}
def test_read_paimon_filter(catalog_options):
data = pa.table(
{
"id": pa.array([1, 2, 3, 4], pa.int64()),
"category": pa.array(["A", "B", "A", "C"], pa.string()),
"amount": pa.array([100, 200, 150, 300], pa.int64()),
}
)
identifier = _create_and_populate_table(catalog_options, "read_filter", data)
result = (
read_paimon(identifier, catalog_options)
.where((col("category") == "A") & (col("amount") >= 120))
.sort("id")
.to_pydict()
)
assert result == {
"id": [3],
"category": ["A"],
"amount": [150],
}
def test_read_paimon_filter_pruning_matches_explain(catalog_options, monkeypatch):
from daft.io.source import DataSourceTask
pa_schema = pa.schema([
("id", pa.int64()),
("name", pa.string()),
])
identifier, table = _create_table(
catalog_options,
"read_filter_pruning",
pa_schema,
options={
"bucket": "1",
"file.format": "parquet",
"metadata.stats-mode": "full",
},
)
_write_arrow(table, pa.table({"id": [1], "name": ["file-a"]}, schema=pa_schema))
_write_arrow(table, pa.table({"id": [999], "name": ["file-b"]}, schema=pa_schema))
planned_paths = []
original_parquet = DataSourceTask.parquet
def capture_parquet_task(**kwargs):
planned_paths.append(kwargs["path"])
return original_parquet(**kwargs)
monkeypatch.setattr(DataSourceTask, "parquet", capture_parquet_task)
predicate = col("id") == 999
result = read_paimon(identifier, catalog_options).where(predicate).to_pydict()
explain = explain_paimon_scan(
identifier,
catalog_options,
filters=predicate,
verbose=True,
)
assert result == {"id": [999], "name": ["file-b"]}
assert len(planned_paths) == 1
assert explain.total_file_count == len(planned_paths)
assert explain.paimon_scan.file_skipping is not None
assert explain.paimon_scan.file_skipping.before == 2
assert explain.paimon_scan.file_skipping.after == 1
def test_read_paimon_limit(catalog_options):
data = pa.table(
{
"id": pa.array(list(range(10)), pa.int64()),
"name": pa.array([f"name-{i}" for i in range(10)], pa.string()),
}
)
identifier = _create_and_populate_table(catalog_options, "read_limit", data)
result = read_paimon(identifier, catalog_options).limit(3).to_pydict()
assert len(result["id"]) == 3
def test_read_paimon_with_snapshot_id(catalog_options):
pa_schema = pa.schema([("id", pa.int64()), ("name", pa.string())])
identifier, table = _create_table(catalog_options, "read_snapshot_id", pa_schema)
_write_arrow(table, pa.table({"id": [1], "name": ["first"]}, schema=pa_schema))
_write_arrow(table, pa.table({"id": [2], "name": ["second"]}, schema=pa_schema))
latest = read_paimon(identifier, catalog_options).sort("id").to_pydict()
snap1 = read_paimon(identifier, catalog_options, snapshot_id=1).to_pydict()
assert latest["id"] == [1, 2]
assert snap1 == {"id": [1], "name": ["first"]}
def test_read_paimon_with_tag_name(catalog_options):
pa_schema = pa.schema([("id", pa.int64()), ("name", pa.string())])
identifier, table = _create_table(catalog_options, "read_tag_name", pa_schema)
_write_arrow(table, pa.table({"id": [1], "name": ["tagged"]}, schema=pa_schema))
table.create_tag("v1")
_write_arrow(table, pa.table({"id": [2], "name": ["latest"]}, schema=pa_schema))
result = read_paimon(identifier, catalog_options, tag_name="v1").to_pydict()
assert result == {"id": [1], "name": ["tagged"]}
def test_read_paimon_with_timestamp(catalog_options):
pa_schema = pa.schema([("id", pa.int64()), ("name", pa.string())])
identifier, table = _create_table(catalog_options, "read_timestamp", pa_schema)
_write_arrow(table, pa.table({"id": [1], "name": ["first"]}, schema=pa_schema))
# A cutoff strictly between the two commits: scan.timestamp-millis returns the
# latest snapshot at or before it, i.e. the first snapshot only.
time.sleep(0.05)
cutoff_ms = int(time.time() * 1000)
time.sleep(0.05)
_write_arrow(table, pa.table({"id": [2], "name": ["second"]}, schema=pa_schema))
latest = read_paimon(identifier, catalog_options).sort("id").to_pydict()
at_millis = read_paimon(identifier, catalog_options, timestamp=cutoff_ms).to_pydict()
at_datetime = read_paimon(
identifier,
catalog_options,
timestamp=datetime.datetime.fromtimestamp(cutoff_ms / 1000),
).to_pydict()
assert latest["id"] == [1, 2]
assert at_millis == {"id": [1], "name": ["first"]}
assert at_datetime == {"id": [1], "name": ["first"]}
def test_timestamp_scan_option_mapping():
assert _timestamp_scan_option(1751990400000) == {"scan.timestamp-millis": "1751990400000"}
assert _timestamp_scan_option("2026-07-09 10:00:00") == {"scan.timestamp": "2026-07-09 10:00:00"}
dt = datetime.datetime(2026, 7, 9, 10, 0, 0)
assert _timestamp_scan_option(dt) == {"scan.timestamp-millis": str(int(dt.timestamp() * 1000))}
with pytest.raises(TypeError):
_timestamp_scan_option(True)
with pytest.raises(TypeError):
_timestamp_scan_option(1.5)
@pytest.mark.parametrize(
"kwargs",
[
{"snapshot_id": 1, "tag_name": "v1"},
{"snapshot_id": 1, "timestamp": 1},
{"tag_name": "v1", "timestamp": 1},
{"snapshot_id": 1, "tag_name": "v1", "timestamp": 1},
],
)
def test_read_paimon_rejects_multiple_time_travel(catalog_options, kwargs):
with pytest.raises(ValueError, match="Only one of snapshot_id, tag_name, timestamp"):
read_paimon("test_db.dummy", catalog_options, **kwargs)
def test_write_paimon_append(catalog_options):
pa_schema = pa.schema([
("id", pa.int64()),
("name", pa.string()),
])
identifier, _ = _create_table(catalog_options, "write_append", pa_schema)
df = daft.from_pydict({"id": [1, 2, 3], "name": ["a", "b", "c"]})
write_paimon(df, identifier, catalog_options)
result = read_paimon(identifier, catalog_options).sort("id").to_pydict()
assert result == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
def test_write_paimon_overwrite(catalog_options):
pa_schema = pa.schema([
("id", pa.int64()),
("name", pa.string()),
])
identifier, _ = _create_table(catalog_options, "write_overwrite", pa_schema)
write_paimon(
daft.from_pydict({"id": [1, 2], "name": ["old-a", "old-b"]}),
identifier,
catalog_options,
)
write_paimon(
daft.from_pydict({"id": [3], "name": ["new"]}),
identifier,
catalog_options,
mode="overwrite",
)
result = read_paimon(identifier, catalog_options).to_pydict()
assert result == {"id": [3], "name": ["new"]}