This is an automated email from the ASF dual-hosted git repository.
JingsongLi 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 1c5743f632 [python] Add scan().read_blobs / stream_blobs for
concurrent blob reads (#8439)
1c5743f632 is described below
commit 1c5743f632aa3821ccde515a6845ec027c1d18c3
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Jul 3 21:47:59 2026 +0800
[python] Add scan().read_blobs / stream_blobs for concurrent blob reads
(#8439)
---
docs/docs/pypaimon/multimodal-api.mdx | 51 ++++
paimon-python/pypaimon/multimodal/query.py | 172 ++++++++++++-
.../pypaimon/tests/multimodal_table_test.py | 282 +++++++++++++++++++++
3 files changed, 504 insertions(+), 1 deletion(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index ebd1427367..b2b63ab19f 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -332,6 +332,57 @@ result = (
)
```
+### Reading BLOB columns
+
+`scan().read_blobs(column)` bulk-fetches a BLOB column's bytes for the filtered
+rows using concurrent, same-file coalesced ranged reads. This is much faster
than
+a per-row loop and avoids the slow row-by-row blob resolution on data-evolution
+tables. It returns `(scalar_table, {column: [bytes | None]})`, row-aligned; the
+scalar table drops all BLOB columns.
+
+```python
+scalar, blobs = (
+ docs.scan()
+ .where("category = 'lake'")
+ .read_blobs("image", parallelism=32)
+)
+images = blobs["image"] # list[bytes | None], aligned with scalar rows
+```
+
+`where()` takes SQL-like strings only, so filter by a list of keys with `IN
(...)`:
+
+```python
+ids = ["a", "b", "c"]
+in_clause = ", ".join(f"'{i}'" for i in ids)
+scalar, blobs = docs.scan().where(f"id IN ({in_clause})").read_blobs("image")
+```
+
+`where()` is a SQL string, so build the `IN (...)` clause only from trusted,
+already-escaped ids -- do not interpolate untrusted external input.
+
+Read several BLOB columns at once (fetched together, not column-by-column):
+
+```python
+scalar, blobs = docs.scan().where("category = 'lake'").read_blobs(["image",
"audio"])
+```
+
+For a memory-bounded read (e.g. streaming into a trainer), `stream_blobs`
yields
+one batch at a time, so peak memory is a single batch rather than the whole
result:
+
+```python
+for scalar, blobs in docs.scan().where("category =
'lake'").stream_blobs("image"):
+ for img in blobs["image"]:
+ ... # feed the trainer, then drop the batch
+```
+
+Notes:
+
+- `parallelism` ~16–32 is the sweet spot over the public network; go higher on
an
+ internal/VPC endpoint.
+- Fastest when the matching rows are contiguous (bulk/range reads coalesce
into a
+ few large reads); scattered point reads coalesce less.
+- Blob reads are available only on `scan()`, not on the `search()` queries.
+
## Row IDs
Multimodal tables enable `row-tracking.enabled` by default, so each row has a
diff --git a/paimon-python/pypaimon/multimodal/query.py
b/paimon-python/pypaimon/multimodal/query.py
index 86012b9608..0ea45b7df5 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -15,7 +15,9 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Callable, List, Optional
+from typing import Callable, Dict, List, Optional, Tuple
+
+import pyarrow as pa
from pypaimon.common.where_parser import parse_where_clause
from pypaimon.table.special_fields import SpecialFields
@@ -98,6 +100,167 @@ class ScanQuery:
def to_list(self) -> List[dict]:
return self.to_arrow().to_pylist()
+ def read_blobs(
+ self, columns=None, *, parallelism: int = 64
+ ) -> Tuple[pa.Table, Dict[str, List[Optional[bytes]]]]:
+ """Materialise BLOB column(s) for the filtered rows with concurrent,
+ coalesced ranged reads. Reads via blob-as-descriptor to skip the slow
+ row-by-row blob resolution on multi-group data-evolution splits.
+
+ ``columns`` picks the BLOB column(s) (default: all, intersected with
+ ``select(...)``). Returns ``(scalar_arrow_table, {column:
[bytes|None]})``,
+ row-aligned. Use :meth:`stream_blobs` for a memory-bounded read.
+
+ Unresolved blob-view columns are not supported and raise
``ValueError``.
+ """
+ blob_cols = self._resolve_blob_columns(columns)
+ read_builder, file_io = self._blob_descriptor_read_builder(blob_cols)
+ arrow = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits())
+ bodies = self._fetch_bodies(
+ file_io, arrow.select(blob_cols).to_pydict(), blob_cols,
parallelism)
+ scalar = arrow.select(self._scalar_columns(arrow.column_names))
+ return scalar, bodies
+
+ def stream_blobs(self, columns=None, *, parallelism: int = 64):
+ """Memory-bounded streaming variant of :meth:`read_blobs`: yield
+ ``(scalar_batch, {column: [bytes|None]})`` per Arrow batch, so peak
memory
+ is one batch rather than the whole result.
+ """
+ # Validate eagerly so a bad column raises here, not on the first
next().
+ blob_cols = self._resolve_blob_columns(columns)
+ read_builder, file_io = self._blob_descriptor_read_builder(blob_cols)
+ return self._iter_blobs(read_builder, file_io, blob_cols, parallelism)
+
+ def _iter_blobs(self, read_builder, file_io, blob_cols, parallelism):
+ reader = read_builder.new_read().to_arrow_batch_reader(
+ read_builder.new_scan().plan().splits())
+ try:
+ for batch in reader:
+ bodies = self._fetch_bodies(
+ file_io, batch.select(blob_cols).to_pydict(), blob_cols,
parallelism)
+ scalar = batch.select(self._scalar_columns(batch.schema.names))
+ yield scalar, bodies
+ finally:
+ # Close the reader even if the caller breaks out early.
+ if hasattr(reader, "close"):
+ reader.close()
+
+ def _blob_descriptor_read_builder(self, blob_cols: List[str]):
+ """Blob-as-descriptor read builder with this query's
filter/projection/limit;
+ returns ``(read_builder, file_io)``."""
+ from pypaimon.common.options.core_options import CoreOptions
+ read_table = self._table.copy({
+ CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
+ })
+ read_builder = read_table.new_read_builder()
+ if self._predicate is not None:
+ read_builder = read_builder.with_filter(self._predicate)
+ projection = self._blob_read_projection(blob_cols)
+ if projection is not None:
+ read_builder = read_builder.with_projection(projection)
+ if self._limit is not None:
+ read_builder = read_builder.with_limit(self._limit)
+ return read_builder, read_table.file_io
+
+ @staticmethod
+ def _fetch_bodies(file_io, data, blob_cols, parallelism):
+ # Decode each descriptor to a (uri, offset, length) range and read
them all in
+ # one coalesced pass on ``file_io`` -- the read table's FileIO, which
already
+ # carries the merged DLF/OSS token. Going through Blob.from_bytes here
would
+ # build a BlobRef whose uri_reader re-resolves the URI via
+ # ``FileIO.get(uri, catalog_options)`` off the raw options (no merged
token),
+ # failing with "endpoint should be non-empty" / "Init credential
failed" unless
+ # the caller also passes fs.oss.* -- which users should not have to.
+ from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
+ ranges = []
+ inline = {} # cell index -> blob stored inline (returned as-is, no
ranged read)
+ i = 0
+ for col in blob_cols:
+ for value in data[col]:
+ if value is None:
+ ranges.append(None)
+ else:
+ raw = bytes(value)
+ if BlobViewStruct.is_blob_view_struct(raw):
+ raise ValueError(
+ "read_blobs does not support unresolved blob-view
columns; "
+ "read such a column on its own, or enable
blob-view resolution.")
+ if BlobDescriptor.is_blob_descriptor(raw):
+ d = BlobDescriptor.deserialize(raw)
+ ranges.append((d.uri, d.offset, d.length))
+ else: # blob stored inline: the bytes are the payload
+ ranges.append(None)
+ inline[i] = raw
+ i += 1
+ fetched = file_io.read_ranges_coalesced(ranges, parallelism)
+ for idx, raw in inline.items():
+ fetched[idx] = raw
+ bodies = {}
+ offset = 0
+ for col in blob_cols:
+ n = len(data[col])
+ bodies[col] = fetched[offset:offset + n]
+ offset += n
+ return bodies
+
+ def _all_blob_columns(self) -> List[str]:
+ return [
+ field.name for field in self._table.fields
+ if getattr(field.type, "type", None) == "BLOB"
+ ]
+
+ def _resolve_blob_columns(self, columns) -> List[str]:
+ all_blob = self._all_blob_columns()
+ if columns is None:
+ selected = all_blob
+ if self._projection is not None:
+ selected = [c for c in all_blob if c in self._projection]
+ if not selected:
+ raise ValueError("No BLOB column to read; pass columns=.")
+ return selected
+ if isinstance(columns, str):
+ columns = [columns]
+ columns = list(dict.fromkeys(columns)) # de-dup, keep order
+ for name in columns:
+ if name not in all_blob:
+ raise ValueError(f"Column {name!r} is not a BLOB column.")
+ return columns
+
+ def _blob_read_projection(self, blob_cols: List[str]) -> List[str]:
+ # Base on _effective_projection() (honours with_row_id), drop BLOB
+ # descriptors, then append the requested BLOB columns and every
predicate
+ # column -- including predicate columns that are themselves BLOB (read
as
+ # descriptor) -- so SplitRead keeps the row-level filter for where().
+ blob_set = set(self._all_blob_columns())
+ effective = self._effective_projection()
+ if effective is None:
+ base = [f.name for f in self._table.fields if f.name not in
blob_set]
+ else:
+ base = [name for name in effective if name not in blob_set]
+ for name in self._predicate_fields():
+ if name not in base and name not in blob_cols:
+ base.append(name)
+ return base + list(blob_cols)
+
+ def _scalar_columns(self, available) -> List[str]:
+ # Non-BLOB columns to expose, based on _effective_projection() so
+ # with_row_id() is honoured; drop BLOBs and predicate-only helpers, and
+ # skip unknown projected names to match to_arrow()'s silent drop.
+ blob_set = set(self._all_blob_columns())
+ effective = self._effective_projection()
+ if effective is None:
+ return [name for name in available if name not in blob_set]
+ available = set(available)
+ return [name for name in effective
+ if name in available and name not in blob_set]
+
+ def _predicate_fields(self):
+ if self._predicate is None:
+ return ()
+ from pypaimon.read.push_down_utils import _get_all_fields
+ return _get_all_fields(self._predicate)
+
def _coerce_predicate(self, predicate, method):
if predicate is None:
return None
@@ -130,6 +293,13 @@ class _PreFilterQuery(ScanQuery):
self._pre_filter = self._and_predicate(self._pre_filter, predicate)
return self
+ # Blob reads bypass the search result_factory, so reject them (else a
plain scan).
+ def read_blobs(self, *args, **kwargs):
+ raise TypeError("read_blobs is only supported on scan(), not search
queries.")
+
+ def stream_blobs(self, *args, **kwargs):
+ raise TypeError("stream_blobs is only supported on scan(), not search
queries.")
+
class VectorQuery(_PreFilterQuery):
"""Chainable query wrapper for vector global-index search."""
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index f880c888e0..d6ed03c969 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -749,6 +749,288 @@ class MultimodalTableTest(unittest.TestCase):
rows,
)
+ def test_scan_read_blobs(self):
+ obs = self.conn.create_table(
+ "obs",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "image": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ payloads = [("blob-%d-" % i).encode() * (i + 1) for i in range(6)]
+ obs.add([
+ {"clip": "c1", "idx": 0, "image": payloads[0]},
+ {"clip": "c1", "idx": 1, "image": payloads[1]},
+ {"clip": "c1", "idx": 2, "image": payloads[2]},
+ {"clip": "c2", "idx": 0, "image": payloads[3]},
+ {"clip": "c2", "idx": 1, "image": payloads[4]},
+ {"clip": "c3", "idx": 0, "image": None},
+ ])
+
+ # scalar table is row-aligned with the blob list and drops the blob
column
+ scalar, blobs = obs.scan().where("clip = 'c1'").read_blobs("image")
+ images = blobs["image"]
+ self.assertEqual(3, len(images))
+ self.assertNotIn("image", scalar.column_names)
+ got = dict(zip(scalar.column("idx").to_pylist(), images))
+ self.assertEqual({0: payloads[0], 1: payloads[1], 2: payloads[2]}, got)
+
+ # blob column auto-detected when columns=None
+ _, blobs2 = obs.scan().where("clip = 'c2'").read_blobs()
+ self.assertEqual({payloads[3], payloads[4]}, set(blobs2["image"]))
+
+ # null blob -> None
+ _, blobs3 = obs.scan().where("clip = 'c3'").read_blobs("image")
+ self.assertEqual([None], blobs3["image"])
+
+ # non-BLOB column is rejected
+ with self.assertRaisesRegex(ValueError, "not a BLOB column"):
+ obs.scan().read_blobs("idx")
+
+ # empty result: 0 matching rows -> empty blob list, schema preserved
+ scalar_e, blobs_e = obs.scan().where("clip =
'none'").read_blobs("image")
+ self.assertEqual(0, scalar_e.num_rows)
+ self.assertEqual([], blobs_e["image"])
+
+ def test_scan_read_blobs_multi_column(self):
+ obs = self.conn.create_table(
+ "multi",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "img": pa.large_binary(),
+ "aud": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ obs.add([
+ {"clip": "c1", "idx": i,
+ "img": ("img-%d" % i).encode(), "aud": ("aud-%d" % i).encode()}
+ for i in range(4)
+ ])
+
+ # both BLOB columns fetched in one call, each row-aligned with the
scalars
+ scalar, blobs = obs.scan().where("clip = 'c1'").read_blobs(["img",
"aud"])
+ self.assertEqual({"img", "aud"}, set(blobs))
+ self.assertNotIn("img", scalar.column_names)
+ self.assertNotIn("aud", scalar.column_names)
+ idx = scalar.column("idx").to_pylist()
+ self.assertEqual({i: ("img-%d" % i).encode() for i in range(4)},
+ dict(zip(idx, blobs["img"])))
+ self.assertEqual({i: ("aud-%d" % i).encode() for i in range(4)},
+ dict(zip(idx, blobs["aud"])))
+
+ # reading only one BLOB column must not leak the other into scalar as
+ # descriptor bytes
+ scalar1, blobs1 = obs.scan().where("clip = 'c1'").read_blobs("img")
+ self.assertEqual({"img"}, set(blobs1))
+ self.assertEqual(["clip", "idx"], scalar1.column_names)
+
+ # columns=None intersects all BLOBs with select() -> only projected
BLOB
+ _, blobs2 = obs.scan().where("clip = 'c1'").select(["clip", "idx",
"img"]).read_blobs()
+ self.assertEqual({"img"}, set(blobs2))
+
+ # duplicate columns are de-duplicated
+ _, blobs3 = obs.scan().where("clip = 'c1'").read_blobs(["img", "img"])
+ self.assertEqual({"img"}, set(blobs3))
+
+ def test_scan_read_blobs_filter_column_not_selected(self):
+ # The row filter must apply even when its column is not in select().
+ obs = self.conn.create_table(
+ "obs",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "image": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ obs.add([
+ {"clip": "c1", "idx": i, "image": ("v-%d" % i).encode()}
+ for i in range(4)
+ ])
+
+ scalar, blobs = (
+ obs.scan().select(["image"]).where("idx = 1").read_blobs("image"))
+ self.assertEqual([b"v-1"], blobs["image"])
+ self.assertNotIn("idx", scalar.schema.names)
+
+ scalar2, blobs2 = (
+ obs.scan().select(["idx", "image"]).where("idx =
2").read_blobs("image"))
+ self.assertEqual([b"v-2"], blobs2["image"])
+ self.assertEqual([2], scalar2.column("idx").to_pylist())
+
+ streamed = []
+ for _, body in (
+ obs.scan().select(["image"]).where("idx =
1").stream_blobs("image")):
+ streamed.extend(body["image"])
+ self.assertEqual([b"v-1"], streamed)
+
+ scalar3, _ = obs.scan().select(["missing",
"image"]).read_blobs("image")
+ self.assertNotIn("missing", scalar3.schema.names)
+
+ def test_scan_read_blobs_with_row_id(self):
+ # with_row_id() must expose _ROW_ID in the scalar table, like
to_arrow().
+ obs = self.conn.create_table(
+ "obs",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "image": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ obs.add([
+ {"clip": "c1", "idx": i, "image": ("v-%d" % i).encode()}
+ for i in range(3)
+ ])
+ row_id = "_ROW_ID"
+
+ expected = {r["idx"]: r[row_id]
+ for r in obs.scan().with_row_id().to_arrow().to_pylist()}
+
+ scalar, blobs = (
+ obs.scan().with_row_id().select(["idx",
"image"]).read_blobs("image"))
+ self.assertIn(row_id, scalar.schema.names)
+ self.assertEqual(expected, dict(zip(scalar.column("idx").to_pylist(),
+
scalar.column(row_id).to_pylist())))
+
+ got = {}
+ for sb, _ in obs.scan().with_row_id().select(["idx",
"image"]).stream_blobs("image"):
+ self.assertIn(row_id, sb.schema.names)
+ got.update(zip(sb.column("idx").to_pylist(),
sb.column(row_id).to_pylist()))
+ self.assertEqual(expected, got)
+
+ def test_scan_read_blobs_where_on_blob_column(self):
+ # A where() on one BLOB column must still filter when a different BLOB
is
+ # read (the predicate BLOB column is read as descriptor for filtering).
+ obs = self.conn.create_table(
+ "obs",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "imga": pa.large_binary(),
+ "imgb": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ obs.add([
+ {"clip": "c1", "idx": 0, "imga": b"a0", "imgb": b"b0"},
+ {"clip": "c1", "idx": 1, "imga": None, "imgb": b"b1"},
+ {"clip": "c1", "idx": 2, "imga": b"a2", "imgb": b"b2"},
+ ])
+
+ scalar, blobs = obs.scan().where("imga IS NOT NULL").read_blobs("imgb")
+ self.assertEqual([b"b0", b"b2"], blobs["imgb"])
+ self.assertNotIn("imga", scalar.schema.names)
+
+ def test_search_query_rejects_blob_reads(self):
+ t = self.conn.create_table(
+ "srch",
+ schema=_schema({
+ "id": pa.int32(),
+ "emb": _vector(3),
+ "img": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ with self.assertRaisesRegex(TypeError, "only supported on scan"):
+ t.search([1.0, 0.0, 0.0], column="emb").read_blobs("img")
+ with self.assertRaisesRegex(TypeError, "only supported on scan"):
+ t.search([1.0, 0.0, 0.0], column="emb").stream_blobs("img")
+
+ def test_scan_stream_blobs(self):
+ obs = self.conn.create_table(
+ "obs",
+ schema=_schema({
+ "clip": pa.string(),
+ "idx": pa.int32(),
+ "image": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ partitioned=["clip"],
+ )
+ payloads = {i: ("s-%d-" % i).encode() * (i + 1) for i in range(5)}
+ obs.add([
+ {"clip": "c1", "idx": i, "image": payloads[i]} for i in range(5)
+ ])
+
+ # collecting every streamed batch must reproduce the full, row-aligned
result
+ got = {}
+ batches = 0
+ for scalar, blobs in obs.scan().where("clip =
'c1'").stream_blobs("image"):
+ batches += 1
+ self.assertNotIn("image", scalar.schema.names)
+ for idx, img in zip(scalar.column("idx").to_pylist(),
blobs["image"]):
+ got[idx] = img
+ self.assertGreaterEqual(batches, 1)
+ self.assertEqual({i: payloads[i] for i in range(5)}, got)
+
+ # bad column is rejected eagerly (not deferred to first iteration)
+ with self.assertRaisesRegex(ValueError, "not a BLOB column"):
+ obs.scan().stream_blobs("idx")
+ # breaking out early closes the reader without error
+ it = obs.scan().where("clip = 'c1'").stream_blobs("image")
+ next(it)
+ it.close()
+ # empty result streams nothing
+ self.assertEqual(
+ [], list(obs.scan().where("clip = 'none'").stream_blobs("image")))
+
+ def test_fetch_bodies_decodes_descriptor_inline_and_null(self):
+ # Cells may be descriptor bytes (incl. -1 read-to-EOF), inline bytes,
or null.
+ from pypaimon.multimodal.query import ScanQuery
+ from pypaimon.common.file_io import FileIO
+ from pypaimon.table.row.blob import BlobDescriptor
+ data = bytes(range(64))
+ path = os.path.join(self.temp_dir, "blob.bin")
+ with open(path, "wb") as f:
+ f.write(data)
+ file_io = FileIO.get("file://" + self.temp_dir, {})
+ cells = [
+ BlobDescriptor(path, 0, -1).serialize(),
+ BlobDescriptor(path, 10, 8).serialize(),
+ b"raw-inline-bytes",
+ None,
+ ]
+ bodies = ScanQuery._fetch_bodies(
+ file_io, {"image": cells}, ["image"], 4)
+ self.assertEqual(
+ [data, data[10:18], b"raw-inline-bytes", None], bodies["image"])
+
+ def test_fetch_bodies_reads_via_file_io_not_uri_reader(self):
+ # Blobs must be read through file_io.read_ranges_coalesced (which
carries the
+ # resolved DLF/OSS token), never via uri_reader_factory -- that would
rebuild a
+ # FileIO from the raw catalog options and fail in DLF data-token mode.
+ from pypaimon.multimodal.query import ScanQuery
+ from pypaimon.table.row.blob import BlobDescriptor
+
+ class _FakeIO:
+ @property
+ def uri_reader_factory(self):
+ raise AssertionError("_fetch_bodies must not use
uri_reader_factory")
+
+ def read_ranges_coalesced(self, ranges, parallelism):
+ return [None if r is None else b"BODY:" + r[0].encode() for r
in ranges]
+
+ cells = [BlobDescriptor("oss://bucket/x", 4, 10).serialize(),
b"inline-blob", None]
+ bodies = ScanQuery._fetch_bodies(_FakeIO(), {"img": cells}, ["img"], 8)
+ self.assertEqual([b"BODY:oss://bucket/x", b"inline-blob", None],
bodies["img"])
+
+ def test_fetch_bodies_rejects_unresolved_blob_view(self):
+ from pypaimon.multimodal.query import ScanQuery
+ from pypaimon.table.row.blob import BlobViewStruct
+ view_bytes = BlobViewStruct("db.tbl", 1, 2).serialize()
+ with self.assertRaisesRegex(ValueError, "blob-view"):
+ ScanQuery._fetch_bodies(None, {"image": [view_bytes]}, ["image"],
4)
+
def test_scan_does_not_expose_pre_filter(self):
users = self.conn.create_table(
"users",