blob: 2cfa1ad0ba9b02ba8fdb8d82c988f48c774f20c7 [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 io
import json
import os
import shutil
import tempfile
import unittest
import pyarrow as pa
import pypaimon.multimodal as pmm
from pypaimon.multimodal import source_col
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon import Schema as PaimonSchema
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.utils.range import Range
try:
import ray
except ImportError:
ray = None
_PARQUET_OPTIONS = {
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"deletion-vectors.enabled": "true",
"file.format": "parquet",
"vector.file.format": "parquet",
}
def _schema(fields):
return pa.schema([
pa.field(name, field_type)
for name, field_type in fields.items()
])
def _vector(dim):
return pa.list_(pa.float32(), dim)
def _raw_schema(options=None, primary_keys=None):
return PaimonSchema.from_pyarrow_schema(
_schema({"id": pa.int32(), "name": pa.string()}),
primary_keys=list(primary_keys or []),
options=dict(options or {"file.format": "parquet"}),
)
class MultimodalTableTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp(prefix="pypaimon_mm_")
self.warehouse = os.path.join(self.temp_dir, "warehouse")
self.conn = pmm.connect(options={"warehouse": self.warehouse})
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_connect_accepts_options(self):
conn = pmm.connect(
database="analytics",
options={
"warehouse": os.path.join(self.temp_dir, "warehouse_options"),
},
)
table = conn.create_table(
"docs",
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
self.assertEqual("analytics.docs", table.identifier)
def test_connect_rejects_positional_warehouse(self):
with self.assertRaises(TypeError):
pmm.connect(self.warehouse)
def test_create_table_defaults_data_evolution_options(self):
table = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
"payload": pa.large_binary(),
}),
)
options = table.raw_table.table_schema.options
self.assertEqual("true", options["row-tracking.enabled"])
self.assertEqual("true", options["data-evolution.enabled"])
self.assertEqual("true", options["deletion-vectors.enabled"])
self.assertEqual("true", options["blob-as-descriptor"])
self.assertNotIn("data-evolution.row-sidecar.enabled", options)
self.assertEqual("full", options["global-index.search-mode"])
self.assertEqual("vortex", options["vector.file.format"])
self.assertEqual("default.docs", self.conn.get_table("docs").identifier)
self.assertEqual(["id", "content", "embedding", "payload"],
[field.name for field in table.raw_table.fields])
def test_create_table_uses_options_and_partitioned(self):
table = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
"dt": pa.string(),
}),
options=dict(_PARQUET_OPTIONS, **{
"deletion-vectors.enabled": "false",
}),
partitioned=["dt"],
)
options = table.raw_table.table_schema.options
self.assertEqual(["dt"], table.raw_table.table_schema.partition_keys)
self.assertEqual("false", options["deletion-vectors.enabled"])
self.assertEqual("parquet", options["file.format"])
self.assertEqual("parquet", options["vector.file.format"])
def test_create_table_rejects_non_arrow_schema(self):
with self.assertRaisesRegex(ValueError, "pyarrow.Schema"):
self.conn.create_table("docs", schema={"id": pa.int32()})
def test_create_table_accepts_pyarrow_schema_types(self):
table = self.conn.create_table(
"typed",
schema=pa.schema([
pa.field("flag", pa.bool_(), nullable=False),
pa.field("tiny", pa.int8()),
pa.field("small", pa.int16()),
pa.field("id", pa.int64()),
pa.field("score", pa.float32()),
pa.field("ratio", pa.float64()),
pa.field("title", pa.string()),
pa.field("payload", pa.binary()),
pa.field("hash", pa.binary(16)),
pa.field("blob", pa.large_binary()),
pa.field("amount", pa.decimal128(12, 2)),
pa.field("dt", pa.date32()),
pa.field("created_at", pa.timestamp("us")),
pa.field("created_ltz", pa.timestamp("us", tz="UTC")),
pa.field("tags", pa.list_(pa.string())),
pa.field("attrs", pa.map_(pa.string(), pa.int32())),
pa.field("meta", pa.struct([pa.field("rank", pa.int32())])),
pa.field("embedding", _vector(3)),
]),
)
types_by_name = {
field.name: str(field.type) for field in table.raw_table.fields
}
self.assertEqual("BOOLEAN NOT NULL", types_by_name["flag"])
self.assertEqual("TINYINT", types_by_name["tiny"])
self.assertEqual("SMALLINT", types_by_name["small"])
self.assertEqual("BIGINT", types_by_name["id"])
self.assertEqual("FLOAT", types_by_name["score"])
self.assertEqual("DOUBLE", types_by_name["ratio"])
self.assertEqual("STRING", types_by_name["title"])
self.assertEqual("BYTES", types_by_name["payload"])
self.assertEqual("BINARY(16)", types_by_name["hash"])
self.assertEqual("BLOB", types_by_name["blob"])
self.assertEqual("DECIMAL(12, 2)", types_by_name["amount"])
self.assertEqual("DATE", types_by_name["dt"])
self.assertEqual("TIMESTAMP(6)", types_by_name["created_at"])
self.assertEqual("TIMESTAMP_LTZ(6)", types_by_name["created_ltz"])
self.assertEqual("ARRAY<STRING>", types_by_name["tags"])
self.assertEqual("MAP<STRING NOT NULL, INT>", types_by_name["attrs"])
self.assertEqual("ROW<rank: INT>", types_by_name["meta"])
self.assertEqual("VECTOR<FLOAT, 3>", types_by_name["embedding"])
def test_blob_store_put_objects_get_list_and_delete(self):
table = self.conn.create_table(
"objects",
schema=_schema({
"key": pa.string(),
"image": pa.large_binary(),
"content_type": pa.string(),
"owner": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
store = table.blobs(column="image")
results = store.put_objects([
{
"key": "images/cat.jpg",
"body": b"cat-image-old",
"columns": {"content_type": "image/gif", "owner": "ignored"},
},
{
"key": "images/cat.jpg",
"body": b"cat-image-v1",
"columns": {"content_type": "image/jpeg", "owner": "alice"},
},
{
"key": "images/dog.jpg",
"body": bytearray(b"dog-image"),
"columns": {"content_type": "image/jpeg", "owner": "bob"},
},
])
self.assertEqual(["images/cat.jpg", "images/dog.jpg"],
[result.key for result in results])
self.assertEqual([12, 9], [result.size for result in results])
cat = store.get_object("images/cat.jpg")
self.assertEqual(b"cat-image-v1", cat.read())
self.assertEqual(b"at-i", store.get_object(
"images/cat.jpg", range="bytes=1-4").read())
clipped = store.get_object("images/cat.jpg", range="bytes=10-999")
self.assertEqual(2, clipped.content_length)
self.assertEqual(b"v1", clipped.read())
with self.assertRaisesRegex(ValueError, "Range start"):
store.get_object("images/cat.jpg", range="bytes=12-13")
self.assertEqual("image/jpeg", cat.columns["content_type"])
self.assertEqual("alice", cat.columns["owner"])
owner_only = store.get_object(
"images/cat.jpg",
columns=["owner"],
)
self.assertEqual({"owner": "alice"}, owner_only.columns)
listed = store.list_objects(prefix="images/")
self.assertEqual(["images/cat.jpg", "images/dog.jpg"],
sorted(obj.key for obj in listed))
self.assertEqual(
{
"images/cat.jpg": "image/jpeg",
"images/dog.jpg": "image/jpeg",
},
{obj.key: obj.columns["content_type"] for obj in listed},
)
listed_without_columns = store.list_objects(
prefix="images/",
columns=[],
)
self.assertEqual(
{"images/cat.jpg": {}, "images/dog.jpg": {}},
{obj.key: obj.columns for obj in listed_without_columns},
)
self.assertEqual([], store.list_objects(prefix="images/", limit=0))
with self.assertRaisesRegex(ValueError, "limit"):
store.list_objects(prefix="images/", limit=-1)
store.put_object(
"images/cat.jpg",
b"cat-image-v2",
columns={"content_type": "image/png", "owner": "alice"},
)
self.assertEqual(b"cat-image-v2", store.get_object("images/cat.jpg").read())
info = store.head_object("images/cat.jpg")
self.assertEqual("image/png", info.columns["content_type"])
self.assertEqual(
{"content_type": "image/png"},
store.head_object(
"images/cat.jpg",
columns="content_type",
).columns,
)
self.assertEqual(2, table.scan().to_arrow().num_rows)
previous_descriptor = info.descriptor
updated = store.update_object_columns(
"images/cat.jpg",
{"content_type": "image/webp"},
)
self.assertEqual(previous_descriptor, updated.descriptor)
self.assertEqual("image/webp", updated.columns["content_type"])
self.assertEqual("alice", updated.columns["owner"])
self.assertEqual(b"cat-image-v2", store.get_object("images/cat.jpg").read())
batch_updates = store.update_objects_columns([
{"key": "images/cat.jpg", "columns": {"owner": "carol"}},
{"key": "images/dog.jpg", "columns": {"owner": "dave"}},
])
self.assertEqual(
["images/cat.jpg", "images/dog.jpg"],
[obj.key for obj in batch_updates],
)
self.assertEqual("carol", store.head_object("images/cat.jpg").columns["owner"])
self.assertEqual("dave", store.head_object("images/dog.jpg").columns["owner"])
self.assertEqual(2, table.scan().to_arrow().num_rows)
with self.assertRaisesRegex(ValueError, "columns must not be empty"):
store.update_object_columns("images/cat.jpg", {})
with self.assertRaises(pmm.NoSuchKey):
store.update_object_columns("images/missing.jpg", {"owner": "nobody"})
with self.assertRaisesRegex(ValueError, "columns must not include"):
store.update_object_columns("images/cat.jpg", {"image": b"new"})
store.delete_objects(["images/dog.jpg", "images/dog.jpg"])
with self.assertRaises(pmm.NoSuchKey):
store.head_object("images/dog.jpg")
self.assertEqual(["images/cat.jpg"],
[obj.key for obj in store.list_objects(prefix="images/")])
store.delete_object("images/cat.jpg")
self.assertEqual([], store.list_objects(prefix="images/"))
def test_blob_store_put_object_accepts_blob_without_materializing(self):
from pypaimon.table.row.blob import Blob, BlobDescriptor
table = self.conn.create_table(
"streamed_objects",
schema=_schema({
"key": pa.string(),
"payload": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
)
data = b"streamed-managed-payload"
stream_uri = "stream://payloads/1"
stream_calls = []
class StreamOnlyReader:
def new_input_stream(self, uri):
stream_calls.append(("new_input_stream", uri))
return io.BytesIO(data)
class StreamOnlyReaderFactory:
def create(self, uri):
stream_calls.append(("create", uri))
return StreamOnlyReader()
class DescriptorOnlyBlob(Blob):
def to_data(self):
raise AssertionError("put_object should not materialize Blob data")
def to_descriptor(self):
return BlobDescriptor(stream_uri, 0, len(data))
def new_input_stream(self):
raise AssertionError("put_object should use the URI stream")
store = table.blobs(column="payload")
original_factory = table.raw_table.file_io.uri_reader_factory
table.raw_table.file_io.uri_reader_factory = StreamOnlyReaderFactory()
try:
result = store.put_object("payloads/1", DescriptorOnlyBlob())
finally:
table.raw_table.file_io.uri_reader_factory = original_factory
self.assertEqual(len(data), result.size)
self.assertEqual([
("create", stream_uri),
("new_input_stream", stream_uri),
], stream_calls)
stored = store.get_object("payloads/1")
self.assertNotEqual(stream_uri, stored.descriptor.uri)
self.assertEqual(data, stored.read())
def test_blob_store_put_object_reference_preserves_descriptor_uri(self):
table = self.conn.create_table(
"referenced_objects",
schema=_schema({
"object_key": pa.string(),
"payload": pa.large_binary(),
"media_type": pa.string(),
}),
options=dict(_PARQUET_OPTIONS, **{
"blob-descriptor-field": "payload",
}),
)
data = b"external-video-payload"
external_path = os.path.join(self.temp_dir, "video.bin")
with open(external_path, "wb") as f:
f.write(data)
store = table.blobs(column="payload")
result = store.put_object(
"videos/1",
uri=external_path,
length=len(data),
columns={"media_type": "video/mp4"},
)
descriptor = store.head_object("videos/1").descriptor
more = store.put_objects([
{
"key": "videos/2",
"descriptor": descriptor,
"columns": {"media_type": "video/mp4"},
}
])
self.assertEqual("videos/1", result.key)
self.assertEqual(len(data), result.size)
self.assertEqual("videos/2", more[0].key)
obj = store.get_object("videos/1")
self.assertEqual(data, obj.read())
self.assertEqual("video/mp4", obj.columns["media_type"])
self.assertEqual(external_path, store.head_object("videos/1").descriptor.uri)
self.assertEqual(data, store.get_object("videos/2").read())
def test_blob_store_put_object_uri_streams_into_managed_blob(self):
table = self.conn.create_table(
"managed_only_objects",
schema=_schema({
"key": pa.string(),
"payload": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
)
data = b"external-managed-payload"
external_path = os.path.join(self.temp_dir, "managed.bin")
with open(external_path, "wb") as f:
f.write(data)
store = table.blobs(column="payload")
result = store.put_object(
"payloads/1",
uri=external_path,
length=len(data),
)
self.assertEqual(len(data), result.size)
stored = store.get_object("payloads/1")
self.assertNotEqual(external_path, stored.descriptor.uri)
self.assertEqual(data, stored.read())
def test_drop_table_can_ignore_missing_table(self):
self.conn.drop_table("missing", ignore_if_not_exists=True)
def test_get_table_rejects_non_data_evolution_table(self):
self.conn.catalog.create_database("default", ignore_if_exists=True)
self.conn.catalog.create_table(
"default.raw",
_raw_schema(),
False,
)
with self.assertRaisesRegex(ValueError, "data-evolution.enabled"):
self.conn.get_table("raw")
def test_get_table_rejects_primary_key_table(self):
self.conn.catalog.create_database("default", ignore_if_exists=True)
self.conn.catalog.create_table(
"default.pk",
_raw_schema(
options=dict(_PARQUET_OPTIONS, **{"bucket": "1"}),
primary_keys=["id"],
),
False,
)
with self.assertRaisesRegex(ValueError, "primary keys"):
self.conn.get_table("pk")
def test_create_table_can_add_initial_data_and_get_by_short_name(self):
self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users = self.conn.get_table("users")
result = users.scan().select(["id", "name"]).to_arrow()
self.assertEqual(["id", "name"], result.column_names)
self.assertEqual([1, 2], result["id"].to_pylist())
def test_add_scan_where_select_limit(self):
users = self.conn.create_table(
"users",
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.add([
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
{"id": 3, "name": "Carol", "age": 40},
])
result = (
users.scan()
.where("age >= 30")
.select(["id", "name"])
.limit(1)
.to_arrow()
)
self.assertEqual(["id", "name"], result.column_names)
self.assertEqual(1, result.num_rows)
self.assertEqual([1], result["id"].to_pylist())
def test_scan_with_row_id_returns_system_column(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
result = (
users.scan()
.with_row_id()
.select(["id"])
.to_arrow()
)
self.assertEqual(["id", "_ROW_ID"], result.column_names)
self.assertEqual([1, 2], result["id"].to_pylist())
self.assertEqual([0, 1], result["_ROW_ID"].to_pylist())
def test_take_row_ids_reads_projected_rows(self):
docs = self.conn.create_table(
"docs",
data=[
{"id": 1, "content": "alpha"},
{"id": 2, "content": "beta"},
{"id": 3, "content": "gamma"},
],
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
manifest = {
row["id"]: row["_ROW_ID"]
for row in docs.scan().select(["id"]).with_row_id().to_list()
}
rows = sorted(
docs.take_row_ids([manifest[3], manifest[1]])
.select(["id", "content"])
.with_row_id()
.to_list(),
key=lambda row: row["id"],
)
self.assertEqual(
[
{"id": 1, "content": "alpha", "_ROW_ID": manifest[1]},
{"id": 3, "content": "gamma", "_ROW_ID": manifest[3]},
],
rows,
)
def test_take_row_ids_accepts_empty_manifest(self):
docs = self.conn.create_table(
"docs",
data=[{"id": 1}],
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
self.assertEqual([], docs.take_row_ids([]).select(["id"]).to_list())
def test_take_row_ids_supports_snapshot_and_tag_time_travel(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "content": "first", "embedding": [1.0, 0.0, 0.0]},
])
snapshot_id = docs.raw_table.snapshot_manager().get_latest_snapshot().id
docs.raw_table.create_tag("v1", snapshot_id=snapshot_id)
row_id = (
docs.scan(snapshot_id=snapshot_id)
.select(["id"])
.with_row_id()
.to_list()[0]["_ROW_ID"]
)
docs.delete(where="id = 1")
self.assertEqual(
[],
docs.take_row_ids([row_id]).select(["id"]).to_list(),
)
self.assertEqual(
[{"id": 1, "_ROW_ID": row_id}],
docs.take_row_ids([row_id], snapshot_id=snapshot_id)
.select(["id"])
.with_row_id()
.to_list(),
)
self.assertEqual(
[{"id": 1}],
docs.take_row_ids([row_id], tag_name="v1")
.select(["id"])
.to_list(),
)
self.assertEqual(
snapshot_id,
docs.search(
[1.0, 0.0, 0.0],
column="embedding",
snapshot_id=snapshot_id,
)._table.options.scan_snapshot_id(),
)
def test_time_travel_rejects_snapshot_id_and_tag_name_together(self):
docs = self.conn.create_table(
"docs",
data=[{"id": 1}],
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
with self.assertRaisesRegex(ValueError, "cannot be set at the same time"):
docs.take_row_ids([0], snapshot_id=1, tag_name="v1")
def test_overwrite_replaces_unpartitioned_table(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
result = users.overwrite([
{"id": 3, "name": "Carol", "age": 40},
])
self.assertIs(users, result)
self.assertEqual(
[
{"id": 3, "name": "Carol", "age": 40},
],
users.scan().to_list(),
)
def test_empty_overwrite_clears_unpartitioned_table(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
users.overwrite([])
self.assertEqual([], users.scan().to_list())
def test_overwrite_replaces_dynamic_partitions(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "dt": "2024-01-01"},
{"id": 2, "name": "Bob", "dt": "2024-01-02"},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"dt": pa.string(),
}),
options=_PARQUET_OPTIONS,
partitioned=["dt"],
)
users.overwrite([
{"id": 3, "name": "Carol", "dt": "2024-01-01"},
])
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 2, "name": "Bob", "dt": "2024-01-02"},
{"id": 3, "name": "Carol", "dt": "2024-01-01"},
],
rows,
)
def test_overwrite_replaces_static_partition(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "dt": "2024-01-01"},
{"id": 2, "name": "Bob", "dt": "2024-01-02"},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"dt": pa.string(),
}),
options=dict(_PARQUET_OPTIONS, **{
"dynamic-partition-overwrite": "false",
}),
partitioned=["dt"],
)
users.overwrite([
{"id": 3, "name": "Carol", "dt": "2024-01-01"},
], partition={"dt": "2024-01-01"})
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 2, "name": "Bob", "dt": "2024-01-02"},
{"id": 3, "name": "Carol", "dt": "2024-01-01"},
],
rows,
)
def test_scan_read_blobs(self):
obs = self.conn.create_table(
"obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
payloads = [("blob-%d-" % i).encode() * (i + 1) for i in range(6)]
obs.add([
{"clip": "c1", "idx": 0, "image": payloads[0]},
{"clip": "c1", "idx": 1, "image": payloads[1]},
{"clip": "c1", "idx": 2, "image": payloads[2]},
{"clip": "c2", "idx": 0, "image": payloads[3]},
{"clip": "c2", "idx": 1, "image": payloads[4]},
{"clip": "c3", "idx": 0, "image": None},
])
# scalar table is row-aligned with the blob list and drops the blob column
scalar, blobs = obs.scan().where("clip = 'c1'").read_blobs("image")
images = blobs["image"]
self.assertEqual(3, len(images))
self.assertNotIn("image", scalar.column_names)
got = dict(zip(scalar.column("idx").to_pylist(), images))
self.assertEqual({0: payloads[0], 1: payloads[1], 2: payloads[2]}, got)
# blob column auto-detected when columns=None
_, blobs2 = obs.scan().where("clip = 'c2'").read_blobs()
self.assertEqual({payloads[3], payloads[4]}, set(blobs2["image"]))
# null blob -> None
_, blobs3 = obs.scan().where("clip = 'c3'").read_blobs("image")
self.assertEqual([None], blobs3["image"])
# non-BLOB column is rejected
with self.assertRaisesRegex(ValueError, "not a BLOB column"):
obs.scan().read_blobs("idx")
# empty result: 0 matching rows -> empty blob list, schema preserved
scalar_e, blobs_e = obs.scan().where("clip = 'none'").read_blobs("image")
self.assertEqual(0, scalar_e.num_rows)
self.assertEqual([], blobs_e["image"])
def test_scan_read_blobs_multi_column(self):
obs = self.conn.create_table(
"multi",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"img": pa.large_binary(),
"aud": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([
{"clip": "c1", "idx": i,
"img": ("img-%d" % i).encode(), "aud": ("aud-%d" % i).encode()}
for i in range(4)
])
# both BLOB columns fetched in one call, each row-aligned with the scalars
scalar, blobs = obs.scan().where("clip = 'c1'").read_blobs(["img", "aud"])
self.assertEqual({"img", "aud"}, set(blobs))
self.assertNotIn("img", scalar.column_names)
self.assertNotIn("aud", scalar.column_names)
idx = scalar.column("idx").to_pylist()
self.assertEqual({i: ("img-%d" % i).encode() for i in range(4)},
dict(zip(idx, blobs["img"])))
self.assertEqual({i: ("aud-%d" % i).encode() for i in range(4)},
dict(zip(idx, blobs["aud"])))
# reading only one BLOB column must not leak the other into scalar as
# descriptor bytes
scalar1, blobs1 = obs.scan().where("clip = 'c1'").read_blobs("img")
self.assertEqual({"img"}, set(blobs1))
self.assertEqual(["clip", "idx"], scalar1.column_names)
# columns=None intersects all BLOBs with select() -> only projected BLOB
_, blobs2 = obs.scan().where("clip = 'c1'").select(["clip", "idx", "img"]).read_blobs()
self.assertEqual({"img"}, set(blobs2))
# duplicate columns are de-duplicated
_, blobs3 = obs.scan().where("clip = 'c1'").read_blobs(["img", "img"])
self.assertEqual({"img"}, set(blobs3))
def test_scan_read_blobs_filter_column_not_selected(self):
# The row filter must apply even when its column is not in select().
obs = self.conn.create_table(
"obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([
{"clip": "c1", "idx": i, "image": ("v-%d" % i).encode()}
for i in range(4)
])
scalar, blobs = (
obs.scan().select(["image"]).where("idx = 1").read_blobs("image"))
self.assertEqual([b"v-1"], blobs["image"])
self.assertNotIn("idx", scalar.schema.names)
scalar2, blobs2 = (
obs.scan().select(["idx", "image"]).where("idx = 2").read_blobs("image"))
self.assertEqual([b"v-2"], blobs2["image"])
self.assertEqual([2], scalar2.column("idx").to_pylist())
streamed = []
for _, body in (
obs.scan().select(["image"]).where("idx = 1").stream_blobs("image")):
streamed.extend(body["image"])
self.assertEqual([b"v-1"], streamed)
scalar3, _ = obs.scan().select(["missing", "image"]).read_blobs("image")
self.assertNotIn("missing", scalar3.schema.names)
def test_scan_read_blobs_with_row_id(self):
# with_row_id() must expose _ROW_ID in the scalar table, like to_arrow().
obs = self.conn.create_table(
"obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([
{"clip": "c1", "idx": i, "image": ("v-%d" % i).encode()}
for i in range(3)
])
row_id = "_ROW_ID"
expected = {r["idx"]: r[row_id]
for r in obs.scan().with_row_id().to_arrow().to_pylist()}
scalar, blobs = (
obs.scan().with_row_id().select(["idx", "image"]).read_blobs("image"))
self.assertIn(row_id, scalar.schema.names)
self.assertEqual(expected, dict(zip(scalar.column("idx").to_pylist(),
scalar.column(row_id).to_pylist())))
got = {}
for sb, _ in obs.scan().with_row_id().select(["idx", "image"]).stream_blobs("image"):
self.assertIn(row_id, sb.schema.names)
got.update(zip(sb.column("idx").to_pylist(), sb.column(row_id).to_pylist()))
self.assertEqual(expected, got)
def test_scan_read_blobs_where_on_blob_column(self):
# A where() on one BLOB column must still filter when a different BLOB is
# read (the predicate BLOB column is read as descriptor for filtering).
obs = self.conn.create_table(
"obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"imga": pa.large_binary(),
"imgb": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([
{"clip": "c1", "idx": 0, "imga": b"a0", "imgb": b"b0"},
{"clip": "c1", "idx": 1, "imga": None, "imgb": b"b1"},
{"clip": "c1", "idx": 2, "imga": b"a2", "imgb": b"b2"},
])
scalar, blobs = obs.scan().where("imga IS NOT NULL").read_blobs("imgb")
self.assertEqual([b"b0", b"b2"], blobs["imgb"])
self.assertNotIn("imga", scalar.schema.names)
def test_search_query_rejects_blob_reads(self):
t = self.conn.create_table(
"srch",
schema=_schema({
"id": pa.int32(),
"emb": _vector(3),
"img": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
)
with self.assertRaisesRegex(TypeError, "only supported on scan"):
t.search([1.0, 0.0, 0.0], column="emb").read_blobs("img")
with self.assertRaisesRegex(TypeError, "only supported on scan"):
t.search([1.0, 0.0, 0.0], column="emb").stream_blobs("img")
with self.assertRaisesRegex(TypeError, "only supported on scan"):
t.search([1.0, 0.0, 0.0], column="emb").to_ray()
def test_scan_stream_blobs(self):
obs = self.conn.create_table(
"obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
payloads = {i: ("s-%d-" % i).encode() * (i + 1) for i in range(5)}
obs.add([
{"clip": "c1", "idx": i, "image": payloads[i]} for i in range(5)
])
# collecting every streamed batch must reproduce the full, row-aligned result
got = {}
batches = 0
for scalar, blobs in obs.scan().where("clip = 'c1'").stream_blobs("image"):
batches += 1
self.assertNotIn("image", scalar.schema.names)
for idx, img in zip(scalar.column("idx").to_pylist(), blobs["image"]):
got[idx] = img
self.assertGreaterEqual(batches, 1)
self.assertEqual({i: payloads[i] for i in range(5)}, got)
# bad column is rejected eagerly (not deferred to first iteration)
with self.assertRaisesRegex(ValueError, "not a BLOB column"):
obs.scan().stream_blobs("idx")
# breaking out early closes the reader without error
it = obs.scan().where("clip = 'c1'").stream_blobs("image")
next(it)
it.close()
# empty result streams nothing
self.assertEqual(
[], list(obs.scan().where("clip = 'none'").stream_blobs("image")))
@unittest.skipIf(ray is None, "ray is not installed")
def test_scan_to_ray_map_with_blobs(self):
started_ray = False
if not ray.is_initialized():
ray.init(ignore_reinit_error=True, num_cpus=2)
started_ray = True
obs = self.conn.create_table(
"ray_obs",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
"audio": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([
{"clip": "c1", "idx": 0, "image": b"img-0", "audio": b"aud-0"},
{"clip": "c1", "idx": 1, "image": b"img-1", "audio": b"aud-1"},
{"clip": "c2", "idx": 0, "image": b"img-x", "audio": b"aud-x"},
])
def collect_batch(scalar, blobs, prefix):
assert isinstance(scalar, pa.Table)
assert ["idx"] == scalar.column_names
idxs = scalar.column("idx").to_pylist()
rows = []
for idx, image in zip(idxs, blobs["image"]):
rows.append({"idx": idx, "image": prefix + image})
return pa.Table.from_pylist(rows)
try:
from pypaimon.ray import map_with_blobs
ds = (
obs.scan()
.where("clip = 'c1'")
.select(["idx", "image", "audio"])
.to_ray(concurrency=1, override_num_blocks=1)
)
ds = ds.filter(lambda row: row["idx"] >= 0)
with self.assertRaisesRegex(ValueError, "not a BLOB column"):
obs.map_with_blobs(ds, ["idx"], collect_batch)
with self.assertRaisesRegex(ValueError, "FileIO"):
map_with_blobs(ds, ["image"], collect_batch)
from pypaimon.table.row.blob import BlobDescriptor
descriptor = BlobDescriptor("oss://bucket/blob", 0, 1).serialize()
foreign_ds = ray.data.from_arrow(pa.table({
"idx": [0],
"image": [descriptor],
"foreign_blob": [descriptor],
}))
with self.assertRaisesRegex(Exception, "does not own"):
map_with_blobs(
foreign_ds,
["image"],
collect_batch,
file_io=obs.raw_table.file_io,
all_blob_columns=["image"],
batch_size=1,
).take_all()
result = obs.map_with_blobs(
ds,
["image"],
collect_batch,
parallelism=2,
batch_size=1,
fn_kwargs={"prefix": b"got-"},
ray_remote_args={"num_cpus": 1},
)
rows = sorted(result.to_pandas().to_dict("records"), key=lambda row: row["idx"])
self.assertEqual(
[
{"idx": 0, "image": b"got-img-0"},
{"idx": 1, "image": b"got-img-1"},
],
rows,
)
finally:
if started_ray:
ray.shutdown()
@unittest.skipIf(ray is None, "ray is not installed")
def test_scan_to_ray_map_with_blobs_guards(self):
started_ray = False
if not ray.is_initialized():
ray.init(ignore_reinit_error=True, num_cpus=2)
started_ray = True
obs = self.conn.create_table(
"ray_obs_guards",
schema=_schema({
"clip": pa.string(),
"idx": pa.int32(),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
partitioned=["clip"],
)
obs.add([{"clip": "c1", "idx": 0, "image": b"img-0"}])
def return_none(scalar, blobs):
return None
try:
from pypaimon.ray import map_with_blobs
ds = (
obs.scan()
.where("clip = 'c1'")
.select(["idx", "image"])
.to_ray(concurrency=1, override_num_blocks=1)
)
with self.assertRaisesRegex(ValueError, "all_blob_columns"):
map_with_blobs(
ray.data.from_arrow(pa.table({"image": [b"inline"]})),
["image"],
lambda scalar, blobs: pa.table({"rows": [scalar.num_rows]}),
file_io=obs.raw_table.file_io,
)
with self.assertRaisesRegex(Exception, "must return"):
obs.map_with_blobs(
ds,
["image"],
return_none,
batch_size=1,
).take_all()
empty_ds = (
obs.scan()
.where("clip = 'none'")
.select(["idx", "image"])
.to_ray(concurrency=1, override_num_blocks=1)
)
result = obs.map_with_blobs(
empty_ds,
["image"],
lambda scalar, blobs: pa.table({"rows": [scalar.num_rows]}),
batch_size=1,
)
self.assertEqual([], result.take_all())
finally:
if started_ray:
ray.shutdown()
def test_scan_to_ray_nested_projection_output_names(self):
obs = self.conn.create_table(
"ray_nested_obs",
schema=_schema({
"id": pa.int32(),
"tag": pa.string(),
"payload": pa.struct([("a", pa.int64()), ("b", pa.string())]),
"image": pa.large_binary(),
}),
options=_PARQUET_OPTIONS,
)
_, _, visible_columns = (
obs.scan()
.where("tag = 'keep'")
.select(["id", "payload.a"])
._blob_descriptor_query_read_builder()
)
self.assertEqual(["id", "payload_a"], visible_columns)
def test_fetch_bodies_decodes_descriptor_inline_and_null(self):
# Cells may be descriptor bytes (incl. -1 read-to-EOF), inline bytes, or null.
from pypaimon.multimodal.query import ScanQuery
from pypaimon.common.file_io import FileIO
from pypaimon.table.row.blob import BlobDescriptor
data = bytes(range(64))
path = os.path.join(self.temp_dir, "blob.bin")
with open(path, "wb") as f:
f.write(data)
file_io = FileIO.get("file://" + self.temp_dir, {})
cells = [
BlobDescriptor(path, 0, -1).serialize(),
BlobDescriptor(path, 10, 8).serialize(),
b"raw-inline-bytes",
None,
]
bodies = ScanQuery._fetch_bodies(
file_io, {"image": cells}, ["image"], 4)
self.assertEqual(
[data, data[10:18], b"raw-inline-bytes", None], bodies["image"])
def test_fetch_bodies_reads_via_file_io_not_uri_reader(self):
# Blobs must be read through file_io.read_ranges_coalesced (which carries the
# resolved DLF/OSS token), never via uri_reader_factory -- that would rebuild a
# FileIO from the raw catalog options and fail in DLF data-token mode.
from pypaimon.multimodal.query import ScanQuery
from pypaimon.table.row.blob import BlobDescriptor
class _FakeIO:
@property
def uri_reader_factory(self):
raise AssertionError("_fetch_bodies must not use uri_reader_factory")
def read_ranges_coalesced(self, ranges, parallelism):
return [None if r is None else b"BODY:" + r[0].encode() for r in ranges]
cells = [BlobDescriptor("oss://bucket/x", 4, 10).serialize(), b"inline-blob", None]
bodies = ScanQuery._fetch_bodies(_FakeIO(), {"img": cells}, ["img"], 8)
self.assertEqual([b"BODY:oss://bucket/x", b"inline-blob", None], bodies["img"])
def test_fetch_bodies_rejects_unresolved_blob_view(self):
from pypaimon.multimodal.query import ScanQuery
from pypaimon.table.row.blob import BlobViewStruct
view_bytes = BlobViewStruct("db.tbl", 1, 2).serialize()
with self.assertRaisesRegex(ValueError, "blob-view"):
ScanQuery._fetch_bodies(None, {"image": [view_bytes]}, ["image"], 4)
def test_scan_does_not_expose_pre_filter(self):
users = self.conn.create_table(
"users",
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
self.assertFalse(hasattr(users.scan(), "pre_filter"))
def test_where_rejects_predicate_object(self):
users = self.conn.create_table(
"users",
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
predicate = PredicateBuilder(users.raw_table.fields).equal("id", 1)
with self.assertRaisesRegex(ValueError, "SQL-like string"):
users.scan().where(predicate)
def test_update_by_filter(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.update(where="id = 2", values={"age": 26})
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 26},
],
rows,
)
def test_delete_by_filter(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
{"id": 3, "name": "Carol", "age": 40},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.delete(where="id = 2")
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
{"id": 3, "name": "Carol", "age": 40},
],
rows,
)
def test_merge_updates_matches_and_inserts_new_rows(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.merge("id") \
.when_matched_update() \
.when_not_matched_insert() \
.execute([
{"id": 2, "name": "Bob_v2", "age": 26},
{"id": 3, "name": "Carol", "age": 40},
])
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob_v2", "age": 26},
{"id": 3, "name": "Carol", "age": 40},
],
rows,
)
def test_merge_deletes_matched_rows(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
{"id": 3, "name": "Carol", "age": 40},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.merge("id") \
.when_matched_delete() \
.execute([
{"id": 2},
{"id": 3},
])
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
],
rows,
)
def test_merge_all_uses_only_source_columns(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.merge("id") \
.when_matched_update() \
.when_not_matched_insert() \
.execute([
{"id": 2, "age": 26},
{"id": 3, "age": 40},
])
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 26},
{"id": 3, "name": None, "age": 40},
],
rows,
)
def test_merge_supports_source_key_mapping(self):
users = self.conn.create_table(
"users",
data=[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
],
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
users.merge({"id": "source_id"}) \
.when_matched_update({"age": source_col("age")}) \
.when_not_matched_insert() \
.execute([
{"source_id": 2, "name": "Bob_v2", "age": 26},
{"source_id": 3, "name": "Carol", "age": 40},
])
rows = sorted(users.scan().to_list(), key=lambda r: r["id"])
self.assertEqual(
[
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 26},
{"id": 3, "name": "Carol", "age": 40},
],
rows,
)
def test_merge_where_uses_source_and_target_aliases(self):
users = self.conn.create_table(
"users",
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
calls = {}
class FakeUpdate:
def merge_into(
self,
source,
on,
when_matched=None,
when_not_matched=None):
calls["on"] = on
calls["matched"] = list(when_matched or [])
calls["not_matched"] = list(when_not_matched or [])
return []
class FakeCommit:
def commit(self, messages):
calls["messages"] = messages
def close(self):
pass
class FakeWriteBuilder:
def new_update(self):
return FakeUpdate()
def new_commit(self):
return FakeCommit()
users.raw_table.new_batch_write_builder = lambda: FakeWriteBuilder()
(
users.merge("id")
.when_matched_update(
{"age": source_col("age")},
where="source.age > target.age and source.name != 'target.name'",
)
.when_not_matched_insert(where="source.age > 0")
.execute([
{"id": 1, "name": "Alice", "age": 31},
])
)
self.assertEqual({"id": "id"}, calls["on"])
self.assertEqual(
"s.age > t.age and s.name != 'target.name'",
calls["matched"][0].condition,
)
self.assertEqual("s.age > 0", calls["not_matched"][0].condition)
self.assertEqual([], calls["messages"])
def test_merge_delete_where_uses_source_and_target_aliases(self):
users = self.conn.create_table(
"users",
schema=_schema({
"id": pa.int32(),
"name": pa.string(),
"age": pa.int32(),
}),
options=_PARQUET_OPTIONS,
)
calls = {}
class FakeUpdate:
def merge_into(
self,
source,
on,
when_matched=None,
when_not_matched=None):
calls["matched"] = list(when_matched or [])
return []
class FakeCommit:
def commit(self, messages):
calls["messages"] = messages
def close(self):
pass
class FakeWriteBuilder:
def new_update(self):
return FakeUpdate()
def new_commit(self):
return FakeCommit()
users.raw_table.new_batch_write_builder = lambda: FakeWriteBuilder()
(
users.merge("id")
.when_matched_delete(
where="source.age < target.age and source.name != 'target.name'",
)
.execute([
{"id": 1, "name": "Alice", "age": 29},
])
)
self.assertTrue(calls["matched"][0].delete)
self.assertEqual(
"s.age < t.age and s.name != 'target.name'",
calls["matched"][0].condition,
)
self.assertEqual([], calls["messages"])
def test_merge_validates_on_columns(self):
users = self.conn.create_table(
"users",
schema=_schema({"id": pa.int32(), "name": pa.string()}),
options=_PARQUET_OPTIONS,
)
with self.assertRaisesRegex(ValueError, "source columns"):
users.merge({"id": "missing"}) \
.when_matched_update() \
.execute([{"id": 1, "name": "Alice"}])
def test_create_index_normalizes_full_text_alias(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
calls = []
def create_global_index(column, index_type, options=None):
calls.append((column, index_type, options))
return index_type
docs.raw_table.create_global_index = create_global_index
options = {"tokenizer": "default"}
self.assertEqual(
"full-text",
docs.create_index("content", index_type="full-text",
options=options),
)
self.assertEqual(
[
("content", "full-text", options),
],
calls,
)
def test_create_index_requires_index_type(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
def create_global_index(column, index_type, options=None):
return index_type
docs.raw_table.create_global_index = create_global_index
with self.assertRaises(TypeError):
docs.create_index("content")
def test_table_does_not_expose_predicate_builder(self):
docs = self.conn.create_table(
"docs",
schema=_schema({"id": pa.int32()}),
options=_PARQUET_OPTIONS,
)
self.assertFalse(hasattr(docs, "predicate_builder"))
def test_search_reads_vector_matching_rows(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "content": "a", "embedding": [1.0, 0.0, 0.0]},
{"id": 2, "content": "b", "embedding": [0.0, 1.0, 0.0]},
{"id": 3, "content": "c", "embedding": [0.0, 0.0, 1.0]},
])
calls = {}
class FakeVectorBuilder:
def with_vector_column(self, column):
calls["column"] = column
return self
def with_query_vector(self, vector):
calls["vector"] = vector
return self
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_options(self, options):
calls["options"] = options
return self
def with_filter(self, predicate):
calls["filter"] = predicate
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(1, 1))
docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
result = (
docs.search([0.0, 1.0, 0.0])
.where("id >= 1")
.limit(5)
.to_arrow()
)
self.assertEqual("embedding", calls["column"])
self.assertEqual([0.0, 1.0, 0.0], calls["vector"])
self.assertEqual(5, calls["limit"])
self.assertEqual([2], result["id"].to_pylist())
def test_search_applies_pre_filter_to_vector_builder(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"category": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]},
{"id": 2, "category": "city", "embedding": [0.0, 1.0, 0.0]},
])
calls = {}
class FakeVectorBuilder:
def with_vector_column(self, column):
return self
def with_query_vector(self, vector):
return self
def with_limit(self, limit):
return self
def with_options(self, options):
return self
def with_filter(self, predicate):
calls["pre_filter"] = predicate
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
docs.search(
[1.0, 0.0, 0.0],
pre_filter="category = 'lake'",
).limit(1).to_list()
self.assertEqual("equal", calls["pre_filter"].method)
self.assertEqual("category", calls["pre_filter"].field)
self.assertEqual(["lake"], calls["pre_filter"].literals)
def test_search_pre_filter_rejects_predicate_object(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
predicate = PredicateBuilder(docs.raw_table.fields).equal("id", 1)
with self.assertRaisesRegex(ValueError, "SQL-like string"):
docs.search([1.0, 0.0, 0.0]).pre_filter(predicate)
def test_search_accepts_generator_vector(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([{"id": 1, "embedding": [1.0, 0.0, 0.0]}])
calls = {}
class FakeVectorBuilder:
def with_vector_column(self, column):
calls["column"] = column
return self
def with_query_vector(self, vector):
calls["vector"] = vector
return self
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_options(self, options):
calls["options"] = options
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
result = docs.search((v for v in [1.0, 0.0, 0.0])).limit(1).to_list()
self.assertEqual("embedding", calls["column"])
self.assertEqual([1.0, 0.0, 0.0], calls["vector"])
self.assertEqual([{"id": 1, "embedding": [1.0, 0.0, 0.0]}], result)
def test_search_with_row_id_returns_system_column(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([{"id": 1, "embedding": [1.0, 0.0, 0.0]}])
class FakeVectorBuilder:
def with_vector_column(self, column):
return self
def with_query_vector(self, vector):
return self
def with_limit(self, limit):
return self
def with_options(self, options):
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder()
result = (
docs.search([1.0, 0.0, 0.0], column="embedding")
.select(["id"])
.with_row_id()
.limit(1)
.to_list()
)
self.assertEqual([{"id": 1, "_ROW_ID": 0}], result)
def test_search_rejects_batch_vectors(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
with self.assertRaisesRegex(ValueError, "use search_vectors"):
docs.search([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
def test_search_vectors_reads_one_result_set_per_query_vector(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "content": "a", "embedding": [1.0, 0.0, 0.0]},
{"id": 2, "content": "b", "embedding": [0.0, 1.0, 0.0]},
{"id": 3, "content": "c", "embedding": [0.0, 0.0, 1.0]},
])
calls = {}
class FakeBatchVectorBuilder:
def with_vector_column(self, column):
calls["column"] = column
return self
def with_query_vectors(self, vectors):
calls["vectors"] = vectors
return self
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_options(self, options):
calls["options"] = options
return self
def with_filter(self, predicate):
calls["filter"] = predicate
return self
def execute_batch_local(self):
return [
GlobalIndexResult.from_range(Range(0, 0)),
GlobalIndexResult.from_range(Range(2, 2)),
]
docs.raw_table.new_batch_vector_search_builder = (
lambda: FakeBatchVectorBuilder())
result = (
docs.search_vectors(
[[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
options={"nprobe": "8"},
pre_filter="content = 'a'",
)
.where("id >= 1")
.select(["id"])
.limit(2)
.to_list()
)
self.assertEqual("embedding", calls["column"])
self.assertEqual(
[[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
calls["vectors"],
)
self.assertEqual(2, calls["limit"])
self.assertEqual({"nprobe": "8"}, calls["options"])
self.assertEqual("content", calls["filter"].field)
self.assertEqual(["a"], calls["filter"].literals)
self.assertEqual([[{"id": 1}], [{"id": 3}]], result)
def test_search_vectors_rejects_text_parameter(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
with self.assertRaises(TypeError):
docs.search_vectors([[1.0, 0.0, 0.0]], text="paimon")
def test_search_reads_text_query(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "content": "paimon vector"},
{"id": 2, "content": "lakehouse"},
])
calls = {}
class FakeFullTextBuilder:
def with_query(self, field_name, query):
calls["query"] = (field_name, json.loads(query))
return self
def with_limit(self, limit):
calls["limit"] = limit
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_full_text_search_builder = lambda: FakeFullTextBuilder()
result = (
docs.search("paimon vector")
.limit(1)
.to_arrow()
)
self.assertEqual(1, calls["limit"])
self.assertEqual("content", calls["query"][0])
self.assertEqual("paimon vector", calls["query"][1]["match"]["query"])
self.assertEqual([1], result["id"].to_pylist())
def test_search_string_requires_unambiguous_text_column(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"title": pa.string(),
"content": pa.string(),
}),
options=_PARQUET_OPTIONS,
)
with self.assertRaisesRegex(ValueError, "Multiple text columns"):
docs.search("paimon")
def test_search_hybrid_rejects_shorthand_arguments(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
with self.assertRaises(TypeError):
docs.search_hybrid(vector=[1.0, 0.0, 0.0])
def test_hybrid_route_helpers_use_route_specific_arguments(self):
with self.assertRaises(TypeError):
pmm.vector_route([1.0, 0.0, 0.0])
route = pmm.text_route("paimon")
self.assertIsNone(route.column)
self.assertEqual("paimon", route.query)
def test_search_can_build_hybrid_routes(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "content": "paimon", "embedding": [1.0, 0.0, 0.0]},
{"id": 2, "content": "vector", "embedding": [0.0, 1.0, 0.0]},
])
calls = {}
class FakeHybridBuilder:
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_ranker(self, ranker):
calls["ranker"] = ranker
return self
def add_vector_route(
self, column, vector, limit, weight=1.0, options=None):
calls["vector"] = (column, vector, limit, weight, options)
return self
def add_full_text_route(
self, field_name, query, limit, weight=1.0, options=None):
calls["text"] = (
field_name, json.loads(query), limit, weight, options)
return self
def with_filter(self, predicate):
calls["filter"] = predicate
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 1))
docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
result = (
docs.search_hybrid(
[
pmm.vector_route("embedding", [1.0, 0.0, 0.0]),
pmm.text_route("paimon"),
],
)
.rerank("rrf")
.limit(2)
.to_arrow()
)
self.assertEqual(2, calls["limit"])
self.assertEqual("rrf", calls["ranker"])
self.assertEqual(
("embedding", [1.0, 0.0, 0.0], 2, 1.0, {}),
calls["vector"],
)
self.assertEqual("content", calls["text"][0])
self.assertEqual("paimon", calls["text"][1]["match"]["query"])
self.assertEqual([1, 2], result["id"].to_pylist())
def test_search_hybrid_applies_pre_filter_to_vector_routes(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"category": pa.string(),
"embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]},
{"id": 2, "category": "city", "embedding": [0.0, 1.0, 0.0]},
])
calls = {}
class FakeHybridBuilder:
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_ranker(self, ranker):
calls["ranker"] = ranker
return self
def add_vector_route(
self, column, vector, limit, weight=1.0, options=None):
calls["vector"] = (column, vector, limit, weight, options)
return self
def with_filter(self, predicate):
calls["pre_filter"] = predicate
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
result = (
docs.search_hybrid(
[pmm.vector_route("embedding", [1.0, 0.0, 0.0])],
pre_filter="category = 'lake'",
)
.limit(1)
.to_list()
)
self.assertEqual(1, calls["limit"])
self.assertEqual(
("embedding", [1.0, 0.0, 0.0], 1, 1.0, {}),
calls["vector"],
)
self.assertEqual("category", calls["pre_filter"].field)
self.assertEqual(["lake"], calls["pre_filter"].literals)
self.assertEqual(
[{"id": 1, "category": "lake", "embedding": [1.0, 0.0, 0.0]}],
result,
)
def test_module_does_not_export_vectors_route(self):
self.assertFalse(hasattr(pmm, "vectors_route"))
self.assertFalse(hasattr(pmm, "VectorsRoute"))
def test_search_hybrid_can_build_multiple_vector_routes(self):
docs = self.conn.create_table(
"docs",
schema=_schema({
"id": pa.int32(),
"content": pa.string(),
"image_embedding": _vector(3),
"text_embedding": _vector(3),
}),
options=_PARQUET_OPTIONS,
)
docs.add([
{
"id": 1,
"content": "paimon",
"image_embedding": [1.0, 0.0, 0.0],
"text_embedding": [0.0, 1.0, 0.0],
},
{
"id": 2,
"content": "vector",
"image_embedding": [0.0, 1.0, 0.0],
"text_embedding": [1.0, 0.0, 0.0],
},
])
calls = {"vector_routes": [], "text_routes": []}
class FakeHybridBuilder:
def with_limit(self, limit):
calls["limit"] = limit
return self
def with_ranker(self, ranker):
calls["ranker"] = ranker
return self
def add_vector_route(
self, column, vector, limit, weight=1.0, options=None):
calls["vector_routes"].append(
(column, vector, limit, weight, options))
return self
def add_full_text_route(
self, field_name, query, limit, weight=1.0, options=None):
calls["text_routes"].append(
(field_name, json.loads(query), limit, weight, options))
return self
def execute_local(self):
return GlobalIndexResult.from_range(Range(0, 0))
docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder()
result = (
docs.search_hybrid(
[
pmm.vector_route(
"image_embedding",
[1.0, 0.0, 0.0],
weight=0.7,
limit=6,
options={"nprobe": "8"},
),
pmm.vector_route(
"text_embedding",
[0.0, 1.0, 0.0],
weight=0.3,
limit=4,
options={"nprobe": "4"},
),
pmm.text_route("paimon", weight=0.2),
],
ranker="weighted_score",
route_limit=4,
)
.limit(2)
.to_list()
)
self.assertEqual(2, calls["limit"])
self.assertEqual("weighted_score", calls["ranker"])
self.assertEqual(
[
(
"image_embedding",
[1.0, 0.0, 0.0],
6,
0.7,
{"nprobe": "8"},
),
(
"text_embedding",
[0.0, 1.0, 0.0],
4,
0.3,
{"nprobe": "4"},
),
],
calls["vector_routes"],
)
self.assertEqual("content", calls["text_routes"][0][0])
self.assertEqual("paimon", calls["text_routes"][0][1]["match"]["query"])
self.assertEqual(4, calls["text_routes"][0][2])
self.assertEqual(0.2, calls["text_routes"][0][3])
self.assertEqual(
[{
"id": 1,
"content": "paimon",
"image_embedding": [1.0, 0.0, 0.0],
"text_embedding": [0.0, 1.0, 0.0],
}],
result,
)
if __name__ == "__main__":
unittest.main()