blob: 6c0ff50ff87fe6ee214589529ef5ea675f7ebe78 [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.
import json
import re
from dataclasses import dataclass
from typing import Dict, Mapping, Optional, Sequence
import pyarrow as pa
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.multimodal.query import (
BatchVectorQuery,
HybridQuery,
ScanQuery,
TextQuery,
VectorQuery,
)
from pypaimon.schema.data_types import (
PyarrowFieldParser, is_array_blob_type, is_blob_file_type, is_map_blob_type,
)
from pypaimon.table.data_evolution_merge_into import (
WhenMatched,
WhenNotMatched,
source_col as _source_col,
)
from pypaimon.table.special_fields import SpecialFields
_ALL_SOURCE_COLUMNS = object()
_MERGE_REF_PATTERN = re.compile(r'\b(source|target)\.(\w+)\b')
_STRING_LITERAL_PATTERN = re.compile(r"'(?:[^']|'')*'")
@dataclass(frozen=True)
class VectorRoute:
"""Vector route spec for hybrid search."""
column: Optional[str]
vector: object
weight: float = 1.0
limit: Optional[int] = None
options: Optional[Dict[str, str]] = None
@dataclass(frozen=True)
class TextRoute:
"""Full-text route spec for hybrid search."""
query: object
column: Optional[str] = None
weight: float = 1.0
limit: Optional[int] = None
options: Optional[Dict[str, str]] = None
def vector_route(column, vector, *, weight: float = 1.0,
limit: Optional[int] = None,
options: Optional[Dict[str, str]] = None) -> VectorRoute:
return VectorRoute(
column=column,
vector=vector,
weight=weight,
limit=limit,
options=dict(options or {}),
)
def text_route(query, *, column: Optional[str] = None, weight: float = 1.0,
limit: Optional[int] = None,
options: Optional[Dict[str, str]] = None) -> TextRoute:
return TextRoute(
query=query,
column=column,
weight=weight,
limit=limit,
options=dict(options or {}),
)
class MultimodalTable:
"""High-level table facade for mutable multimodal application data."""
def __init__(self, catalog, identifier: str, raw_table):
self.catalog = catalog
self.identifier = identifier
self.name = identifier
self.raw_table = raw_table
self.table = raw_table
def add(self, data):
arrow_table = _to_arrow_table(data, _target_schema(self.raw_table))
write_builder = self.raw_table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
try:
table_write.write_arrow(arrow_table)
table_commit.commit(table_write.prepare_commit())
finally:
table_write.close()
table_commit.close()
return self
def add_video(
self,
video,
frames,
*,
video_column=None,
first_frame=0):
"""Append logical frame rows backed by one complete encoded video.
``frames`` supplies every table column except the configured
``video-frame-field``. Frame ordinals are generated from
``first_frame`` and stored in the video descriptor, not in the normal
data file.
"""
return self.add_videos(
[(video, frames, first_frame)], video_column=video_column
)
def add_images_as_video(
self,
images,
frames,
*,
fps,
codec,
pixel_format,
gop_size,
codec_options=None,
video_column=None):
"""Encode ordered images as one MP4 and append its logical frame rows.
Images may be encoded image bytes or Pillow images. ``frames`` supplies
every table column except ``video_column`` and must contain one row per
image. Encoding finishes before the Paimon write starts.
"""
import os
import tempfile
from pypaimon.multimodal.video import _encode_images_to_video
column = self._resolve_video_frame_column(video_column)
target_schema = _target_schema(self.raw_table)
non_video_schema = pa.schema([
field for field in target_schema if field.name != column
])
frame_table = _to_arrow_table(frames, non_video_schema)
with tempfile.TemporaryDirectory(prefix="pypaimon_video_") as directory:
video_path = os.path.join(directory, "video.mp4")
image_count = _encode_images_to_video(
images,
video_path,
fps=fps,
codec=codec,
pixel_format=pixel_format,
gop_size=gop_size,
codec_options=codec_options,
)
if image_count != frame_table.num_rows:
raise ValueError(
"Image count %d does not match frame row count %d."
% (image_count, frame_table.num_rows)
)
return self.add_video(
video_path, frame_table, video_column=column
)
def add_videos(self, videos, *, video_column=None):
"""Append several encoded videos with one writer and one commit.
Each item is ``(video, frames)`` or ``(video, frames, first_frame)``.
Keeping one writer open lets a single ``.video`` object pack multiple
complete encoded videos up to the configured rolling target.
"""
column = self._resolve_video_frame_column(video_column)
target_schema = _target_schema(self.raw_table)
def frame_batches():
for item in videos:
try:
item = tuple(item)
except TypeError as error:
raise ValueError(
"Each videos item must be (video, frames) or "
"(video, frames, first_frame)."
) from error
if len(item) == 2:
video, frames = item
first_frame = 0
elif len(item) == 3:
video, frames, first_frame = item
else:
raise ValueError(
"Each videos item must be (video, frames) or "
"(video, frames, first_frame)."
)
yield _video_frame_table(
video,
frames,
column,
first_frame,
target_schema,
)
return self.add_batches(frame_batches())
def _resolve_video_frame_column(self, requested):
configured = self.raw_table.options.video_frame_fields()
if not configured:
raise ValueError(
"add_video requires table option 'video-frame-field'."
)
if requested is None and len(configured) > 1:
raise ValueError(
"video_column is required when 'video-frame-field' configures "
"multiple fields."
)
column = requested or next(iter(configured))
if column not in configured:
raise ValueError(
"Video column %r is not configured by 'video-frame-field'."
% column
)
return column
def add_batches(self, batches):
"""Append an iterable of batches with one writer and one commit.
Keeping the writer open across batches also preserves video payload
groups and lets one ``.video`` file pack several encoded videos.
"""
try:
iterator = iter(batches)
except TypeError as error:
raise ValueError("batches must be an iterable of input batches.") from error
target_schema = _target_schema(self.raw_table)
table_write = None
table_commit = None
commit_started = False
try:
for data in iterator:
arrow_table = _to_arrow_table(data, target_schema)
if arrow_table.num_rows == 0:
continue
if table_write is None:
write_builder = self.raw_table.new_batch_write_builder()
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(arrow_table)
close_iterator = getattr(iterator, "close", None)
iterator = None
if close_iterator is not None:
close_iterator()
if table_write is None:
return self
commit_messages = table_write.prepare_commit()
commit_started = True
table_commit.commit(commit_messages)
return self
except BaseException:
if table_write is not None and not commit_started:
table_write.abort()
raise
finally:
if iterator is not None:
close_iterator = getattr(iterator, "close", None)
if close_iterator is not None:
close_iterator()
try:
if table_write is not None:
table_write.close()
finally:
if table_commit is not None:
table_commit.close()
def overwrite(self, data, partition: Optional[Mapping[str, object]] = None):
arrow_table = _to_arrow_table(data, _target_schema(self.raw_table))
overwrite_partition = dict(partition) if partition is not None else None
write_builder = (
self.raw_table.new_batch_write_builder()
.overwrite(overwrite_partition)
)
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
try:
table_write.write_arrow(arrow_table)
table_commit.commit(table_write.prepare_commit())
finally:
table_write.close()
table_commit.close()
return self
def update(self, where, values):
video_columns = self.raw_table.options.video_frame_fields()
if isinstance(values, Mapping):
assigned_video_columns = video_columns.intersection(values)
if assigned_video_columns:
raise ValueError(
"update() cannot write video-frame-field %r; use "
"replace_video() with a complete encoded video."
% sorted(assigned_video_columns)[0]
)
query = self.scan().where(where)
predicate = query._predicate
write_builder = self.raw_table.new_batch_write_builder()
table_update = write_builder.new_update()
table_commit = write_builder.new_commit()
try:
messages = table_update.update_by_predicate(predicate, values)
table_commit.commit(messages)
finally:
table_commit.close()
return self
def replace_video(
self,
where,
video,
*,
video_column=None,
first_frame=0):
"""Replace the video backing the logical frame rows matching ``where``.
Matching rows are ordered by ``_ROW_ID`` and assigned consecutive
frame ordinals starting at ``first_frame``. Only the configured video
column is updated; ordinary columns and the normal data files remain
untouched.
"""
column = self._resolve_video_frame_column(video_column)
target_schema = _target_schema(self.raw_table)
payload, keyframe_index, first_frame = _video_payload(
video, first_frame)
row_ids = (
self.scan()
.where(where)
.select([])
.with_row_id()
.to_arrow()[SpecialFields.ROW_ID.name]
.to_pylist()
)
row_ids.sort()
if not row_ids:
return self
descriptors = _video_frame_descriptors(
payload, keyframe_index, len(row_ids), first_frame)
update_data = pa.Table.from_arrays(
[
pa.array(row_ids, type=pa.int64()),
pa.array(descriptors, type=target_schema.field(column).type),
],
names=[SpecialFields.ROW_ID.name, column],
)
write_builder = self.raw_table.new_batch_write_builder()
table_update = write_builder.new_update().with_update_type([column])
table_commit = write_builder.new_commit()
try:
messages = table_update.update_by_arrow_with_row_id(update_data)
table_commit.commit(messages)
finally:
table_commit.close()
return self
def delete(self, where):
query = self.scan().where(where)
predicate = query._predicate
write_builder = self.raw_table.new_batch_write_builder()
table_update = write_builder.new_update()
table_commit = write_builder.new_commit()
try:
messages = table_update.delete_by_predicate(predicate)
table_commit.commit(messages)
finally:
table_commit.close()
return self
def merge(self, on):
return _MergeBuilder(self, on)
def map_with_blobs(self, dataset, columns, fn, **kwargs):
from pypaimon.ray import map_with_blobs
return map_with_blobs(
dataset,
columns,
fn,
file_io=self.raw_table.file_io,
all_blob_columns=_blob_columns(self.raw_table),
map_blob_columns=[field.name for field in self.raw_table.fields
if is_map_blob_type(field.type)],
array_blob_columns=[field.name for field in self.raw_table.fields
if is_array_blob_type(field.type)],
**kwargs,
)
def scan(
self,
*,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None):
return ScanQuery(_time_travel_table(
self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name))
def take_row_ids(
self,
row_ids,
*,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None):
row_ids = _coerce_row_ids(row_ids)
read_table = _time_travel_table(
self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
read_builder = read_table.new_read_builder().with_projection(
[field.name for field in read_table.fields]
+ [SpecialFields.ROW_ID.name]
)
predicate = read_builder.new_predicate_builder().is_in(
SpecialFields.ROW_ID.name, row_ids)
query = ScanQuery(read_table)
query._predicate = predicate
return query
def blobs(self, *, column: Optional[str] = None, key_column: Optional[str] = None):
from pypaimon.multimodal.blob_store import BlobStore
return BlobStore(self, column=column, key_column=key_column)
def search(
self,
query,
*,
column: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
pre_filter=None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None):
read_table = _time_travel_table(
self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
schema = _target_schema(read_table)
if isinstance(query, str):
return TextQuery(
read_table,
text_query=_coerce_full_text_query(
query, "search", schema, column=column),
pre_filter=pre_filter,
)
vector = _coerce_vector(query, "search")
if _looks_like_batch_vectors(vector):
raise ValueError(
"search() accepts a single query; use search_vectors() for "
"multiple vectors.")
return VectorQuery(
read_table,
vector=vector,
vector_column=_resolve_vector_column(
schema, column, len(vector)),
vector_options=options,
pre_filter=pre_filter,
)
def search_vectors(
self,
vectors,
*,
column: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
pre_filter=None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None):
read_table = _time_travel_table(
self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
schema = _target_schema(read_table)
vectors = _coerce_vectors(vectors)
dimension = len(vectors[0])
if any(len(vector) != dimension for vector in vectors):
raise ValueError(
"search_vectors requires all query vectors to have the same "
"dimension.")
vector_column = _resolve_vector_column(
schema, column, dimension)
return BatchVectorQuery(
read_table,
vectors=vectors,
vector_column=vector_column,
vector_options=options,
pre_filter=pre_filter,
)
def search_hybrid(
self,
routes,
*,
ranker: str = "rrf",
route_limit: Optional[int] = None,
pre_filter=None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None):
read_table = _time_travel_table(
self.raw_table, snapshot_id=snapshot_id, tag_name=tag_name)
schema = _target_schema(read_table)
vector_routes, text_routes = _normalize_hybrid_routes(
schema,
routes=routes,
method="search_hybrid",
)
if not vector_routes and not text_routes:
raise ValueError(
"search_hybrid requires at least one route.")
return HybridQuery(
read_table,
vector_routes=vector_routes,
text_routes=text_routes,
ranker=ranker,
route_limit=route_limit,
pre_filter=pre_filter,
)
def create_index(self, column, index_type, options=None):
return self.raw_table.create_global_index(
column, index_type=_normalize_index_type(index_type), options=options)
def _merge(
self,
source,
on,
when_matched: Sequence[WhenMatched],
when_not_matched: Sequence[WhenNotMatched],
operation: str):
target_cols, source_cols = _normalize_merge_on(on, operation)
target_schema = _target_schema(self.raw_table)
source_table = _to_arrow_table(source)
_validate_merge_on(
target_schema, source_table, target_cols, source_cols, operation)
source_table = _cast_merge_on_columns(
source_table, target_schema, target_cols, source_cols)
when_matched, when_not_matched = _resolve_merge_clauses(
when_matched,
when_not_matched,
target_schema,
source_table,
dict(zip(target_cols, source_cols)),
)
write_builder = self.raw_table.new_batch_write_builder()
table_update = write_builder.new_update()
table_commit = write_builder.new_commit()
try:
messages = table_update.merge_into(
source_table,
on=dict(zip(target_cols, source_cols)),
when_matched=list(when_matched),
when_not_matched=list(when_not_matched),
)
table_commit.commit(messages)
finally:
table_commit.close()
return self
class _MergeBuilder:
"""Builder for idempotent merge writes."""
def __init__(self, table: MultimodalTable, on):
self._table = table
self._on = on
self._when_matched = []
self._when_not_matched = []
def when_matched_update(self, values=None, where: Optional[str] = None):
self._when_matched.append(
WhenMatched.update(
_ALL_SOURCE_COLUMNS if values is None else values,
condition=_normalize_merge_where(where),
))
return self
def when_matched_delete(self, where: Optional[str] = None):
self._when_matched.append(
WhenMatched.delete(
condition=_normalize_merge_where(where),
))
return self
def when_not_matched_insert(self, values=None, where: Optional[str] = None):
self._when_not_matched.append(
WhenNotMatched(
insert=_ALL_SOURCE_COLUMNS if values is None else values,
condition=_normalize_merge_where(where),
))
return self
def execute(self, data):
if not self._when_matched and not self._when_not_matched:
raise ValueError(
"merge requires at least one matched or not-matched clause.")
return self._table._merge(
data,
self._on,
when_matched=self._when_matched,
when_not_matched=self._when_not_matched,
operation="merge",
)
def _blob_columns(table):
return tuple(
field.name for field in table.fields
if is_blob_file_type(field.type)
)
def _to_arrow_table(data, target_schema=None):
if target_schema is not None:
data = _serialize_blob_values(data, target_schema)
if isinstance(data, list):
# Untyped from_pylist only discovers columns present in the first row.
data = {
field.name: [row.get(field.name) for row in data]
for field in target_schema
}
if isinstance(data, dict):
data = dict(data)
for field in target_schema:
if field.name in data and pa.types.is_map(field.type):
# Inferred STRUCT/LIST arrays cannot be cast back to MAP.
data[field.name] = pa.array(data[field.name], type=field.type)
if isinstance(data, pa.Table):
table = data
elif isinstance(data, pa.RecordBatch):
table = pa.Table.from_batches([data])
elif isinstance(data, list):
# Inference from the first row alone drops fields in later source rows.
names = dict.fromkeys(name for row in data for name in row)
table = pa.Table.from_pydict({
name: [row.get(name) for row in data] for name in names
})
elif isinstance(data, dict):
table = pa.Table.from_pydict(data)
elif hasattr(data, "__dataframe__") or data.__class__.__module__.startswith("pandas"):
table = pa.Table.from_pandas(data, preserve_index=False)
else:
raise ValueError("Unsupported multimodal data type: %r" % type(data))
if target_schema is None:
return table
return _align_to_schema(table, target_schema)
def _serialize_blob_values(data, target_schema):
if isinstance(data, (pa.Table, pa.RecordBatch)):
return data
binary_fields = {
field.name: field.type
for field in target_schema
if _contains_binary(field.type)
}
if not binary_fields:
return data
if isinstance(data, list):
return [
{
name: _serialize_blob_value(value, binary_fields.get(name))
for name, value in row.items()
}
if isinstance(row, Mapping)
else row
for row in data
]
if isinstance(data, dict):
converted = dict(data)
for name, arrow_type in binary_fields.items():
if name not in converted:
continue
column = converted[name]
if isinstance(column, (pa.Array, pa.ChunkedArray)):
column = column.to_pylist()
converted[name] = [
_serialize_blob_value(value, arrow_type)
for value in column
]
return converted
if (
hasattr(data, "__dataframe__")
or data.__class__.__module__.startswith("pandas")
):
converted = data.copy()
for name, arrow_type in binary_fields.items():
if name in converted.columns:
converted[name] = converted[name].map(
lambda value: _serialize_blob_value(value, arrow_type)
)
return converted
return data
def _contains_binary(arrow_type):
if pa.types.is_binary(arrow_type) or pa.types.is_large_binary(arrow_type):
return True
if pa.types.is_list(arrow_type) or pa.types.is_large_list(arrow_type):
return _contains_binary(arrow_type.value_type)
if pa.types.is_map(arrow_type):
return _contains_binary(arrow_type.item_type)
return False
def _serialize_blob_value(value, arrow_type):
if value is None or arrow_type is None:
return value
if pa.types.is_binary(arrow_type) or pa.types.is_large_binary(arrow_type):
from pypaimon.table.row.blob import Blob, BlobDescriptor
if isinstance(value, BlobDescriptor):
return value.serialize()
if isinstance(value, Blob):
try:
return value.to_descriptor().serialize()
except RuntimeError:
return value.to_data()
return value
if pa.types.is_list(arrow_type) or pa.types.is_large_list(arrow_type):
return [
_serialize_blob_value(element, arrow_type.value_type)
for element in value
]
if pa.types.is_map(arrow_type):
entries = value.items() if isinstance(value, Mapping) else value
return [
(key, _serialize_blob_value(element, arrow_type.item_type))
for key, element in entries
]
return value
def _coerce_row_ids(row_ids):
if row_ids is None or isinstance(row_ids, (str, bytes)):
raise ValueError("row_ids must be an iterable of row id integers.")
try:
iterator = iter(row_ids)
except TypeError:
raise ValueError("row_ids must be an iterable of row id integers.")
coerced = []
for row_id in iterator:
if hasattr(row_id, "as_py"):
row_id = row_id.as_py()
coerced.append(int(row_id))
return coerced
def _time_travel_table(table, snapshot_id=None, tag_name=None):
if snapshot_id is not None and tag_name is not None:
raise ValueError(
"snapshot_id and tag_name cannot be set at the same time")
if snapshot_id is not None:
return table.copy({
CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot_id),
})
if tag_name is not None:
return table.copy({
CoreOptions.SCAN_TAG_NAME.key(): tag_name,
})
return table
def _target_schema(table):
return PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
def _video_payload(video, first_frame):
from pypaimon.table.row.blob import (
Blob,
BlobDescriptor,
VideoFrameDescriptor,
)
if isinstance(first_frame, bool) or not isinstance(first_frame, int):
raise ValueError("first_frame must be a non-negative int.")
if first_frame < 0:
raise ValueError("first_frame must be a non-negative int.")
if isinstance(video, str):
video = Blob.from_local(video)
if isinstance(video, Blob):
try:
payload = video.to_descriptor()
except RuntimeError as error:
raise ValueError(
"video must be descriptor-backed; inline video bytes are not "
"accepted by video write APIs."
) from error
elif isinstance(video, BlobDescriptor):
payload = video
else:
raise ValueError(
"video must be a path, Blob, or BlobDescriptor, got %r."
% type(video)
)
keyframe_index = None
if isinstance(payload, VideoFrameDescriptor):
keyframe_index = payload.keyframe_index_descriptor
payload = payload.payload_descriptor
return payload, keyframe_index, first_frame
def _video_frame_descriptors(payload, keyframe_index, count, first_frame):
from pypaimon.table.row.blob import VideoFrameDescriptor
index_offset = -1 if keyframe_index is None else keyframe_index.offset
index_length = 0 if keyframe_index is None else keyframe_index.length
return [
VideoFrameDescriptor(
payload.uri,
payload.offset,
payload.length,
first_frame + index,
index_offset,
index_length,
).serialize()
for index in range(count)
]
def _video_frame_table(video, frames, video_column, first_frame, target_schema):
payload, keyframe_index, first_frame = _video_payload(video, first_frame)
non_video_schema = pa.schema([
field for field in target_schema if field.name != video_column
])
frame_table = _to_arrow_table(frames, non_video_schema)
descriptor_values = _video_frame_descriptors(
payload, keyframe_index, frame_table.num_rows, first_frame)
arrays = []
for field in target_schema:
if field.name == video_column:
arrays.append(pa.array(descriptor_values, type=field.type))
else:
arrays.append(frame_table[field.name])
return pa.Table.from_arrays(arrays, schema=target_schema)
def _align_to_schema(
table: pa.Table,
schema: pa.Schema,
column_mapping: Optional[Dict[str, str]] = None) -> pa.Table:
column_mapping = column_mapping or {}
arrays = []
for field in schema:
source_name = column_mapping.get(field.name, field.name)
if source_name in table.column_names:
array = table[source_name]
if array.type != field.type:
array = array.cast(field.type)
else:
array = pa.nulls(table.num_rows, type=field.type)
arrays.append(array)
return pa.Table.from_arrays(arrays, schema=schema)
def _normalize_merge_on(on, operation: str):
if isinstance(on, Mapping):
target_cols = list(on.keys())
source_cols = list(on.values())
elif isinstance(on, str):
target_cols = [on]
source_cols = [on]
else:
target_cols = list(on)
source_cols = list(on)
if not target_cols:
raise ValueError("%s requires at least one ON column." % operation)
if len(target_cols) != len(source_cols):
raise ValueError(
"%s ON target/source column counts do not match." % operation)
return target_cols, source_cols
def _validate_merge_on(
target_schema,
source_table,
target_cols,
source_cols,
operation: str):
target_names = set(target_schema.names)
source_names = set(source_table.column_names)
missing_targets = [col for col in target_cols if col not in target_names]
if missing_targets:
raise ValueError(
"%s ON target columns are not in table schema: %s"
% (operation, missing_targets))
missing_sources = [col for col in source_cols if col not in source_names]
if missing_sources:
raise ValueError(
"%s ON source columns are not in source data: %s"
% (operation, missing_sources))
def _cast_merge_on_columns(source_table, target_schema, target_cols, source_cols):
result = source_table
for target_col, source_col_name in zip(target_cols, source_cols):
target_type = target_schema.field(target_col).type
index = result.schema.get_field_index(source_col_name)
if index < 0:
continue
array = result[source_col_name]
if array.type == target_type:
continue
source_field = result.schema.field(index)
result = result.set_column(
index,
pa.field(
source_col_name,
target_type,
nullable=source_field.nullable,
metadata=source_field.metadata,
),
array.cast(target_type),
)
return result
def _resolve_merge_clauses(
when_matched,
when_not_matched,
target_schema,
source_table,
on_map):
target_names = list(target_schema.names)
source_names = set(source_table.column_names)
return (
[
(
WhenMatched.delete(condition=clause.condition)
if clause.delete
else WhenMatched.update(
_resolve_merge_set_spec(
clause.update, target_names, source_names, on_map),
condition=clause.condition,
)
)
for clause in when_matched
],
[
WhenNotMatched(
insert=_resolve_merge_set_spec(
clause.insert, target_names, source_names, on_map),
condition=clause.condition,
)
for clause in when_not_matched
],
)
def _resolve_merge_set_spec(spec, target_names, source_names, on_map):
if spec is not _ALL_SOURCE_COLUMNS:
return spec
return {
target_name: _source_col(source_name)
for target_name in target_names
for source_name in [on_map.get(target_name, target_name)]
if source_name in source_names
}
def _normalize_merge_where(where):
if where is None:
return None
if not isinstance(where, str):
raise ValueError("merge where must be a string.")
parts, last = [], 0
for match in _STRING_LITERAL_PATTERN.finditer(where):
parts.append(_rewrite_merge_refs(where[last:match.start()]))
parts.append(match.group())
last = match.end()
parts.append(_rewrite_merge_refs(where[last:]))
return ''.join(parts)
def _rewrite_merge_refs(text):
return _MERGE_REF_PATTERN.sub(
lambda m: "%s.%s" % (
"s" if m.group(1) == "source" else "t",
m.group(2),
),
text,
)
def _normalize_index_type(index_type):
if not isinstance(index_type, str):
return index_type
normalized = index_type.strip().lower()
if normalized == "full-text":
return "full-text"
return index_type
def _coerce_vector(value, method):
if value is None or isinstance(value, str):
raise ValueError("%s requires a vector query." % method)
if hasattr(value, "tolist"):
value = value.tolist()
try:
return list(value)
except TypeError as e:
raise ValueError("%s requires a vector query." % method) from e
def _coerce_vectors(values):
method = "search_vectors"
if values is None or isinstance(values, str):
raise ValueError("%s requires query vectors." % method)
if hasattr(values, "tolist"):
values = values.tolist()
try:
vectors = list(values)
except TypeError as e:
raise ValueError("%s requires query vectors." % method) from e
if not vectors:
raise ValueError("%s requires at least one vector query." % method)
return [_coerce_vector(vector, method) for vector in vectors]
def _looks_like_batch_vectors(value):
if not value:
return False
first = value[0]
return not isinstance(first, (int, float)) and hasattr(first, "__iter__")
def _normalize_hybrid_routes(
schema,
routes,
method):
vector_routes, text_routes = [], []
for route in _route_specs(routes):
if _is_text_route_spec(route):
text_routes.append(_normalize_text_route(route, method, schema))
else:
vector_routes.append(
_normalize_vector_route(route, method, schema))
return vector_routes, text_routes
def _route_specs(routes):
if routes is None:
return []
if isinstance(routes, (VectorRoute, TextRoute, Mapping)):
return [routes]
if _is_column_vector_pair(routes):
return [routes]
return list(routes)
def _is_text_route_spec(route):
if isinstance(route, TextRoute):
return True
if isinstance(route, VectorRoute):
return False
if isinstance(route, Mapping):
route_type = route.get("type") or route.get("route_type")
if route_type is not None:
return str(route_type).replace("-", "_").lower() in (
"text", "full_text", "fulltext")
return (
"query" in route
or "text" in route
or "terms" in route
or "full_text_query" in route
) and not _has_vector_query(route)
return False
def _normalize_vector_route(route, method, schema):
if isinstance(route, VectorRoute):
column = route.column
vector = route.vector
weight = route.weight
limit = route.limit
options = route.options
elif isinstance(route, Mapping):
column = (
route.get("column")
or route.get("vector_column")
or route.get("field")
or route.get("anns_field")
)
vector = _mapping_vector(route)
weight = route.get("weight", 1.0)
limit = route.get("limit")
options = _mapping_options(route)
elif _is_column_vector_pair(route):
column, vector = route
weight = 1.0
limit = None
options = None
else:
raise ValueError(
"%s vector routes require a route spec with a column and vector."
% method)
if not column:
raise ValueError("%s vector routes require a column." % method)
return {
"column": column,
"vector": _coerce_vector(vector, method),
"weight": weight,
"limit": limit,
"options": dict(options or {}),
}
def _normalize_text_route(route, method, schema):
if isinstance(route, TextRoute):
column = route.column
query = route.query
weight = route.weight
limit = route.limit
options = route.options
elif isinstance(route, Mapping):
column = route.get("column")
query = (
route.get("query")
or route.get("text")
or route.get("terms")
or route.get("full_text_query")
)
weight = route.get("weight", 1.0)
limit = route.get("limit")
options = route.get("options")
else:
raise ValueError(
"%s text routes require a route spec with a query."
% method)
text_query = _coerce_full_text_query(query, method, schema, column=column)
return {
"column": text_query["column"],
"query": text_query["query"],
"weight": weight,
"limit": limit,
"options": dict(options or {}),
}
def _has_vector_query(route):
return any(key in route for key in ("vector", "query_vector", "data"))
def _mapping_vector(route):
if "vector" in route:
return route["vector"]
if "query_vector" in route:
return route["query_vector"]
if "data" in route:
data = route["data"]
if hasattr(data, "tolist"):
data = data.tolist()
values = list(data)
if len(values) == 1 and hasattr(values[0], "__iter__"):
return values[0]
return values
raise ValueError("Vector route requires vector, query_vector, or data.")
def _mapping_options(route):
if "options" in route:
return route["options"]
if "param" in route:
param = route["param"]
if isinstance(param, Mapping) and isinstance(param.get("params"), Mapping):
return param["params"]
return param
if "params" in route:
return route["params"]
return None
def _is_column_vector_pair(route):
return (
isinstance(route, (list, tuple))
and len(route) == 2
and isinstance(route[0], str)
)
def _coerce_full_text_query(query, method, schema, column=None):
field_name = column or _infer_text_column(schema, "text")
if isinstance(query, str):
stripped = query.lstrip()
query_json = (
query
if stripped.startswith("{")
else json.dumps({"match": {"query": query}}, separators=(",", ":"))
)
return {"column": field_name, "query": query_json}
if isinstance(query, Mapping):
return {
"column": field_name,
"query": json.dumps(query, separators=(",", ":")),
}
raise ValueError("%s requires a text string or query mapping." % method)
def _resolve_vector_column(
schema: pa.Schema,
column: Optional[str],
dimension: int) -> str:
if column is not None:
try:
field = schema.field(column)
except KeyError as e:
raise ValueError(
"Vector column '%s' not found in table schema." % column) from e
if not pa.types.is_fixed_size_list(field.type):
raise ValueError(
"Column '%s' is not a fixed-size vector column." % column)
if field.type.list_size != dimension:
raise ValueError(
"Vector dimension %d does not match column '%s' dimension %d."
% (dimension, column, field.type.list_size))
return column
candidates = [
(field.name, field.type.list_size)
for field in schema
if pa.types.is_fixed_size_list(field.type)
]
matches = [
name
for name, column_dimension in candidates
if column_dimension == dimension
]
if len(matches) == 1:
return matches[0]
if not matches:
available = ", ".join(
"%s(%d)" % candidate for candidate in candidates) or "none"
raise ValueError(
"No vector column found with dimension %d; available vector "
"columns: %s."
% (dimension, available))
raise ValueError(
"Multiple vector columns found with dimension %d: %s; pass column."
% (dimension, ", ".join(matches)))
def _infer_text_column(schema: pa.Schema, parameter: str = "text_column"):
columns = [
field.name
for field in schema
if pa.types.is_string(field.type) or pa.types.is_large_string(field.type)
]
return _infer_single_column(columns, "text", parameter)
def _infer_single_column(columns, kind, parameter):
if len(columns) == 1:
return columns[0]
if not columns:
raise ValueError("No %s column found; pass %s." % (kind, parameter))
raise ValueError(
"Multiple %s columns found %s; pass %s."
% (kind, columns, parameter))