| # 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. | |
| """Lance exports retain logical scan results and own their BLOB payloads.""" | |
| import shutil | |
| import sys | |
| from unittest.mock import patch | |
| import pyarrow as pa | |
| import pytest | |
| import pypaimon.multimodal as pmm | |
| lance = pytest.importorskip("lance", minversion="13.0.0", reason="pylance >= 13.0.0 is required for Lance exports") | |
| @pytest.fixture | |
| def conn(tmp_path): | |
| return pmm.connect(options={"warehouse": str(tmp_path / "warehouse")}) | |
| def _table(conn, name, fields, data=None, options=None): | |
| return conn.create_table( | |
| name, schema=pa.schema([pa.field(name, dtype) for name, dtype in fields.items()]), | |
| data=data, options=dict({"file.format": "parquet", "read.batch-size": "2"}, **(options or {}))) | |
| def _rows(path): | |
| dataset = lance.dataset(str(path)) | |
| rows = dataset.to_table().to_pylist() | |
| for field in dataset.schema: | |
| if (field.metadata or {}).get(b"lance-encoding:blob") != b"true": | |
| continue | |
| # Native scans return descriptors. take_blobs gives NULL and empty | |
| # BLOBs the same zero-length file, so preserve NULL from the scan. | |
| indices = [index for index, row in enumerate(rows) if row[field.name] is not None] | |
| if not indices: | |
| continue | |
| for index, blob in zip(indices, dataset.take_blobs(field.name, indices=indices)): | |
| try: | |
| rows[index][field.name] = blob.read() | |
| finally: | |
| blob.close() | |
| return rows | |
| def test_export_lance_preserves_filter_projection_limit_and_vector(conn, tmp_path): | |
| table = _table(conn, "vectors", { | |
| "id": pa.int32(), "split": pa.string(), | |
| "embedding": pa.list_(pa.float32(), 3), "text": pa.string(), | |
| }, [{"id": i, "split": "train" if i % 2 else "test", | |
| "embedding": [float(i), 0.0, 1.0], "text": "row-%s" % i} | |
| for i in range(6)]) | |
| query = table.scan().where("split = 'train'").select(["embedding", "text", "id"]).limit(2) | |
| path = tmp_path / "train.lance" | |
| exported = query.export_lance(str(path)) | |
| assert isinstance(exported, lance.LanceDataset) | |
| result = lance.dataset(str(path)).to_table() | |
| assert result.column_names == ["embedding", "text", "id"] | |
| assert result.schema.field("embedding").type == pa.list_(pa.float32(), 3) | |
| assert result.num_rows == 2 | |
| assert {row["id"] for row in result.to_pylist()} <= {1, 3, 5} | |
| assert len({row["id"] for row in result.to_pylist()}) == 2 | |
| for row in result.to_pylist(): | |
| assert row["text"] == "row-%s" % row["id"] | |
| assert row["embedding"] == [float(row["id"]), 0.0, 1.0] | |
| def test_export_lance_exports_logical_updates_deletes_and_historical_schema(conn, tmp_path): | |
| from pypaimon.schema.data_types import AtomicType | |
| from pypaimon.schema.schema_change import SchemaChange | |
| table = _table(conn, "history", { | |
| "id": pa.int32(), "text": pa.string(), "image": pa.large_binary(), | |
| }, [ | |
| {"id": 1, "text": "before", "image": b"before"}, | |
| {"id": 2, "text": "deleted", "image": b"deleted"}, | |
| ], options={"deletion-vectors.enabled": "true"}) | |
| snapshot_id = table.raw_table.snapshot_manager().get_latest_snapshot().id | |
| table.raw_table.create_tag("training-v1", snapshot_id=snapshot_id) | |
| conn.catalog.alter_table("default.history", [ | |
| SchemaChange.add_column("note", AtomicType("STRING")), | |
| ]) | |
| table = conn.get_table("history") | |
| table.update("id = 1", {"text": "after", "image": b"after"}) | |
| table.delete("id = 2") | |
| table.add([{"id": 3, "text": "new", "image": b"new", "note": "added"}]) | |
| current_path = tmp_path / "current.lance" | |
| table.scan().export_lance(str(current_path)) | |
| assert lance.dataset(str(current_path)).schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| assert sorted(_rows(current_path), key=lambda row: row["id"]) == [ | |
| {"id": 1, "text": "after", "image": b"after", "note": None}, | |
| {"id": 3, "text": "new", "image": b"new", "note": "added"}, | |
| ] | |
| for label, version in (("snapshot", {"snapshot_id": snapshot_id}), | |
| ("tag", {"tag_name": "training-v1"})): | |
| path = tmp_path / (label + ".lance") | |
| query = table.scan(**version).where("id <= 2") | |
| names = ["id", "text", "image"] | |
| if label == "tag": | |
| names = ["text", "image", "id"] | |
| query.select(names) | |
| query.export_lance(str(path)) | |
| assert lance.dataset(str(path)).schema.names == names | |
| assert lance.dataset(str(path)).schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == [ | |
| {"text": "before", "image": b"before", "id": 1}, | |
| {"text": "deleted", "image": b"deleted", "id": 2}, | |
| ] | |
| @pytest.mark.parametrize("empty", ["uncommitted", "filter", "limit"]) | |
| def test_export_lance_empty_scan_preserves_schema(conn, tmp_path, empty): | |
| table = _table(conn, "empty", {"id": pa.int32(), "image": pa.large_binary()}) | |
| if empty != "uncommitted": | |
| table.add([{"id": 1, "image": b"body"}]) | |
| query = table.scan().select(["image", "id"]) | |
| if empty == "filter": | |
| query.where("id < 0") | |
| elif empty == "limit": | |
| query.limit(0) | |
| path = tmp_path / "empty.lance" | |
| query.export_lance(str(path)) | |
| reopened = lance.dataset(str(path)) | |
| assert reopened.count_rows() == 0 | |
| assert reopened.schema.names == ["image", "id"] | |
| assert reopened.schema.field("id").type == pa.int32() | |
| assert reopened.schema.field("image").type == pa.large_binary() | |
| assert reopened.schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| assert _rows(path) == [] | |
| def test_export_lance_defaults_scalar_blobs_to_native_lazy_files(conn, tmp_path): | |
| payload = b"0123456789abcdef" | |
| expected = [ | |
| {"id": 1, "image": payload, "raw": b"plain-bytes"}, | |
| {"id": 2, "image": b"", "raw": b""}, | |
| {"id": 3, "image": None, "raw": None}, | |
| ] | |
| table = _table(conn, "native_blobs", { | |
| "id": pa.int32(), "image": pa.large_binary(), "raw": pa.binary(), | |
| }, expected) | |
| path = tmp_path / "native.lance" | |
| table.scan().export_lance(str(path)) | |
| shutil.rmtree(str(tmp_path / "warehouse")) | |
| reopened = lance.dataset(str(path)) | |
| image = reopened.schema.field("image") | |
| assert image.type == pa.large_binary() | |
| assert (image.metadata or {}).get(b"lance-encoding:blob") == b"true" | |
| raw = reopened.schema.field("raw") | |
| assert raw.type == pa.binary() | |
| assert b"lance-encoding:blob" not in (raw.metadata or {}) | |
| descriptors = reopened.to_table().to_pylist() | |
| indices = {row["id"]: index for index, row in enumerate(descriptors)} | |
| assert descriptors[indices[1]]["image"]["size"] == len(payload) | |
| assert descriptors[indices[2]]["image"] is not None | |
| assert descriptors[indices[2]]["image"]["size"] == 0 | |
| assert descriptors[indices[3]]["image"] is None | |
| assert [row["raw"] for row in descriptors] == [row["raw"] for row in expected] | |
| files = reopened.take_blobs("image", indices=[indices[1], indices[2]]) | |
| with files[0] as blob: | |
| assert blob.readable() | |
| assert blob.seekable() | |
| assert blob.size() == len(payload) | |
| assert blob.tell() == 0 | |
| assert blob.read(3) == payload[:3] | |
| assert blob.tell() == 3 | |
| blob.seek(5) | |
| assert blob.read(4) == payload[5:9] | |
| blob.seek(-4, 2) | |
| assert blob.read() == payload[-4:] | |
| assert files[0].closed | |
| with files[1] as empty_blob: | |
| assert empty_blob.size() == 0 | |
| assert empty_blob.read() == b"" | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == expected | |
| def test_export_lance_materializes_blob_and_array_payloads_independently(conn, tmp_path): | |
| schema = {"id": pa.int32(), "image": pa.large_binary(), "pages": pa.list_(pa.large_binary())} | |
| expected = [ | |
| {"id": 1, "image": b"first", "pages": [b"one", None, b"", b"one"]}, | |
| {"id": 2, "image": b"", "pages": None}, | |
| {"id": 3, "image": None, "pages": []}, | |
| ] | |
| table = _table(conn, "blobs", schema, expected) | |
| path = tmp_path / "blobs.lance" | |
| table.scan().select(["pages", "id", "image"]).export_lance(str(path), blob_parallelism=2) | |
| shutil.rmtree(str(tmp_path / "warehouse")) | |
| reopened = lance.dataset(str(path)) | |
| assert reopened.schema.names == ["pages", "id", "image"] | |
| assert reopened.schema.field("image").type == pa.large_binary() | |
| assert reopened.schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| assert reopened.schema.field("pages").type.value_type == pa.large_binary() | |
| assert b"lance-encoding:blob" not in (reopened.schema.field("pages").metadata or {}) | |
| assert b"lance-encoding:blob" not in (reopened.schema.field("pages").type.value_field.metadata or {}) | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == expected | |
| def test_export_lance_blob_only_projection_filters_on_hidden_column(conn, tmp_path): | |
| table = _table(conn, "blob_only", {"id": pa.int32(), "image": pa.large_binary()}, [ | |
| {"id": 1, "image": b"one"}, {"id": 2, "image": b""}, | |
| {"id": 3, "image": None}, {"id": 4, "image": b"excluded"}, | |
| ]) | |
| path = tmp_path / "blob_only.lance" | |
| table.scan().where("id <= 3").select("image").export_lance(str(path)) | |
| assert lance.dataset(str(path)).schema.names == ["image"] | |
| rows = _rows(path) | |
| assert len(rows) == 3 | |
| assert {row["image"] for row in rows} == {b"one", b"", None} | |
| def test_export_lance_materializes_map_blobs_as_ordered_key_value_entries(conn, tmp_path): | |
| table = _table(conn, "map_blobs", { | |
| "id": pa.int32(), "image": pa.large_binary(), | |
| "assets": pa.map_(pa.string(), pa.large_binary()), | |
| "counts": pa.map_(pa.string(), pa.int32()), | |
| }, [ | |
| {"id": 1, "image": b"preview", "assets": [ | |
| ("last", b"last"), ("empty", b""), ("null", None), ("first", b"first"), | |
| ], "counts": [("z", 3), ("a", 1)]}, | |
| {"id": 2, "image": b"", "assets": None, "counts": None}, | |
| {"id": 3, "image": None, "assets": [], "counts": []}, | |
| ]) | |
| path = tmp_path / "maps.lance" | |
| table.scan().select(["assets", "id", "counts", "image"]).export_lance(str(path)) | |
| shutil.rmtree(str(tmp_path / "warehouse")) | |
| reopened = lance.dataset(str(path)) | |
| assert reopened.schema.names == ["assets", "id", "counts", "image"] | |
| assert reopened.schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| for name, value_type in (("assets", pa.large_binary()), ("counts", pa.int32())): | |
| dtype = reopened.schema.field(name).type | |
| assert pa.types.is_list(dtype) | |
| assert [field.name for field in dtype.value_type] == ["key", "value"] | |
| assert dtype.value_type.field("value").type == value_type | |
| assert b"lance-encoding:blob" not in (reopened.schema.field(name).metadata or {}) | |
| assert b"lance-encoding:blob" not in (dtype.value_type.field("value").metadata or {}) | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == [ | |
| {"id": 1, "image": b"preview", "assets": [ | |
| {"key": "last", "value": b"last"}, {"key": "empty", "value": b""}, | |
| {"key": "null", "value": None}, {"key": "first", "value": b"first"}, | |
| ], "counts": [{"key": "z", "value": 3}, {"key": "a", "value": 1}]}, | |
| {"id": 2, "image": b"", "assets": None, "counts": None}, | |
| {"id": 3, "image": None, "assets": [], "counts": []}, | |
| ] | |
| def test_export_lance_converts_maps_nested_in_rows_and_arrays(conn, tmp_path): | |
| map_type = pa.map_(pa.string(), pa.int32()) | |
| row_type = pa.struct([pa.field("label", pa.string()), pa.field("counts", map_type)]) | |
| table = _table(conn, "nested_maps", { | |
| "id": pa.int32(), "metadata": row_type, "groups": pa.list_(map_type), | |
| }) | |
| rows = [ | |
| {"id": 1, "metadata": {"label": "first", "counts": [("z", 3), ("a", 1)]}, | |
| "groups": [[("b", 2)], None, []]}, | |
| {"id": 2, "metadata": None, "groups": None}, | |
| {"id": 3, "metadata": {"label": None, "counts": None}, "groups": []}, | |
| ] | |
| table.add(pa.Table.from_pylist(rows, schema=pa.schema([ | |
| pa.field("id", pa.int32()), pa.field("metadata", row_type), pa.field("groups", pa.list_(map_type)), | |
| ]))) | |
| path = tmp_path / "nested_maps.lance" | |
| table.scan().export_lance(str(path)) | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == [ | |
| {"id": 1, "metadata": {"label": "first", "counts": [ | |
| {"key": "z", "value": 3}, {"key": "a", "value": 1}, | |
| ]}, "groups": [[{"key": "b", "value": 2}], None, []]}, | |
| {"id": 2, "metadata": None, "groups": None}, | |
| {"id": 3, "metadata": {"label": None, "counts": None}, "groups": []}, | |
| ] | |
| @pytest.mark.parametrize("initial_rows", [[], [{"id": 1}]]) | |
| def test_export_lance_pins_snapshot_before_concurrent_commit(conn, tmp_path, initial_rows): | |
| from pypaimon.snapshot.time_travel_util import TimeTravelUtil | |
| table = _table(conn, "concurrent", {"id": pa.int32()}, initial_rows or None) | |
| query = table.scan() | |
| path = tmp_path / "concurrent.lance" | |
| resolve_snapshot = TimeTravelUtil.resolve_snapshot | |
| committed = [] | |
| def commit_after_snapshot(read_table): | |
| snapshot = resolve_snapshot(read_table) | |
| if not committed: | |
| committed.append(True) | |
| table.add([{"id": 2}]) | |
| return snapshot | |
| with patch.object(TimeTravelUtil, "resolve_snapshot", side_effect=commit_after_snapshot): | |
| query.export_lance(str(path)) | |
| assert _rows(path) == initial_rows | |
| expected = initial_rows + [{"id": 2}] | |
| assert sorted(query.to_list(), key=lambda row: row["id"]) == expected | |
| assert sorted(table.scan().to_list(), key=lambda row: row["id"]) == expected | |
| def test_export_lance_preserves_requested_row_ids(conn, tmp_path): | |
| table = _table(conn, "row_ids", {"id": pa.int32(), "image": pa.large_binary()}, [ | |
| {"id": 1, "image": b"one"}, {"id": 2, "image": b"two"}, | |
| ]) | |
| path = tmp_path / "row_ids.lance" | |
| query = table.scan().where("id = 2").select("image").with_row_id() | |
| row_id = table.scan().where("id = 2").select("id").with_row_id().to_list()[0]["_ROW_ID"] | |
| query.export_lance(str(path)) | |
| assert lance.dataset(str(path)).schema.names == ["image", "_ROW_ID"] | |
| assert lance.dataset(str(path)).schema.field("image").metadata[b"lance-encoding:blob"] == b"true" | |
| assert _rows(path) == [{"image": b"two", "_ROW_ID": row_id}] | |
| def test_export_lance_create_protects_existing_dataset_and_overwrite_replaces_it(conn, tmp_path): | |
| table = _table(conn, "modes", {"id": pa.int32()}, [{"id": 1}, {"id": 2}]) | |
| path = tmp_path / "modes.lance" | |
| table.scan().where("id = 1").export_lance(str(path)) | |
| with pytest.raises((OSError, ValueError), match="(?i)exist|already"): | |
| table.scan().where("id = 2").export_lance(str(path)) | |
| assert _rows(path) == [{"id": 1}] | |
| table.scan().where("id = 2").export_lance(str(path), mode="overwrite") | |
| assert _rows(path) == [{"id": 2}] | |
| @pytest.mark.parametrize("kwargs, message", [ | |
| ({"mode": "append"}, "mode"), | |
| ({"blob_mode": "reference"}, "blob_mode"), | |
| ({"blob_parallelism": 0}, "blob_parallelism"), | |
| ]) | |
| def test_export_lance_rejects_unsupported_options_before_writing(conn, tmp_path, kwargs, message): | |
| table = _table(conn, "invalid_options", {"id": pa.int32()}, [{"id": 1}]) | |
| path = tmp_path / "invalid.lance" | |
| with pytest.raises(ValueError, match=message): | |
| table.scan().export_lance(str(path), **kwargs) | |
| assert not path.exists() | |
| @pytest.mark.parametrize("projection", [[], ["missing"], ["id", "id"]]) | |
| def test_export_lance_rejects_invalid_projection_before_writing(conn, tmp_path, projection): | |
| table = _table(conn, "invalid_projection", {"id": pa.int32()}, [{"id": 1}]) | |
| path = tmp_path / "invalid.lance" | |
| with pytest.raises(ValueError, match="(?i)column|projection|duplicate"): | |
| table.scan().select(projection).export_lance(str(path)) | |
| assert not path.exists() | |
| def test_export_lance_rejects_search_queries(conn, tmp_path): | |
| table = _table(conn, "search", {"id": pa.int32(), "embedding": pa.list_(pa.float32(), 2)}) | |
| queries = [ | |
| table.search([1.0, 0.0], column="embedding"), | |
| table.search_vectors([[1.0, 0.0], [0.0, 1.0]], column="embedding"), | |
| ] | |
| for query in queries: | |
| with pytest.raises(TypeError, match="only supported on scan"): | |
| query.export_lance(str(tmp_path / "search.lance")) | |
| assert not (tmp_path / "search.lance").exists() | |
| def test_export_lance_missing_dependency_has_installation_hint(conn, tmp_path): | |
| table = _table(conn, "missing_dependency", {"id": pa.int32()}, [{"id": 1}]) | |
| path = tmp_path / "missing_dependency.lance" | |
| with patch.dict(sys.modules, {"lance": None}): | |
| with pytest.raises(ImportError, match="pylance|pypaimon\\[lance\\]"): | |
| table.scan().export_lance(str(path)) | |
| assert not path.exists() | |
| def test_export_lance_retained_tag_query_uses_schema_of_resolved_snapshot(conn, tmp_path): | |
| from pypaimon.schema.data_types import AtomicType | |
| from pypaimon.schema.schema_change import SchemaChange | |
| table = _table(conn, "moved_tag", {"id": pa.int32(), "text": pa.string()}, [ | |
| {"id": 1, "text": "before"}, | |
| ]) | |
| table.raw_table.create_tag("training") | |
| query = table.scan(tag_name="training") | |
| conn.catalog.alter_table("default.moved_tag", [ | |
| SchemaChange.add_column("note", AtomicType("STRING")), | |
| ]) | |
| current = conn.get_table("moved_tag") | |
| current.add([{"id": 2, "text": "after", "note": "new-schema"}]) | |
| current.raw_table.replace_tag("training") | |
| path = tmp_path / "moved_tag.lance" | |
| query.export_lance(str(path)) | |
| assert lance.dataset(str(path)).schema.names == ["id", "text", "note"] | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == [ | |
| {"id": 1, "text": "before", "note": None}, | |
| {"id": 2, "text": "after", "note": "new-schema"}, | |
| ] | |
| def test_export_lance_rejects_video_frame_but_allows_projection(conn, tmp_path): | |
| table = _table(conn, "video", {"id": pa.int32(), "video": pa.large_binary()}, | |
| options={"video-frame-field": "video"}) | |
| table.add([{"id": 1, "video": None}]) | |
| path = tmp_path / "video.lance" | |
| with pytest.raises((ValueError, NotImplementedError), match="(?i)video"): | |
| table.scan().export_lance(str(path)) | |
| assert not path.exists() | |
| table.scan().select("id").export_lance(str(path)) | |
| assert _rows(path) == [{"id": 1}] | |
| def test_export_lance_rejects_variant_but_allows_projection(conn, tmp_path): | |
| from pypaimon.data.generic_variant import GenericVariant | |
| values = GenericVariant.to_arrow_array([GenericVariant.from_python({"answer": 42})]) | |
| table = _table(conn, "variant", {"id": pa.int32(), "details": values.type}) | |
| table.add(pa.Table.from_arrays([pa.array([1], type=pa.int32()), values], names=["id", "details"])) | |
| path = tmp_path / "variant.lance" | |
| with pytest.raises((ValueError, NotImplementedError), match="(?i)variant"): | |
| table.scan().export_lance(str(path)) | |
| assert not path.exists() | |
| table.scan().select("id").export_lance(str(path)) | |
| assert _rows(path) == [{"id": 1}] | |
| @pytest.mark.python_read | |
| @pytest.mark.parametrize("failure", ["read", "write", "blob"]) | |
| def test_export_lance_closes_source_on_failure(conn, tmp_path, failure): | |
| from pypaimon.execution.python.read.table_read import PythonTableRead | |
| from pypaimon.multimodal import lance_export | |
| table = _table(conn, "failure", {"id": pa.int32(), "image": pa.large_binary()}, [ | |
| {"id": i, "image": b"body"} for i in range(3) | |
| ]) | |
| original = PythonTableRead._arrow_batch_generator | |
| fetch_blob_bodies = lance_export.fetch_blob_bodies | |
| closed = [] | |
| def tracked_read(read, *args, **kwargs): | |
| reader = original(read, *args, **kwargs) | |
| try: | |
| for batch in reader: | |
| yield batch | |
| if failure == "read": | |
| raise RuntimeError("source read failed") | |
| finally: | |
| reader.close() | |
| closed.append(True) | |
| def failed_write(data, *args, **kwargs): | |
| next(iter(data)) | |
| raise RuntimeError("destination write failed") | |
| def fetch(*args, **kwargs): | |
| if failure == "blob": | |
| raise OSError("payload fetch failed") | |
| return fetch_blob_bodies(*args, **kwargs) | |
| write = failed_write if failure == "write" else lance.write_dataset | |
| expected_error = {"read": "source read failed", "write": "destination write failed", "blob": "payload fetch failed"} | |
| with patch("pypaimon.read.read_dispatch._native_read_execution", return_value=None), \ | |
| patch.object(PythonTableRead, "_arrow_batch_generator", tracked_read), \ | |
| patch.object(lance_export, "fetch_blob_bodies", side_effect=fetch), \ | |
| patch.object(lance, "write_dataset", write): | |
| try: | |
| table.scan().export_lance(str(tmp_path / "failure.lance")) | |
| except Exception as error: | |
| assert expected_error[failure] in str(error) | |
| # Check while the traceback still retains the generator, ruling out | |
| # closure that only happens later through garbage collection. | |
| assert closed == [True] | |
| else: | |
| pytest.fail("Expected export to propagate its read/write failure") | |
| @pytest.mark.parametrize("mode", ["create", "overwrite"]) | |
| def test_export_lance_failed_later_blob_batch_never_publishes_partial_dataset(conn, tmp_path, mode): | |
| from pypaimon.multimodal import lance_export | |
| table = _table(conn, "partial_failure", {"id": pa.int32(), "image": pa.large_binary()}, [ | |
| {"id": i, "image": ("image-%s" % i).encode()} for i in range(5) | |
| ]) | |
| path = tmp_path / "partial.lance" | |
| previous = pa.table({"id": [99], "image": [b"previous"]}, schema=pa.schema([ | |
| pa.field("id", pa.int32()), | |
| pa.field("image", pa.large_binary(), metadata={b"lance-encoding:blob": b"true"}), | |
| ])) | |
| if mode == "overwrite": | |
| lance.write_dataset(previous, str(path), data_storage_version="2.1") | |
| fetch_blob_bodies = lance_export.fetch_blob_bodies | |
| write_dataset = lance.write_dataset | |
| fetched = [] | |
| def fetch(*args, **kwargs): | |
| if fetched: | |
| raise OSError("BLOB unavailable in a later batch") | |
| fetched.append(True) | |
| return fetch_blob_bodies(*args, **kwargs) | |
| def write(*args, **kwargs): | |
| # Exercise Lance's real writer with small file limits and a later | |
| # payload failure, checking only whether a dataset was published. | |
| return write_dataset(*args, max_rows_per_file=1, max_rows_per_group=1, **kwargs) | |
| with patch.object(lance_export, "fetch_blob_bodies", side_effect=fetch), \ | |
| patch.object(lance, "write_dataset", side_effect=write): | |
| with pytest.raises(OSError, match="BLOB unavailable in a later batch"): | |
| table.scan().export_lance(str(path), mode=mode) | |
| if mode == "overwrite": | |
| assert lance.dataset(str(path)).schema.equals(previous.schema, check_metadata=True) | |
| assert _rows(path) == previous.to_pylist() | |
| else: | |
| with pytest.raises(ValueError): | |
| lance.dataset(str(path)) | |
| @pytest.mark.python_plan | |
| @pytest.mark.parametrize("view", ["latest", "moved_tag"]) | |
| def test_export_lance_rebinds_predicates_after_schema_reordering(conn, tmp_path, view): | |
| from copy import deepcopy | |
| from pypaimon.schema.data_types import AtomicType | |
| from pypaimon.schema.schema_change import Move, SchemaChange | |
| table = conn.create_table( | |
| "reordered", schema=pa.schema([ | |
| pa.field("part", pa.int32()), pa.field("id", pa.int32()), | |
| pa.field("value", pa.int32()), | |
| ]), data=[{"part": 99, "id": 1, "value": 20}], | |
| partitioned=["part", "id"], options={"file.format": "parquet"}) | |
| if view == "moved_tag": | |
| table.raw_table.create_tag("training") | |
| query = table.scan(**({"tag_name": "training"} if view == "moved_tag" else {})) | |
| query.where("id = 1 AND (value >= 20 OR part = 3)").select(["value", "id"]) | |
| original_predicate = deepcopy(query._predicate) | |
| conn.catalog.alter_table("default.reordered", [ | |
| SchemaChange.add_column("pad", AtomicType("INT"), move=Move.first("pad")), | |
| ]) | |
| current = conn.get_table("reordered") | |
| current.add([ | |
| {"pad": 999, "part": 99, "id": 1, "value": 30}, | |
| {"pad": 999, "part": 99, "id": 1, "value": 5}, | |
| {"pad": 999, "part": 99, "id": 2, "value": 40}, | |
| ]) | |
| if view == "moved_tag": | |
| current.raw_table.replace_tag("training") | |
| path = tmp_path / (view + ".lance") | |
| query.export_lance(str(path)) | |
| assert query._predicate == original_predicate | |
| assert [field.name for field in query._table.fields] == ["part", "id", "value"] | |
| assert lance.dataset(str(path)).schema.names == ["value", "id"] | |
| assert sorted(_rows(path), key=lambda row: row["value"]) == [ | |
| {"value": 20, "id": 1}, {"value": 30, "id": 1}, | |
| ] | |
| def test_export_lance_rebinds_row_id_predicate_after_schema_reordering(conn, tmp_path): | |
| from copy import deepcopy | |
| from pypaimon.schema.data_types import AtomicType | |
| from pypaimon.schema.schema_change import Move, SchemaChange | |
| table = _table(conn, "reordered_row_ids", {"id": pa.int32()}, [{"id": 1}]) | |
| row_id = table.scan().with_row_id().to_list()[0]["_ROW_ID"] | |
| query = table.take_row_ids([row_id]).select("id").with_row_id() | |
| original_predicate = deepcopy(query._predicate) | |
| conn.catalog.alter_table("default.reordered_row_ids", [ | |
| SchemaChange.add_column("pad", AtomicType("INT"), move=Move.first("pad")), | |
| ]) | |
| conn.get_table("reordered_row_ids").add([{"pad": 999, "id": 2}]) | |
| path = tmp_path / "reordered_row_ids.lance" | |
| query.export_lance(str(path)) | |
| assert query._predicate == original_predicate | |
| assert _rows(path) == [{"id": 1, "_ROW_ID": row_id}] | |
| def test_export_lance_allows_scalar_alias_matching_unselected_video_column(conn, tmp_path): | |
| table = _table(conn, "video_alias", { | |
| "id": pa.int32(), "video": pa.struct([pa.field("frame", pa.int32())]), | |
| "video_frame": pa.large_binary(), | |
| }, [ | |
| {"id": 1, "video": {"frame": 7}, "video_frame": None}, | |
| {"id": 2, "video": {"frame": None}, "video_frame": None}, | |
| {"id": 3, "video": None, "video_frame": None}, | |
| ], options={"video-frame-field": "video_frame"}) | |
| query = table.scan().select(["id", "video.frame"]) | |
| expected = [ | |
| {"id": 1, "video_frame": 7}, | |
| {"id": 2, "video_frame": None}, | |
| {"id": 3, "video_frame": None}, | |
| ] | |
| assert sorted(query.to_list(), key=lambda row: row["id"]) == expected | |
| path = tmp_path / "video_alias.lance" | |
| query.export_lance(str(path)) | |
| reopened = lance.dataset(str(path)) | |
| assert reopened.schema.names == ["id", "video_frame"] | |
| assert reopened.schema.field("video_frame").type == pa.int32() | |
| assert sorted(_rows(path), key=lambda row: row["id"]) == expected |