blob: d492fc3a58dcaf8190d506146755de7c43e20c30 [file]
# 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.
from typing import Any, Callable, Dict, List, Optional, Tuple
import pyarrow as pa
from pypaimon.common.where_parser import parse_where_clause
from pypaimon.multimodal.blob_read import fetch_blob_bodies
from pypaimon.schema.data_types import is_blob_type
from pypaimon.table.special_fields import SpecialFields
def _select_arrow_columns(batch, columns):
return batch.select(columns)
class ScanQuery:
"""Chainable scan wrapper for MultimodalTable."""
def __init__(
self,
table,
result_factory: Optional[Callable] = None):
self._table = table
self._predicate = None
self._projection = None
self._limit = None
self._include_row_id = False
self._result_factory = result_factory
def where(self, predicate):
predicate = self._coerce_predicate(predicate, "where()")
if predicate is not None:
self._predicate = self._and_predicate(self._predicate, predicate)
return self
def select(self, columns):
if isinstance(columns, str):
columns = [columns]
self._projection = list(columns)
return self
def with_row_id(self):
self._include_row_id = True
return self
def limit(self, limit: int):
self._limit = limit
return self
def to_arrow(self):
if self._result_factory is not None:
return self._read_global_index_result(self._result_factory(self))
read_builder = self._configured_read_builder()
scan = read_builder.new_scan()
plan = scan.plan()
return read_builder.new_read().to_arrow(plan.splits())
def _configured_read_builder(self):
read_builder = self._table.new_read_builder()
if self._predicate is not None:
read_builder = read_builder.with_filter(self._predicate)
projection = self._effective_projection()
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
def _effective_projection(self):
if self._projection is None:
if not self._include_row_id:
return None
projection = [field.name for field in self._table.fields]
else:
projection = list(self._projection)
if self._include_row_id and SpecialFields.ROW_ID.name not in projection:
projection.append(SpecialFields.ROW_ID.name)
return projection
def _read_global_index_result(self, result):
read_builder = self._configured_read_builder()
scan = read_builder.new_scan().with_global_index_result(result)
plan = scan.plan()
return read_builder.new_read().to_arrow(plan.splits())
def to_pandas(self):
return self.to_arrow().to_pandas()
def to_list(self) -> List[dict]:
return self.to_arrow().to_pylist()
def to_ray(
self,
*,
ray_remote_args: Optional[Dict[str, Any]] = None,
concurrency: Optional[int] = None,
override_num_blocks: Optional[int] = None,
**read_args):
"""Read this scan as a Ray Dataset.
BLOB columns are read as serialized descriptors. Use
``table.map_with_blobs(...)`` to fetch payload bytes on Ray workers.
"""
if self._result_factory is not None:
raise TypeError("to_ray is only supported on scan(), not search queries.")
read_builder, file_io, visible_columns = self._blob_descriptor_query_read_builder()
plan = read_builder.new_scan().plan()
ds = read_builder.new_read().to_ray(
plan.splits(),
ray_remote_args=ray_remote_args,
concurrency=concurrency,
override_num_blocks=override_num_blocks,
**read_args)
if visible_columns is not None:
ds = ds.map_batches(
_select_arrow_columns,
fn_kwargs={"columns": visible_columns},
batch_format="pyarrow")
setattr(ds, "_paimon_blob_file_io", file_io)
setattr(ds, "_paimon_blob_columns", self._all_blob_columns())
return ds
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_query_read_builder(self):
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._effective_projection()
if projection is not None:
internal_projection = list(projection)
for name in self._predicate_fields():
if name not in internal_projection:
internal_projection.append(name)
read_builder = read_builder.with_projection(internal_projection)
if self._limit is not None:
read_builder = read_builder.with_limit(self._limit)
if projection is None:
return read_builder, read_table.file_io, None
internal_column_names = [field.name for field in read_builder.read_type()]
visible_columns = self._projected_output_columns(read_table, projection)
if visible_columns == internal_column_names:
visible_columns = None
return read_builder, read_table.file_io, visible_columns
@staticmethod
def _projected_output_columns(table, projection):
builder = table.new_read_builder().with_projection(projection)
return [field.name for field in builder.read_type()]
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.
return fetch_blob_bodies(file_io, data, blob_cols, parallelism)
def _all_blob_columns(self) -> List[str]:
return [
field.name for field in self._table.fields
if is_blob_type(field.type)
]
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
if isinstance(predicate, str):
return parse_where_clause(predicate, self._table.fields)
raise ValueError("%s expects a SQL-like string." % method)
def _and_predicate(self, left, right):
if left is None:
return right
from pypaimon.common.predicate_builder import PredicateBuilder
return PredicateBuilder.and_predicates([left, right])
class _PreFilterQuery(ScanQuery):
def __init__(
self,
table,
result_factory: Optional[Callable] = None,
pre_filter=None):
self._pre_filter = None
super().__init__(table, result_factory=result_factory)
if pre_filter is not None:
self.pre_filter(pre_filter)
def pre_filter(self, predicate):
predicate = self._coerce_predicate(predicate, "pre_filter()")
if predicate is not None:
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.")
def to_ray(self, *args, **kwargs):
raise TypeError("to_ray is only supported on scan(), not search queries.")
class VectorQuery(_PreFilterQuery):
"""Chainable query wrapper for vector global-index search."""
def __init__(
self,
table,
vector,
vector_column,
vector_options=None,
pre_filter=None):
self._vector = vector
self._vector_column = vector_column
self._vector_options = dict(vector_options or {})
super().__init__(
table, result_factory=self._execute_vector, pre_filter=pre_filter)
def _execute_vector(self, query):
limit = query._limit if query._limit is not None else 10
builder = (
self._table.new_vector_search_builder()
.with_vector_column(self._vector_column)
.with_query_vector(self._vector)
.with_limit(limit)
.with_options(self._vector_options)
)
if query._pre_filter is not None:
builder = builder.with_filter(query._pre_filter)
return builder.execute_local()
class TextQuery(_PreFilterQuery):
"""Chainable query wrapper for full-text global-index search."""
def __init__(self, table, text_query, pre_filter=None):
self._text_query = text_query
super().__init__(
table, result_factory=self._execute_fts, pre_filter=pre_filter)
def _execute_fts(self, query):
limit = query._limit if query._limit is not None else 10
builder = (
self._table.new_full_text_search_builder()
.with_query(self._text_query["column"], self._text_query["query"])
.with_limit(limit)
)
if query._pre_filter is not None:
builder = builder.with_partition_filter(query._pre_filter)
return builder.execute_local()
class HybridQuery(_PreFilterQuery):
"""Chainable query wrapper for hybrid global-index search."""
def __init__(
self,
table,
vector_routes=None,
text_routes=None,
ranker="rrf",
route_limit=None,
pre_filter=None):
self._vector_routes = list(vector_routes or [])
self._text_routes = list(text_routes or [])
self._ranker = ranker
self._route_limit = route_limit
super().__init__(
table, result_factory=self._execute_hybrid, pre_filter=pre_filter)
def rerank(self, ranker):
self._ranker = ranker
return self
def _execute_hybrid(self, query):
final_limit = query._limit if query._limit is not None else 10
route_limit = self._route_limit or final_limit
builder = (
self._table.new_hybrid_search_builder()
.with_limit(final_limit)
.with_ranker(self._ranker)
)
for route in self._vector_routes:
builder = builder.add_vector_route(
route["column"],
route["vector"],
limit=route.get("limit") or route_limit,
weight=route["weight"],
options=route["options"],
)
for route in self._text_routes:
builder = builder.add_full_text_route(
route["column"],
route["query"],
limit=route.get("limit") or route_limit,
weight=route["weight"],
options=route["options"],
)
if query._pre_filter is not None:
builder = builder.with_filter(query._pre_filter)
return builder.execute_local()
class BatchVectorQuery(_PreFilterQuery):
"""Chainable query wrapper for batch vector global-index search."""
def __init__(
self,
table,
vectors,
vector_column,
vector_options=None,
pre_filter=None):
self._vectors = vectors
self._vector_column = vector_column
self._vector_options = dict(vector_options or {})
super().__init__(table, pre_filter=pre_filter)
def to_arrow(self):
return [
self._read_global_index_result(result)
for result in self._execute_batch_vector(self)
]
def to_pandas(self):
return [table.to_pandas() for table in self.to_arrow()]
def to_list(self) -> List[List[dict]]:
return [table.to_pylist() for table in self.to_arrow()]
def _execute_batch_vector(self, query):
limit = query._limit if query._limit is not None else 10
builder = (
self._table.new_batch_vector_search_builder()
.with_vector_column(self._vector_column)
.with_query_vectors(self._vectors)
.with_limit(limit)
.with_options(self._vector_options)
)
if query._pre_filter is not None:
builder = builder.with_filter(query._pre_filter)
return builder.execute_batch_local()