blob: c0ea736756562384b141ee084abb35bc0d194851 [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 pickle
import tempfile
import pyarrow as pa
import pytest
from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
def _make_table_with_data(warehouse):
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.rdb")
ctx.sql("CREATE TABLE paimon.rdb.t (id INT, name STRING)")
ctx.sql("INSERT INTO paimon.rdb.t VALUES (1, 'a'), (2, 'b'), (3, 'c')")
catalog = PaimonCatalog({"warehouse": warehouse})
return catalog.get_table("rdb.t")
def test_read_builder_chain_exists():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
builder = table.new_read_builder()
scan = builder.with_projection(["id"]).with_limit(2).new_scan()
# plan() returns a Plan; deeper assertions are in later tasks.
plan = scan.plan()
assert plan is not None
def test_new_read_builder_plan():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
plan = table.new_read_builder().new_scan().plan()
assert len(plan.splits()) >= 1
def test_with_projection():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
plan = table.new_read_builder().with_projection(["id"]).new_scan().plan()
assert plan is not None
def test_with_limit():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
# limit is a planning hint; assert only that planning succeeds.
plan = table.new_read_builder().with_limit(1).new_scan().plan()
assert plan is not None
def test_plan_len():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
plan = table.new_read_builder().new_scan().plan()
assert len(plan) == len(plan.splits())
def test_plan_without_filter_succeeds():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
plan = table.new_read_builder().new_scan().plan()
assert len(plan.splits()) >= 1
def test_split_pickle_roundtrip():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
splits = table.new_read_builder().new_scan().plan().splits()
assert len(splits) >= 1
split = splits[0]
restored = pickle.loads(pickle.dumps(split))
assert restored.row_count() == split.row_count()
def _make_partitioned_table(warehouse):
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.pdb")
ctx.sql("CREATE TABLE paimon.pdb.pt (dt STRING, id INT) PARTITIONED BY (dt)")
ctx.sql("INSERT INTO paimon.pdb.pt VALUES ('p1', 1), ('p1', 2), ('p2', 3), ('p3', 4)")
catalog = PaimonCatalog({"warehouse": warehouse})
return catalog.get_table("pdb.pt")
def test_filter_equal_converts_and_plans():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
plan = table.new_read_builder().with_filter(
{"method": "equal", "field": "id", "literals": [1]}).new_scan().plan()
assert plan is not None
def test_filter_prunes_partition_splits():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_partitioned_table(warehouse)
unfiltered = len(table.new_read_builder().new_scan().plan().splits())
filtered = len(table.new_read_builder().with_filter(
{"method": "equal", "field": "dt", "literals": ["p1"]}).new_scan().plan().splits())
assert filtered < unfiltered
def test_filter_and_or_compound():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
pred = {"method": "and", "children": [
{"method": "greaterOrEqual", "field": "id", "literals": [1]},
{"method": "lessThan", "field": "id", "literals": [99]},
]}
assert table.new_read_builder().with_filter(pred).new_scan().plan() is not None
def test_filter_in_notin():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
assert b.with_filter({"method": "in", "field": "id", "literals": [1, 2]}).new_scan().plan() is not None
# Fresh builder for notIn (with_filter overwrites; avoid relying on overwrite here).
b2 = table.new_read_builder()
assert b2.with_filter({"method": "notIn", "field": "id", "literals": [1, 2]}).new_scan().plan() is not None
def test_filter_isnull_isnotnull():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
assert b.with_filter({"method": "isNotNull", "field": "name", "literals": []}).new_scan().plan() is not None
b2 = table.new_read_builder()
assert b2.with_filter({"method": "isNull", "field": "name", "literals": []}).new_scan().plan() is not None
def test_filter_null_check_with_literals_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
with pytest.raises(ValueError):
b.with_filter({"method": "isNull", "field": "name", "literals": [1]})
with pytest.raises(ValueError):
b.with_filter({"method": "isNotNull", "field": "name", "literals": [1]})
# Valid empty/missing cases still succeed.
assert b.with_filter(
{"method": "isNull", "field": "name", "literals": []}).new_scan().plan() is not None
assert b.with_filter(
{"method": "isNull", "field": "name"}).new_scan().plan() is not None
def test_filter_bool_literal_converts():
with tempfile.TemporaryDirectory() as warehouse:
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.bdb")
ctx.sql("CREATE TABLE paimon.bdb.bt (id INT, flag BOOLEAN)")
ctx.sql("INSERT INTO paimon.bdb.bt VALUES (1, true), (2, false)")
table = PaimonCatalog({"warehouse": warehouse}).get_table("bdb.bt")
plan = table.new_read_builder().with_filter(
{"method": "equal", "field": "flag", "literals": [True]}).new_scan().plan()
assert plan is not None
def test_filter_unsupported_operator_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
with pytest.raises(NotImplementedError):
table.new_read_builder().with_filter(
{"method": "not", "field": "name", "literals": ["x"]})
def test_filter_unsupported_operator_precedes_shape_errors():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
# 'not' with no field -> NotImplementedError, not ValueError about missing field
with pytest.raises(NotImplementedError):
b.with_filter({"method": "not", "children": []})
def _make_string_table(warehouse):
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.sdb")
ctx.sql("CREATE TABLE paimon.sdb.st (id INT, name STRING)")
# Two separate INSERTs -> two files, so file stats can prune per-file.
ctx.sql("INSERT INTO paimon.sdb.st VALUES (1, 'apple'), (2, 'apricot')")
ctx.sql("INSERT INTO paimon.sdb.st VALUES (3, 'banana'), (4, 'cherry')")
return PaimonCatalog({"warehouse": warehouse}).get_table("sdb.st")
def _read_ids(builder):
splits = builder.new_scan().plan().splits()
batches = builder.new_read().read(splits)
if not batches:
return []
return sorted(pa.Table.from_batches(batches).column("id").to_pylist())
def test_filter_starts_with_prunes_and_reads():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "startsWith", "field": "name", "literals": ["ap"]})
assert len(b.new_scan().plan().splits()) == 1
assert _read_ids(b) == [1, 2]
def test_filter_ends_with_reads_matching_rows():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "endsWith", "field": "name", "literals": ["y"]})
assert _read_ids(b) == [4]
def test_filter_contains_reads_matching_rows():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "contains", "field": "name", "literals": ["an"]})
assert _read_ids(b) == [3]
def test_filter_like_prefix_reads_matching_rows():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "like", "field": "name", "literals": ["ap%"]})
assert _read_ids(b) == [1, 2]
def test_filter_like_residual_pattern_reads_matching_rows():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
# '_' single-char wildcard is not rewritten; exercises the Like evaluator.
b = table.new_read_builder().with_filter(
{"method": "like", "field": "name", "literals": ["b_nana"]})
assert _read_ids(b) == [3]
def test_filter_like_escape_literal():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder()
# Optional second literal is the ESCAPE character; only '\\' is accepted.
assert b.with_filter(
{"method": "like", "field": "name",
"literals": ["100\\%%", "\\"]}).new_scan().plan() is not None
with pytest.raises(ValueError):
b.with_filter(
{"method": "like", "field": "name", "literals": ["100!%%", "!"]})
with pytest.raises(ValueError):
b.with_filter(
{"method": "like", "field": "name", "literals": ["a%", "ab"]})
def test_filter_string_op_on_non_string_column_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse)
b = table.new_read_builder()
for method in ["startsWith", "endsWith", "contains", "like"]:
with pytest.raises(ValueError):
b.with_filter({"method": method, "field": "id", "literals": ["a"]})
def test_filter_unsupported_type_raises():
# Binary columns have no literal conversion -> NotImplementedError
with tempfile.TemporaryDirectory() as warehouse:
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.tdb")
ctx.sql("CREATE TABLE paimon.tdb.tt (id INT, payload BYTEA)")
ctx.sql("INSERT INTO paimon.tdb.tt VALUES (1, X'00')")
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.tt")
with pytest.raises(NotImplementedError):
table.new_read_builder().with_filter(
{"method": "equal", "field": "payload", "literals": [0]})
def _make_temporal_table(warehouse):
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.tmp")
ctx.sql(
"CREATE TABLE paimon.tmp.tt (id INT, d DATE, ts TIMESTAMP, dec DECIMAL(10, 2))")
# Two separate INSERTs -> two files, so file stats can prune per-file.
ctx.sql(
"INSERT INTO paimon.tmp.tt VALUES "
"(1, DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 12.34)")
ctx.sql(
"INSERT INTO paimon.tmp.tt VALUES "
"(2, DATE '2024-06-15', TIMESTAMP '2024-06-15 12:30:00', 56.78)")
return PaimonCatalog({"warehouse": warehouse}).get_table("tmp.tt")
def test_filter_date_literal_prunes_and_reads():
import datetime
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "equal", "field": "d", "literals": [datetime.date(2024, 1, 1)]})
splits = b.new_scan().plan().splits()
assert len(splits) == 1
t = pa.Table.from_batches(b.new_read().read(splits))
assert t.column("id").to_pylist() == [1]
def test_filter_timestamp_literal_prunes_and_reads():
import datetime
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "greaterThan", "field": "ts",
"literals": [datetime.datetime(2024, 3, 1, 0, 0, 0)]})
splits = b.new_scan().plan().splits()
assert len(splits) == 1
t = pa.Table.from_batches(b.new_read().read(splits))
assert t.column("id").to_pylist() == [2]
def test_filter_timestamp_rejects_aware_datetime():
import datetime
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
aware = datetime.datetime(2024, 3, 1, tzinfo=datetime.timezone.utc)
with pytest.raises(ValueError):
table.new_read_builder().with_filter(
{"method": "greaterThan", "field": "ts", "literals": [aware]})
def test_filter_decimal_literal_prunes_and_reads():
from decimal import Decimal
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "equal", "field": "dec", "literals": [Decimal("12.34")]})
splits = b.new_scan().plan().splits()
assert len(splits) == 1
t = pa.Table.from_batches(b.new_read().read(splits))
assert t.column("id").to_pylist() == [1]
def test_filter_decimal_rejects_float_and_rounding():
from decimal import Decimal
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
b = table.new_read_builder()
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "dec", "literals": [12.34]})
with pytest.raises(ValueError):
b.with_filter(
{"method": "equal", "field": "dec", "literals": [Decimal("0.005")]})
def test_filter_unknown_field_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
with pytest.raises(ValueError):
table.new_read_builder().with_filter(
{"method": "equal", "field": "nope", "literals": [1]})
def test_filter_type_mismatch_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "id", "literals": [True]})
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "id", "literals": ["x"]})
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "id", "literals": [None]})
def test_filter_out_of_range_raises():
# needs a TinyInt column
with tempfile.TemporaryDirectory() as warehouse:
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.ndb")
ctx.sql("CREATE TABLE paimon.ndb.nt (id INT, small TINYINT)")
ctx.sql("INSERT INTO paimon.ndb.nt VALUES (1, 5)")
table = PaimonCatalog({"warehouse": warehouse}).get_table("ndb.nt")
with pytest.raises(ValueError):
table.new_read_builder().with_filter(
{"method": "equal", "field": "small", "literals": [9999]})
def test_filter_wrong_literal_count_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "id", "literals": [1, 2]})
with pytest.raises(ValueError):
b.with_filter({"method": "in", "field": "id", "literals": []})
def test_filter_compound_with_unsupported_child_fails():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
pred = {"method": "and", "children": [
{"method": "equal", "field": "id", "literals": [1]},
{"method": "not", "children": [
{"method": "equal", "field": "name", "literals": ["a"]},
]},
]}
with pytest.raises(NotImplementedError):
table.new_read_builder().with_filter(pred)
def test_filter_empty_children_raises():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
with pytest.raises(ValueError):
table.new_read_builder().with_filter({"method": "and", "children": []})
def test_filter_overwrite():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_partitioned_table(warehouse)
only_p2 = len(table.new_read_builder().with_filter(
{"method": "equal", "field": "dt", "literals": ["p2"]}).new_scan().plan().splits())
overwritten = len(table.new_read_builder()
.with_filter({"method": "equal", "field": "dt", "literals": ["p1"]})
.with_filter({"method": "equal", "field": "dt", "literals": ["p2"]})
.new_scan().plan().splits())
assert overwritten == only_p2
def test_read_returns_expected_rows():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
splits = b.new_scan().plan().splits()
batches = b.new_read().read(splits)
t = pa.Table.from_batches(batches)
assert t.num_rows == 3
assert t.sort_by("id").to_pydict() == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
def test_read_empty_splits():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
assert table.new_read_builder().new_read().read([]) == []
def test_read_empty_splits_still_validates_projection():
# An invalid projection must fail regardless of split count; the empty-splits
# fast path should not bypass config validation.
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
read = table.new_read_builder().with_projection(["does_not_exist"]).new_read()
with pytest.raises(Exception):
read.read([])
def test_read_non_split_raises_typeerror():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
with pytest.raises(TypeError):
table.new_read_builder().new_read().read([object()])
def test_read_pickled_split():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder()
splits = b.new_scan().plan().splits()
pickled = [pickle.loads(pickle.dumps(s)) for s in splits]
batches = b.new_read().read(pickled)
t = pa.Table.from_batches(batches)
assert t.num_rows == 3
assert sorted(t.column("id").to_pylist()) == [1, 2, 3]
def test_read_projection_applied():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder().with_projection(["id"])
splits = b.new_scan().plan().splits()
batches = b.new_read().read(splits)
t = pa.Table.from_batches(batches)
assert t.schema.names == ["id"]
assert sorted(t.column("id").to_pylist()) == [1, 2, 3]
def test_read_is_config_snapshot():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder().with_projection(["id"])
splits = b.new_scan().plan().splits()
read = b.new_read()
b.with_projection(["name"]) # mutate builder AFTER new_read()
t = pa.Table.from_batches(read.read(splits))
assert t.schema.names == ["id"]
def test_read_with_filter_smoke():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
b = table.new_read_builder().with_filter(
{"method": "greaterOrEqual", "field": "id", "literals": [1]})
splits = b.new_scan().plan().splits()
batches = b.new_read().read(splits)
assert isinstance(batches, list)
def _make_two_snapshot_table(warehouse):
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.tdb")
ctx.sql("CREATE TABLE paimon.tdb.t (id INT, name STRING)")
ctx.sql("INSERT INTO paimon.tdb.t VALUES (1, 'a')") # snapshot 1
ctx.sql("INSERT INTO paimon.tdb.t VALUES (2, 'b'), (3, 'c')") # snapshot 2
return ctx
def _rows(batches):
return sum(b.num_rows for b in batches)
def test_time_travel_by_snapshot_id():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
builder = table.new_read_builder({"scan.snapshot-id": "1"})
splits = builder.new_scan().plan().splits()
batches = builder.new_read().read(splits)
assert _rows(batches) == 1 # only snapshot 1's row
def test_time_travel_by_tag_name():
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_two_snapshot_table(warehouse)
ctx.sql("CALL sys.create_tag(table => 'tdb.t', tag => 'v1', snapshot_id => 1)")
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
builder = table.new_read_builder({"scan.tag-name": "v1"})
splits = builder.new_scan().plan().splits()
assert _rows(builder.new_read().read(splits)) == 1
def test_time_travel_unresolved_snapshot_raises():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(ValueError, match="did not resolve"):
table.new_read_builder({"scan.snapshot-id": "999"})
def test_unsupported_scan_option_raises_not_implemented():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(NotImplementedError):
table.new_read_builder({"incremental-between": "1,2"})
def test_explicit_scan_mode_with_matching_selector_reads_snapshot():
# Java's CoreOptions.setDefaultValues() writes scan.mode=from-snapshot next
# to scan.snapshot-id, so configs from the Java toolchain carry both keys.
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
builder = table.new_read_builder(
{"scan.mode": "from-snapshot", "scan.snapshot-id": "1"})
splits = builder.new_scan().plan().splits()
assert _rows(builder.new_read().read(splits)) == 1
def test_explicit_scan_mode_without_selector_raises():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(ValueError, match="from-snapshot"):
table.new_read_builder({"scan.mode": "from-snapshot"})
def test_unimplemented_scan_mode_raises_not_implemented():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(NotImplementedError):
table.new_read_builder({"scan.mode": "incremental"})
def test_scan_option_non_string_value_raises_type_error():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(TypeError):
table.new_read_builder({"scan.snapshot-id": 1})
def test_new_read_builder_none_options_reads_latest():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
builder = table.new_read_builder() # no options → latest
splits = builder.new_scan().plan().splits()
assert _rows(builder.new_read().read(splits)) == 3
def test_time_travel_filter_uses_travelled_schema():
# Snapshot 1 predates the 'age' column. Travelling to it and filtering on
# 'age' must fail (not in the travelled schema), while filtering on a
# column present in that schema ('name') must succeed. This proves
# with_filter validates against the travelled schema, not the latest one.
with tempfile.TemporaryDirectory() as warehouse:
ctx = SQLContext()
ctx.register_catalog("paimon", {"warehouse": warehouse})
ctx.sql("CREATE SCHEMA paimon.edb")
ctx.sql("CREATE TABLE paimon.edb.t (id INT, name STRING)")
ctx.sql("INSERT INTO paimon.edb.t VALUES (1, 'a')") # snapshot 1, schema 0
ctx.sql("ALTER TABLE paimon.edb.t ADD COLUMN age INT")
ctx.sql("INSERT INTO paimon.edb.t VALUES (2, 'b', 20)") # snapshot 2, schema 1
catalog = PaimonCatalog({"warehouse": warehouse})
# Filtering on the post-travel-absent column must fail.
travelled = catalog.get_table("edb.t").new_read_builder({"scan.snapshot-id": "1"})
with pytest.raises(ValueError):
travelled.with_filter({"method": "equal", "field": "age", "literals": [20]})
# Filtering on a column present in the travelled schema must succeed.
travelled_ok = catalog.get_table("edb.t").new_read_builder({"scan.snapshot-id": "1"})
plan = travelled_ok.with_filter(
{"method": "equal", "field": "name", "literals": ["a"]}
).new_scan().plan()
assert plan is not None
def test_time_travel_conflicting_selectors_raises():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
with pytest.raises(ValueError, match="Only one time-travel selector") as exc:
table.new_read_builder({"scan.snapshot-id": "1", "scan.tag-name": "t"})
# both offending keys are named
assert "scan.snapshot-id" in str(exc.value)
assert "scan.tag-name" in str(exc.value)
def test_split_serialize_produces_split_v1_binary():
import struct
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
splits = table.new_read_builder().new_scan().plan().splits()
assert splits
data = splits[0].serialize()
assert isinstance(data, (bytes, bytearray))
# SplitSerializer frame: "SPLIT_V1" + version(1) + type-id(1=DATA_SPLIT)
assert data[:8] == b"SPLIT_V1"
version, type_id = struct.unpack_from(">ii", data, 8)
assert version == 1
assert type_id == 1
# DataSplit v8 body follows: MAGIC + VERSION(8)
magic, body_version = struct.unpack_from(">qi", data, 16)
assert magic == -2394839472490812314
assert body_version == 8
assert splits[0].serialize() == data # deterministic
def test_split_serialize_carries_partition_and_reads():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_partitioned_table(warehouse)
b = table.new_read_builder().with_filter(
{"method": "equal", "field": "dt", "literals": ["p1"]})
splits = b.new_scan().plan().splits()
assert splits
assert b"p1" in splits[0].serialize() # partition value encoded
# partition filter pruned to p1
t = pa.Table.from_batches(b.new_read().read(splits))
assert sorted(t.column("id").to_pylist()) == [1, 2]
def test_split_serialize_encodes_deletions_and_external_path():
# plan() can't produce these; inject them via the serde payload.
import json
with tempfile.TemporaryDirectory() as warehouse:
table = _make_string_table(warehouse) # two files
split = table.new_read_builder().new_scan().plan().splits()[0]
cls, (raw,) = split.__reduce__()
payload = json.loads(bytes(raw))
n = len(payload["data_files"])
assert n >= 2
payload["data_deletion_files"] = [
{"path": "dv/idx-0", "offset": 4, "length": 20, "cardinality": 3}
] + [None] * (n - 1)
payload["data_files"][0]["_EXTERNAL_PATH"] = "s3://ext/data-0.parquet"
data = cls(json.dumps(payload).encode()).serialize()
assert b"dv/idx-0" in data # deletion file path
assert b"s3://ext/data-0.parquet" in data # external path