This is an automated email from the ASF dual-hosted git repository.
YannByron pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 99661104ad [python][daft] Read non-blob columns of blob tables via the
native parquet reader (#8619)
99661104ad is described below
commit 99661104ad7f266ead18026f446831292634f0dd
Author: Kerwin Zhang <[email protected]>
AuthorDate: Thu Jul 16 17:22:06 2026 +0800
[python][daft] Read non-blob columns of blob tables via the native parquet
reader (#8619)
---
paimon-python/pypaimon/daft/daft_datasource.py | 168 ++++++++-
paimon-python/pypaimon/read/explain.py | 4 +
paimon-python/pypaimon/read/read_builder.py | 1 +
.../tests/daft/daft_blob_native_read_test.py | 375 +++++++++++++++++++++
4 files changed, 535 insertions(+), 13 deletions(-)
diff --git a/paimon-python/pypaimon/daft/daft_datasource.py
b/paimon-python/pypaimon/daft/daft_datasource.py
index ccb98c1140..10c2a51cf9 100644
--- a/paimon-python/pypaimon/daft/daft_datasource.py
+++ b/paimon-python/pypaimon/daft/daft_datasource.py
@@ -324,6 +324,78 @@ def _cast_blob_columns_to_file(
return RecordBatch.from_pydict(columns)
+def _blob_native_covering_files(
+ files: list[DataFileMeta],
+ task_columns: list[str],
+ blob_column_names: set[str],
+ partition_keys: list[str],
+) -> list[DataFileMeta] | None:
+ """Return the parquet files that can serve a blob-table split via Daft's
+ native reader, or ``None`` if the split must use the pypaimon fallback.
+
+ A blob table stores each column bunch in its own file: scalar columns in
+ parquet, BLOB / ARRAY<BLOB> columns in ``.blob`` files, vector columns in
+ ``.vector`` files, aligned by row id. Reading the base parquet files
+ natively is only correct when every projected data column lives in parquet
+ files that each fully cover the projection over disjoint row-id ranges --
+ i.e. no blob / vector file carries a projected column, no cross-file field
+ merge is required, and no two covering files overlap. Partition columns are
+ path-derived, so they are excluded from file coverage. File-name
+ conventions mirror ``DataFileMeta.is_blob_file`` / ``is_vector_file``.
+ """
+ partitions = set(partition_keys)
+ projected = {c for c in task_columns if c not in partitions}
+ if projected & set(blob_column_names):
+ return None
+
+ covering: list[DataFileMeta] = []
+ for f in files:
+ name = f.file_name
+ write_cols = set(f.write_cols or [])
+ carried = write_cols & projected
+ if name.endswith(".blob") or ".vector." in name:
+ if carried:
+ return None # a projected column lives in a blob/vector bunch
+ continue
+ if not name.endswith(".parquet"):
+ return None # unknown bunch format; stay on the safe fallback path
+ if projected <= write_cols:
+ covering.append(f)
+ elif carried:
+ return None # partial coverage -> cross-file field merge required
+ # else: parquet file irrelevant to the projection -> skip it
+
+ if not covering:
+ return None
+
+ # The covering parquet files must not overlap (else rows are duplicated)
+ # and together must span every row-id range present in the split. Otherwise
+ # a row-id range whose projected column is absent from any covering file --
+ # e.g. an older data-evolution range written before the column existed --
+ # would be silently dropped here, whereas the pypaimon fallback returns
+ # those rows with the column read as null via schema evolution.
+ covering_ranges = []
+ for f in covering:
+ if f.first_row_id is None:
+ return None
+ covering_ranges.append((f.first_row_id, f.first_row_id + f.row_count))
+ covering_ranges.sort()
+ merged: list[tuple[int, int]] = []
+ for start, end in covering_ranges:
+ if merged and start < merged[-1][1]:
+ return None
+ if merged and start == merged[-1][1]:
+ merged[-1] = (merged[-1][0], end) # adjacent -> extend
+ else:
+ merged.append((start, end))
+
+ for f in files:
+ start, end = f.first_row_id, f.first_row_id + f.row_count
+ if not any(ms <= start and end <= me for ms, me in merged):
+ return None # a present row-id range is not covered -> would drop
rows
+ return covering
+
+
class PaimonDataSource(DataSource):
"""DataSource for Apache Paimon tables.
@@ -528,22 +600,36 @@ class PaimonDataSource(DataSource):
if self._partition_filter_skips_split(split, pushdowns, pv_cache):
continue
+ has_deletion_vectors = self._split_has_deletion_vectors(split)
routing = self._reader_routing(
raw_convertible=split.raw_convertible,
- has_deletion_vectors=self._split_has_deletion_vectors(split),
+ has_deletion_vectors=has_deletion_vectors,
has_auth=self._split_has_auth(split),
)
- if routing.use_native_reader:
+ native_files = (
+ split.files
+ if routing.use_native_reader
+ else self._blob_table_native_files(
+ split.files, read_pushdowns.task_columns,
has_deletion_vectors
+ )
+ )
+
+ if native_files is not None:
+ task_schema = (
+ self._schema
+ if routing.use_native_reader
+ else self._project_schema(read_pushdowns.task_columns)
+ )
pv = None
if self._table.partition_keys:
pv = self._partition_values(split, pv_cache)
- for data_file in split.files:
+ for data_file in native_files:
file_uri =
self._build_file_uri(self._data_file_path(data_file))
yield DataSourceTask.parquet(
path=file_uri,
- schema=self._schema,
+ schema=task_schema,
pushdowns=pushdowns,
num_rows=data_file.row_count,
size_bytes=data_file.file_size,
@@ -597,12 +683,45 @@ class PaimonDataSource(DataSource):
has_deletion_vectors=split.has_deletion_vectors,
has_auth=paimon_scan.has_auth,
)
- if routing.use_native_reader:
+ blob_native_files = (
+ None
+ if routing.use_native_reader
+ else self._blob_table_native_files(
+ getattr(split, "data_files", None) or [],
+ read_pushdowns.task_columns,
+ split.has_deletion_vectors,
+ )
+ )
+
+ # For a blob-native split only the covering parquet files are read
+ # natively; report their counts so the verbose per-split detail
+ # matches the native_parquet_file_count aggregate (the skipped
+ # .blob / .vector files must not appear as natively read). The
+ # Paimon split row_count sums every bunch file, so it double-counts
+ # the same rows across the parquet and .blob bunches; the parquet
+ # reader only returns the covering files' rows.
+ split_file_count = split.file_count
+ split_file_size = split.file_size
+ split_file_paths = split.file_paths
+ split_row_count = split.row_count
+
+ if routing.use_native_reader or blob_native_files is not None:
native_split_count += 1
- native_file_count += split.file_count
+ if blob_native_files is not None:
+ split_file_count = len(blob_native_files)
+ split_file_size = sum(f.file_size for f in
blob_native_files)
+ split_file_paths = [
+ f.file_path for f in blob_native_files if f.file_path
is not None
+ ]
+ split_row_count = sum(f.row_count for f in
blob_native_files)
+ native_file_count += split_file_count
+ reader_mode = READER_MODE_NATIVE_PARQUET
+ fallback_reason = None
else:
fallback_split_count += 1
- fallback_file_count += split.file_count
+ fallback_file_count += split_file_count
+ reader_mode = routing.reader_mode
+ fallback_reason = routing.fallback_reason
reason = routing.fallback_reason or "unknown"
fallback_reasons[reason] = fallback_reasons.get(reason, 0) + 1
@@ -611,12 +730,12 @@ class PaimonDataSource(DataSource):
PaimonReaderSplitExplain(
partition=split.partition,
bucket=split.bucket,
- file_count=split.file_count,
- row_count=split.row_count,
- file_size=split.file_size,
- reader_mode=routing.reader_mode,
- fallback_reason=routing.fallback_reason,
- file_paths=split.file_paths,
+ file_count=split_file_count,
+ row_count=split_row_count,
+ file_size=split_file_size,
+ reader_mode=reader_mode,
+ fallback_reason=fallback_reason,
+ file_paths=split_file_paths,
)
)
@@ -673,6 +792,29 @@ class PaimonDataSource(DataSource):
reason = "data-evolution merge required"
return _ReaderRouting(READER_MODE_PYPAIMON_FALLBACK, reason)
+ def _blob_table_native_files(
+ self,
+ files: list[DataFileMeta],
+ task_columns: list[str] | None,
+ has_deletion_vectors: bool,
+ ) -> list[DataFileMeta] | None:
+ """Files of a blob-table split that can be read via the native parquet
+ reader because no BLOB column is projected, or ``None`` to keep the
+ pypaimon fallback. Only applies to non-PK parquet blob tables without
+ deletion vectors and with an explicit projection."""
+ if (
+ not self._has_blob_columns
+ or not self._is_parquet
+ or has_deletion_vectors
+ or self._table.is_primary_key_table
+ or task_columns is None
+ ):
+ return None
+ blob_column_names = self._scalar_blob_column_names |
self._array_blob_column_names
+ return _blob_native_covering_files(
+ files, task_columns, blob_column_names, self._table.partition_keys
+ )
+
@staticmethod
def _split_has_deletion_vectors(split: Split) -> bool:
deletion_files = getattr(split, "data_deletion_files", None)
diff --git a/paimon-python/pypaimon/read/explain.py
b/paimon-python/pypaimon/read/explain.py
index dc1f0db599..29deae951f 100644
--- a/paimon-python/pypaimon/read/explain.py
+++ b/paimon-python/pypaimon/read/explain.py
@@ -64,6 +64,10 @@ class ExplainSplitInfo:
level_histogram: Dict[int, int]
deletion_file_count: int
file_paths: List[str]
+ # Per-file metadata (DataFileMeta) for consumers that need write_cols /
+ # row-id ranges, e.g. projection-aware reader routing. Optional so callers
+ # constructing ExplainSplitInfo without file objects stay compatible.
+ data_files: Optional[List[Any]] = None
@dataclass
diff --git a/paimon-python/pypaimon/read/read_builder.py
b/paimon-python/pypaimon/read/read_builder.py
index a36b7d81f3..b7eebc2ffd 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -273,6 +273,7 @@ def _build_explain_result(table, scan: TableScan, plan,
stats: ScanStats,
level_histogram=per_split_levels,
deletion_file_count=dv_count_here,
file_paths=list(getattr(split, 'file_paths', []) or []),
+ data_files=list(files),
))
fps_min, fps_max, fps_avg = _min_max_avg(files_per_split)
diff --git a/paimon-python/pypaimon/tests/daft/daft_blob_native_read_test.py
b/paimon-python/pypaimon/tests/daft/daft_blob_native_read_test.py
new file mode 100644
index 0000000000..202361e3ab
--- /dev/null
+++ b/paimon-python/pypaimon/tests/daft/daft_blob_native_read_test.py
@@ -0,0 +1,375 @@
+################################################################################
+# 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 G1: projection-aware native parquet reads on blob tables.
+
+When a query on a blob table does not project any BLOB column, the base
+(non-blob) parquet columns should be read via Daft's native parquet reader
+instead of falling back to the pypaimon Python reader; all cases that touch a
+blob column, need cross-file field merge, or have deletion vectors must keep
+falling back.
+"""
+import os
+import shutil
+import tempfile
+import unittest
+from dataclasses import dataclass
+from typing import List, Optional
+
+import pyarrow as pa
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+daft = pytest.importorskip("daft")
+
+from daft import col
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.daft import explain_paimon_scan, read_paimon
+from pypaimon.daft.daft_compat import has_file_range_reads
+from pypaimon.daft.daft_datasource import (
+ READER_MODE_NATIVE_PARQUET,
+ _blob_native_covering_files,
+)
+
+
+@dataclass
+class _FakeFile:
+ file_name: str
+ write_cols: Optional[List[str]]
+ first_row_id: Optional[int]
+ row_count: int
+
+
+class BlobNativeCoveringFilesTest(unittest.TestCase):
+ """Pure-function safety logic; runs on any installed Daft."""
+
+ BLOBS = {"content"}
+ PARTS: List[str] = []
+
+ def _files(self, *specs):
+ return [_FakeFile(*s) for s in specs]
+
+ def call(self, files, task_columns, blob=None, parts=None):
+ return _blob_native_covering_files(
+ files, task_columns,
+ self.BLOBS if blob is None else blob,
+ self.PARTS if parts is None else parts,
+ )
+
+ def test_scalar_projection_returns_covering_parquet_files(self):
+ files = self._files(
+ ("data-a.parquet", ["id", "name"], 0, 4),
+ ("data-a.blob", ["content"], 0, 4),
+ ("data-b.parquet", ["id", "name"], 4, 4),
+ ("data-b.blob", ["content"], 4, 4),
+ )
+ got = self.call(files, ["id", "name"])
+ self.assertIsNotNone(got)
+ self.assertEqual([f.file_name for f in got], ["data-a.parquet",
"data-b.parquet"])
+
+ def test_blob_column_projected_returns_none(self):
+ files = self._files(
+ ("data-a.parquet", ["id", "name"], 0, 4),
+ ("data-a.blob", ["content"], 0, 4),
+ )
+ self.assertIsNone(self.call(files, ["id", "content"]))
+
+ def test_projected_column_only_in_vector_file_returns_none(self):
+ files = self._files(
+ ("data-a.parquet", ["id"], 0, 4),
+ ("data-a.vector.foo", ["emb"], 0, 4),
+ )
+ self.assertIsNone(self.call(files, ["id", "emb"]))
+
+ def test_field_merge_partial_parquet_coverage_returns_none(self):
+ # id and name live in separate parquet files for the SAME rows ->
merge.
+ files = self._files(
+ ("data-a.parquet", ["id"], 0, 4),
+ ("data-b.parquet", ["name"], 0, 4),
+ )
+ self.assertIsNone(self.call(files, ["id", "name"]))
+
+ def test_missing_first_row_id_returns_none(self):
+ files = self._files(("data-a.parquet", ["id", "name"], None, 4))
+ self.assertIsNone(self.call(files, ["id", "name"]))
+
+ def test_uncovered_row_range_returns_none(self):
+ # An older row-id range's parquet lacks the projected column; those
rows
+ # must still be read (as null) via fallback, not dropped.
+ files = self._files(
+ ("data-a.parquet", ["id"], 0, 4), # rows 0-3: no "name"
+ ("data-a.blob", ["content"], 0, 4),
+ ("data-b.parquet", ["id", "name"], 4, 4), # rows 4-7: has "name"
+ ("data-b.blob", ["content"], 4, 4),
+ )
+ self.assertIsNone(self.call(files, ["name"]))
+
+ def test_overlapping_covering_row_ranges_returns_none(self):
+ files = self._files(
+ ("data-a.parquet", ["id", "name"], 0, 10),
+ ("data-b.parquet", ["id", "name"], 5, 10),
+ )
+ self.assertIsNone(self.call(files, ["name"]))
+
+ def test_covering_files_span_full_split_are_kept(self):
+ # Every row-id range's projected column is in a covering parquet file.
+ files = self._files(
+ ("data-a.parquet", ["id", "name"], 0, 4),
+ ("data-a.blob", ["content"], 0, 4),
+ ("data-b.parquet", ["id", "name"], 4, 4),
+ ("data-b.blob", ["content"], 4, 4),
+ )
+ got = self.call(files, ["name"])
+ self.assertIsNotNone(got)
+ self.assertEqual([f.file_name for f in got], ["data-a.parquet",
"data-b.parquet"])
+
+ def test_partition_column_projection_ignored_for_coverage(self):
+ files = self._files(
+ ("data-a.parquet", ["id", "name"], 0, 4),
+ ("data-a.blob", ["content"], 0, 4),
+ )
+ got = self.call(files, ["dt", "id", "name"], parts=["dt"])
+ self.assertIsNotNone(got)
+ self.assertEqual([f.file_name for f in got], ["data-a.parquet"])
+
+
[email protected](has_file_range_reads(), "installed Daft lacks File range
reads")
+class DaftBlobNativeReadE2ETest(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ self.catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")}
+ self.catalog = CatalogFactory.create(self.catalog_options)
+ self.catalog.create_database("default", True)
+ self.table = "default.blob_native"
+ self.pa_schema = pa.schema([
+ ("id", pa.int32()), ("name", pa.string()), ("content",
pa.large_binary())])
+ self.catalog.create_table(self.table, Schema.from_pyarrow_schema(
+ self.pa_schema,
+ options={"row-tracking.enabled": "true", "data-evolution.enabled":
"true"}),
+ False)
+ self.payloads = [os.urandom(64) for _ in range(6)]
+ t = self.catalog.get_table(self.table)
+ w = t.new_batch_write_builder().new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(6)), "name": [f"n{i}" for i in range(6)],
+ "content": self.payloads}, schema=self.pa_schema))
+ t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
+ w.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.tempdir, ignore_errors=True)
+
+ def test_scalar_projection_uses_native_reader(self):
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["id", "name"],
verbose=True)
+ self.assertGreater(result.native_parquet_split_count, 0)
+ self.assertEqual(result.pypaimon_fallback_split_count, 0)
+ self.assertTrue(
+ all(s.reader_mode == READER_MODE_NATIVE_PARQUET for s in
result.splits))
+
+ def test_verbose_split_detail_excludes_blob_files(self):
+ # A blob-native split reads only its parquet files: the verbose
per-split
+ # detail must not count/list the skipped .blob files, and must agree
with
+ # the native_parquet_file_count aggregate.
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["id", "name"],
verbose=True)
+ native = [s for s in result.splits if s.reader_mode ==
READER_MODE_NATIVE_PARQUET]
+ self.assertTrue(native)
+ self.assertEqual(
+ sum(s.file_count for s in result.splits),
result.native_parquet_file_count)
+ for s in native:
+ self.assertEqual(s.file_count, len(s.file_paths))
+ self.assertTrue(all(p.endswith(".parquet") for p in s.file_paths))
+
+ def test_verbose_split_row_count_matches_native_rows(self):
+ # The Paimon split row_count double-counts rows across the parquet and
+ # .blob bunches; the per-split detail must report the rows the parquet
+ # reader actually returns, matching the collected row count.
+ actual_rows = len(read_paimon(self.table, self.catalog_options)
+ .select(col("id"), col("name")).to_pylist())
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["id", "name"],
verbose=True)
+ native = [s for s in result.splits if s.reader_mode ==
READER_MODE_NATIVE_PARQUET]
+ self.assertEqual(sum(s.row_count for s in native), actual_rows)
+
+ def test_scalar_projection_results_correct(self):
+ out = (read_paimon(self.table, self.catalog_options)
+ .select(col("id"), col("name")).to_pylist())
+ got = {r["id"]: r["name"] for r in out}
+ self.assertEqual(got, {i: f"n{i}" for i in range(6)})
+
+ def test_blob_projection_falls_back(self):
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["id", "content"],
verbose=True)
+ self.assertEqual(result.native_parquet_split_count, 0)
+ self.assertGreater(result.pypaimon_fallback_split_count, 0)
+ self.assertIn("blob columns present", result.fallback_reasons)
+
+ def test_no_projection_falls_back(self):
+ result = explain_paimon_scan(self.table, self.catalog_options,
verbose=True)
+ self.assertEqual(result.native_parquet_split_count, 0)
+ self.assertGreater(result.pypaimon_fallback_split_count, 0)
+
+ def test_multi_append_concat_native_and_correct(self):
+ # A second append packs another scalar parquet + blob into the split
+ # (raw_convertible=False, disjoint row-id ranges). Selecting scalars
must
+ # still go native and concatenate both parquet bunches correctly.
+ t = self.catalog.get_table(self.table)
+ w = t.new_batch_write_builder().new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(6, 10)), "name": [f"n{i}" for i in range(6, 10)],
+ "content": [os.urandom(64) for _ in range(4)]},
schema=self.pa_schema))
+ t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
+ w.close()
+
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["id", "name"],
verbose=True)
+ self.assertGreater(result.native_parquet_split_count, 0)
+ self.assertEqual(result.pypaimon_fallback_split_count, 0)
+
+ out = (read_paimon(self.table, self.catalog_options)
+ .select(col("id"), col("name")).to_pylist())
+ got = {r["id"]: r["name"] for r in out}
+ self.assertEqual(got, {i: f"n{i}" for i in range(10)})
+
+
[email protected](has_file_range_reads(), "installed Daft lacks File range
reads")
+class DaftBlobSchemaEvolutionReadTest(unittest.TestCase):
+ """A blob-table split whose older row-id range was written without the
+ projected column must not be read natively (rows would be dropped); the
+ fallback returns those rows with the column as null."""
+
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ self.catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")}
+ self.catalog = CatalogFactory.create(self.catalog_options)
+ self.catalog.create_database("default", True)
+ self.table = "default.blob_evo"
+ self.pa_schema = pa.schema([
+ ("id", pa.int32()), ("name", pa.string()), ("content",
pa.large_binary())])
+ self.catalog.create_table(self.table, Schema.from_pyarrow_schema(
+ self.pa_schema,
+ options={"row-tracking.enabled": "true", "data-evolution.enabled":
"true"}),
+ False)
+ t = self.catalog.get_table(self.table)
+ wb = t.new_batch_write_builder()
+
+ # rows 0-3: id + content only (no name), first_row_id = 0
+ w = wb.new_write().with_write_type(["id", "content"])
+ c = wb.new_commit()
+ w.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(4)), "content": [os.urandom(16) for _ in
range(4)]},
+ schema=pa.schema([("id", pa.int32()), ("content",
pa.large_binary())])))
+ cmts = w.prepare_commit()
+ for m in cmts:
+ for nf in m.new_files:
+ nf.first_row_id = 0
+ c.commit(cmts)
+ w.close()
+ c.close()
+
+ # rows 4-7: id + name + content, first_row_id = 4
+ w = wb.new_write().with_write_type(["id", "name", "content"])
+ c = wb.new_commit()
+ w.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(4, 8)), "name": [f"n{i}" for i in range(4, 8)],
+ "content": [os.urandom(16) for _ in range(4)]},
schema=self.pa_schema))
+ cmts = w.prepare_commit()
+ for m in cmts:
+ for nf in m.new_files:
+ nf.first_row_id = 4
+ c.commit(cmts)
+ w.close()
+ c.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.tempdir, ignore_errors=True)
+
+ def _pypaimon_names(self, projection):
+ t = self.catalog.get_table(self.table).copy({"blob-as-descriptor":
"true"})
+ rb = t.new_read_builder().with_projection(projection)
+ rows =
rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist()
+ return sorted((r["name"] for r in rows), key=lambda v: (v is not None,
v))
+
+ def test_projecting_evolved_only_column_falls_back(self):
+ # A covering parquet file exists only for the newer row-id range, so
the
+ # native path cannot span the whole split and must fall back.
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["name"], verbose=True)
+ self.assertEqual(result.native_parquet_split_count, 0)
+ self.assertGreater(result.pypaimon_fallback_split_count, 0)
+
+ def test_projecting_evolved_only_column_matches_pypaimon(self):
+ # Transparency contract: the Daft result must equal the pypaimon
reader's
+ # result for the same projection (native must never diverge from
fallback).
+ daft_names = sorted(
+ (r["name"] for r in read_paimon(self.table, self.catalog_options)
+ .select(col("name")).to_pylist()),
+ key=lambda v: (v is not None, v))
+ self.assertEqual(daft_names, self._pypaimon_names(["name"]))
+
+ def test_projecting_stable_and_evolved_columns_matches_pypaimon(self):
+ # Projecting id (present in every range) + name: pypaimon spans all
rows
+ # with name null for the old range; the native path must match it.
+ daft_rows = read_paimon(self.table, self.catalog_options).select(
+ col("id"), col("name")).to_pylist()
+ daft_names = sorted((r["name"] for r in daft_rows), key=lambda v: (v
is not None, v))
+ self.assertEqual(daft_names, self._pypaimon_names(["id", "name"]))
+ self.assertEqual(len(daft_rows), 8)
+
+
[email protected](has_file_range_reads(), "installed Daft lacks File range
reads")
+class DaftPartitionedBlobNativeReadTest(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ self.catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")}
+ self.catalog = CatalogFactory.create(self.catalog_options)
+ self.catalog.create_database("default", True)
+ self.table = "default.blob_native_part"
+ self.pa_schema = pa.schema([
+ ("dt", pa.string()), ("id", pa.int32()), ("content",
pa.large_binary())])
+ schema = Schema.from_pyarrow_schema(
+ self.pa_schema,
+ partition_keys=["dt"],
+ options={"row-tracking.enabled": "true", "data-evolution.enabled":
"true"})
+ self.catalog.create_table(self.table, schema, False)
+ t = self.catalog.get_table(self.table)
+ w = t.new_batch_write_builder().new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"dt": ["a", "a", "b"], "id": [1, 2, 3],
+ "content": [os.urandom(32) for _ in range(3)]},
schema=self.pa_schema))
+ t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
+ w.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.tempdir, ignore_errors=True)
+
+ def test_scalar_projection_native_with_partition_values(self):
+ result = explain_paimon_scan(
+ self.table, self.catalog_options, columns=["dt", "id"],
verbose=True)
+ self.assertGreater(result.native_parquet_split_count, 0)
+ self.assertEqual(result.pypaimon_fallback_split_count, 0)
+
+ out = (read_paimon(self.table, self.catalog_options)
+ .select(col("dt"), col("id")).to_pylist())
+ got = {r["id"]: r["dt"] for r in out}
+ self.assertEqual(got, {1: "a", 2: "a", 3: "b"})
+
+
+if __name__ == "__main__":
+ unittest.main()