mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-15 07:06:52 +00:00
fix(python): support legacy blob query bytes (#4947)
## Summary - Let local plain queries over row-addressable legacy v1 and v2 blob columns use `blob_mode="bytes"` without importing `pylance` through pandas, batch, or Arrow conversion. - Preserve aliases, filters, ordering, limits, null and empty payloads, empty result schemas, and the caller's `_rowid` choice. - Build the hidden row-id query separately so reusing an async builder for lazy reads or non-blob projections cannot retain an automatic `_rowid`. - Keep byte reads closed for blobs inside lists, remote or namespace pushdown, and query shapes whose result rows cannot be matched back to the source. Blob v2 lazy auto-row-id behavior is unchanged. Fixes #4645 ## Validation - 26 focused Python tests passed with the test subprocess blocking all `lance` imports. The persisted fixture writes and reopens a storage-v2.1 table, then covers sync and async pandas, batches, and Arrow conversion plus async builder reuse. - The existing unsupported vector and hybrid reranker regressions now assert the current eligibility diagnostic, and the legacy table-level Arrow regression now asserts successful bytes, schema, values, and hidden row IDs. Both focused tests passed. - The runtime receipt records no installed `pylance` distribution and no importable `lance` module. The Python-only change was verified with an existing compatible 0.42.0b9 extension; the relevant query and fetch APIs are unchanged between that source and this 0.42.0b10 base. - Ruff format and lint pass for every changed Python file.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Blob fetch API and v2 projection helpers."""
|
||||
"""Blob fetch API and projection helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +13,11 @@ from typing import TYPE_CHECKING, Optional, Union
|
||||
import pyarrow as pa
|
||||
|
||||
from .expr import Expr
|
||||
from .schema import blob_column_paths, row_addressable_blob_v2_paths
|
||||
from .schema import (
|
||||
blob_column_paths,
|
||||
row_addressable_blob_paths,
|
||||
row_addressable_blob_v2_paths,
|
||||
)
|
||||
from .types import BlobMode, QueryProjection, QueryProjectionSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -165,6 +169,25 @@ def blob_v2_projection_sources(
|
||||
return _project_blob_paths(row_addressable_blob_v2_paths(schema), projection)
|
||||
|
||||
|
||||
def blob_projection_sources(
|
||||
schema: pa.Schema,
|
||||
projection: QueryProjection,
|
||||
*,
|
||||
include_legacy: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""Map projected blob outputs to row-addressable source columns.
|
||||
|
||||
Legacy v1 columns are included only when the caller can safely fetch them
|
||||
through the same row-id path as v2.
|
||||
"""
|
||||
paths = (
|
||||
row_addressable_blob_paths(schema)
|
||||
if include_legacy
|
||||
else row_addressable_blob_v2_paths(schema)
|
||||
)
|
||||
return _project_blob_paths(paths, projection)
|
||||
|
||||
|
||||
def reject_unfetchable_blob_columns(
|
||||
schema: pa.Schema, projection: QueryProjection, blob_sources: dict[str, str]
|
||||
) -> None:
|
||||
@@ -177,8 +200,9 @@ def reject_unfetchable_blob_columns(
|
||||
if unfetchable:
|
||||
raise ValueError(
|
||||
f"blob_mode='bytes' cannot fetch blob columns {unfetchable}. Bytes are "
|
||||
"fetched by row id, which covers blob v2 columns with one blob per "
|
||||
"row but not legacy blob columns or blobs inside lists. Use "
|
||||
"fetched by row id for legacy and v2 columns in local plain scans, "
|
||||
"but not for blobs inside lists or query shapes whose result columns "
|
||||
"cannot be matched back to the source. Use "
|
||||
"blob_mode='descriptions' or remove these columns from the projection."
|
||||
)
|
||||
|
||||
@@ -269,7 +293,7 @@ def finalize_blob_query_table(
|
||||
return stash_auto_row_ids(tbl, blob_paths)
|
||||
|
||||
|
||||
async def replace_v2_blob_columns_with_bytes(
|
||||
async def replace_blob_columns_with_bytes(
|
||||
tbl: pa.Table,
|
||||
blob_sources: dict[str, str],
|
||||
fetch_blobs: FetchBlobsAsync,
|
||||
@@ -289,7 +313,7 @@ async def replace_v2_blob_columns_with_bytes(
|
||||
return tbl
|
||||
|
||||
|
||||
def replace_v2_blob_columns_with_bytes_sync(
|
||||
def replace_blob_columns_with_bytes_sync(
|
||||
tbl: pa.Table,
|
||||
blob_sources: dict[str, str],
|
||||
fetch_blobs: FetchBlobsSync,
|
||||
|
||||
+179
-75
@@ -48,12 +48,13 @@ from ._blob import (
|
||||
FetchBlobsAsync,
|
||||
FetchBlobsSync,
|
||||
blob_auto_row_id_for_scan,
|
||||
blob_projection_sources,
|
||||
blob_v2_projection_sources,
|
||||
finalize_blob_query_table,
|
||||
reject_unfetchable_blob_columns,
|
||||
reject_unmatched_blob_outputs,
|
||||
replace_v2_blob_columns_with_bytes,
|
||||
replace_v2_blob_columns_with_bytes_sync,
|
||||
replace_blob_columns_with_bytes,
|
||||
replace_blob_columns_with_bytes_sync,
|
||||
strip_auto_row_ids,
|
||||
validate_blob_mode,
|
||||
)
|
||||
@@ -245,6 +246,17 @@ def _query_is_plain_scan(query: Query) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _query_is_plain_blob_scan(query: Query) -> bool:
|
||||
"""Whether native batch conversion can preserve this query's row result."""
|
||||
return (
|
||||
query.vector is None
|
||||
and query.full_text_query is None
|
||||
and query.take_offsets is None
|
||||
and query.take_row_ids is None
|
||||
and not query.postfilter
|
||||
)
|
||||
|
||||
|
||||
def _filter_to_sql(filter: Optional[Union[str, Expr]]) -> Optional[str]:
|
||||
if filter is None:
|
||||
return None
|
||||
@@ -421,7 +433,7 @@ def _finish_plain_scan_pandas(
|
||||
) -> pd.DataFrame:
|
||||
if blob_sources:
|
||||
tbl = _scanner_to_table(scanner)
|
||||
tbl = replace_v2_blob_columns_with_bytes_sync(tbl, blob_sources, fetch_blobs)
|
||||
tbl = replace_blob_columns_with_bytes_sync(tbl, blob_sources, fetch_blobs)
|
||||
if strip_auto_row_id and "_rowid" in tbl.column_names:
|
||||
tbl = tbl.drop_columns(["_rowid"])
|
||||
if flatten is not None:
|
||||
@@ -475,7 +487,7 @@ async def _finish_plain_scan_pandas_async(
|
||||
) -> pd.DataFrame:
|
||||
if blob_sources:
|
||||
tbl = _scanner_to_table(scanner)
|
||||
tbl = await replace_v2_blob_columns_with_bytes(tbl, blob_sources, fetch_blobs)
|
||||
tbl = await replace_blob_columns_with_bytes(tbl, blob_sources, fetch_blobs)
|
||||
if strip_auto_row_id and "_rowid" in tbl.column_names:
|
||||
tbl = tbl.drop_columns(["_rowid"])
|
||||
if flatten is not None:
|
||||
@@ -1281,10 +1293,10 @@ class LanceQueryBuilder(ABC):
|
||||
blob_mode: str, default "lazy"
|
||||
Controls how blob columns are returned. Local blob v2 queries support
|
||||
``"bytes"`` and ``"descriptions"`` without pylance. Eligible local
|
||||
plain scans use pylance's scanner for lazy blob handles in ``"lazy"``
|
||||
mode and for legacy blob columns. Other local blob v2
|
||||
queries return descriptors in ``"lazy"`` and ``"descriptions"``
|
||||
modes, or payloads in ``"bytes"`` mode, through :meth:`to_arrow`.
|
||||
plain scans also support ``"bytes"`` for row-addressable legacy blob
|
||||
columns without pylance. Other local blob v2 queries return
|
||||
descriptors in ``"lazy"`` and ``"descriptions"`` modes, or payloads
|
||||
in ``"bytes"`` mode, through :meth:`to_arrow`.
|
||||
LanceDB Enterprise blob queries support only ``"descriptions"`` until a
|
||||
stable
|
||||
table snapshot can bind the query and blob fetches.
|
||||
@@ -1352,8 +1364,8 @@ class LanceQueryBuilder(ABC):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1370,7 +1382,7 @@ class LanceQueryBuilder(ABC):
|
||||
Execute the query and return the results as a pyarrow
|
||||
[RecordBatchReader](https://arrow.apache.org/docs/python/generated/pyarrow.RecordBatchReader.html)
|
||||
|
||||
For v2 blob projections, the default ``blob_mode="lazy"`` keeps the
|
||||
For blob v2 projections, the default ``blob_mode="lazy"`` keeps the
|
||||
auto ``_rowid`` column visible so batch consumers can call
|
||||
``fetch_blobs``. The ``"bytes"`` and ``"descriptions"`` modes hide
|
||||
automatic row ids.
|
||||
@@ -1390,10 +1402,11 @@ class LanceQueryBuilder(ABC):
|
||||
The timeout also covers reading the batches, so consuming them
|
||||
slowly can exceed it.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` keeps blob descriptors and auto row ids for manual fetches.
|
||||
``"descriptions"`` omits auto row ids. ``"bytes"`` fetches blob v2
|
||||
payloads one batch at a time, without requiring pylance. Bytes mode
|
||||
has the same restrictions as :meth:`to_arrow`.
|
||||
``"lazy"`` keeps blob v2 descriptors and auto row ids for manual
|
||||
fetches. ``"descriptions"`` omits auto row ids. ``"bytes"`` fetches
|
||||
supported row-addressable blob payloads one batch at a time, without
|
||||
requiring pylance. Bytes mode has the same restrictions as
|
||||
:meth:`to_arrow`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
@@ -1599,20 +1612,41 @@ class LanceQueryBuilder(ABC):
|
||||
def _scan_needs_row_id(self) -> bool:
|
||||
return self._user_requested_row_id() or self._blob_auto_row_id_enabled()
|
||||
|
||||
def _query_for_scan(self) -> Query:
|
||||
def _query_for_scan(self, blob_sources: Optional[dict[str, str]] = None) -> Query:
|
||||
query = self.to_query_object()
|
||||
if self._scan_needs_row_id():
|
||||
if self._scan_needs_row_id() or blob_sources:
|
||||
query.with_row_id = True
|
||||
return query
|
||||
|
||||
def _can_fetch_plain_legacy_blobs(self, blob_mode: BlobMode) -> bool:
|
||||
if blob_mode != "bytes":
|
||||
return False
|
||||
try:
|
||||
query = self.to_query_object()
|
||||
except NotImplementedError:
|
||||
return False
|
||||
return (
|
||||
_query_is_plain_blob_scan(query)
|
||||
and getattr(self, "_reranker", None) is None
|
||||
and not _query_needs_lance_scanner(self)
|
||||
and _supports_local_blob_fetch(
|
||||
self._table._table,
|
||||
getattr(self._table, "_pushdown_operations", ()),
|
||||
)
|
||||
)
|
||||
|
||||
def _validated_blob_sources(self, blob_mode: BlobMode) -> dict[str, str]:
|
||||
"""Map projected blob v2 outputs to their source columns.
|
||||
"""Map projected blob outputs to their source columns.
|
||||
|
||||
Bytes mode is checked here, before the query runs.
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
schema = self._table.schema
|
||||
blob_sources = blob_v2_projection_sources(schema, self._columns)
|
||||
blob_sources = blob_projection_sources(
|
||||
schema,
|
||||
self._columns,
|
||||
include_legacy=self._can_fetch_plain_legacy_blobs(blob_mode),
|
||||
)
|
||||
if blob_mode != "bytes":
|
||||
return blob_sources
|
||||
reject_unfetchable_blob_columns(schema, self._columns, blob_sources)
|
||||
@@ -1633,7 +1667,7 @@ class LanceQueryBuilder(ABC):
|
||||
return tbl
|
||||
if blob_mode == "descriptions":
|
||||
return strip_auto_row_ids(tbl, blob_sources)
|
||||
return replace_v2_blob_columns_with_bytes_sync(
|
||||
return replace_blob_columns_with_bytes_sync(
|
||||
tbl, blob_sources, self._table.fetch_blobs
|
||||
)
|
||||
|
||||
@@ -1645,18 +1679,23 @@ class LanceQueryBuilder(ABC):
|
||||
blob_sources: dict[str, str],
|
||||
) -> pa.Table:
|
||||
blob_auto_row_id = self._blob_auto_row_id_enabled()
|
||||
finalized = finalize_blob_query_table(
|
||||
tbl,
|
||||
user_requested_row_id=self._user_requested_row_id(),
|
||||
blob_auto_row_id=blob_auto_row_id,
|
||||
blob_paths=blob_sources.keys() if blob_auto_row_id else (),
|
||||
)
|
||||
if blob_mode == "bytes" and blob_sources:
|
||||
# Bytes conversion reads _rowid directly, including when the
|
||||
# projection mixes legacy and v2 columns.
|
||||
finalized = tbl
|
||||
else:
|
||||
finalized = finalize_blob_query_table(
|
||||
tbl,
|
||||
user_requested_row_id=self._user_requested_row_id(),
|
||||
blob_auto_row_id=blob_auto_row_id,
|
||||
blob_paths=blob_sources.keys() if blob_auto_row_id else (),
|
||||
)
|
||||
finalized = self._apply_arrow_blob_mode(finalized, blob_mode, blob_sources)
|
||||
# with_row_id(False) does not reach the scan, which adds _rowid anyway.
|
||||
# Lazy keeps it because fetch_blobs(hits) reads it there.
|
||||
if (
|
||||
blob_mode != "lazy"
|
||||
and self._with_row_id is False
|
||||
and not self._user_requested_row_id()
|
||||
and "_rowid" in finalized.column_names
|
||||
):
|
||||
finalized = finalized.drop_columns(["_rowid"])
|
||||
@@ -1896,18 +1935,29 @@ class LanceQueryBuilder(ABC):
|
||||
query = self.to_query_object()
|
||||
if not _query_is_plain_scan(query):
|
||||
return None
|
||||
blob_sources = (
|
||||
blob_projection_sources(
|
||||
self._table.schema,
|
||||
query.columns,
|
||||
include_legacy=self._can_fetch_plain_legacy_blobs(blob_mode),
|
||||
)
|
||||
if blob_mode == "bytes"
|
||||
else {}
|
||||
)
|
||||
|
||||
# Remote and in-memory tables have no Lance dataset to scan.
|
||||
if not self._table._table._inner._is_native() or (
|
||||
get_uri_scheme(self._table._dataset_path) == "memory"
|
||||
):
|
||||
return None
|
||||
# Blob v2 payloads and descriptions use LanceDB's native fetch path.
|
||||
# Only lazy handles, legacy blobs, and scanner-only options need pylance.
|
||||
if (
|
||||
blob_mode != "lazy"
|
||||
and not _query_needs_lance_scanner(self)
|
||||
and blob_v2_projection_sources(self._table.schema, query.columns)
|
||||
# Bytes conversions use LanceDB's fetch path for both blob versions.
|
||||
# Descriptions for v2 do too; lazy handles still need the scanner.
|
||||
if not _query_needs_lance_scanner(self) and (
|
||||
(blob_mode == "bytes" and blob_sources)
|
||||
or (
|
||||
blob_mode == "descriptions"
|
||||
and blob_v2_projection_sources(self._table.schema, query.columns)
|
||||
)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
@@ -1915,12 +1965,7 @@ class LanceQueryBuilder(ABC):
|
||||
except ImportError as err:
|
||||
raise _unsupported_blob_pandas_error(str(err)) from err
|
||||
blob_auto_row_id = self._blob_auto_row_id_enabled()
|
||||
blob_sources = (
|
||||
blob_v2_projection_sources(self._table.schema, query.columns)
|
||||
if blob_mode == "bytes"
|
||||
else {}
|
||||
)
|
||||
if blob_sources:
|
||||
if blob_mode == "bytes":
|
||||
reject_unfetchable_blob_columns(
|
||||
self._table.schema, query.columns, blob_sources
|
||||
)
|
||||
@@ -2254,8 +2299,8 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
"""
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
return self._finalize_blob_query_table(
|
||||
@@ -2333,7 +2378,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
vector = self._query if isinstance(self._query, list) else self._query.tolist()
|
||||
if isinstance(vector[0], np.ndarray):
|
||||
vector = [v.tolist() for v in vector]
|
||||
query = self._query_for_scan()
|
||||
query = self._query_for_scan(blob_sources)
|
||||
result_set = self._table._execute_query(
|
||||
query, batch_size=batch_size, timeout=timeout
|
||||
)
|
||||
@@ -2551,7 +2596,7 @@ class LanceFtsQueryBuilder(LanceQueryBuilder):
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
self._table._ensure_no_legacy_fts_index()
|
||||
|
||||
query = self._query_for_scan()
|
||||
query = self._query_for_scan(blob_sources)
|
||||
results = self._table._execute_query(query, timeout=timeout).read_all()
|
||||
if self._reranker is not None:
|
||||
results = self._reranker.rerank_fts(self._query, results)
|
||||
@@ -2597,6 +2642,8 @@ class LanceEmptyQueryBuilder(LanceQueryBuilder):
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
if blob_mode == "bytes" and blob_sources:
|
||||
return self.to_batches(timeout=timeout, blob_mode=blob_mode).read_all()
|
||||
return self._finalize_blob_query_table(
|
||||
self.to_batches(timeout=timeout).read_all(),
|
||||
blob_mode=blob_mode,
|
||||
@@ -2630,7 +2677,7 @@ class LanceEmptyQueryBuilder(LanceQueryBuilder):
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.RecordBatchReader:
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
query = self._query_for_scan()
|
||||
query = self._query_for_scan(blob_sources)
|
||||
reader = self._table._execute_query(
|
||||
query, batch_size=batch_size, timeout=timeout
|
||||
)
|
||||
@@ -3501,6 +3548,22 @@ class AsyncQueryBase(object):
|
||||
self._blob_paths = tuple(blob_v2_projection_sources(schema, projection).keys())
|
||||
self._inner.with_row_id()
|
||||
|
||||
async def _can_fetch_plain_legacy_blobs(self, blob_mode: BlobMode) -> bool:
|
||||
if blob_mode != "bytes" or self._table is None:
|
||||
return False
|
||||
try:
|
||||
query = self.to_query_object()
|
||||
except NotImplementedError:
|
||||
return False
|
||||
return (
|
||||
_query_is_plain_blob_scan(query)
|
||||
and getattr(self, "_reranker", None) is None
|
||||
and not _query_needs_lance_scanner(self)
|
||||
and _supports_local_blob_fetch(
|
||||
self._table, self._table._pushdown_operations
|
||||
)
|
||||
)
|
||||
|
||||
def select(self, columns: Union[List[str], dict[str, str]]) -> Self:
|
||||
"""
|
||||
Return only the specified columns.
|
||||
@@ -3608,10 +3671,11 @@ class AsyncQueryBase(object):
|
||||
The timeout also covers reading the batches, so consuming them
|
||||
slowly can exceed it.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` keeps blob descriptors and auto row ids for manual fetches.
|
||||
``"descriptions"`` omits auto row ids. ``"bytes"`` fetches blob v2
|
||||
payloads one batch at a time, without requiring pylance. Bytes mode
|
||||
has the same restrictions as :meth:`to_arrow`.
|
||||
``"lazy"`` keeps blob v2 descriptors and auto row ids for manual
|
||||
fetches. ``"descriptions"`` omits auto row ids. ``"bytes"`` fetches
|
||||
supported row-addressable blob payloads one batch at a time, without
|
||||
requiring pylance. Bytes mode has the same restrictions as
|
||||
:meth:`to_arrow`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
@@ -3620,13 +3684,16 @@ class AsyncQueryBase(object):
|
||||
"""
|
||||
blob_sources = await self._validated_blob_sources(blob_mode)
|
||||
reader = await self._execute_batches(
|
||||
max_batch_length=max_batch_length, timeout=timeout
|
||||
max_batch_length=max_batch_length,
|
||||
timeout=timeout,
|
||||
blob_sources=blob_sources,
|
||||
)
|
||||
if blob_mode == "lazy":
|
||||
return reader
|
||||
|
||||
async def convert(tbl: pa.Table) -> pa.Table:
|
||||
tbl = self._finalize_blob_query_table(tbl)
|
||||
if not (blob_mode == "bytes" and blob_sources):
|
||||
tbl = self._finalize_blob_query_table(tbl)
|
||||
tbl = await self._apply_arrow_blob_mode(tbl, blob_mode, blob_sources)
|
||||
if not self._user_requested_row_id() and "_rowid" in tbl.column_names:
|
||||
tbl = tbl.drop_columns(["_rowid"])
|
||||
@@ -3647,7 +3714,24 @@ class AsyncQueryBase(object):
|
||||
*,
|
||||
max_batch_length: Optional[int] = None,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_sources: Optional[dict[str, str]] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
if (
|
||||
blob_sources
|
||||
and not self._user_requested_row_id()
|
||||
and await self._can_fetch_plain_legacy_blobs("bytes")
|
||||
):
|
||||
# Build a separate query so the hidden row id needed for byte
|
||||
# conversion cannot leak into later executions of this builder.
|
||||
query = self.to_query_object()
|
||||
query.with_row_id = True
|
||||
if self._table is None:
|
||||
raise RuntimeError("blob query is not bound to a table")
|
||||
row_id_query = self._table._sync_query_to_async(query)
|
||||
return await row_id_query._execute_batches(
|
||||
max_batch_length=max_batch_length,
|
||||
timeout=timeout,
|
||||
)
|
||||
await self._maybe_add_blob_row_id()
|
||||
return AsyncRecordBatchReader(
|
||||
await self._inner.execute(
|
||||
@@ -3664,7 +3748,7 @@ class AsyncQueryBase(object):
|
||||
return await self._inner.output_schema()
|
||||
|
||||
async def _validated_blob_sources(self, blob_mode: BlobMode) -> dict[str, str]:
|
||||
"""Map projected blob v2 outputs to their source columns.
|
||||
"""Map projected blob outputs to their source columns.
|
||||
|
||||
Bytes mode is checked here, before the query runs.
|
||||
"""
|
||||
@@ -3674,7 +3758,11 @@ class AsyncQueryBase(object):
|
||||
return {}
|
||||
schema = await self._table.schema()
|
||||
projection = _query_request_projection(self._inner.to_query_request())
|
||||
blob_sources = blob_v2_projection_sources(schema, projection)
|
||||
blob_sources = blob_projection_sources(
|
||||
schema,
|
||||
projection,
|
||||
include_legacy=await self._can_fetch_plain_legacy_blobs(blob_mode),
|
||||
)
|
||||
if blob_mode != "bytes":
|
||||
return blob_sources
|
||||
reject_unfetchable_blob_columns(schema, projection, blob_sources)
|
||||
@@ -3695,7 +3783,7 @@ class AsyncQueryBase(object):
|
||||
return tbl
|
||||
if blob_mode == "descriptions":
|
||||
return strip_auto_row_ids(tbl, blob_sources)
|
||||
return await replace_v2_blob_columns_with_bytes(
|
||||
return await replace_blob_columns_with_bytes(
|
||||
tbl, blob_sources, self._table.fetch_blobs
|
||||
)
|
||||
|
||||
@@ -3721,11 +3809,14 @@ class AsyncQueryBase(object):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
LanceDB Enterprise and namespace query pushdown refuse ``"bytes"``.
|
||||
"""
|
||||
blob_sources = await self._validated_blob_sources(blob_mode)
|
||||
if blob_mode == "bytes" and blob_sources:
|
||||
reader = await self.to_batches(timeout=timeout, blob_mode=blob_mode)
|
||||
return pa.Table.from_batches(await reader.read_all(), schema=reader.schema)
|
||||
batch_iter = await self.to_batches(timeout=timeout)
|
||||
tbl = self._finalize_blob_query_table(
|
||||
pa.Table.from_batches(await batch_iter.read_all(), schema=batch_iter.schema)
|
||||
@@ -3798,10 +3889,10 @@ class AsyncQueryBase(object):
|
||||
blob_mode: str, default "lazy"
|
||||
Controls how blob columns are returned. Local blob v2 queries support
|
||||
``"bytes"`` and ``"descriptions"`` without pylance. Eligible local
|
||||
plain scans use pylance's scanner for lazy blob handles in ``"lazy"``
|
||||
mode and for legacy blob columns. Other local blob v2
|
||||
queries return descriptors in ``"lazy"`` and ``"descriptions"``
|
||||
modes, or payloads in ``"bytes"`` mode, through :meth:`to_arrow`.
|
||||
plain scans also support ``"bytes"`` for row-addressable legacy blob
|
||||
columns without pylance. Other local blob v2 queries return
|
||||
descriptors in ``"lazy"`` and ``"descriptions"`` modes, or payloads
|
||||
in ``"bytes"`` mode, through :meth:`to_arrow`.
|
||||
LanceDB Enterprise blob queries support only ``"descriptions"`` until a
|
||||
stable
|
||||
table snapshot can bind the query and blob fetches.
|
||||
@@ -3879,21 +3970,27 @@ class AsyncQueryBase(object):
|
||||
with_row_id=self._with_row_id,
|
||||
)
|
||||
blob_sources = (
|
||||
blob_v2_projection_sources(schema, query.columns)
|
||||
blob_projection_sources(
|
||||
schema,
|
||||
query.columns,
|
||||
include_legacy=await self._can_fetch_plain_legacy_blobs(blob_mode),
|
||||
)
|
||||
if blob_mode == "bytes"
|
||||
else {}
|
||||
)
|
||||
if blob_sources:
|
||||
if blob_mode == "bytes":
|
||||
reject_unfetchable_blob_columns(schema, query.columns, blob_sources)
|
||||
# Remote and in-memory tables have no Lance dataset to scan.
|
||||
if not self._table._inner._is_native() or (
|
||||
get_uri_scheme(await self._table.uri()) == "memory"
|
||||
):
|
||||
return None
|
||||
if (
|
||||
blob_mode != "lazy"
|
||||
and not _query_needs_lance_scanner(self)
|
||||
and blob_v2_projection_sources(schema, query.columns)
|
||||
if not _query_needs_lance_scanner(self) and (
|
||||
(blob_mode == "bytes" and blob_sources)
|
||||
or (
|
||||
blob_mode == "descriptions"
|
||||
and blob_v2_projection_sources(schema, query.columns)
|
||||
)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
@@ -4404,8 +4501,11 @@ class AsyncFTSQuery(AsyncStandardQuery):
|
||||
*,
|
||||
max_batch_length: Optional[int] = None,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_sources: Optional[dict[str, str]] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
reader = await super()._execute_batches(timeout=timeout)
|
||||
reader = await super()._execute_batches(
|
||||
timeout=timeout, blob_sources=blob_sources
|
||||
)
|
||||
results = pa.Table.from_batches(await reader.read_all(), reader.schema)
|
||||
if self._reranker:
|
||||
results = self._reranker.rerank_fts(self.get_query(), results)
|
||||
@@ -4728,8 +4828,11 @@ class AsyncVectorQuery(AsyncStandardQuery, AsyncVectorQueryBase):
|
||||
*,
|
||||
max_batch_length: Optional[int] = None,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_sources: Optional[dict[str, str]] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
reader = await super()._execute_batches(timeout=timeout)
|
||||
reader = await super()._execute_batches(
|
||||
timeout=timeout, blob_sources=blob_sources
|
||||
)
|
||||
results = pa.Table.from_batches(await reader.read_all(), reader.schema)
|
||||
if self._reranker:
|
||||
results = self._reranker.rerank_vector(self._query_string, results)
|
||||
@@ -4857,11 +4960,14 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
|
||||
*,
|
||||
max_batch_length: Optional[int] = None,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_sources: Optional[dict[str, str]] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
# WAL-PK-FUSION: without the fallback, this body is `_run_hybrid`'s.
|
||||
return await _wal_hybrid.with_pk_fallback_async(
|
||||
lambda: self._run_hybrid(
|
||||
max_batch_length=max_batch_length, timeout=timeout
|
||||
max_batch_length=max_batch_length,
|
||||
timeout=timeout,
|
||||
blob_sources=blob_sources,
|
||||
),
|
||||
self._table,
|
||||
fused=lambda: self._pk_fusion is not None,
|
||||
@@ -4873,19 +4979,18 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
|
||||
*,
|
||||
max_batch_length: Optional[int] = None,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_sources: Optional[dict[str, str]] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
fts_query, vec_query, limit, offset = await self._create_child_queries()
|
||||
|
||||
req = fts_query._inner.to_query_request()
|
||||
blob_auto_row_id = False
|
||||
blob_auto_row_id = bool(blob_sources)
|
||||
blob_paths: tuple[str, ...] = ()
|
||||
if self._table is not None:
|
||||
schema = await self._table.schema()
|
||||
projection = _query_request_projection(req)
|
||||
blob_auto_row_id = blob_auto_row_id_for_scan(
|
||||
schema,
|
||||
projection,
|
||||
with_row_id=self._with_row_id,
|
||||
blob_auto_row_id = blob_auto_row_id or blob_auto_row_id_for_scan(
|
||||
schema, projection, with_row_id=self._with_row_id
|
||||
)
|
||||
if blob_auto_row_id:
|
||||
blob_paths = tuple(
|
||||
@@ -5182,8 +5287,7 @@ class BaseQueryBuilder(object):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` is not supported for remote or pushdown blob queries.
|
||||
"""
|
||||
return LOOP.run(self._inner.to_arrow(timeout, blob_mode=blob_mode))
|
||||
|
||||
|
||||
@@ -122,6 +122,15 @@ def blob_v2_column_paths(schema: pa.Schema) -> list[str]:
|
||||
return [path for path, _ in _collect_blob_paths(schema, is_blob_v2_field)]
|
||||
|
||||
|
||||
def row_addressable_blob_paths(schema: pa.Schema) -> list[str]:
|
||||
"""Blob v1 and v2 paths with one blob addressable by table row id."""
|
||||
return [
|
||||
path
|
||||
for path, has_list_ancestor in _collect_blob_paths(schema, is_blob_like_field)
|
||||
if not has_list_ancestor
|
||||
]
|
||||
|
||||
|
||||
def row_addressable_blob_v2_paths(schema: pa.Schema) -> list[str]:
|
||||
"""Blob v2 paths with one blob addressable by table row id.
|
||||
|
||||
|
||||
@@ -1589,8 +1589,8 @@ class Table(ABC):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -4160,7 +4160,7 @@ class LanceTable(Table):
|
||||
):
|
||||
return self.to_arrow().to_pandas(**kwargs)
|
||||
|
||||
if blob_mode == "bytes" and blob_v2_column_paths(self.schema):
|
||||
if blob_mode == "bytes" and schema_has_blob_field(self.schema):
|
||||
return self.search().to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
|
||||
return self.to_lance().to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
@@ -4173,8 +4173,8 @@ class LanceTable(Table):
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -6640,7 +6640,7 @@ class AsyncTable:
|
||||
|
||||
if blob_mode == "lazy" and get_uri_scheme(await self.uri()) == "memory":
|
||||
return (await self.to_arrow()).to_pandas(**kwargs)
|
||||
if blob_mode == "bytes" and blob_v2_column_paths(schema):
|
||||
if blob_mode == "bytes" and schema_has_blob_field(schema):
|
||||
return await self.query().to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
return (await self.to_lance()).to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
|
||||
@@ -6652,8 +6652,8 @@ class AsyncTable:
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
``"bytes"`` returns payload bytes for supported row-addressable blob
|
||||
columns and raises for blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -7460,7 +7460,10 @@ class AsyncTable:
|
||||
if query.offset is not None:
|
||||
async_query = async_query.offset(query.offset)
|
||||
if query.columns:
|
||||
async_query = async_query.select(query.columns)
|
||||
columns = query.columns
|
||||
if isinstance(columns, list) and columns and isinstance(columns[0], tuple):
|
||||
columns = dict(columns)
|
||||
async_query = async_query.select(columns)
|
||||
if query.filter is not None:
|
||||
async_query = async_query.where(query.filter)
|
||||
if query.fast_search:
|
||||
|
||||
@@ -1651,16 +1651,41 @@ def test_bytes_mode_rejects_blobs_inside_lists():
|
||||
|
||||
|
||||
def test_row_addressable_paths_exclude_list_children():
|
||||
from lancedb.schema import row_addressable_blob_v2_paths
|
||||
from lancedb.schema import (
|
||||
row_addressable_blob_paths,
|
||||
row_addressable_blob_v2_paths,
|
||||
)
|
||||
|
||||
schema = pa.schema(
|
||||
[
|
||||
pa.field("id", pa.int64()),
|
||||
pa.field("info", pa.struct([lancedb.blob("blob")])),
|
||||
pa.field(
|
||||
"info",
|
||||
pa.struct(
|
||||
[
|
||||
lancedb.blob("blob"),
|
||||
pa.field(
|
||||
"legacy",
|
||||
pa.large_binary(),
|
||||
metadata={"lance-encoding:blob": "true"},
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
pa.field(
|
||||
"legacy",
|
||||
pa.large_binary(),
|
||||
metadata={"lance-encoding:blob": "true"},
|
||||
),
|
||||
pa.field("images", pa.list_(lancedb.blob("image"))),
|
||||
]
|
||||
)
|
||||
assert blob_v2_column_paths(schema) == ["info.blob", "images.image"]
|
||||
assert row_addressable_blob_paths(schema) == [
|
||||
"info.blob",
|
||||
"info.legacy",
|
||||
"legacy",
|
||||
]
|
||||
assert row_addressable_blob_v2_paths(schema) == ["info.blob"]
|
||||
|
||||
|
||||
@@ -2325,6 +2350,8 @@ async def test_async_to_batches_blob_modes(shape, blob_mode, monkeypatch):
|
||||
if blob_mode != "lazy":
|
||||
assert "_rowid" not in reader.schema.names
|
||||
assert "_lance_row_id" not in str(reader.schema)
|
||||
else:
|
||||
assert "_rowid" in reader.schema.names
|
||||
|
||||
|
||||
class _KeepAllScoresReranker(Reranker):
|
||||
@@ -2368,7 +2395,7 @@ def test_bytes_mode_rejects_legacy_blob_after_reranker_drops_metadata(tmp_path):
|
||||
.rerank(_KeepAllScoresReranker())
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"\['blob'\].*legacy blob columns"):
|
||||
with pytest.raises(ValueError, match=r"\['blob'\].*local plain scans"):
|
||||
query.to_arrow(blob_mode="bytes")
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ import textwrap
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api", ["sync", "async"])
|
||||
@pytest.mark.parametrize("result", ["pandas", "batches", "lazy_error"])
|
||||
def test_blob_query_without_pylance(tmp_path, api, result):
|
||||
if result == "pandas":
|
||||
pytest.importorskip("pandas")
|
||||
script = """\
|
||||
_WITHOUT_LANCE = """\
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
@@ -23,7 +18,15 @@ class MissingLance(importlib.abc.MetaPathFinder):
|
||||
raise ModuleNotFoundError(fullname, name="lance")
|
||||
|
||||
sys.meta_path.insert(0, MissingLance())
|
||||
""" + textwrap.dedent(
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api", ["sync", "async"])
|
||||
@pytest.mark.parametrize("result", ["pandas", "batches", "lazy_error"])
|
||||
def test_blob_query_without_pylance(tmp_path, api, result):
|
||||
if result == "pandas":
|
||||
pytest.importorskip("pandas")
|
||||
script = _WITHOUT_LANCE + textwrap.dedent(
|
||||
"""\
|
||||
import asyncio
|
||||
import lancedb
|
||||
@@ -94,3 +97,187 @@ sys.meta_path.insert(0, MissingLance())
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api", ["sync", "async"])
|
||||
@pytest.mark.parametrize("result", ["pandas", "batches", "arrow"])
|
||||
def test_plain_legacy_blob_query_without_pylance(tmp_path, api, result):
|
||||
if result == "pandas":
|
||||
pytest.importorskip("pandas")
|
||||
script = _WITHOUT_LANCE + textwrap.dedent(
|
||||
"""\
|
||||
import asyncio
|
||||
import lancedb
|
||||
import pyarrow as pa
|
||||
from lancedb.query import ColumnOrdering
|
||||
|
||||
schema = pa.schema(
|
||||
[
|
||||
pa.field("id", pa.int64()),
|
||||
pa.field("filename", pa.string()),
|
||||
pa.field(
|
||||
"video_blob",
|
||||
pa.large_binary(),
|
||||
metadata={"lance-encoding:blob": "true"},
|
||||
),
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
{"id": 1, "filename": "file-1.mp4", "video_blob": b"outside"},
|
||||
{"id": 2, "filename": "file-2.mp4", "video_blob": b""},
|
||||
{"id": 3, "filename": "file-3.mp4", "video_blob": None},
|
||||
{"id": 4, "filename": "file-4.mp4", "video_blob": b"four"},
|
||||
]
|
||||
expected = [
|
||||
{"filename": "file-2.mp4", "payload": b""},
|
||||
{"filename": "file-3.mp4", "payload": None},
|
||||
{"filename": "file-4.mp4", "payload": b"four"},
|
||||
]
|
||||
|
||||
def configure(query):
|
||||
return (
|
||||
query.select({"filename": "filename", "payload": "video_blob"})
|
||||
.where("id >= 2")
|
||||
.order_by(
|
||||
[ColumnOrdering(column_name="id", ascending=True)]
|
||||
)
|
||||
.limit(3)
|
||||
)
|
||||
|
||||
async def main():
|
||||
storage_options = {"new_table_data_storage_version": "2.1"}
|
||||
if sys.argv[2] == "async":
|
||||
db = await lancedb.connect_async(
|
||||
sys.argv[1], storage_options=storage_options
|
||||
)
|
||||
table = await db.create_table("legacy", schema=schema)
|
||||
await table.add(rows)
|
||||
table = await db.open_table("legacy")
|
||||
def query():
|
||||
return configure(table.query())
|
||||
def original_query():
|
||||
return table.query().select(
|
||||
{"filename": "filename", "payload": "video_blob"}
|
||||
).limit(1)
|
||||
def empty_query():
|
||||
return query().where("id < 0")
|
||||
def batches():
|
||||
return query().to_batches(
|
||||
max_batch_length=1, blob_mode="bytes"
|
||||
)
|
||||
else:
|
||||
db = lancedb.connect(
|
||||
sys.argv[1], storage_options=storage_options
|
||||
)
|
||||
table = db.create_table("legacy", schema=schema)
|
||||
table.add(rows)
|
||||
table = db.open_table("legacy")
|
||||
def query():
|
||||
return configure(table.search())
|
||||
def original_query():
|
||||
return table.search().select(
|
||||
{"filename": "filename", "payload": "video_blob"}
|
||||
).limit(1)
|
||||
def empty_query():
|
||||
return query().where("id < 0")
|
||||
def batches():
|
||||
return query().to_batches(
|
||||
batch_size=1, blob_mode="bytes"
|
||||
)
|
||||
|
||||
if sys.argv[2] == "async":
|
||||
reused = table.query().select(
|
||||
{"filename": "filename", "payload": "video_blob"}
|
||||
).limit(3)
|
||||
byte_reader = await reused.to_batches(blob_mode="bytes")
|
||||
await byte_reader.read_all()
|
||||
lazy_reader = await reused.to_batches(blob_mode="lazy")
|
||||
assert "_rowid" not in lazy_reader.schema.names
|
||||
await lazy_reader.read_all()
|
||||
reused.select(["filename"])
|
||||
nonblob_reader = await reused.to_batches(blob_mode="lazy")
|
||||
assert nonblob_reader.schema.names == ["filename"]
|
||||
await nonblob_reader.read_all()
|
||||
|
||||
if sys.argv[3] == "pandas":
|
||||
frame = query().to_pandas(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
frame = await frame
|
||||
assert frame.to_dict("records") == expected
|
||||
original = original_query().to_pandas(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
original = await original
|
||||
assert original.to_dict("records") == [
|
||||
{"filename": "file-1.mp4", "payload": b"outside"}
|
||||
]
|
||||
direct = table.to_pandas(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
direct = await direct
|
||||
assert direct.to_dict("records") == rows
|
||||
empty = empty_query().to_pandas(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
empty = await empty
|
||||
assert empty.to_dict("records") == []
|
||||
elif sys.argv[3] == "batches":
|
||||
reader = batches()
|
||||
if sys.argv[2] == "async":
|
||||
reader = await reader
|
||||
rows_out = [batch async for batch in reader]
|
||||
else:
|
||||
rows_out = list(reader)
|
||||
assert reader.schema.field("payload").type == pa.large_binary()
|
||||
assert "_rowid" not in reader.schema.names
|
||||
assert all(batch.num_rows == 1 for batch in rows_out)
|
||||
assert all(batch.schema == reader.schema for batch in rows_out)
|
||||
actual = [row for batch in rows_out for row in batch.to_pylist()]
|
||||
assert actual == expected
|
||||
original_reader = original_query().to_batches(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
original_reader = await original_reader
|
||||
original_rows = [batch async for batch in original_reader]
|
||||
else:
|
||||
original_rows = list(original_reader)
|
||||
assert [
|
||||
row for batch in original_rows for row in batch.to_pylist()
|
||||
] == [{"filename": "file-1.mp4", "payload": b"outside"}]
|
||||
empty_reader = empty_query().to_batches(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
empty_reader = await empty_reader
|
||||
empty_batches = [batch async for batch in empty_reader]
|
||||
else:
|
||||
empty_batches = list(empty_reader)
|
||||
assert empty_batches == []
|
||||
assert empty_reader.schema.field("payload").type == pa.large_binary()
|
||||
else:
|
||||
table_out = query().to_arrow(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
table_out = await table_out
|
||||
assert table_out.schema.field("payload").type == pa.large_binary()
|
||||
assert "_rowid" not in table_out.column_names
|
||||
assert table_out.to_pylist() == expected
|
||||
original = original_query().to_arrow(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
original = await original
|
||||
assert original.to_pylist() == [
|
||||
{"filename": "file-1.mp4", "payload": b"outside"}
|
||||
]
|
||||
direct = table.to_arrow(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
direct = await direct
|
||||
assert direct.to_pylist() == rows
|
||||
empty_table = empty_query().to_arrow(blob_mode="bytes")
|
||||
if sys.argv[2] == "async":
|
||||
empty_table = await empty_table
|
||||
assert empty_table.num_rows == 0
|
||||
assert empty_table.schema.field("payload").type == pa.large_binary()
|
||||
assert "lance" not in sys.modules
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, str(tmp_path), api, result],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
|
||||
@@ -348,7 +348,7 @@ def test_plain_scan_query_to_pandas_blob_bytes_projection(tmp_db, blob_schema):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blob_mode", ["bytes", "descriptions"])
|
||||
def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
|
||||
def test_plain_scan_query_to_pandas_blob_mode_uses_native_path(
|
||||
tmp_db, monkeypatch, blob_mode
|
||||
):
|
||||
pytest.importorskip("lance")
|
||||
@@ -362,7 +362,8 @@ def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
|
||||
def fail_to_arrow(*args, **kwargs):
|
||||
raise AssertionError("to_arrow should not be called before native pandas")
|
||||
|
||||
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
|
||||
if blob_mode == "descriptions":
|
||||
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
|
||||
|
||||
df = query.to_pandas(blob_mode=blob_mode)
|
||||
|
||||
@@ -488,7 +489,7 @@ async def test_async_plain_scan_query_to_pandas_blob_bytes_projection(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("blob_mode", ["bytes", "descriptions"])
|
||||
async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
|
||||
async def test_async_plain_scan_query_to_pandas_blob_mode_uses_native_path(
|
||||
tmp_db_async, monkeypatch, blob_mode
|
||||
):
|
||||
pytest.importorskip("lance")
|
||||
@@ -502,7 +503,8 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow
|
||||
async def fail_to_arrow(*args, **kwargs):
|
||||
raise AssertionError("to_arrow should not be called before native pandas")
|
||||
|
||||
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
|
||||
if blob_mode == "descriptions":
|
||||
monkeypatch.setattr(query, "to_arrow", fail_to_arrow)
|
||||
|
||||
df = await query.to_pandas(blob_mode=blob_mode)
|
||||
|
||||
@@ -541,12 +543,14 @@ def test_vector_query_to_pandas_legacy_blob_descriptions_and_bytes(tmp_db):
|
||||
df = query.to_pandas(blob_mode="descriptions")
|
||||
assert df["blob"].tolist() == [{"position": 0, "size": 3}]
|
||||
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
with pytest.raises(ValueError, match=r"\['blob'\].*local plain scans"):
|
||||
query.to_pandas(blob_mode="bytes")
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
with pytest.raises(ValueError, match=r"\['blob'\].*local plain scans"):
|
||||
query.to_arrow(blob_mode="bytes")
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
table.to_arrow(blob_mode="bytes")
|
||||
table_arrow = table.to_arrow(blob_mode="bytes")
|
||||
assert table_arrow.schema.field("blob").type == pa.large_binary()
|
||||
assert "_rowid" not in table_arrow.column_names
|
||||
assert table_arrow["blob"].to_pylist() == [b"one", b"two", b"three", b"four"]
|
||||
assert table.to_pandas(blob_mode="bytes")["blob"].tolist() == [
|
||||
b"one",
|
||||
b"two",
|
||||
|
||||
Reference in New Issue
Block a user