| # 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 |