mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 15:22:30 +00:00
Merge main into gatekeeper/fix-4392-1
Preserve backend-neutral remote errors and the query conversion alternatives. Update unsupported-operation assertions for the combined behavior.
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lancedb-python"
|
||||
version = "0.40.0-beta.12"
|
||||
version = "0.41.0-beta.1"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
description = "Python bindings for LanceDB"
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
rust-version = "1.91.0"
|
||||
rust-version = "1.94.0"
|
||||
|
||||
[lib]
|
||||
name = "_lancedb"
|
||||
|
||||
@@ -63,7 +63,7 @@ tests = [
|
||||
"polars>=0.19, <=1.32.3",
|
||||
"pyarrow<25",
|
||||
"pyarrow-stubs>=16.0",
|
||||
"pylance==9.0.0",
|
||||
"pylance==14.0.0b6",
|
||||
"requests>=2.31.0",
|
||||
"datafusion>=54,<55",
|
||||
"opentelemetry-sdk>=1.30.0",
|
||||
|
||||
@@ -46,7 +46,6 @@ from .materialized_view import (
|
||||
AsyncMaterializedView,
|
||||
MaterializedView,
|
||||
MaterializedViewDefinition,
|
||||
vector_dedup as vector_dedup,
|
||||
)
|
||||
from .view import ViewDescription as ViewDescription
|
||||
from .table import AsyncTable, Table
|
||||
@@ -62,7 +61,6 @@ from .namespace import (
|
||||
from .catalog import (
|
||||
AsyncCatalog,
|
||||
Catalog,
|
||||
ListDatabasesResponse,
|
||||
connect_catalog,
|
||||
connect_catalog_async,
|
||||
)
|
||||
@@ -578,7 +576,6 @@ async def connect_async(
|
||||
__all__ = [
|
||||
"Catalog",
|
||||
"AsyncCatalog",
|
||||
"ListDatabasesResponse",
|
||||
"connect_catalog",
|
||||
"connect_catalog_async",
|
||||
"AsyncMaterializedView",
|
||||
|
||||
@@ -155,9 +155,13 @@ class Connection(object):
|
||||
async def get_function(
|
||||
self, name: str, version: str, namespace_path: Optional[List[str]] = None
|
||||
) -> str: ...
|
||||
async def list_functions(
|
||||
self, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]: ...
|
||||
def list_functions(
|
||||
self,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
*,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> FunctionListing: ...
|
||||
async def drop_function(
|
||||
self, name: str, version: str, namespace_path: Optional[List[str]] = None
|
||||
) -> bool: ...
|
||||
@@ -170,9 +174,13 @@ class Connection(object):
|
||||
async def alter_secret(
|
||||
self, name: str, value: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
async def list_secrets(
|
||||
self, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]: ...
|
||||
def list_secrets(
|
||||
self,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
*,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> NameListing: ...
|
||||
async def drop_secret(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
@@ -191,10 +199,16 @@ class Connection(object):
|
||||
async def drop_view_async(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> Job: ...
|
||||
async def list_views(
|
||||
self, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]: ...
|
||||
async def list_jobs(self) -> List[JobInfo]: ...
|
||||
def list_views(
|
||||
self,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
*,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> NameListing: ...
|
||||
def list_jobs(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> JobListing: ...
|
||||
async def cancel_job(self, job_id: str) -> bool: ...
|
||||
async def pause_job(self, job_id: str) -> str: ...
|
||||
async def resume_job(self, job_id: str) -> str: ...
|
||||
@@ -203,6 +217,7 @@ class Connection(object):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[pa.RecordBatch] = None,
|
||||
) -> SqlQuery: ...
|
||||
async def describe_query(self, query_id: UUID) -> QueryDescription: ...
|
||||
async def create_table(
|
||||
@@ -255,8 +270,6 @@ class Connection(object):
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
with_no_data: bool = False,
|
||||
*,
|
||||
vector_source_json: Optional[str] = None,
|
||||
) -> Table: ...
|
||||
async def create_materialized_view_async(
|
||||
self,
|
||||
@@ -266,10 +279,10 @@ class Connection(object):
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
with_no_data: bool = False,
|
||||
*,
|
||||
vector_source_json: Optional[str] = None,
|
||||
) -> Job: ...
|
||||
async def list_materialized_views(self) -> List[str]: ...
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> NameListing: ...
|
||||
async def drop_materialized_view(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
@@ -500,10 +513,10 @@ class Table:
|
||||
limit: Optional[int] = None,
|
||||
) -> FunctionErrors: ...
|
||||
async def refresh_materialized_view(
|
||||
self, full: bool = False, source_version: Optional[int] = None
|
||||
self, source_version: Optional[int] = None
|
||||
) -> RefreshMaterializedViewResult: ...
|
||||
async def refresh_materialized_view_async(
|
||||
self, full: bool = False, source_version: Optional[int] = None
|
||||
self, source_version: Optional[int] = None
|
||||
) -> Job: ...
|
||||
async def materialized_view_definition(self) -> str: ...
|
||||
async def add_columns_with_schema(self, schema: pa.Schema) -> AddColumnsResult: ...
|
||||
@@ -946,9 +959,15 @@ class Catalog:
|
||||
async def drop_database(
|
||||
self, name: str, *, ignore_missing: bool = False
|
||||
) -> None: ...
|
||||
async def list_databases(
|
||||
self, *, limit: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> tuple[list[str], Optional[str]]: ...
|
||||
def list_databases(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> DatabaseNames: ...
|
||||
|
||||
class DatabaseNames:
|
||||
def num_page_results(self) -> int: ...
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> DatabaseNames: ...
|
||||
async def __anext__(self) -> str: ...
|
||||
|
||||
async def connect_catalog(
|
||||
endpoint: str,
|
||||
@@ -959,3 +978,21 @@ async def connect_catalog(
|
||||
read_consistency_interval: Optional[float] = None,
|
||||
oauth_config: Optional[Any] = None,
|
||||
) -> Catalog: ...
|
||||
|
||||
class NameListing:
|
||||
def num_page_results(self) -> int: ...
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> NameListing: ...
|
||||
async def __anext__(self) -> str: ...
|
||||
|
||||
class FunctionListing:
|
||||
def num_page_results(self) -> int: ...
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> FunctionListing: ...
|
||||
async def __anext__(self) -> str: ...
|
||||
|
||||
class JobListing:
|
||||
def num_page_results(self) -> int: ...
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> JobListing: ...
|
||||
async def __anext__(self) -> JobInfo: ...
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
"""Remote catalogs manage databases through a server's root namespace."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Union
|
||||
|
||||
from . import _lancedb
|
||||
from .background_loop import LOOP
|
||||
@@ -14,12 +13,66 @@ from .remote import ClientConfig, OAuthConfig
|
||||
from .remote.db import RemoteDBConnection
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListDatabasesResponse:
|
||||
"""A page of database names and an optional continuation token."""
|
||||
class AsyncDatabaseNames(AsyncIterator[str]):
|
||||
"""An async iterator of database names with pagination state.
|
||||
|
||||
databases: list[str]
|
||||
page_token: Optional[str] = None
|
||||
Returned by [list_databases][lancedb.catalog.AsyncCatalog.list_databases].
|
||||
"""
|
||||
|
||||
def __init__(self, inner: _lancedb.DatabaseNames):
|
||||
self._inner = inner
|
||||
|
||||
def __aiter__(self) -> "AsyncDatabaseNames":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
return await self._inner.__anext__()
|
||||
|
||||
def num_page_results(self) -> int:
|
||||
"""Return the number of names available without another REST request."""
|
||||
return self._inner.num_page_results()
|
||||
|
||||
def page_token(self) -> Optional[str]:
|
||||
"""Return the token for the next REST request.
|
||||
|
||||
Before the first request, this is the supplied starting token (``None``
|
||||
starts at the beginning). After the final page is fetched, it is ``None``,
|
||||
even if names remain cached. Drain the cache before saving a token to
|
||||
avoid skipping those names when resuming. A failed request terminates
|
||||
iteration and leaves its token available for resuming a new iterator.
|
||||
"""
|
||||
return self._inner.page_token()
|
||||
|
||||
|
||||
class DatabaseNames(Iterator[str]):
|
||||
"""A synchronous iterator of database names with pagination state.
|
||||
|
||||
Returned by [Catalog.list_databases][lancedb.catalog.Catalog.list_databases].
|
||||
"""
|
||||
|
||||
def __init__(self, inner: AsyncDatabaseNames):
|
||||
self._inner = inner
|
||||
|
||||
def __iter__(self) -> "DatabaseNames":
|
||||
return self
|
||||
|
||||
def __next__(self) -> str:
|
||||
try:
|
||||
return LOOP.run(self._inner.__anext__())
|
||||
except StopAsyncIteration:
|
||||
raise StopIteration from None
|
||||
|
||||
def num_page_results(self) -> int:
|
||||
"""Return the number of names available without another REST request."""
|
||||
return self._inner.num_page_results()
|
||||
|
||||
def page_token(self) -> Optional[str]:
|
||||
"""Return the token for the next REST request.
|
||||
|
||||
See [page_token][lancedb.catalog.AsyncDatabaseNames.page_token]
|
||||
for initial, cached, final-page, and error behavior.
|
||||
"""
|
||||
return self._inner.page_token()
|
||||
|
||||
|
||||
class AsyncCatalog:
|
||||
@@ -50,14 +103,25 @@ class AsyncCatalog:
|
||||
"""Connect to an existing database by its logical name."""
|
||||
return AsyncConnection(await self._inner.connect_database(name))
|
||||
|
||||
async def list_databases(
|
||||
self, *, limit: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> ListDatabasesResponse:
|
||||
"""List a page of databases. Pass the returned token for the next page."""
|
||||
names, token = await self._inner.list_databases(
|
||||
limit=limit, page_token=page_token
|
||||
def list_databases(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> AsyncDatabaseNames:
|
||||
"""Iterate lazily over all database names using ``async for``.
|
||||
|
||||
``page_token`` resumes from a saved token; ``None`` starts at the beginning.
|
||||
``page_limit`` sets the maximum names per REST response (not the total);
|
||||
``None`` uses the server default. Request errors are raised during iteration.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
async for name in catalog.list_databases():
|
||||
print(name)
|
||||
```
|
||||
"""
|
||||
return AsyncDatabaseNames(
|
||||
self._inner.list_databases(page_token=page_token, page_limit=page_limit)
|
||||
)
|
||||
return ListDatabasesResponse(names, token)
|
||||
|
||||
async def drop_database(self, name: str, *, ignore_missing: bool = False) -> None:
|
||||
"""Drop an empty database. A nonempty database is an error."""
|
||||
@@ -73,7 +137,8 @@ class Catalog:
|
||||
```python
|
||||
catalog = lancedb.connect_catalog("https://my-server.example", api_key="secret")
|
||||
db = catalog.create_database("analytics", exist_ok=True)
|
||||
page = catalog.list_databases(limit=20)
|
||||
for name in catalog.list_databases():
|
||||
print(name)
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -118,10 +183,17 @@ class Catalog:
|
||||
)
|
||||
|
||||
def list_databases(
|
||||
self, *, limit: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> ListDatabasesResponse:
|
||||
"""List a page of databases. Pass the returned token for the next page."""
|
||||
return LOOP.run(self._inner.list_databases(limit=limit, page_token=page_token))
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> DatabaseNames:
|
||||
"""Iterate lazily over database names, fetching more results as needed.
|
||||
|
||||
``page_token`` resumes from a saved token; ``None`` starts at the beginning.
|
||||
``page_limit`` sets the maximum names per REST response (not the total);
|
||||
``None`` uses the server default. Request errors are raised during iteration.
|
||||
"""
|
||||
return DatabaseNames(
|
||||
self._inner.list_databases(page_token=page_token, page_limit=page_limit)
|
||||
)
|
||||
|
||||
def drop_database(self, name: str, *, ignore_missing: bool = False) -> None:
|
||||
"""Drop an empty database. A nonempty database is an error."""
|
||||
|
||||
+311
-85
@@ -31,6 +31,7 @@ if sys.version_info >= (3, 12):
|
||||
else:
|
||||
from overrides import EnforceOverrides, override # type: ignore
|
||||
|
||||
from lancedb.listing import AsyncListing, Listing
|
||||
from lancedb.embeddings.registry import EmbeddingFunctionRegistry
|
||||
|
||||
from lancedb.common import data_to_reader, sanitize_uri, validate_schema
|
||||
@@ -52,12 +53,11 @@ from .functions import FunctionVersion, UdfDefinition
|
||||
from .job import AsyncJob, Job, _typed_job
|
||||
from .sql import AsyncQuery as AsyncSqlQuery
|
||||
from .sql import Query as SqlQuery
|
||||
from .sql import QueryDescription
|
||||
from .sql import QueryDescription, QueryParameters, to_parameter_batch
|
||||
from .materialized_view import (
|
||||
AsyncMaterializedView,
|
||||
MaterializedView,
|
||||
MaterializedViewSource,
|
||||
VectorDedupSource,
|
||||
SelectArg,
|
||||
normalize_select,
|
||||
)
|
||||
@@ -122,7 +122,48 @@ def _view_description(
|
||||
|
||||
|
||||
class DBConnection(EnforceOverrides):
|
||||
"""An active LanceDB connection interface."""
|
||||
"""An active LanceDB connection interface.
|
||||
|
||||
Use [close][lancedb.db.DBConnection.close] to release the connection's
|
||||
underlying resources, or use the connection as a context manager to close it
|
||||
automatically when leaving the block, including when an exception is raised.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> with lancedb.connect("memory://") as db:
|
||||
... assert db.is_open()
|
||||
>>> db.is_open()
|
||||
False
|
||||
"""
|
||||
|
||||
def __enter__(self) -> DBConnection:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_) -> None:
|
||||
self.close()
|
||||
|
||||
@abstractmethod
|
||||
def is_open(self) -> bool:
|
||||
"""Return True if the connection is open."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Close the connection, releasing any underlying resources.
|
||||
|
||||
It is safe to call this method multiple times. Database operations on a
|
||||
closed connection raise ``RuntimeError: Connection is closed``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect("memory://")
|
||||
>>> db.close()
|
||||
>>> db.is_open()
|
||||
False
|
||||
"""
|
||||
pass
|
||||
|
||||
def list_namespaces(
|
||||
self,
|
||||
@@ -368,9 +409,10 @@ class DBConnection(EnforceOverrides):
|
||||
- [LanceModel][lancedb.pydantic.LanceModel]
|
||||
mode: str; default "create"
|
||||
The mode to use when creating the table.
|
||||
Can be either "create" or "overwrite".
|
||||
Can be "create", "overwrite", or "exist_ok".
|
||||
By default, if the table already exists, an exception is raised.
|
||||
If you want to overwrite the table, use mode="overwrite".
|
||||
To open an existing table without adding data, use mode="exist_ok".
|
||||
exist_ok: bool, default False
|
||||
If a table by the same name already exists, then raise an exception
|
||||
if exist_ok=False. If exist_ok=True, then open the existing table;
|
||||
@@ -575,21 +617,12 @@ class DBConnection(EnforceOverrides):
|
||||
table. The view is a normal table: it can be queried, indexed and
|
||||
searched, and it appears in ``table_names``.
|
||||
|
||||
Ordinary source tables must have stable row ids (create them with the
|
||||
``new_table_enable_stable_row_ids`` storage option): they keep the
|
||||
view's provenance valid across source compactions, and cannot be
|
||||
enabled after a table exists. Native dedup sources also support physical
|
||||
row IDs interpreted against their pinned snapshot.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: str
|
||||
The name of the view.
|
||||
source: str or VectorDedupSource
|
||||
The name of the source table, or an indexed dedup source declared
|
||||
with [vector_dedup][lancedb.vector_dedup]. Dedup sources capture a
|
||||
fixed snapshot and preserve the source table. They accept no
|
||||
additional select, where or limit options.
|
||||
source: str
|
||||
The name of the source table.
|
||||
select: list or dict, optional
|
||||
The view's columns: column names, ``(alias, SQL expression)``
|
||||
pairs, or a dict of the same. Omitting it selects every source
|
||||
@@ -640,11 +673,16 @@ class DBConnection(EnforceOverrides):
|
||||
"materialized views are not supported on this connection type"
|
||||
)
|
||||
|
||||
def list_materialized_views(self) -> List[str]:
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[str]:
|
||||
"""The names of the materialized views in this database.
|
||||
|
||||
Found by reading every table's schema, so this costs an open per
|
||||
table.
|
||||
Local listings inspect table schemas one page at a time.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"materialized views are not supported on this connection type"
|
||||
@@ -870,8 +908,12 @@ class DBConnection(EnforceOverrides):
|
||||
)
|
||||
|
||||
def list_functions(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[FunctionVersion]:
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[FunctionVersion]:
|
||||
"""List every published immutable Function version in a namespace.
|
||||
|
||||
Functions in child namespaces are not included. Results are ordered by
|
||||
@@ -885,6 +927,10 @@ class DBConnection(EnforceOverrides):
|
||||
```python
|
||||
[(function.name, function.version) for function in db.list_functions()]
|
||||
```
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Function catalog operations are not supported for this connection type"
|
||||
@@ -956,11 +1002,21 @@ class DBConnection(EnforceOverrides):
|
||||
"Secret operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
def list_secrets(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""The names of every Secret in this database.
|
||||
|
||||
Names only. No method returns a stored credential, by construction
|
||||
rather than by policy. Local connections raise ``NotImplementedError``.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Secret operations are not supported for this connection type"
|
||||
@@ -1058,12 +1114,22 @@ class DBConnection(EnforceOverrides):
|
||||
"View operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
def list_views(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""The names of the views in one namespace.
|
||||
|
||||
Names only; a definition comes from
|
||||
[describe_view][lancedb.db.DBConnection.describe_view]. Local
|
||||
connections raise ``NotImplementedError``.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"View operations are not supported for this connection type"
|
||||
@@ -1082,8 +1148,15 @@ class DBConnection(EnforceOverrides):
|
||||
"""
|
||||
raise NotImplementedError("open_job is not supported for this connection type")
|
||||
|
||||
def list_jobs(self) -> List[JobInfo]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
def list_jobs(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[JobInfo]:
|
||||
"""List server-side jobs across the database's tables.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
raise NotImplementedError("list_jobs is not supported for this connection type")
|
||||
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
@@ -1122,6 +1195,7 @@ class DBConnection(EnforceOverrides):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> pa.RecordBatchReader:
|
||||
"""Execute SQL and return a blocking Arrow reader.
|
||||
|
||||
@@ -1132,6 +1206,7 @@ class DBConnection(EnforceOverrides):
|
||||
return self.execute_query_async(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=parameters,
|
||||
).reader()
|
||||
|
||||
def execute_query_async(
|
||||
@@ -1139,9 +1214,32 @@ class DBConnection(EnforceOverrides):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> SqlQuery:
|
||||
"""Start executing SQL and return its query handle.
|
||||
|
||||
``parameters`` binds values to the statement's ``$1`` / ``$name``
|
||||
placeholders. They travel as Arrow rather than as SQL text, so a
|
||||
float keeps its exact value and type and a vector stays a compact
|
||||
fixed-size list. A list binds by position, a dict binds by name, and
|
||||
a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds both
|
||||
ways. A 1-D numpy or pyarrow array becomes a fixed-size list of its
|
||||
element type, the shape of a vector column:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db.execute_query(
|
||||
"SELECT id FROM docs ORDER BY distance(vector, $1) LIMIT $2",
|
||||
parameters=[np.array([0.1, 0.2, 0.3], dtype=np.float32), 10],
|
||||
)
|
||||
|
||||
Parameters are supported in queries, not in DDL or DML. A
|
||||
parameterized query runs on the call that returns its rows:
|
||||
cancelling it, or dropping its handle before reading, stops it on the
|
||||
server. Its status follows that stream as well: ``describe()``
|
||||
reports ``running`` until the reader has received every row, so open
|
||||
``reader()`` directly rather than waiting for ``finished``.
|
||||
|
||||
Local connections do not support SQL.
|
||||
"""
|
||||
raise NotImplementedError("SQL is not supported for this connection type")
|
||||
@@ -1292,6 +1390,14 @@ class LanceDBConnection(DBConnection):
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(uri={self._conn.uri!r})"
|
||||
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._conn.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
@override
|
||||
def serialize(self) -> str:
|
||||
import json
|
||||
@@ -1514,8 +1620,6 @@ class LanceDBConnection(DBConnection):
|
||||
"""
|
||||
if namespace_path is None:
|
||||
namespace_path = []
|
||||
if mode.lower() not in ["create", "overwrite"]:
|
||||
raise ValueError("mode must be either 'create' or 'overwrite'")
|
||||
validate_table_name(name)
|
||||
|
||||
tbl = LanceTable.create(
|
||||
@@ -1623,10 +1727,7 @@ class LanceDBConnection(DBConnection):
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect(
|
||||
... "./.lancedb",
|
||||
... storage_options={"new_table_enable_stable_row_ids": "true"},
|
||||
... )
|
||||
>>> db = lancedb.connect("./.lancedb")
|
||||
>>> data = [{"name": "ada", "age": 36}, {"name": "kid", "age": 7}]
|
||||
>>> table = db.create_table("people", data)
|
||||
>>> view = db.create_materialized_view(
|
||||
@@ -1681,9 +1782,20 @@ class LanceDBConnection(DBConnection):
|
||||
return view
|
||||
|
||||
@override
|
||||
def list_materialized_views(self) -> List[str]:
|
||||
"""The names of the materialized views in this database."""
|
||||
return LOOP.run(self._conn.list_materialized_views())
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[str]:
|
||||
"""The names of the materialized views in this database.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_materialized_views(
|
||||
page_token=page_token, page_limit=page_limit
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_materialized_view(
|
||||
@@ -1878,9 +1990,25 @@ class LanceDBConnection(DBConnection):
|
||||
|
||||
@override
|
||||
def list_functions(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[FunctionVersion]:
|
||||
return LOOP.run(self._conn.list_functions(namespace_path=namespace_path))
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[FunctionVersion]:
|
||||
"""List functions.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_functions(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_function(
|
||||
@@ -1924,8 +2052,26 @@ class LanceDBConnection(DBConnection):
|
||||
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||
def list_secrets(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""List secrets.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_secrets(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_secret(
|
||||
@@ -1968,13 +2114,40 @@ class LanceDBConnection(DBConnection):
|
||||
)
|
||||
|
||||
@override
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
|
||||
def list_views(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""List views.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_views(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def list_jobs(self) -> List[JobInfo]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
return LOOP.run(self._conn.list_jobs())
|
||||
def list_jobs(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[JobInfo]:
|
||||
"""List server-side jobs across the database's tables.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_jobs(page_token=page_token, page_limit=page_limit)
|
||||
)
|
||||
|
||||
@override
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
@@ -2318,11 +2491,12 @@ class AsyncConnection(object):
|
||||
- pyarrow.Schema
|
||||
|
||||
- [LanceModel][lancedb.pydantic.LanceModel]
|
||||
mode: Literal["create", "overwrite"]; default "create"
|
||||
mode: Literal["create", "overwrite", "exist_ok"]; default "create"
|
||||
The mode to use when creating the table.
|
||||
Can be either "create" or "overwrite".
|
||||
Can be "create", "overwrite", or "exist_ok".
|
||||
By default, if the table already exists, an exception is raised.
|
||||
If you want to overwrite the table, use mode="overwrite".
|
||||
To open an existing table without adding data, use mode="exist_ok".
|
||||
exist_ok: bool, default False
|
||||
If a table by the same name already exists, then raise an exception
|
||||
if exist_ok=False. If exist_ok=True, then open the existing table;
|
||||
@@ -2593,19 +2767,7 @@ class AsyncConnection(object):
|
||||
where: Optional[str],
|
||||
limit: Optional[int],
|
||||
) -> Tuple[str, Dict[str, str]]:
|
||||
if not isinstance(source, VectorDedupSource):
|
||||
return source, {}
|
||||
if select is not None or where is not None or limit is not None:
|
||||
raise ValueError(
|
||||
"vector_dedup cannot be combined with select, where or limit"
|
||||
)
|
||||
version = source.dataset_version
|
||||
if version is None:
|
||||
table = await self.open_table(source.source)
|
||||
version = await table.version()
|
||||
return source.source, {
|
||||
"vector_source_json": source._native_source_json(version)
|
||||
}
|
||||
return source, {}
|
||||
|
||||
async def create_materialized_view(
|
||||
self,
|
||||
@@ -2673,13 +2835,22 @@ class AsyncConnection(object):
|
||||
await view.definition()
|
||||
return view
|
||||
|
||||
async def list_materialized_views(self) -> List[str]:
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> AsyncListing[str]:
|
||||
"""The names of the materialized views in this database.
|
||||
|
||||
Found by reading every table's schema, so this costs an open per
|
||||
table.
|
||||
Local listings inspect table schemas one page at a time.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return await self._inner.list_materialized_views()
|
||||
return AsyncListing(
|
||||
self._inner.list_materialized_views(
|
||||
page_token=page_token, page_limit=page_limit
|
||||
)
|
||||
)
|
||||
|
||||
async def drop_materialized_view(
|
||||
self,
|
||||
@@ -2901,19 +3072,31 @@ class AsyncConnection(object):
|
||||
await self._inner.get_function(name, version, namespace_path)
|
||||
)
|
||||
|
||||
async def list_functions(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[FunctionVersion]:
|
||||
def list_functions(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> AsyncListing[FunctionVersion]:
|
||||
"""List every published immutable Function version in a namespace.
|
||||
|
||||
Functions in child namespaces are not included. Results are ordered by
|
||||
Function name then version. Local connections raise
|
||||
``NotImplementedError``.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return [
|
||||
FunctionVersion.from_json(value)
|
||||
for value in await self._inner.list_functions(namespace_path)
|
||||
]
|
||||
return AsyncListing(
|
||||
self._inner.list_functions(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
),
|
||||
FunctionVersion.from_json,
|
||||
)
|
||||
|
||||
async def drop_function(
|
||||
self,
|
||||
@@ -2971,12 +3154,25 @@ class AsyncConnection(object):
|
||||
list(validate_namespace_path(namespace_path)),
|
||||
)
|
||||
|
||||
async def list_secrets(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]:
|
||||
"""The names of every Secret in this database. Names only."""
|
||||
return await self._inner.list_secrets(
|
||||
list(validate_namespace_path(namespace_path))
|
||||
def list_secrets(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> AsyncListing[str]:
|
||||
"""The names of every Secret in this database. Names only.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return AsyncListing(
|
||||
self._inner.list_secrets(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
async def drop_secret(
|
||||
@@ -3048,15 +3244,39 @@ class AsyncConnection(object):
|
||||
await self._inner.drop_view_async(name, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
async def list_views(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]:
|
||||
"""The names of the views in one namespace."""
|
||||
return await self._inner.list_views(list(namespace_path or []))
|
||||
def list_views(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> AsyncListing[str]:
|
||||
"""The names of the views in one namespace.
|
||||
|
||||
async def list_jobs(self) -> List[JobInfo]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
return await self._inner.list_jobs()
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return AsyncListing(
|
||||
self._inner.list_views(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
def list_jobs(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> AsyncListing[JobInfo]:
|
||||
"""List server-side jobs across the database's tables.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return AsyncListing(
|
||||
self._inner.list_jobs(page_token=page_token, page_limit=page_limit)
|
||||
)
|
||||
|
||||
async def cancel_job(self, job_id: str) -> bool:
|
||||
"""Request cancellation of a server-side job by id.
|
||||
@@ -3089,6 +3309,7 @@ class AsyncConnection(object):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
"""Execute SQL and return an asynchronous Arrow reader.
|
||||
|
||||
@@ -3099,6 +3320,7 @@ class AsyncConnection(object):
|
||||
submitted = await self.execute_query_async(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=parameters,
|
||||
)
|
||||
return await submitted.reader()
|
||||
|
||||
@@ -3107,17 +3329,21 @@ class AsyncConnection(object):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> AsyncSqlQuery:
|
||||
"""Start executing SQL and return its query handle.
|
||||
|
||||
The database from ``connect_async`` is used for unqualified database
|
||||
references. The namespace defaults to ``["public"]``. Local
|
||||
connections raise ``NotImplementedError``.
|
||||
references. The namespace defaults to ``["public"]``. ``parameters``
|
||||
binds values to ``$1`` / ``$name`` placeholders; see
|
||||
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
|
||||
Local connections raise ``NotImplementedError``.
|
||||
"""
|
||||
return AsyncSqlQuery(
|
||||
await self._inner.execute_query_async(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=to_parameter_batch(parameters),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Lazy, resumable resource listings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncIterator, Callable, Generic, Iterator, Optional, TypeVar
|
||||
|
||||
from .background_loop import LOOP
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class AsyncListing(AsyncIterator[T], Generic[T]):
|
||||
"""A lazy async iterator with inspectable pagination state.
|
||||
|
||||
Returned by connection resource-listing methods. For example::
|
||||
|
||||
async for name in connection.list_views(page_limit=20):
|
||||
print(name)
|
||||
|
||||
Requests happen during iteration; request errors terminate the iterator.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Any, convert: Callable[[Any], T] = lambda value: value):
|
||||
self._inner = inner
|
||||
self._convert = convert
|
||||
|
||||
def __aiter__(self) -> AsyncListing[T]:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> T:
|
||||
return self._convert(await self._inner.__anext__())
|
||||
|
||||
def num_page_results(self) -> int:
|
||||
"""Return the number of cached items available without another request."""
|
||||
return self._inner.num_page_results()
|
||||
|
||||
def page_token(self) -> Optional[str]:
|
||||
"""Return the token for the next request.
|
||||
|
||||
Initially this is the supplied starting token (``None`` starts at the
|
||||
beginning). After fetching the final page it is ``None``, even if items
|
||||
remain cached. Drain the cache before saving a token to avoid skipping
|
||||
items when resuming. A failed request terminates iteration and retains
|
||||
its token for retrying with a new iterator.
|
||||
"""
|
||||
return self._inner.page_token()
|
||||
|
||||
|
||||
class Listing(Iterator[T], Generic[T]):
|
||||
"""A lazy synchronous iterator with inspectable pagination state.
|
||||
|
||||
For example::
|
||||
|
||||
for name in connection.list_views(page_limit=20):
|
||||
print(name)
|
||||
|
||||
Use ``list(connection.list_views())`` to collect all results.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: AsyncListing[T]):
|
||||
self._inner = inner
|
||||
|
||||
def __iter__(self) -> Listing[T]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> T:
|
||||
try:
|
||||
return LOOP.run(self._inner.__anext__())
|
||||
except StopAsyncIteration:
|
||||
raise StopIteration from None
|
||||
|
||||
def num_page_results(self) -> int:
|
||||
"""Return the number of cached items available without another request."""
|
||||
return self._inner.num_page_results()
|
||||
|
||||
def page_token(self) -> Optional[str]:
|
||||
"""Return the next request's token.
|
||||
|
||||
See [AsyncListing.page_token][lancedb.listing.AsyncListing.page_token]
|
||||
for initial, cached, final-page, and error behavior.
|
||||
"""
|
||||
return self._inner.page_token()
|
||||
@@ -6,11 +6,11 @@ maintained by refresh. See ``DBConnection.create_materialized_view``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .background_loop import LOOP
|
||||
from .job import AsyncJob, Job, _typed_job
|
||||
|
||||
@@ -21,7 +21,6 @@ if TYPE_CHECKING:
|
||||
from .table import AsyncTable, LanceTable
|
||||
|
||||
DEFINITION_META_KEY = b"mv.definition"
|
||||
_MAX_FLOAT32 = float.fromhex("0x1.fffffep+127")
|
||||
|
||||
SelectArg = Union[
|
||||
str,
|
||||
@@ -31,104 +30,12 @@ SelectArg = Union[
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorDedupSource:
|
||||
"""A declarative source of retained original rows from an indexed snapshot.
|
||||
|
||||
Construct with [vector_dedup][lancedb.vector_dedup] and pass it as the
|
||||
``source`` of ``create_materialized_view`` or
|
||||
``create_materialized_view_async``. An omitted ``dataset_version`` is
|
||||
captured once when creation is submitted, not when this object is made.
|
||||
Refreshes keep that captured version. The source table is never modified.
|
||||
"""
|
||||
|
||||
source: str
|
||||
column: str
|
||||
distance_threshold: float
|
||||
dataset_version: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("source", "column"):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{name} must be a non-empty string")
|
||||
if self.dataset_version is not None and (
|
||||
type(self.dataset_version) is not int
|
||||
or not 0 < self.dataset_version < 2**64
|
||||
):
|
||||
raise ValueError("dataset_version must be a positive uint64 integer")
|
||||
value = self.distance_threshold
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError("distance_threshold must be a number")
|
||||
try:
|
||||
value = float(value)
|
||||
except OverflowError as exc:
|
||||
raise ValueError("distance_threshold must fit a finite float32") from exc
|
||||
if not math.isfinite(value) or abs(value) > _MAX_FLOAT32:
|
||||
raise ValueError("distance_threshold must fit a finite float32")
|
||||
object.__setattr__(self, "distance_threshold", value)
|
||||
|
||||
def _native_source_json(self, version: int) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"kind": "Dedup",
|
||||
"dataset_version": version,
|
||||
"column": self.column,
|
||||
"distance_threshold": repr(self.distance_threshold),
|
||||
},
|
||||
allow_nan=False,
|
||||
)
|
||||
MaterializedViewSource = str
|
||||
|
||||
|
||||
def vector_dedup(
|
||||
source: str,
|
||||
*,
|
||||
column: str,
|
||||
distance_threshold: float,
|
||||
dataset_version: Optional[int] = None,
|
||||
) -> VectorDedupSource:
|
||||
"""Declare an indexed dedup source for a materialized view.
|
||||
|
||||
The default policy keeps the smallest unassigned snapshot row ID and removes
|
||||
only its direct qualifying neighbors: an A-B-C chain without an A-C edge
|
||||
retains A and C. Rows with no pair, including null vectors, survive.
|
||||
Only same-segment/same-IVF-partition pairs are considered; quantized distances
|
||||
use reconstructed index vectors. The index must cover the selected snapshot.
|
||||
|
||||
``source`` is an exact table name in the connection's root namespace, just
|
||||
as for ``create_materialized_view``. ``dataset_version=None`` captures the
|
||||
source's version when creation is submitted. Pass an explicit positive
|
||||
version to reproduce a previous snapshot. The result contains original
|
||||
source columns and rows. Do not combine this source with ``select``,
|
||||
``where`` or ``limit``; query the resulting table for further transforms.
|
||||
|
||||
Creation follows the usual MV contract: by default it waits for the result.
|
||||
Use ``with_no_data=True`` to declare only, or
|
||||
``create_materialized_view_async`` to get a job handle. On Cloud/Enterprise,
|
||||
execution uses server-side MV registry jobs; local connections run in process.
|
||||
|
||||
Examples
|
||||
--------
|
||||
Given a connected ``db`` and an indexed ``images.phash`` column::
|
||||
|
||||
from lancedb import vector_dedup
|
||||
|
||||
view = db.create_materialized_view(
|
||||
"images_clean",
|
||||
vector_dedup("images", column="phash", distance_threshold=4),
|
||||
)
|
||||
cleaned = view.table
|
||||
"""
|
||||
return VectorDedupSource(source, column, distance_threshold, dataset_version)
|
||||
|
||||
|
||||
MaterializedViewSource = Union[str, VectorDedupSource]
|
||||
|
||||
|
||||
DEFINITION_FORMAT = 4
|
||||
DEFINITION_FORMAT = 2
|
||||
"""The newest stored layout this version reads: ``{"format": N, "query": "<SQL>"}``,
|
||||
format 2 being a query with ``GROUP BY``, format 3 native pairs and format 4
|
||||
native dedup results. A ``kind`` key beside it is for
|
||||
format 2 being a query with ``GROUP BY``. A ``kind`` key beside it is for
|
||||
readers older than the format number."""
|
||||
|
||||
|
||||
@@ -272,38 +179,31 @@ class AsyncMaterializedView:
|
||||
return _definition_from_json(raw)
|
||||
|
||||
async def refresh(
|
||||
self, *, full: bool = False, source_version: Optional[int] = None
|
||||
self, *, source_version: Optional[int] = None
|
||||
) -> "RefreshMaterializedViewResult":
|
||||
"""Recompute the view from its source.
|
||||
|
||||
The refresh is incremental when the source's changes can be
|
||||
reconciled into the view -- rows added, changed or removed since the
|
||||
last one -- and otherwise rebuilds. ``full=True`` forces a rebuild;
|
||||
Every refresh rebuilds the complete view. For local views,
|
||||
``source_version`` refreshes to that source version instead of the
|
||||
latest.
|
||||
|
||||
Concurrent refreshes of one view do not duplicate its rows. Two that
|
||||
plan the same source rows conflict on commit, and the loser raises
|
||||
rather than writing them a second time.
|
||||
latest; remote SQL refreshes do not support source-version pinning.
|
||||
"""
|
||||
return await self._table._inner.refresh_materialized_view(
|
||||
full=full, source_version=source_version
|
||||
source_version=source_version
|
||||
)
|
||||
|
||||
async def refresh_async(
|
||||
self, *, full: bool = False, source_version: Optional[int] = None
|
||||
self, *, source_version: Optional[int] = None
|
||||
) -> "AsyncJob[RefreshMaterializedViewResult]":
|
||||
"""Submit a refresh and return its job without waiting.
|
||||
|
||||
The job may already be complete for a local view. On LanceDB Cloud
|
||||
and Enterprise, its ``id`` is the server job identifier returned by
|
||||
the refresh endpoint.
|
||||
The job may already be complete for a local view. Remote refreshes are
|
||||
submitted through the SQL service.
|
||||
"""
|
||||
from ._lancedb import RefreshMaterializedViewResult
|
||||
|
||||
return _typed_job(
|
||||
await self._table._inner.refresh_materialized_view_async(
|
||||
full=full, source_version=source_version
|
||||
source_version=source_version
|
||||
),
|
||||
RefreshMaterializedViewResult.from_json,
|
||||
)
|
||||
@@ -335,22 +235,18 @@ class MaterializedView:
|
||||
return LOOP.run(self._async.definition())
|
||||
|
||||
def refresh(
|
||||
self, *, full: bool = False, source_version: Optional[int] = None
|
||||
self, *, source_version: Optional[int] = None
|
||||
) -> "RefreshMaterializedViewResult":
|
||||
"""Recompute the view from its source. See
|
||||
[AsyncMaterializedView.refresh][lancedb.materialized_view.AsyncMaterializedView.refresh]."""
|
||||
return LOOP.run(self._async.refresh(full=full, source_version=source_version))
|
||||
return LOOP.run(self._async.refresh(source_version=source_version))
|
||||
|
||||
def refresh_async(
|
||||
self, *, full: bool = False, source_version: Optional[int] = None
|
||||
self, *, source_version: Optional[int] = None
|
||||
) -> "Job[RefreshMaterializedViewResult]":
|
||||
"""Submit a refresh and return its job without waiting.
|
||||
|
||||
See
|
||||
[AsyncMaterializedView.refresh_async][lancedb.materialized_view.AsyncMaterializedView.refresh_async].
|
||||
"""
|
||||
return Job(
|
||||
LOOP.run(
|
||||
self._async.refresh_async(full=full, source_version=source_version)
|
||||
)
|
||||
)
|
||||
return Job(LOOP.run(self._async.refresh_async(source_version=source_version)))
|
||||
|
||||
@@ -11,7 +11,7 @@ through a namespace abstraction.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
|
||||
from typing import Any, Dict, Iterable, List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
@@ -19,8 +19,7 @@ if sys.version_info >= (3, 12):
|
||||
else:
|
||||
from overrides import override
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lancedb.query import Query
|
||||
from lancedb.query import Query
|
||||
|
||||
from datetime import timedelta
|
||||
import pyarrow as pa
|
||||
@@ -52,8 +51,9 @@ from lancedb.background_loop import LOOP
|
||||
from lancedb.arrow import AsyncRecordBatchReader
|
||||
from lancedb.db import AsyncConnection, DBConnection
|
||||
from lancedb.job import AsyncJob, Job
|
||||
from lancedb.listing import AsyncListing, Listing
|
||||
from lancedb.sql import AsyncQuery as AsyncSqlQuery
|
||||
from lancedb.sql import QueryDescription
|
||||
from lancedb.sql import QueryDescription, QueryParameters
|
||||
from lance_namespace import (
|
||||
LanceNamespace,
|
||||
connect as namespace_connect,
|
||||
@@ -489,6 +489,14 @@ class LanceNamespaceDBConnection(DBConnection):
|
||||
)
|
||||
self._uri = self._inner.uri
|
||||
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._inner.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._inner.close()
|
||||
|
||||
@override
|
||||
def serialize(self) -> str:
|
||||
import json
|
||||
@@ -690,9 +698,20 @@ class LanceNamespaceDBConnection(DBConnection):
|
||||
return view
|
||||
|
||||
@override
|
||||
def list_materialized_views(self) -> List[str]:
|
||||
"""The names of the materialized views in the root namespace."""
|
||||
return LOOP.run(self._inner.list_materialized_views())
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[str]:
|
||||
"""The names of the materialized views in the root namespace.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._inner.list_materialized_views(
|
||||
page_token=page_token, page_limit=page_limit
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_materialized_view(
|
||||
@@ -1289,9 +1308,20 @@ class AsyncLanceNamespaceDBConnection:
|
||||
await view.definition()
|
||||
return view
|
||||
|
||||
async def list_materialized_views(self) -> List[str]:
|
||||
"""The names of the materialized views in the root namespace."""
|
||||
return await self._inner.list_materialized_views()
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> AsyncListing[str]:
|
||||
"""The names of the materialized views in the root namespace.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return AsyncListing(
|
||||
self._inner.list_materialized_views(
|
||||
page_token=page_token, page_limit=page_limit
|
||||
)
|
||||
)
|
||||
|
||||
async def drop_materialized_view(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
@@ -1554,11 +1584,13 @@ class AsyncLanceNamespaceDBConnection:
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> AsyncRecordBatchReader:
|
||||
"""Execute SQL when supported by the underlying connection."""
|
||||
return await self._inner.execute_query(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
async def execute_query_async(
|
||||
@@ -1566,6 +1598,7 @@ class AsyncLanceNamespaceDBConnection:
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> AsyncSqlQuery:
|
||||
"""Start executing SQL when supported by the underlying connection.
|
||||
|
||||
@@ -1574,6 +1607,7 @@ class AsyncLanceNamespaceDBConnection:
|
||||
return await self._inner.execute_query_async(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
async def describe_query(self, query_id: UUID) -> QueryDescription:
|
||||
|
||||
@@ -40,7 +40,7 @@ from .rerankers.base import Reranker
|
||||
from .rerankers.rrf import RRFReranker
|
||||
from .rerankers.util import check_reranker_result
|
||||
from .schema import is_blob_like_field, schema_has_blob_field
|
||||
from .util import flatten_columns
|
||||
from .util import _validate_query_vector, flatten_columns
|
||||
from . import _wal_hybrid # WAL-PK-FUSION: delete.
|
||||
from ._blob import (
|
||||
BLOB_MODE_TO_HANDLING,
|
||||
@@ -479,7 +479,7 @@ class MatchQuery(FullTextQuery):
|
||||
boost : float, default 1.0
|
||||
The boost factor for the query.
|
||||
The score of each matching document is multiplied by this value.
|
||||
fuzziness : int, optional
|
||||
fuzziness : int or None, default 0
|
||||
The maximum edit distance for each term in the match query.
|
||||
Defaults to 0 (exact match).
|
||||
If None, fuzziness is applied automatically by the rules:
|
||||
@@ -506,7 +506,7 @@ class MatchQuery(FullTextQuery):
|
||||
query: str
|
||||
column: str
|
||||
boost: float = pydantic.Field(1.0, kw_only=True)
|
||||
fuzziness: int = pydantic.Field(0, kw_only=True)
|
||||
fuzziness: Optional[int] = pydantic.Field(0, kw_only=True)
|
||||
max_expansions: int = pydantic.Field(50, kw_only=True)
|
||||
operator: FullTextOperator = pydantic.Field(FullTextOperator.OR, kw_only=True)
|
||||
prefix_length: int = pydantic.Field(0, kw_only=True)
|
||||
@@ -907,6 +907,9 @@ class LanceQueryBuilder(ABC):
|
||||
fast_search: bool
|
||||
Skip flat search of unindexed data.
|
||||
"""
|
||||
if query_type != "fts":
|
||||
_validate_query_vector(query)
|
||||
|
||||
if ordering_field_name is not None:
|
||||
import warnings
|
||||
|
||||
@@ -1619,6 +1622,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
str_query: Optional[str] = None,
|
||||
fast_search: bool = None,
|
||||
):
|
||||
_validate_query_vector(query)
|
||||
super().__init__(table)
|
||||
self._query = query
|
||||
self._distance_type = None
|
||||
@@ -1693,13 +1697,15 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
Parameters
|
||||
----------
|
||||
nprobes: int
|
||||
The number of probes to use.
|
||||
The number of probes to use. Must be greater than 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
LanceVectorQueryBuilder
|
||||
The LanceQueryBuilder object.
|
||||
"""
|
||||
if nprobes <= 0:
|
||||
raise ValueError("Invalid input, nprobes must be greater than 0")
|
||||
self._minimum_nprobes = nprobes
|
||||
self._maximum_nprobes = nprobes
|
||||
return self
|
||||
@@ -2495,13 +2501,15 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
Parameters
|
||||
----------
|
||||
nprobes: int
|
||||
The number of probes to use.
|
||||
The number of probes to use. Must be greater than 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
LanceHybridQueryBuilder
|
||||
The LanceHybridQueryBuilder object.
|
||||
"""
|
||||
if nprobes <= 0:
|
||||
raise ValueError("Invalid input, nprobes must be greater than 0")
|
||||
self._minimum_nprobes = nprobes
|
||||
self._maximum_nprobes = nprobes
|
||||
return self
|
||||
@@ -3654,6 +3662,8 @@ class AsyncVectorQueryBase:
|
||||
"""
|
||||
Set the number of partitions to search (probe)
|
||||
|
||||
The number of probes must be greater than 0.
|
||||
|
||||
This argument is only used when the vector column has an IVF-based index.
|
||||
If there is no index then this value is ignored.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
import json
|
||||
@@ -34,11 +36,12 @@ from lancedb.remote import ClientConfig, RetryConfig, TimeoutConfig, TlsConfig
|
||||
import pyarrow as pa
|
||||
|
||||
from ..common import DATA
|
||||
from lancedb.listing import Listing
|
||||
from ..db import DBConnection, LOOP
|
||||
from ..functions import FunctionVersion, UdfDefinition
|
||||
from ..job import AsyncJob, Job
|
||||
from ..sql import Query as SqlQuery
|
||||
from ..sql import QueryDescription
|
||||
from ..sql import QueryDescription, QueryParameters
|
||||
from ..materialized_view import MaterializedView, MaterializedViewSource, SelectArg
|
||||
from ..secrets import EnvVarSecret, SecretInfo
|
||||
from ..view import ViewDescription
|
||||
@@ -483,12 +486,12 @@ class RemoteDBConnection(DBConnection):
|
||||
namespace_path = []
|
||||
if storage_options is not None:
|
||||
logging.info(
|
||||
"storage_options is ignored in LanceDb Cloud"
|
||||
"storage_options is ignored for remote tables"
|
||||
" (storage is managed; set storage_options on connect() instead)"
|
||||
)
|
||||
if index_cache_size is not None:
|
||||
logging.info(
|
||||
"index_cache_size is ignored in LanceDb Cloud"
|
||||
"index_cache_size is ignored for remote tables"
|
||||
" (there is no local cache to configure)"
|
||||
)
|
||||
|
||||
@@ -608,6 +611,9 @@ class RemoteDBConnection(DBConnection):
|
||||
One of "error", "drop", "fill".
|
||||
fill_value: float
|
||||
The value to use when filling vectors. Only used if on_bad_vectors="fill".
|
||||
embedding_functions: list of EmbeddingFunctionConfig, optional
|
||||
The embedding functions to store in the table schema and use to
|
||||
generate vectors when creating the table or adding data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -682,12 +688,6 @@ class RemoteDBConnection(DBConnection):
|
||||
if namespace_path is None:
|
||||
namespace_path = []
|
||||
validate_table_name(name)
|
||||
if embedding_functions is not None:
|
||||
logging.warning(
|
||||
"embedding_functions is not yet supported on LanceDB Cloud."
|
||||
"Please vote https://github.com/lancedb/lancedb/issues/626 "
|
||||
"for this feature."
|
||||
)
|
||||
|
||||
from .table import RemoteTable
|
||||
|
||||
@@ -700,6 +700,7 @@ class RemoteDBConnection(DBConnection):
|
||||
schema=schema,
|
||||
on_bad_vectors=on_bad_vectors,
|
||||
fill_value=fill_value,
|
||||
embedding_functions=embedding_functions,
|
||||
)
|
||||
)
|
||||
return RemoteTable(
|
||||
@@ -771,8 +772,20 @@ class RemoteDBConnection(DBConnection):
|
||||
return view
|
||||
|
||||
@override
|
||||
def list_materialized_views(self) -> List[str]:
|
||||
return LOOP.run(self._conn.list_materialized_views())
|
||||
def list_materialized_views(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[str]:
|
||||
"""List materialized views.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_materialized_views(
|
||||
page_token=page_token, page_limit=page_limit
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_materialized_view(
|
||||
@@ -901,9 +914,25 @@ class RemoteDBConnection(DBConnection):
|
||||
|
||||
@override
|
||||
def list_functions(
|
||||
self, *, namespace_path: Optional[List[str]] = None
|
||||
) -> List[FunctionVersion]:
|
||||
return LOOP.run(self._conn.list_functions(namespace_path=namespace_path))
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[FunctionVersion]:
|
||||
"""List functions.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_functions(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_function(
|
||||
@@ -953,8 +982,26 @@ class RemoteDBConnection(DBConnection):
|
||||
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||
def list_secrets(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""List secrets.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_secrets(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_secret(
|
||||
@@ -990,13 +1037,40 @@ class RemoteDBConnection(DBConnection):
|
||||
return Job(job)
|
||||
|
||||
@override
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
|
||||
def list_views(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
"""List views.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_views(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def list_jobs(self) -> List["JobInfo"]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
return LOOP.run(self._conn.list_jobs())
|
||||
def list_jobs(
|
||||
self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None
|
||||
) -> Listing[JobInfo]:
|
||||
"""List server-side jobs across the database's tables.
|
||||
|
||||
Returns a lazy iterator. ``page_limit`` limits each request, not the total;
|
||||
``page_token`` resumes from a saved token. Requests and errors occur during
|
||||
iteration. See [pagination state][lancedb.listing.AsyncListing].
|
||||
"""
|
||||
return Listing(
|
||||
self._conn.list_jobs(page_token=page_token, page_limit=page_limit)
|
||||
)
|
||||
|
||||
@override
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
@@ -1030,18 +1104,22 @@ class RemoteDBConnection(DBConnection):
|
||||
query: str,
|
||||
*,
|
||||
default_namespace_path: Optional[List[str]] = None,
|
||||
parameters: Optional[QueryParameters] = None,
|
||||
) -> SqlQuery:
|
||||
"""Start executing SQL through this remote connection.
|
||||
|
||||
Unqualified tables use this connection's database and the
|
||||
``["public"]`` namespace by default. Fully qualified table names may
|
||||
reference other databases available to the same deployment.
|
||||
``parameters`` binds values to ``$1`` / ``$name`` placeholders; see
|
||||
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
|
||||
"""
|
||||
return SqlQuery(
|
||||
LOOP.run(
|
||||
self._conn.execute_query_async(
|
||||
query,
|
||||
default_namespace_path=default_namespace_path,
|
||||
parameters=parameters,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1068,6 +1146,10 @@ class RemoteDBConnection(DBConnection):
|
||||
"""
|
||||
return LOOP.run(self._conn.namespace_client())
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection to the database."""
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._conn.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
@@ -43,6 +43,7 @@ from lancedb.index import (
|
||||
BTree,
|
||||
Bitmap,
|
||||
HnswFlat,
|
||||
HnswPq,
|
||||
HnswSq,
|
||||
IvfFlat,
|
||||
IvfPq,
|
||||
@@ -64,6 +65,7 @@ from lancedb.table import _normalize_progress
|
||||
from ..query import (
|
||||
AnalyzePlanDistributedMetrics,
|
||||
DocumentGranularity,
|
||||
FullTextQuery,
|
||||
LanceQueryBuilder,
|
||||
LanceTakeQueryBuilder,
|
||||
LanceVectorQueryBuilder,
|
||||
@@ -78,6 +80,7 @@ from ..table import (
|
||||
Tags,
|
||||
)
|
||||
from ..types import BaseTokenizerType
|
||||
from ..util import infer_vector_column_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import lance
|
||||
@@ -242,16 +245,16 @@ class RemoteTable(Table):
|
||||
return LOOP.run(self._table.list_versions())
|
||||
|
||||
def to_arrow(self) -> pa.Table:
|
||||
"""to_arrow() is not yet supported on LanceDB cloud."""
|
||||
"""to_arrow() is not supported for remote tables."""
|
||||
raise NotImplementedError(
|
||||
"to_arrow() is not yet supported on LanceDB cloud. "
|
||||
"to_arrow() is not supported for remote tables. "
|
||||
"Use table.search().to_arrow() instead."
|
||||
)
|
||||
|
||||
def to_pandas(self, blob_mode: BlobMode = "lazy", **kwargs):
|
||||
"""to_pandas() is not yet supported on LanceDB cloud."""
|
||||
"""to_pandas() is not supported for remote tables."""
|
||||
raise NotImplementedError(
|
||||
"to_pandas() is not yet supported on LanceDB cloud. "
|
||||
"to_pandas() is not supported for remote tables. "
|
||||
"Use table.search().to_pandas() instead."
|
||||
)
|
||||
|
||||
@@ -266,7 +269,7 @@ class RemoteTable(Table):
|
||||
def to_polars(self, **kwargs) -> "pl.LazyFrame":
|
||||
"""Use ``table.search().to_polars()`` to read remote query results."""
|
||||
raise NotImplementedError(
|
||||
"to_polars() is not supported on LanceDB cloud. "
|
||||
"to_polars() is not supported for remote tables. "
|
||||
"Use table.search().to_polars() instead."
|
||||
)
|
||||
|
||||
@@ -525,14 +528,8 @@ class RemoteTable(Table):
|
||||
|
||||
if accelerator is not None:
|
||||
logging.warning(
|
||||
"GPU accelerator is not yet supported on LanceDB cloud."
|
||||
"If you have 100M+ vectors to index,"
|
||||
"please contact us at contact@lancedb.com"
|
||||
)
|
||||
if replace is not None:
|
||||
logging.warning(
|
||||
"replace is not supported on LanceDB cloud."
|
||||
"Existing indexes will always be replaced."
|
||||
"GPU accelerator is not supported for remote tables "
|
||||
"and will be ignored."
|
||||
)
|
||||
|
||||
idx_type = index_type.upper()
|
||||
@@ -552,9 +549,11 @@ class RemoteTable(Table):
|
||||
elif idx_type == "IVF_SQ":
|
||||
config = IvfSq(distance_type=metric, num_partitions=num_partitions)
|
||||
elif idx_type == "IVF_HNSW_PQ":
|
||||
raise ValueError(
|
||||
"IVF_HNSW_PQ is not supported on LanceDB cloud."
|
||||
"Please use IVF_HNSW_SQ instead."
|
||||
config = HnswPq(
|
||||
distance_type=metric,
|
||||
num_partitions=num_partitions,
|
||||
num_sub_vectors=num_sub_vectors,
|
||||
num_bits=num_bits,
|
||||
)
|
||||
elif idx_type == "IVF_HNSW_SQ":
|
||||
config = HnswSq(distance_type=metric, num_partitions=num_partitions)
|
||||
@@ -683,7 +682,7 @@ class RemoteTable(Table):
|
||||
capped at the number of CPU cores. Lower this if bulk ingestion is
|
||||
using too much memory.
|
||||
allow_external_blob_outside_bases: bool, default False
|
||||
Not supported on LanceDB Cloud. Setting this raises.
|
||||
Not supported for remote tables. Setting this raises.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -713,7 +712,7 @@ class RemoteTable(Table):
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type="auto",
|
||||
fts_columns: Optional[Union[str, List[str]]] = None,
|
||||
fast_search: bool = False,
|
||||
fast_search: Optional[bool] = None,
|
||||
) -> LanceVectorQueryBuilder:
|
||||
"""Create a search query to find the nearest neighbors
|
||||
of the given query vector. We currently support
|
||||
@@ -760,11 +759,17 @@ class RemoteTable(Table):
|
||||
- If the table has multiple vector columns then the *vector_column_name*
|
||||
needs to be specified. Otherwise, an error is raised.
|
||||
|
||||
query_type: str, default "auto"
|
||||
The type of search: "auto", "vector", "fts", or "hybrid".
|
||||
With "auto", a string query uses the embedding function for the
|
||||
inferred or specified vector column if available, otherwise it uses
|
||||
full-text search.
|
||||
|
||||
fast_search: bool, optional
|
||||
Skip a flat search of unindexed data. This may improve
|
||||
search performance but search results will not include unindexed data.
|
||||
|
||||
- *default False*.
|
||||
- *default None*, which leaves fast search disabled.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -779,12 +784,27 @@ class RemoteTable(Table):
|
||||
- and also the "_distance" column which is the distance between the query
|
||||
vector and the returned vector.
|
||||
"""
|
||||
if isinstance(query, FullTextQuery):
|
||||
query_type = "fts"
|
||||
elif (
|
||||
query_type == "auto"
|
||||
and isinstance(query, str)
|
||||
and not self.embedding_functions
|
||||
):
|
||||
query_type = "fts"
|
||||
vector_column_name = infer_vector_column_name(
|
||||
schema=self.schema,
|
||||
query_type=query_type,
|
||||
query=query,
|
||||
vector_column_name=vector_column_name,
|
||||
)
|
||||
|
||||
return LanceQueryBuilder.create(
|
||||
self,
|
||||
query,
|
||||
query_type,
|
||||
vector_column_name=vector_column_name,
|
||||
fts_columns=fts_columns,
|
||||
fts_columns=fts_columns or [],
|
||||
fast_search=fast_search,
|
||||
)
|
||||
|
||||
@@ -959,26 +979,20 @@ class RemoteTable(Table):
|
||||
|
||||
def cleanup_old_versions(self, *_):
|
||||
"""
|
||||
cleanup_old_versions() is a no-op on LanceDB Cloud.
|
||||
|
||||
Tables are automatically cleaned up and optimized.
|
||||
cleanup_old_versions() is a no-op for remote tables.
|
||||
"""
|
||||
warnings.warn(
|
||||
"cleanup_old_versions() is a no-op on LanceDB Cloud. "
|
||||
"Tables are automatically cleaned up and optimized.",
|
||||
"cleanup_old_versions() is a no-op for remote tables.",
|
||||
stacklevel=2,
|
||||
)
|
||||
pass
|
||||
|
||||
def compact_files(self, *_):
|
||||
"""
|
||||
compact_files() is a no-op on LanceDB Cloud.
|
||||
|
||||
Tables are automatically compacted and optimized.
|
||||
compact_files() is a no-op for remote tables.
|
||||
"""
|
||||
warnings.warn(
|
||||
"compact_files() is a no-op on LanceDB Cloud. "
|
||||
"Tables are automatically compacted and optimized.",
|
||||
"compact_files() is a no-op for remote tables.",
|
||||
stacklevel=2,
|
||||
)
|
||||
pass
|
||||
@@ -989,17 +1003,13 @@ class RemoteTable(Table):
|
||||
cleanup_older_than: Optional[timedelta] = None,
|
||||
delete_unverified: bool = False,
|
||||
):
|
||||
"""
|
||||
optimize() is a no-op on LanceDB Cloud.
|
||||
|
||||
Indices are optimized automatically.
|
||||
"""
|
||||
warnings.warn(
|
||||
"optimize() is a no-op on LanceDB Cloud. "
|
||||
"Indices are optimized automatically.",
|
||||
stacklevel=2,
|
||||
"""optimize() is not supported for remote tables."""
|
||||
return LOOP.run(
|
||||
self._table.optimize(
|
||||
cleanup_older_than=cleanup_older_than,
|
||||
delete_unverified=delete_unverified,
|
||||
)
|
||||
)
|
||||
pass
|
||||
|
||||
def count_rows(self, filter: Optional[str] = None) -> int:
|
||||
return LOOP.run(self._table.count_rows(filter))
|
||||
@@ -1047,7 +1057,7 @@ class RemoteTable(Table):
|
||||
return LOOP.run(self._table.drop_columns(columns))
|
||||
|
||||
def set_unenforced_primary_key(self, columns: Union[str, Iterable[str]]) -> None:
|
||||
"""Not supported on LanceDB Cloud."""
|
||||
"""Not supported for remote tables."""
|
||||
return LOOP.run(self._table.set_unenforced_primary_key(columns))
|
||||
|
||||
def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
|
||||
@@ -1093,7 +1103,7 @@ class RemoteTable(Table):
|
||||
)
|
||||
|
||||
def close_lsm_writers(self) -> None:
|
||||
"""No-op on LanceDB Cloud (no local shard writers)."""
|
||||
"""No-op for remote tables (no local shard writers)."""
|
||||
return LOOP.run(self._table.close_lsm_writers())
|
||||
|
||||
def drop_index(self, index_name: str):
|
||||
@@ -1161,14 +1171,10 @@ class RemoteTable(Table):
|
||||
return LanceTakeQueryBuilder(self._table.take_row_ids(row_ids))
|
||||
|
||||
def uses_v2_manifest_paths(self) -> bool:
|
||||
raise NotImplementedError(
|
||||
"uses_v2_manifest_paths() is not supported on the LanceDB Cloud"
|
||||
)
|
||||
return LOOP.run(self._table.uses_v2_manifest_paths())
|
||||
|
||||
def migrate_v2_manifest_paths(self):
|
||||
raise NotImplementedError(
|
||||
"migrate_v2_manifest_paths() is not supported on the LanceDB Cloud"
|
||||
)
|
||||
return LOOP.run(self._table.migrate_manifest_paths_v2())
|
||||
|
||||
def blob_columns(self) -> list[str]:
|
||||
return LOOP.run(self._table.blob_columns())
|
||||
@@ -1179,9 +1185,7 @@ class RemoteTable(Table):
|
||||
return LOOP.run(self._table.fetch_blobs(column, row_ids))
|
||||
|
||||
def fetch_blob_ranges(self, column: str, requests) -> pa.LargeBinaryArray:
|
||||
raise NotImplementedError(
|
||||
"fetch_blob_ranges() is not supported on LanceDB Cloud"
|
||||
)
|
||||
return LOOP.run(self._table.fetch_blob_ranges(column, list(requests)))
|
||||
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
"""Handles to SQL queries running on a remote database."""
|
||||
|
||||
from typing import Any, Mapping, Optional, Sequence, Union
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
|
||||
from lancedb.background_loop import LOOP
|
||||
@@ -14,6 +16,82 @@ from .arrow import AsyncRecordBatchReader
|
||||
|
||||
QueryDescription = _lancedb.QueryDescription
|
||||
|
||||
QueryParameters = Union[pa.RecordBatch, pa.Table, Sequence[Any], Mapping[str, Any]]
|
||||
"""Values for a statement's ``$1`` / ``$name`` placeholders.
|
||||
|
||||
A sequence binds by position (``$1``, ``$2``, ...), a mapping binds by name
|
||||
(``$name``), and a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds
|
||||
both ways: column ``i`` is ``$<i + 1>`` and, when its name is not a number,
|
||||
also ``$<name>``.
|
||||
"""
|
||||
|
||||
|
||||
def _parameter_array(value: Any) -> pa.Array:
|
||||
"""One parameter as a one-element array of its own type."""
|
||||
if isinstance(value, pa.Scalar):
|
||||
return pa.array([value.as_py()], type=value.type)
|
||||
# A 1-D array is a vector: keep its element type and make it the
|
||||
# fixed-size list a vector column stores, rather than a list of Python
|
||||
# floats that would come back as float64.
|
||||
if isinstance(value, pa.ChunkedArray):
|
||||
value = value.combine_chunks()
|
||||
if isinstance(value, np.ndarray):
|
||||
if value.ndim == 0:
|
||||
return pa.array(value.reshape(1))
|
||||
if value.ndim != 1:
|
||||
raise ValueError(
|
||||
"a numpy query parameter must be a scalar or a 1-D vector, "
|
||||
f"got {value.ndim} dimensions"
|
||||
)
|
||||
value = pa.array(value)
|
||||
if isinstance(value, pa.Array):
|
||||
return pa.FixedSizeListArray.from_arrays(value, len(value))
|
||||
return pa.array([value])
|
||||
|
||||
|
||||
def to_parameter_batch(
|
||||
parameters: Optional[QueryParameters],
|
||||
) -> Optional[pa.RecordBatch]:
|
||||
"""Turn ``parameters`` into the one-row batch a parameterized query sends.
|
||||
|
||||
Scalars keep the type pyarrow infers for them (numpy scalars keep their
|
||||
dtype), a ``pyarrow.Scalar`` keeps its own type, and a 1-D numpy or
|
||||
pyarrow array becomes a ``FixedSizeList`` of its element type, which is
|
||||
what a vector column stores.
|
||||
"""
|
||||
if parameters is None:
|
||||
return None
|
||||
batch: pa.RecordBatch
|
||||
if isinstance(parameters, pa.RecordBatch):
|
||||
batch = parameters
|
||||
elif isinstance(parameters, pa.Table):
|
||||
batches = parameters.combine_chunks().to_batches()
|
||||
batch = (
|
||||
batches[0]
|
||||
if len(batches) == 1
|
||||
else pa.RecordBatch.from_pylist([], schema=parameters.schema)
|
||||
)
|
||||
elif isinstance(parameters, Mapping):
|
||||
batch = pa.RecordBatch.from_arrays(
|
||||
[_parameter_array(value) for value in parameters.values()],
|
||||
names=[str(name) for name in parameters.keys()],
|
||||
)
|
||||
elif isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)):
|
||||
batch = pa.RecordBatch.from_arrays(
|
||||
[_parameter_array(value) for value in parameters],
|
||||
names=[str(position + 1) for position in range(len(parameters))],
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
"query parameters must be a sequence, a mapping, or a one-row "
|
||||
f"pyarrow RecordBatch or Table, not {type(parameters).__name__}"
|
||||
)
|
||||
if batch.num_rows != 1:
|
||||
raise ValueError(
|
||||
f"query parameters must be exactly one row, got {batch.num_rows}"
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
class AsyncQuery:
|
||||
"""A handle to a submitted SQL query on an asynchronous connection."""
|
||||
@@ -85,4 +163,4 @@ class Query:
|
||||
LOOP.run(self._inner.cancel())
|
||||
|
||||
|
||||
__all__ = ["AsyncQuery", "Query", "QueryDescription"]
|
||||
__all__ = ["AsyncQuery", "Query", "QueryDescription", "QueryParameters"]
|
||||
|
||||
@@ -2629,6 +2629,10 @@ class Table(ABC):
|
||||
to nullable. Currently, you cannot change a nullable column to
|
||||
non-nullable.
|
||||
|
||||
Each alteration must specify at least one of "rename", "data_type",
|
||||
or "nullable". The legacy key "name" is also accepted for renaming.
|
||||
Unknown keys raise ValueError before any alterations are applied.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AlterColumnsResult
|
||||
@@ -4344,9 +4348,9 @@ class LanceTable(Table):
|
||||
)
|
||||
if storage_options is None:
|
||||
storage_options = {}
|
||||
storage_options["new_table_enable_v2_manifest_paths"] = (
|
||||
storage_options["new_table_enable_v2_manifest_paths"] = str(
|
||||
enable_v2_manifest_paths
|
||||
)
|
||||
).lower()
|
||||
|
||||
self._table = LOOP.run(
|
||||
self._conn._conn.create_table(
|
||||
@@ -4778,7 +4782,7 @@ class LanceTable(Table):
|
||||
[LanceTable.uses_v2_manifest_paths][lancedb.table.LanceTable.uses_v2_manifest_paths]
|
||||
to check if the table is already using the new path style.
|
||||
"""
|
||||
LOOP.run(self._table.migrate_v2_manifest_paths())
|
||||
LOOP.run(self._table.migrate_manifest_paths_v2())
|
||||
|
||||
@deprecation.deprecated(
|
||||
deprecated_in="0.33.1",
|
||||
@@ -5453,11 +5457,16 @@ class AsyncTable:
|
||||
async def get_lsm_write_spec(self) -> Optional["LsmWriteSpec"]:
|
||||
"""Read the LsmWriteSpec currently installed on this table.
|
||||
|
||||
Returns ``None`` when the MemWAL LSM write path is not enabled (no
|
||||
spec has been set, or it was removed with `unset_lsm_write_spec`).
|
||||
The returned spec mirrors what was passed to `set_lsm_write_spec`,
|
||||
except that ``maintained_indexes`` always reports the concrete list
|
||||
resolved when the spec was set — ``None`` never round-trips.
|
||||
Returns ``None`` when the LSM write path is not enabled at all — no
|
||||
spec has been set, or one was removed with `unset_lsm_write_spec`.
|
||||
That is a different answer from a spec whose ``maintained_indexes``
|
||||
is ``None``, which is an installed spec selecting indexes
|
||||
automatically.
|
||||
|
||||
The spec read back is the one that was installed, selection
|
||||
included: ``None`` maintains every supported index the table has now
|
||||
or gains later, ``[]`` maintains none, and a non-empty list maintains
|
||||
exactly those. All three round-trip.
|
||||
"""
|
||||
return await self._inner.get_lsm_write_spec()
|
||||
|
||||
@@ -6119,7 +6128,17 @@ class AsyncTable:
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
query: None = None,
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: QueryType = "auto",
|
||||
ordering_field_name: Optional[str] = None,
|
||||
fts_columns: Optional[Union[str, List[str]]] = None,
|
||||
) -> AsyncQuery: ...
|
||||
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: Literal["auto"] = ...,
|
||||
ordering_field_name: Optional[str] = None,
|
||||
@@ -6129,7 +6148,7 @@ class AsyncTable:
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
query: str,
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: Literal["hybrid"] = ...,
|
||||
ordering_field_name: Optional[str] = None,
|
||||
@@ -6139,7 +6158,7 @@ class AsyncTable:
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: Optional[Union[VEC, "PIL.Image.Image", Tuple]] = None,
|
||||
query: Union[VEC, "PIL.Image.Image", Tuple],
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: Literal["auto"] = ...,
|
||||
ordering_field_name: Optional[str] = None,
|
||||
@@ -6149,7 +6168,7 @@ class AsyncTable:
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
query: str,
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: Literal["fts"] = ...,
|
||||
ordering_field_name: Optional[str] = None,
|
||||
@@ -6159,9 +6178,7 @@ class AsyncTable:
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
query: Optional[
|
||||
Union[VEC, str, "PIL.Image.Image", Tuple, FullTextQuery]
|
||||
] = None,
|
||||
query: Union[VEC, str, "PIL.Image.Image", Tuple, FullTextQuery],
|
||||
vector_column_name: Optional[str] = None,
|
||||
query_type: Literal["vector"] = ...,
|
||||
ordering_field_name: Optional[str] = None,
|
||||
@@ -6177,7 +6194,7 @@ class AsyncTable:
|
||||
query_type: QueryType = "auto",
|
||||
ordering_field_name: Optional[str] = None,
|
||||
fts_columns: Optional[Union[str, List[str]]] = None,
|
||||
) -> Union[AsyncHybridQuery, AsyncFTSQuery, AsyncVectorQuery]:
|
||||
) -> Union[AsyncQuery, AsyncHybridQuery, AsyncFTSQuery, AsyncVectorQuery]:
|
||||
"""Create a search query to find the nearest neighbors
|
||||
of the given query vector. We currently support [vector search](https://lancedb.com/docs/search/vector-search/)
|
||||
and [full-text search](https://lancedb.com/docs/search/full-text-search/).
|
||||
@@ -6192,8 +6209,9 @@ class AsyncTable:
|
||||
- *default None*.
|
||||
Acceptable types are: list, np.ndarray, PIL.Image.Image
|
||||
|
||||
- If None then the select/where/limit clauses are applied to filter
|
||||
the table
|
||||
- If None then a plain [AsyncQuery][lancedb.query.AsyncQuery] is
|
||||
returned, equivalent to calling [query][lancedb.table.AsyncTable.query].
|
||||
The select/where/limit clauses are applied to filter the table.
|
||||
vector_column_name: str, optional
|
||||
The name of the vector column to search.
|
||||
|
||||
@@ -6221,10 +6239,13 @@ class AsyncTable:
|
||||
|
||||
Returns
|
||||
-------
|
||||
LanceQueryBuilder
|
||||
AsyncQuery, AsyncHybridQuery, AsyncFTSQuery, or AsyncVectorQuery
|
||||
A query builder object representing the query.
|
||||
"""
|
||||
|
||||
if query is None:
|
||||
return self.query()
|
||||
|
||||
def is_embedding(query):
|
||||
return isinstance(query, (list, np.ndarray, pa.Array, pa.ChunkedArray))
|
||||
|
||||
@@ -6874,6 +6895,10 @@ class AsyncTable:
|
||||
to nullable. Currently, you cannot change a nullable column to
|
||||
non-nullable.
|
||||
|
||||
Each alteration must specify at least one of "rename", "data_type",
|
||||
or "nullable". The legacy key "name" is also accepted for renaming.
|
||||
Unknown keys raise ValueError before any alterations are applied.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AlterColumnsResult
|
||||
@@ -6917,9 +6942,11 @@ class AsyncTable:
|
||||
"""
|
||||
versions = await self._inner.list_versions()
|
||||
for v in versions:
|
||||
ts_nanos = v["timestamp"]
|
||||
v["timestamp"] = datetime.fromtimestamp(ts_nanos // 1e9) + timedelta(
|
||||
microseconds=(ts_nanos % 1e9) // 1e3
|
||||
# Use integer math: float division on ~1e18 nanosecond
|
||||
# values loses sub-millisecond precision.
|
||||
seconds, nanos = divmod(v["timestamp"], 1_000_000_000)
|
||||
v["timestamp"] = datetime.fromtimestamp(seconds) + timedelta(
|
||||
microseconds=nanos // 1000
|
||||
)
|
||||
|
||||
return versions
|
||||
@@ -7314,6 +7341,13 @@ class TableStatistics:
|
||||
and manifests.
|
||||
num_rows: int
|
||||
The total number of rows in the table.
|
||||
num_deleted_rows: Optional[int]
|
||||
The total number of rows marked as deleted across all fragments of the
|
||||
table. These rows are not counted in ``num_rows``, but still occupy space
|
||||
on disk until the table is compacted, so a large value here indicates
|
||||
that the table should be optimized. Fragments in which every row was
|
||||
deleted are dropped outright, so their rows are not counted here.
|
||||
``None`` when the backend does not report deletion counts.
|
||||
num_indices: int
|
||||
The total number of indices in the table.
|
||||
fragment_stats: FragmentStatistics
|
||||
@@ -7322,6 +7356,7 @@ class TableStatistics:
|
||||
|
||||
total_bytes: int
|
||||
num_rows: int
|
||||
num_deleted_rows: Optional[int]
|
||||
num_indices: int
|
||||
fragment_stats: FragmentStatistics
|
||||
|
||||
|
||||
@@ -280,6 +280,14 @@ def infer_vector_column_dim(data_type: pa.DataType) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_query_vector(query: Any) -> None:
|
||||
"""Reject empty vector inputs before vector-column inference or execution."""
|
||||
if (isinstance(query, list) and not query) or (
|
||||
isinstance(query, np.ndarray) and query.size == 0
|
||||
):
|
||||
raise ValueError("Query vector must not be empty")
|
||||
|
||||
|
||||
def _query_vector_dim(query: Optional[Any]) -> Optional[int]:
|
||||
if query is None:
|
||||
return None
|
||||
@@ -301,6 +309,9 @@ def infer_vector_column_name(
|
||||
query: Optional[Any], # inferred later in query builder
|
||||
vector_column_name: Optional[str],
|
||||
):
|
||||
if query_type != "fts":
|
||||
_validate_query_vector(query)
|
||||
|
||||
if vector_column_name is not None:
|
||||
return vector_column_name
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_catalog_sync_scope_and_serialization(catalog_server):
|
||||
(200, {}),
|
||||
(200, {"tables": []}),
|
||||
(200, {"tables": []}),
|
||||
(200, {"namespaces": ["team/search"], "page_token": "next"}),
|
||||
(200, {"namespaces": ["team/search"]}),
|
||||
(204, None),
|
||||
]
|
||||
)
|
||||
@@ -85,13 +85,12 @@ def test_catalog_sync_scope_and_serialization(catalog_server):
|
||||
with pytest.raises(ValueError, match="sql_host_override must use"):
|
||||
connection.execute_query_async("SELECT 1")
|
||||
assert restored.table_names() == []
|
||||
page = catalog.list_databases(limit=1, page_token="a/b")
|
||||
assert page == lancedb.ListDatabasesResponse(["team/search"], "next")
|
||||
assert list(catalog.list_databases()) == ["team/search"]
|
||||
catalog.drop_database("team/search", ignore_missing=True)
|
||||
assert requests[0][0] == "/v1/namespace/team%2Fsearch/create"
|
||||
assert requests[0][2] == {"mode": "ExistOk"}
|
||||
assert requests[1][0] == "/v1/namespace/team%2Fsearch/describe"
|
||||
assert requests[4][0] == "/v1/namespace/$/list?limit=1&page_token=a%2Fb"
|
||||
assert requests[4][0] == "/v1/namespace/$/list"
|
||||
assert requests[5][2] == {"mode": "Skip", "behavior": "Restrict"}
|
||||
for i, (_, headers, _) in enumerate(requests):
|
||||
headers = {key.lower(): value for key, value in headers.items()}
|
||||
@@ -138,3 +137,129 @@ async def test_catalog_async_and_errors(catalog_server):
|
||||
def test_catalog_requires_remote_endpoint(endpoint):
|
||||
with pytest.raises(ValueError, match="endpoint"):
|
||||
lancedb.connect_catalog(endpoint)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("page_token,page_limit", [(None, None), ("start/token", 2)])
|
||||
async def test_database_names_iteration(
|
||||
catalog_server, asynchronous, page_token, page_limit
|
||||
):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {"namespaces": ["first", "second"], "page_token": "a/b"}),
|
||||
(200, {"namespaces": [], "page_token": "next"}),
|
||||
(200, {"namespaces": ["last", "final"], "page_token": ""}),
|
||||
]
|
||||
)
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
names = catalog.list_databases(page_token=page_token, page_limit=page_limit)
|
||||
assert requests == []
|
||||
assert names.num_page_results() == 0
|
||||
assert names.page_token() == page_token
|
||||
for cached, expected in [(1, "first"), (0, "second")]:
|
||||
assert (await names.__anext__() if asynchronous else next(names)) == expected
|
||||
assert names.num_page_results() == cached
|
||||
assert names.page_token() == "a/b"
|
||||
assert len(requests) == 1
|
||||
assert (await names.__anext__() if asynchronous else next(names)) == "last"
|
||||
assert names.num_page_results() == 1
|
||||
assert names.page_token() is None
|
||||
assert (await names.__anext__() if asynchronous else next(names)) == "final"
|
||||
assert names.num_page_results() == 0
|
||||
assert names.page_token() is None
|
||||
for _ in range(2):
|
||||
with pytest.raises(StopAsyncIteration if asynchronous else StopIteration):
|
||||
if asynchronous:
|
||||
await names.__anext__()
|
||||
else:
|
||||
next(names)
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
for request, token in zip(requests, [page_token, "a/b", "next"]):
|
||||
url = urlsplit(request[0])
|
||||
assert url.path == "/v1/namespace/$/list"
|
||||
expected = {}
|
||||
if token is not None:
|
||||
expected["page_token"] = [token]
|
||||
if page_limit is not None:
|
||||
expected["limit"] = [str(page_limit)]
|
||||
assert parse_qs(url.query) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("fail", [False, True])
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_names_empty_or_error(catalog_server, asynchronous, fail):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append(
|
||||
(401, {"error": "unauthorized"}) if fail else (200, {"namespaces": []})
|
||||
)
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
names = catalog.list_databases()
|
||||
if fail:
|
||||
with pytest.raises(HttpError):
|
||||
if asynchronous:
|
||||
await names.__anext__()
|
||||
else:
|
||||
next(names)
|
||||
assert ([name async for name in names] if asynchronous else list(names)) == []
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_names_resume(catalog_server, asynchronous):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {"namespaces": ["first"], "page_token": "resume/token"}),
|
||||
(200, {"namespaces": ["second"]}),
|
||||
]
|
||||
)
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
names = catalog.list_databases(page_limit=1)
|
||||
assert (await names.__anext__() if asynchronous else next(names)) == "first"
|
||||
assert names.num_page_results() == 0
|
||||
resumed = catalog.list_databases(page_token=names.page_token(), page_limit=1)
|
||||
assert resumed.page_token() == "resume/token"
|
||||
assert len(requests) == 1
|
||||
assert ([name async for name in resumed] if asynchronous else list(resumed)) == [
|
||||
"second"
|
||||
]
|
||||
assert resumed.page_token() is None
|
||||
assert resumed.num_page_results() == 0
|
||||
assert requests[1][0] == "/v1/namespace/$/list?limit=1&page_token=resume%2Ftoken"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("page_limit", [0, 2**32 - 1])
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_names_invalid_limit(catalog_server, asynchronous, page_limit):
|
||||
endpoint, requests, _ = catalog_server
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
names = catalog.list_databases(page_limit=page_limit)
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
if asynchronous:
|
||||
await names.__anext__()
|
||||
else:
|
||||
next(names)
|
||||
assert names.num_page_results() == 0
|
||||
assert requests == []
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
@@ -350,6 +351,40 @@ async def test_create_table_from_iterator_async(mem_db_async: lancedb.AsyncConne
|
||||
assert await table.count_rows() == 10
|
||||
|
||||
|
||||
def test_create_exist_ok_mode(tmp_db: lancedb.DBConnection):
|
||||
db = tmp_db
|
||||
data = [{"vector": [1.1, 1.2]}, {"vector": [0.2, 1.8]}]
|
||||
table = db.create_table("t1", data, mode="exist_ok")
|
||||
assert table.count_rows() == 2
|
||||
original = table.to_arrow()
|
||||
|
||||
reopened = db.create_table("t1", [{"vector": [9.0, 8.0]}], mode="exist_ok")
|
||||
assert reopened.to_arrow().equals(original)
|
||||
|
||||
|
||||
def test_create_empty_exist_ok_mode(tmp_db: lancedb.DBConnection):
|
||||
schema = pa.schema([pa.field("id", pa.int64())])
|
||||
table = tmp_db.create_table("empty", schema=schema, mode="exist_ok")
|
||||
assert table.schema == schema
|
||||
assert table.count_rows() == 0
|
||||
table.add([{"id": 7}])
|
||||
|
||||
reopened = tmp_db.create_table("empty", schema=schema, mode="exist_ok")
|
||||
assert reopened.to_arrow().to_pylist() == [{"id": 7}]
|
||||
|
||||
bad_schema = pa.schema([pa.field("other", pa.int64())])
|
||||
with pytest.raises(ValueError):
|
||||
tmp_db.create_table("empty", schema=bad_schema, mode="exist_ok")
|
||||
|
||||
|
||||
def test_create_table_invalid_mode(tmp_db: lancedb.DBConnection):
|
||||
db = tmp_db
|
||||
data = [{"vector": [1.1, 1.2]}, {"vector": [0.2, 1.8]}]
|
||||
with pytest.raises(ValueError, match="Invalid mode bogus"):
|
||||
db.create_table("tb", data, mode="bogus")
|
||||
assert "tb" not in db
|
||||
|
||||
|
||||
def test_create_exist_ok(tmp_db: lancedb.DBConnection):
|
||||
from conftest import pandas_string_type
|
||||
|
||||
@@ -405,6 +440,49 @@ async def test_connect(tmp_path):
|
||||
assert str(db) == f"ListingDatabase(uri={tmp_path}, read_consistency_interval=5s)"
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "local", "namespace", "from_inner"])
|
||||
def sync_db(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
return lancedb.connect("memory://")
|
||||
if request.param == "namespace":
|
||||
return lancedb.connect_namespace("dir", {"root": str(tmp_path)})
|
||||
db = lancedb.connect(tmp_path)
|
||||
if request.param == "from_inner":
|
||||
return lancedb.db.LanceDBConnection.from_inner(db._inner, None)
|
||||
return db
|
||||
|
||||
|
||||
def test_sync_close(sync_db):
|
||||
assert sync_db.is_open()
|
||||
assert sync_db.close() is None
|
||||
assert not sync_db.is_open()
|
||||
assert sync_db.close() is None
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="table_names"):
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.table_names()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.list_tables()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.open_table("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raise_error", [False, True])
|
||||
def test_sync_context_manager(sync_db, raise_error):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if raise_error:
|
||||
stack.enter_context(pytest.raises(ValueError, match="test error"))
|
||||
with sync_db as db:
|
||||
assert db is sync_db
|
||||
assert db.is_open()
|
||||
if raise_error:
|
||||
raise ValueError("test error")
|
||||
|
||||
assert not sync_db.is_open()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.list_tables()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close(mem_db_async: lancedb.AsyncConnection):
|
||||
assert mem_db_async.is_open()
|
||||
@@ -495,6 +573,27 @@ async def test_create_exist_ok_async(tmp_db_async: lancedb.AsyncConnection):
|
||||
# await db.create_table("test", schema=bad_schema, exist_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_v2_manifest_paths", [False, True])
|
||||
@pytest.mark.parametrize("empty", [False, True])
|
||||
def test_create_table_deprecated_v2_manifest_paths(
|
||||
tmp_path, enable_v2_manifest_paths, empty
|
||||
):
|
||||
db = lancedb.connect(tmp_path)
|
||||
data = None if empty else [{"id": 1}]
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="enable_v2_manifest_paths"):
|
||||
table = db.create_table(
|
||||
"test",
|
||||
data=data,
|
||||
schema=pa.schema([("id", pa.int64())]),
|
||||
enable_v2_manifest_paths=enable_v2_manifest_paths,
|
||||
)
|
||||
|
||||
assert table.uses_v2_manifest_paths() == enable_v2_manifest_paths
|
||||
assert table.to_arrow().to_pylist() == ([] if empty else data)
|
||||
assert db.open_table("test").uses_v2_manifest_paths() == enable_v2_manifest_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_v2_manifest_paths_async(tmp_path):
|
||||
db_with_v2_paths = await lancedb.connect_async(
|
||||
|
||||
@@ -1499,7 +1499,7 @@ def test_local_function_catalog_operations_are_not_supported(tmp_path):
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.get_function("normalize_score", version=FUNCTION_VERSION)
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.list_functions()
|
||||
list(db.list_functions())
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.drop_function("normalize_score", version=FUNCTION_VERSION)
|
||||
|
||||
@@ -1720,7 +1720,7 @@ def test_remote_secret_verbs_round_trip():
|
||||
)
|
||||
assert db.create_secret("openai-prod", "sk-live-0001") is None
|
||||
assert db.alter_secret("openai-prod", "sk-live-0002") is None
|
||||
assert db.list_secrets() == ["openai-prod", "hf-prod"]
|
||||
assert list(db.list_secrets()) == ["openai-prod", "hf-prod"]
|
||||
assert db.drop_secret("openai-prod") is None
|
||||
|
||||
routes = [path for path, _ in state["requests"]]
|
||||
@@ -1781,7 +1781,7 @@ def test_remote_list_functions_paginates_and_returns_typed_versions():
|
||||
)
|
||||
created = db.create_function(normalize_score)
|
||||
state["requests"].clear()
|
||||
functions = db.list_functions()
|
||||
functions = list(db.list_functions())
|
||||
|
||||
assert functions == [created]
|
||||
assert state["requests"] == [
|
||||
@@ -1805,7 +1805,7 @@ async def test_async_remote_list_functions_returns_typed_versions():
|
||||
registration = await db.create_function_async(normalize_score)
|
||||
created = await registration.wait()
|
||||
state["requests"].clear()
|
||||
functions = await db.list_functions()
|
||||
functions = [item async for item in db.list_functions()]
|
||||
|
||||
assert functions == [created]
|
||||
assert [path for path, _ in state["requests"]] == [
|
||||
@@ -1878,7 +1878,7 @@ def test_remote_function_verbs_address_a_namespace_in_the_path():
|
||||
assert reopened(value=lancedb.col("score")).function.namespace_path == tuple(
|
||||
namespace
|
||||
)
|
||||
db.list_functions(namespace_path=namespace)
|
||||
list(db.list_functions(namespace_path=namespace))
|
||||
assert (
|
||||
db.drop_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
@@ -1918,7 +1918,7 @@ async def test_async_remote_function_verbs_address_a_namespace_in_the_path():
|
||||
await db.get_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
)
|
||||
await db.list_functions(namespace_path=namespace)
|
||||
[item async for item in db.list_functions(namespace_path=namespace)]
|
||||
assert (
|
||||
await db.drop_function(
|
||||
"normalize_score", version=FUNCTION_VERSION, namespace_path=namespace
|
||||
@@ -1947,6 +1947,6 @@ def test_a_function_namespace_path_must_be_a_list_of_segments():
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
db.list_functions(namespace_path="analytics")
|
||||
list(db.list_functions(namespace_path="analytics"))
|
||||
|
||||
assert state["requests"] == []
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# 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 os
|
||||
import random
|
||||
import shutil
|
||||
@@ -1137,6 +1138,34 @@ def test_fts_lindera_tokenizer(
|
||||
assert [row["text"] for row in results] == ["成田国際空港"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fuzziness", [None, 0, 1, 2])
|
||||
def test_match_query_fuzziness_to_json(fuzziness):
|
||||
query = MatchQuery("puppy", "text", fuzziness=fuzziness)
|
||||
|
||||
assert query.fuzziness == fuzziness
|
||||
assert json.loads(query.to_json())["match"]["fuzziness"] == fuzziness
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options, expected",
|
||||
[
|
||||
pytest.param({}, ["ab"], id="default-exact"),
|
||||
pytest.param({"fuzziness": 0}, ["ab"], id="explicit-exact"),
|
||||
pytest.param({"fuzziness": None}, ["ab"], id="automatic"),
|
||||
pytest.param({"fuzziness": 1}, ["ab", "ac"], id="one-edit"),
|
||||
pytest.param({"fuzziness": 2}, ["ab", "ac", "zz"], id="two-edits"),
|
||||
],
|
||||
)
|
||||
def test_match_query_fuzziness_search(tmp_db, options, expected):
|
||||
# Automatic fuzziness is zero for two-character terms.
|
||||
table = tmp_db.create_table("fuzzy", pa.table({"text": ["ab", "ac", "zz"]}))
|
||||
table.create_index("text", config=FTS(stem=False, remove_stop_words=False))
|
||||
|
||||
results = table.search(MatchQuery("ab", "text", **options)).to_list()
|
||||
|
||||
assert sorted(row["text"] for row in results) == sorted(expected)
|
||||
|
||||
|
||||
def test_fts_query_to_json():
|
||||
"""Test that FTS query to_json() produces valid JSON strings with exact format."""
|
||||
|
||||
@@ -1267,7 +1296,10 @@ def test_fts_query_to_json():
|
||||
json_str = multi_match.to_json()
|
||||
expected = (
|
||||
'{"multi_match":{"query":"python","columns":["tags","title"],'
|
||||
'"boost":[1.0,1.0]}}'
|
||||
'"boost":[1.0,1.0],"match_queries":[{"column":"tags","terms":"python",'
|
||||
'"boost":1.0,"fuzziness":0,"max_expansions":50,"operator":"Or",'
|
||||
'"prefix_length":0},{"column":"title","terms":"python","boost":1.0,'
|
||||
'"fuzziness":0,"max_expansions":50,"operator":"Or","prefix_length":0}]}}'
|
||||
)
|
||||
assert json_str == expected
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import asyncio
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import lancedb
|
||||
from lancedb.remote.errors import HttpError
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listing_server():
|
||||
requests, responses = [], []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def handle_request(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
requests.append((self.command, self.path, json.loads(body) if body else {}))
|
||||
status, response = responses.pop(0)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode())
|
||||
|
||||
do_GET = handle_request
|
||||
do_POST = handle_request
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", requests, responses
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
def response_page(kind, names, token):
|
||||
if kind == "secrets":
|
||||
items = [{"name": name} for name in names]
|
||||
elif kind == "jobs":
|
||||
items = [
|
||||
{
|
||||
"job_id": name,
|
||||
"table": "t",
|
||||
"job_type": "create_index",
|
||||
"state": "in_progress",
|
||||
"created_at_millis": 1,
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
elif kind == "functions":
|
||||
fixture = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "rust/lancedb/tests/fixtures/first_class_functions/v1"
|
||||
/ "remote_function_version.canonical.json"
|
||||
)
|
||||
definition = json.loads(fixture.read_text())
|
||||
items = [
|
||||
{"name": "embed", "version": "1", "definition": definition} for _ in names
|
||||
]
|
||||
else:
|
||||
items = names
|
||||
key = "views" if kind == "materialized_views" else kind
|
||||
return {key: items, "page_token": token}
|
||||
|
||||
|
||||
async def connection(endpoint, asynchronous):
|
||||
kwargs = dict(
|
||||
api_key="fake",
|
||||
host_override=endpoint,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
if asynchronous:
|
||||
return await lancedb.connect_async("db://dev", **kwargs)
|
||||
return lancedb.connect("db://dev", **kwargs)
|
||||
|
||||
|
||||
async def advance(items, asynchronous):
|
||||
return await items.__anext__() if asynchronous else next(items)
|
||||
|
||||
|
||||
KINDS = ["secrets", "views", "jobs", "functions", "materialized_views"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", KINDS)
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_listing_lazy_pages_and_resume(listing_server, kind, asynchronous):
|
||||
endpoint, requests, responses = listing_server
|
||||
responses.extend(
|
||||
(200, response_page(kind, names, token))
|
||||
for names, token in [(["a", "b"], "empty"), ([], "last"), (["c", "d"], "")]
|
||||
)
|
||||
db = await connection(endpoint, asynchronous)
|
||||
method = getattr(db, "list_" + kind)
|
||||
kwargs = (
|
||||
{} if kind in ("jobs", "materialized_views") else {"namespace_path": ["team"]}
|
||||
)
|
||||
items = method(page_token="start/token", page_limit=2, **kwargs)
|
||||
assert requests == []
|
||||
assert items.page_token() == "start/token"
|
||||
assert items.num_page_results() == 0
|
||||
first = await advance(items, asynchronous)
|
||||
if kind == "functions":
|
||||
assert first.name == "embed"
|
||||
assert first.version == "1"
|
||||
elif kind == "jobs":
|
||||
assert first.job_id == "a"
|
||||
assert first.state == "running"
|
||||
else:
|
||||
assert first == "a"
|
||||
assert items.num_page_results() == 1
|
||||
assert items.page_token() == "empty"
|
||||
await advance(items, asynchronous)
|
||||
assert items.num_page_results() == 0
|
||||
assert len(requests) == 1
|
||||
resumed = method(page_token=items.page_token(), page_limit=2, **kwargs)
|
||||
await advance(resumed, asynchronous)
|
||||
assert len(requests) == 3
|
||||
assert resumed.page_token() is None
|
||||
assert resumed.num_page_results() == 1
|
||||
remaining = [item async for item in resumed] if asynchronous else list(resumed)
|
||||
assert len(remaining) == 1
|
||||
assert resumed.num_page_results() == 0
|
||||
assert ([item async for item in resumed] if asynchronous else list(resumed)) == []
|
||||
for (verb, url, body), token in zip(requests, ["start/token", "empty", "last"]):
|
||||
path = urlsplit(url)
|
||||
if kind == "jobs":
|
||||
assert verb == "POST"
|
||||
assert path.path == "/v1/jobs/list"
|
||||
assert body == {"page_token": token, "limit": 2}
|
||||
else:
|
||||
namespace = "$" if kind == "materialized_views" else "team"
|
||||
resource = kind.removesuffix("s")
|
||||
assert path.path == f"/v1/namespace/{namespace}/{resource}/list"
|
||||
expected = {"page_token": [token], "limit": ["2"]}
|
||||
if kind == "functions":
|
||||
expected["include_definition"] = ["true"]
|
||||
assert parse_qs(path.query) == expected
|
||||
assert responses == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", KINDS)
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_listing_error_retains_token(listing_server, kind, asynchronous):
|
||||
endpoint, requests, responses = listing_server
|
||||
responses.extend([(401, {}), (200, response_page(kind, ["a"], None))])
|
||||
db = await connection(endpoint, asynchronous)
|
||||
method = getattr(db, "list_" + kind)
|
||||
items = method(page_token="retry")
|
||||
with pytest.raises(HttpError):
|
||||
await advance(items, asynchronous)
|
||||
assert items.page_token() == "retry"
|
||||
assert items.num_page_results() == 0
|
||||
assert ([item async for item in items] if asynchronous else list(items)) == []
|
||||
assert len(requests) == 1
|
||||
resumed = method(page_token=items.page_token())
|
||||
assert len([item async for item in resumed] if asynchronous else list(resumed)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", KINDS)
|
||||
async def test_listing_serializes_concurrent_advances(listing_server, kind):
|
||||
endpoint, requests, responses = listing_server
|
||||
responses.append((200, response_page(kind, ["a", "b"], None)))
|
||||
db = await connection(endpoint, True)
|
||||
items = getattr(db, "list_" + kind)()
|
||||
results = await asyncio.gather(
|
||||
items.__anext__(), items.__anext__(), items.__anext__(), return_exceptions=True
|
||||
)
|
||||
assert (
|
||||
len([result for result in results if isinstance(result, StopAsyncIteration)])
|
||||
== 1
|
||||
)
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", KINDS)
|
||||
@pytest.mark.parametrize("page_limit", [0, 2**32 - 1])
|
||||
async def test_listing_invalid_limit_does_not_fetch(listing_server, kind, page_limit):
|
||||
endpoint, requests, _ = listing_server
|
||||
db = await connection(endpoint, True)
|
||||
items = getattr(db, "list_" + kind)(page_limit=page_limit)
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
await items.__anext__()
|
||||
assert requests == []
|
||||
@@ -93,28 +93,10 @@ def test_remote_list_uses_namespace_route():
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
assert db.list_materialized_views() == ["daily_sales"]
|
||||
assert list(db.list_materialized_views()) == ["daily_sales"]
|
||||
assert requests == ["/v1/namespace/$/materialized_view/list"]
|
||||
|
||||
|
||||
def test_remote_create_async_returns_server_job():
|
||||
with mock_remote_materialized_view_create() as (host, requests):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
job = db.create_materialized_view_async("adults", "people", where="age >= 18")
|
||||
assert job.id == "mv-create-123"
|
||||
assert requests == [
|
||||
(
|
||||
"/v1/materialized_view/adults/create",
|
||||
{"query": 'SELECT * FROM "people" WHERE age >= 18', "with_no_data": False},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_remote_drop_async_returns_server_job():
|
||||
with mock_remote_materialized_view_create() as (host, requests):
|
||||
db = lancedb.connect(
|
||||
@@ -208,7 +190,7 @@ def test_create_and_refresh_jobs(tmp_path):
|
||||
drop_job = db.drop_materialized_view_async("adults")
|
||||
assert drop_job.id is None
|
||||
assert drop_job.wait() is None
|
||||
assert "adults" not in db.list_materialized_views()
|
||||
assert "adults" not in list(db.list_materialized_views())
|
||||
|
||||
|
||||
def test_definition_round_trips(tmp_path):
|
||||
@@ -221,29 +203,29 @@ def test_definition_round_trips(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_incremental_refresh_after_append(tmp_path):
|
||||
def test_full_refresh_after_append(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
view = db.create_materialized_view("copy", "people", with_no_data=True)
|
||||
view.refresh()
|
||||
|
||||
db.open_table("people").add([{"name": "alan", "age": 41}])
|
||||
result = view.refresh()
|
||||
assert result.mode == "incremental"
|
||||
assert result.rows_written == 1
|
||||
assert result.mode == "rebuild"
|
||||
assert result.rows_written == 4
|
||||
assert view.table.count_rows() == 4
|
||||
|
||||
assert view.refresh().mode == "no_op"
|
||||
assert view.refresh().mode == "rebuild"
|
||||
|
||||
|
||||
def test_incremental_refresh_after_update(tmp_path):
|
||||
def test_full_refresh_after_update(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
view = db.create_materialized_view("copy", "people", with_no_data=True)
|
||||
view.refresh()
|
||||
|
||||
db.open_table("people").update(where="name = 'kid'", values={"age": 8})
|
||||
result = view.refresh()
|
||||
assert result.mode == "incremental"
|
||||
assert result.rows_written == 1
|
||||
assert result.mode == "rebuild"
|
||||
assert result.rows_written == 3
|
||||
rows = view.table.search().to_list()
|
||||
assert sorted(row["age"] for row in rows) == [8, 36, 85]
|
||||
|
||||
@@ -264,18 +246,43 @@ def test_legacy_storage_source_update_rebuilds(tmp_path):
|
||||
assert sorted(row["age"] for row in rows) == [8, 36]
|
||||
|
||||
|
||||
def test_direct_writes_to_a_view_are_refused(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
view = db.create_materialized_view("copy", "people")
|
||||
table = view.table
|
||||
row = [{"name": "eve", "age": 1}]
|
||||
refused = pytest.raises(NotImplementedError, match="is a materialized view")
|
||||
|
||||
with refused:
|
||||
table.add(row)
|
||||
with refused:
|
||||
table.update(where="name = 'kid'", values={"age": 8})
|
||||
with refused:
|
||||
table.delete("age < 18")
|
||||
with refused:
|
||||
table.merge_insert("name").when_not_matched_insert_all().execute(row)
|
||||
with refused:
|
||||
table.add_columns({"older": "age + 1"})
|
||||
with refused:
|
||||
table.drop_columns(["age"])
|
||||
assert table.count_rows() == 3
|
||||
|
||||
db.open_table("people").add(row)
|
||||
assert view.refresh().rows_written == 4
|
||||
|
||||
|
||||
def test_list_and_not_a_view(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
db.create_materialized_view("adults", "people", where="age >= 18")
|
||||
|
||||
assert db.list_materialized_views() == ["adults"]
|
||||
assert list(db.list_materialized_views()) == ["adults"]
|
||||
with pytest.raises(ValueError, match="not a materialized view"):
|
||||
db.open_materialized_view("people")
|
||||
with pytest.raises(ValueError, match="not a materialized view"):
|
||||
db.drop_materialized_view("people")
|
||||
|
||||
db.drop_materialized_view("adults")
|
||||
assert db.list_materialized_views() == []
|
||||
assert list(db.list_materialized_views()) == []
|
||||
|
||||
|
||||
def test_invalid_expression_fails_at_create(tmp_path):
|
||||
@@ -303,7 +310,7 @@ async def test_async_create_refresh_and_open(tmp_path):
|
||||
reopened = await db.open_materialized_view("shouts")
|
||||
definition = await reopened.definition()
|
||||
assert definition.query == "SELECT upper(name) AS shout FROM people"
|
||||
assert await db.list_materialized_views() == ["shouts"]
|
||||
assert [item async for item in db.list_materialized_views()] == ["shouts"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -327,12 +334,12 @@ async def test_async_create_and_refresh_jobs(tmp_path):
|
||||
drop_job = await db.drop_materialized_view_async("adults")
|
||||
assert drop_job.id is None
|
||||
assert await drop_job.wait() is None
|
||||
assert "adults" not in await db.list_materialized_views()
|
||||
assert "adults" not in [item async for item in db.list_materialized_views()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_incremental(tmp_path):
|
||||
db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS)
|
||||
async def test_async_refresh_rebuilds(tmp_path):
|
||||
db = await lancedb.connect_async(tmp_path)
|
||||
await db.create_table("people", [{"name": "ada", "age": 36}])
|
||||
view = await db.create_materialized_view("copy", "people", with_no_data=True)
|
||||
await view.refresh()
|
||||
@@ -340,15 +347,15 @@ async def test_async_incremental(tmp_path):
|
||||
table = await db.open_table("people")
|
||||
await table.add([{"name": "alan", "age": 41}])
|
||||
result = await view.refresh()
|
||||
assert result.mode == "incremental"
|
||||
assert result.rows_written == 1
|
||||
assert result.mode == "rebuild"
|
||||
assert result.rows_written == 2
|
||||
|
||||
|
||||
def test_source_requires_stable_row_ids(tmp_path):
|
||||
def test_source_does_not_require_stable_row_ids(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
db.create_table("plain", [{"x": 1}])
|
||||
with pytest.raises(Exception, match="stable row ids"):
|
||||
db.create_materialized_view("v", "plain")
|
||||
view = db.create_materialized_view("v", "plain")
|
||||
assert view.table.count_rows() == 1
|
||||
|
||||
|
||||
def test_bare_select_names_are_quoted(tmp_path):
|
||||
@@ -373,7 +380,7 @@ def test_scalar_select_is_one_column(tmp_path):
|
||||
view = db.create_materialized_view("just_name", "people", select="name")
|
||||
view.refresh()
|
||||
rows = view.table.search().to_list()
|
||||
assert set(rows[0]) - {"__source_row_id"} == {"name"}
|
||||
assert set(rows[0]) == {"name"}
|
||||
assert sorted(row["name"] for row in rows) == ["ada", "grace", "kid"]
|
||||
|
||||
|
||||
@@ -384,7 +391,7 @@ async def test_async_scalar_select_is_one_column(tmp_path):
|
||||
view = await db.create_materialized_view("just_name", "people", select="name")
|
||||
await view.refresh()
|
||||
rows = await view.table.query().to_list()
|
||||
assert set(rows[0]) - {"__source_row_id"} == {"name"}
|
||||
assert set(rows[0]) == {"name"}
|
||||
|
||||
|
||||
def test_limit_above_i64_max_is_refused(tmp_path):
|
||||
@@ -417,7 +424,7 @@ def test_namespace_connection_materialized_views(tmp_path):
|
||||
view = db.create_materialized_view("adults", "people", where="age >= 18")
|
||||
view.refresh()
|
||||
assert view.table.count_rows() == 1
|
||||
assert db.list_materialized_views() == ["adults"]
|
||||
assert list(db.list_materialized_views()) == ["adults"]
|
||||
|
||||
reopened = db.open_materialized_view("adults")
|
||||
assert reopened.definition.query.startswith("SELECT name, age FROM ")
|
||||
@@ -433,7 +440,7 @@ def test_namespace_connection_materialized_views(tmp_path):
|
||||
|
||||
assert db.drop_materialized_view_async("job_view").wait() is None
|
||||
db.drop_materialized_view("adults")
|
||||
assert db.list_materialized_views() == []
|
||||
assert list(db.list_materialized_views()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -452,7 +459,7 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
|
||||
view = await db.create_materialized_view("adults", "people", where="age >= 18")
|
||||
await view.refresh()
|
||||
assert await view.table.count_rows() == 1
|
||||
assert await db.list_materialized_views() == ["adults"]
|
||||
assert [item async for item in db.list_materialized_views()] == ["adults"]
|
||||
|
||||
reopened = await db.open_materialized_view("adults")
|
||||
assert (await reopened.definition()).query.startswith("SELECT name, age FROM ")
|
||||
@@ -478,7 +485,7 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
|
||||
drop_job = await db.drop_materialized_view_async("job_view")
|
||||
assert await drop_job.wait() is None
|
||||
await db.drop_materialized_view("adults")
|
||||
assert await db.list_materialized_views() == []
|
||||
assert [item async for item in db.list_materialized_views()] == []
|
||||
|
||||
|
||||
def test_stored_queries_and_legacy_layouts_are_read():
|
||||
@@ -520,166 +527,26 @@ def test_stored_queries_and_legacy_layouts_are_read():
|
||||
"SELECT * FROM people"
|
||||
)
|
||||
|
||||
for fmt, function in [(3, "vector_duplicate_pairs"), (4, "vector_dedup")]:
|
||||
native = f"SELECT * FROM {function}('images', 3, 'phash', 4)"
|
||||
assert read({"format": fmt, "query": native}).query == native
|
||||
|
||||
# A newer writer's layout is reported, never guessed at.
|
||||
for newer in (
|
||||
{"format": 5, "query": query},
|
||||
{"format": 3, "query": query},
|
||||
{"kind": "select_v3", "source_table": "people"},
|
||||
):
|
||||
with pytest.raises(NotImplementedError, match="cannot refresh"):
|
||||
read(newer)
|
||||
|
||||
|
||||
def indexed_dedup_source(db, name="images"):
|
||||
import pyarrow as pa
|
||||
from lancedb.index import IvfFlat
|
||||
|
||||
vectors = pa.FixedSizeListArray.from_arrays(
|
||||
pa.array(
|
||||
[v for i in range(256) for v in (float(i if i < 3 else i * 10), 0.0)],
|
||||
type=pa.float32(),
|
||||
),
|
||||
2,
|
||||
)
|
||||
table = db.create_table(name, pa.table({"id": range(256), "vector": vectors}))
|
||||
table.create_index("vector", config=IvfFlat(num_partitions=1))
|
||||
return table
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stable", [False, True])
|
||||
def test_vector_dedup_source_captures_snapshot_and_preserves_source(tmp_path, stable):
|
||||
db = lancedb.connect(
|
||||
tmp_path,
|
||||
storage_options={"new_table_enable_stable_row_ids": str(stable).lower()},
|
||||
)
|
||||
source = indexed_dedup_source(db, "images.with.dot")
|
||||
version = source.version
|
||||
expression = lancedb.vector_dedup(
|
||||
source.name, column="vector", distance_threshold=1
|
||||
)
|
||||
view = db.create_materialized_view("clean", expression)
|
||||
assert "vector_dedup(" in view.definition.query
|
||||
assert f", {version}, 'vector', 1.0)" in view.definition.query
|
||||
assert view.table.count_rows() == 255
|
||||
assert [r["id"] for r in view.table.search().where("id < 3").to_list()] == [0, 2]
|
||||
assert source.version == version
|
||||
assert source.count_rows() == 256
|
||||
|
||||
source.delete("id = 0")
|
||||
view.refresh_async().wait()
|
||||
assert view.table.search().where("id = 0").to_list() == [
|
||||
{"id": 0, "vector": [0.0, 0.0]}
|
||||
]
|
||||
assert expression.dataset_version is None # reusable immutable declaration
|
||||
|
||||
|
||||
def test_vector_dedup_create_job_and_explicit_snapshot(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
source = indexed_dedup_source(db)
|
||||
version = source.version
|
||||
source.delete("id = 0")
|
||||
job = db.create_materialized_view_async(
|
||||
"clean",
|
||||
lancedb.vector_dedup(
|
||||
"images", column="vector", distance_threshold=1, dataset_version=version
|
||||
),
|
||||
)
|
||||
assert job.wait() is None
|
||||
view = db.open_materialized_view("clean")
|
||||
assert view.table.count_rows() == 255
|
||||
assert len(view.table.search().where("id = 0").to_list()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_vector_dedup_declaration_and_creation(tmp_path):
|
||||
sync_db = lancedb.connect(tmp_path)
|
||||
source = indexed_dedup_source(sync_db)
|
||||
db = await lancedb.connect_async(tmp_path)
|
||||
expression = lancedb.vector_dedup("images", column="vector", distance_threshold=1)
|
||||
view = await db.create_materialized_view("clean", expression, with_no_data=True)
|
||||
assert await view.table.count_rows() == 0
|
||||
# The snapshot is captured at declaration even if refresh comes later.
|
||||
source.delete("id = 0")
|
||||
job = await view.refresh_async()
|
||||
await job.wait()
|
||||
assert await view.table.count_rows() == 255
|
||||
another = await db.create_materialized_view_async(
|
||||
"later", expression, with_no_data=True
|
||||
)
|
||||
await another.wait()
|
||||
later = await db.open_materialized_view("later")
|
||||
assert (await later.definition()).query != (await view.definition()).query
|
||||
assert await later.table.count_rows() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options", [{"select": ["id"]}, {"select": []}, {"where": "id > 0"}, {"limit": 1}]
|
||||
)
|
||||
def test_vector_dedup_rejects_extra_clauses_before_opening_source(tmp_path, options):
|
||||
db = lancedb.connect(tmp_path)
|
||||
expression = lancedb.vector_dedup("missing", column="vector", distance_threshold=1)
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
db.create_materialized_view("bad", expression, **options)
|
||||
assert db.list_tables().tables == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [0, -1, True, 1.5, "2", 2**64])
|
||||
def test_vector_dedup_rejects_invalid_snapshot(version):
|
||||
with pytest.raises(ValueError, match="dataset_version"):
|
||||
lancedb.vector_dedup(
|
||||
"images", column="vector", distance_threshold=1, dataset_version=version
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"threshold", [float("nan"), float("inf"), -float("inf"), 1e100, 10**1000, True, "4"]
|
||||
)
|
||||
def test_vector_dedup_rejects_invalid_threshold(threshold):
|
||||
with pytest.raises((ValueError, TypeError), match="distance_threshold"):
|
||||
lancedb.vector_dedup("images", column="vector", distance_threshold=threshold)
|
||||
|
||||
|
||||
def test_vector_dedup_descriptor_is_frozen():
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
expression = lancedb.vector_dedup("images", column="vector", distance_threshold=1)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
expression.column = "other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_no_data", [False, True])
|
||||
def test_remote_vector_dedup_uses_mv_endpoint_without_sql_client(with_no_data):
|
||||
with mock_remote_materialized_view_create() as (host, requests):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
sql_host_override="grpc://127.0.0.1:1",
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
job = db.create_materialized_view_async(
|
||||
"clean",
|
||||
lancedb.vector_dedup(
|
||||
"images.with'dot",
|
||||
column="vector'field",
|
||||
distance_threshold=4,
|
||||
dataset_version=7,
|
||||
),
|
||||
with_no_data=with_no_data,
|
||||
)
|
||||
assert job.id == "mv-create-123"
|
||||
assert requests == [
|
||||
(
|
||||
"/v1/materialized_view/clean/create",
|
||||
{
|
||||
"query": (
|
||||
"SELECT * FROM vector_dedup('`images.with''dot`', "
|
||||
"7, 'vector''field', 4.0)"
|
||||
),
|
||||
"with_no_data": with_no_data,
|
||||
},
|
||||
)
|
||||
]
|
||||
def test_local_materialized_view_listing_pages_and_resume(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
db.create_materialized_view("a_view", "people", with_no_data=True)
|
||||
db.create_materialized_view("z_view", "people", with_no_data=True)
|
||||
db.create_table("ordinary", [{"id": 1}])
|
||||
names = db.list_materialized_views(page_limit=1)
|
||||
assert names.num_page_results() == 0
|
||||
first = next(names)
|
||||
assert names.num_page_results() == 0
|
||||
assert names.page_token() is not None
|
||||
resumed = db.list_materialized_views(page_token=names.page_token(), page_limit=1)
|
||||
assert sorted([first, *resumed]) == ["a_view", "z_view"]
|
||||
assert resumed.page_token() is None
|
||||
assert list(resumed) == []
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
import pyarrow as pa
|
||||
import lancedb
|
||||
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
|
||||
from lancedb.listing import AsyncListing, Listing
|
||||
from lancedb.namespace import _MAX_QUERY_K
|
||||
from lancedb.table import AsyncTable, LanceTable
|
||||
|
||||
@@ -104,6 +105,18 @@ class TestNamespaceConnection:
|
||||
assert isinstance(db, lancedb.LanceNamespaceDBConnection)
|
||||
assert len(list(db.table_names())) == 0
|
||||
|
||||
def test_list_materialized_views(self):
|
||||
db = lancedb.connect_namespace("dir", {"root": self.temp_dir})
|
||||
db.create_table("source", [{"id": 1}])
|
||||
db.create_materialized_view("view", "source", with_no_data=True)
|
||||
|
||||
names = db.list_materialized_views(page_limit=1)
|
||||
assert isinstance(names, Listing)
|
||||
assert names.num_page_results() == 0
|
||||
assert list(names) == ["view"]
|
||||
assert names.num_page_results() == 0
|
||||
assert names.page_token() is None
|
||||
|
||||
def test_sync_builtin_namespace_uses_rust_without_python_client(self, monkeypatch):
|
||||
"""Built-in sync namespace connections should not construct or call the
|
||||
Python namespace client for normal namespace/table management."""
|
||||
@@ -605,6 +618,18 @@ class TestAsyncNamespaceConnection:
|
||||
table_names = await db.table_names()
|
||||
assert len(list(table_names)) == 0
|
||||
|
||||
async def test_list_materialized_views(self):
|
||||
db = lancedb.connect_namespace_async("dir", {"root": self.temp_dir})
|
||||
await db.create_table("source", [{"id": 1}])
|
||||
await db.create_materialized_view("view", "source", with_no_data=True)
|
||||
|
||||
names = db.list_materialized_views(page_limit=1)
|
||||
assert isinstance(names, AsyncListing)
|
||||
assert names.num_page_results() == 0
|
||||
assert [name async for name in names] == ["view"]
|
||||
assert names.num_page_results() == 0
|
||||
assert names.page_token() is None
|
||||
|
||||
async def test_async_builtin_namespace_uses_rust_without_python_client(
|
||||
self, monkeypatch
|
||||
):
|
||||
|
||||
@@ -24,6 +24,7 @@ from lancedb.pydantic import LanceModel, Vector
|
||||
from lancedb.query import (
|
||||
AsyncFTSQuery,
|
||||
AsyncHybridQuery,
|
||||
AsyncQuery,
|
||||
AsyncQueryBase,
|
||||
AsyncVectorQuery,
|
||||
ColumnOrdering,
|
||||
@@ -974,6 +975,19 @@ def test_query_builder_with_filter(table):
|
||||
assert all(np.array(rs[0]["vector"]) == [3, 4])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_type", ["vector", "hybrid"])
|
||||
@pytest.mark.parametrize("nprobes", [0, -1])
|
||||
def test_nprobes_nonpositive_sync(table, query_type, nprobes):
|
||||
if query_type == "hybrid":
|
||||
query = table.search(query_type="hybrid").vector([0, 0]).text("a")
|
||||
else:
|
||||
query = table.search([0, 0])
|
||||
with pytest.raises(
|
||||
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
||||
):
|
||||
query.nprobes(nprobes)
|
||||
|
||||
|
||||
def test_invalid_nprobes_sync(table):
|
||||
with pytest.raises(ValueError, match="minimum_nprobes must be greater than 0"):
|
||||
LanceVectorQueryBuilder(table, [0, 0], "vector").minimum_nprobes(0).to_list()
|
||||
@@ -1005,6 +1019,18 @@ def test_multiple_nprobes_calls_works_sync(table):
|
||||
).minimum_nprobes(20).to_list()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("hybrid", [False, True])
|
||||
async def test_nprobes_zero_async(table_async: AsyncTable, hybrid):
|
||||
query = table_async.query().nearest_to([0, 0])
|
||||
if hybrid:
|
||||
query = query.nearest_to_text("dog")
|
||||
with pytest.raises(
|
||||
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
||||
):
|
||||
query.nprobes(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_nprobes_async(table_async: AsyncTable):
|
||||
with pytest.raises(ValueError, match="minimum_nprobes must be greater than 0"):
|
||||
@@ -1279,6 +1305,27 @@ async def test_query_to_polars_async(table_async: AsyncTable):
|
||||
assert df.shape == (0, num_columns)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("search_kwargs", [{}, {"query": None}])
|
||||
@pytest.mark.parametrize("with_vector", [False, True])
|
||||
async def test_async_search_without_query(mem_db_async, search_kwargs, with_vector):
|
||||
data = [{"id": i} for i in range(10)]
|
||||
if with_vector:
|
||||
for row in data:
|
||||
row["vector"] = [float(row["id"]), 1.0]
|
||||
table = await mem_db_async.create_table("test", data)
|
||||
|
||||
query = await table.search(**search_kwargs)
|
||||
assert isinstance(query, AsyncQuery)
|
||||
assert await query.limit(3).to_arrow() == await table.query().limit(3).to_arrow()
|
||||
|
||||
query = await table.search(**search_kwargs)
|
||||
assert await query.where("id >= 8").select(["id"]).limit(3).to_list() == [
|
||||
{"id": 8},
|
||||
{"id": 9},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_query(table_async: AsyncTable):
|
||||
with pytest.raises(ValueError):
|
||||
@@ -2091,6 +2138,12 @@ def test_ensure_vector_query_nested_empty_list():
|
||||
ensure_vector_query([[]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
|
||||
def test_vector_query_builder_empty_vector(table, query):
|
||||
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
|
||||
LanceVectorQueryBuilder(table, query, "vector")
|
||||
|
||||
|
||||
def test_fast_search(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
|
||||
|
||||
@@ -16,8 +16,15 @@ import uuid
|
||||
from packaging.version import Version
|
||||
|
||||
import lancedb
|
||||
from lancedb.conftest import MockTextEmbeddingFunction
|
||||
from lancedb.query import ColumnOrdering
|
||||
import numpy as np
|
||||
from lancedb.conftest import MockNonNormTextEmbeddingFunction, MockTextEmbeddingFunction
|
||||
from lancedb.embeddings import EmbeddingFunctionConfig, EmbeddingFunctionRegistry
|
||||
from lancedb.query import (
|
||||
AsyncQuery,
|
||||
ColumnOrdering,
|
||||
LanceVectorQueryBuilder,
|
||||
MatchQuery,
|
||||
)
|
||||
from lancedb.remote import ClientConfig
|
||||
from lancedb.remote.errors import HttpError, RetryError
|
||||
import pytest
|
||||
@@ -119,6 +126,77 @@ async def test_async_remote_db():
|
||||
assert table_names == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alteration, match",
|
||||
[
|
||||
({"path": "id"}, "One of rename, nullable or data_type"),
|
||||
(
|
||||
{"path": "id", "nulable": False}, # spellchecker:disable-line
|
||||
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
||||
),
|
||||
(
|
||||
{
|
||||
"path": "id",
|
||||
"rename": "new_id",
|
||||
"nulable": False, # spellchecker:disable-line
|
||||
},
|
||||
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_remote_alter_columns_rejects_invalid_before_request(alteration, match):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request.path)
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
table = db.open_table("test")
|
||||
requests.clear()
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
table.alter_columns(alteration)
|
||||
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"alteration, match",
|
||||
[
|
||||
({"path": "id"}, "One of rename, nullable or data_type"),
|
||||
(
|
||||
{"path": "id", "nulable": False}, # spellchecker:disable-line
|
||||
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_async_remote_alter_columns_rejects_invalid_before_request(
|
||||
alteration, match
|
||||
):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request.path)
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
||||
|
||||
async with mock_lancedb_connection_async(handler) as db:
|
||||
table = await db.open_table("test")
|
||||
requests.clear()
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
await table.alter_columns(alteration)
|
||||
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_checkout():
|
||||
def handler(request):
|
||||
@@ -524,6 +602,106 @@ def test_remote_permutation_is_picklable():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_schema", [True, False])
|
||||
def test_create_table_embedding_functions(with_schema):
|
||||
func = MockNonNormTextEmbeddingFunction.create()
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text", vector_column="vector", function=func
|
||||
)
|
||||
schema = pa.schema(
|
||||
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
|
||||
)
|
||||
received = {}
|
||||
|
||||
def handler(request):
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
# Echo the metadata actually sent by create_table, as the server does.
|
||||
metadata = received["create"].schema.metadata or {}
|
||||
send_json(
|
||||
request,
|
||||
{
|
||||
"version": 1,
|
||||
"schema": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": {"type": "string"},
|
||||
"nullable": True,
|
||||
},
|
||||
{
|
||||
"name": "vector",
|
||||
"type": {
|
||||
"type": "fixed_size_list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"type": {"type": "float"},
|
||||
"nullable": True,
|
||||
}
|
||||
],
|
||||
"length": 10,
|
||||
},
|
||||
"nullable": True,
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
k.decode(): v.decode() for k, v in metadata.items()
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
elif request.path in (
|
||||
"/v1/table/test/create/?mode=create",
|
||||
"/v1/table/test/insert/",
|
||||
):
|
||||
if request.headers.get("Transfer-Encoding") == "chunked":
|
||||
body = bytearray()
|
||||
while True:
|
||||
size = int(request.rfile.readline(), 16)
|
||||
if size == 0:
|
||||
request.rfile.readline()
|
||||
break
|
||||
body.extend(request.rfile.read(size))
|
||||
request.rfile.read(2)
|
||||
else:
|
||||
body = request.rfile.read(int(request.headers["Content-Length"]))
|
||||
operation = "create" if "/create/" in request.path else "insert"
|
||||
received[operation] = pa.ipc.open_stream(body).read_all()
|
||||
send_json(request, {})
|
||||
else:
|
||||
request.send_response(404)
|
||||
request.end_headers()
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
table = db.create_table(
|
||||
"test",
|
||||
schema=schema if with_schema else None,
|
||||
data=None if with_schema else [{"text": "hello world"}],
|
||||
embedding_functions=[config],
|
||||
)
|
||||
metadata = pa.schema(
|
||||
[],
|
||||
metadata=EmbeddingFunctionRegistry.get_instance().get_table_metadata(
|
||||
[config]
|
||||
),
|
||||
).metadata
|
||||
assert received["create"].schema.metadata == metadata
|
||||
assert table.schema.metadata == metadata
|
||||
assert table.schema.field("vector").type == pa.list_(pa.float32(), 10)
|
||||
assert table.embedding_functions["vector"].source_column == "text"
|
||||
if with_schema:
|
||||
assert received["create"].num_rows == 0
|
||||
else:
|
||||
assert received["create"]["vector"].to_pylist() == [
|
||||
func.compute_source_embeddings(["hello world"])[0].tolist()
|
||||
]
|
||||
|
||||
table.add([{"text": "goodbye world"}])
|
||||
assert received["insert"]["vector"].to_pylist() == [
|
||||
func.compute_source_embeddings(["goodbye world"])[0].tolist()
|
||||
]
|
||||
|
||||
|
||||
def test_create_table_exist_ok():
|
||||
def handler(request):
|
||||
if request.path == "/v1/table/test/create/?mode=exist_ok":
|
||||
@@ -622,6 +800,8 @@ def test_table_unimplemented_functions():
|
||||
message = str(exc_info.value)
|
||||
assert method in message
|
||||
assert "not" in message and "supported" in message
|
||||
assert "remote tables" in message
|
||||
assert "cloud" not in message.lower()
|
||||
assert f"search().{method}()" in message
|
||||
|
||||
|
||||
@@ -1202,7 +1382,7 @@ def test_remote_job_wait_raises_on_failure():
|
||||
job.wait()
|
||||
|
||||
|
||||
def test_remote_create_index_new_api():
|
||||
def test_remote_create_index_new_api(caplog):
|
||||
received_requests = []
|
||||
|
||||
def handler(request):
|
||||
@@ -1263,7 +1443,8 @@ def test_remote_create_index_new_api():
|
||||
from lancedb.index import BTree, FTS, IvfPq, IvfRq
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
table = db.create_table("test", [{"id": 1}])
|
||||
table = db.create_table("test", [{"id": 1}], embedding_functions=[])
|
||||
assert "embedding_functions is not yet supported" not in caplog.text
|
||||
|
||||
# New API: column-first, config= kwarg. Should NOT emit DeprecationWarning.
|
||||
import warnings as _warnings
|
||||
@@ -1285,6 +1466,8 @@ def test_remote_create_index_new_api():
|
||||
vector_column_name="vector",
|
||||
index_type="IVF_RQ",
|
||||
num_partitions=8,
|
||||
replace=False,
|
||||
accelerator="cuda",
|
||||
)
|
||||
|
||||
assert len(received_requests) == 6
|
||||
@@ -1298,6 +1481,163 @@ def test_remote_create_index_new_api():
|
||||
]
|
||||
assert received_requests[2]["block_size"] == 256
|
||||
assert received_requests[4]["replace"] is False
|
||||
assert received_requests[5]["replace"] is False
|
||||
assert "replace is not supported" not in caplog.text
|
||||
assert caplog.messages == [
|
||||
"GPU accelerator is not supported for remote tables and will be ignored."
|
||||
]
|
||||
|
||||
|
||||
def remote_unsupported_handler(request):
|
||||
assert request.path == "/v1/table/test/describe/"
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(
|
||||
json.dumps(
|
||||
dict(
|
||||
version=1,
|
||||
schema=dict(
|
||||
fields=[
|
||||
dict(name="id", type={"type": "int64"}, nullable=False),
|
||||
dict(
|
||||
name="vector",
|
||||
type={
|
||||
"type": "fixed_size_list",
|
||||
"fields": [
|
||||
dict(
|
||||
name="item",
|
||||
type={"type": "float"},
|
||||
nullable=True,
|
||||
)
|
||||
],
|
||||
"length": 2,
|
||||
},
|
||||
nullable=False,
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
).encode()
|
||||
)
|
||||
|
||||
|
||||
def remote_unsupported_operations():
|
||||
from lancedb.index import HnswPq
|
||||
|
||||
return [
|
||||
("uses_v2_manifest_paths", (), {}, "uses_v2_manifest_paths"),
|
||||
("migrate_v2_manifest_paths", (), {}, "migrate_manifest_paths_v2"),
|
||||
("set_unenforced_primary_key", (["id"],), {}, "set_unenforced_primary_key"),
|
||||
("fetch_blob_ranges", ("image", [(1, 0, 1)]), {}, "fetch_blob_ranges"),
|
||||
("optimize", (), {}, "optimize"),
|
||||
("create_index", ("vector",), {"config": HnswPq()}, "IVF_HNSW_PQ"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,kwargs,operation", remote_unsupported_operations()
|
||||
)
|
||||
def test_remote_unsupported_operations_sync(method, args, kwargs, operation):
|
||||
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
||||
table = db.open_table("test")
|
||||
with pytest.raises(NotImplementedError) as error:
|
||||
getattr(table, method)(*args, **kwargs)
|
||||
expected = f"LanceDBError: not supported: {operation} is not supported "
|
||||
expected += "for remote tables."
|
||||
if operation == "IVF_HNSW_PQ":
|
||||
expected += " Please use IVF_HNSW_SQ instead."
|
||||
assert str(error.value) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,kwargs,operation", remote_unsupported_operations()
|
||||
)
|
||||
async def test_remote_unsupported_operations_async(method, args, kwargs, operation):
|
||||
async with mock_lancedb_connection_async(remote_unsupported_handler) as db:
|
||||
table = await db.open_table("test")
|
||||
if method == "migrate_v2_manifest_paths":
|
||||
method = "migrate_manifest_paths_v2"
|
||||
with pytest.raises(NotImplementedError) as error:
|
||||
await getattr(table, method)(*args, **kwargs)
|
||||
expected = f"LanceDBError: not supported: {operation} is not supported "
|
||||
expected += "for remote tables."
|
||||
if operation == "IVF_HNSW_PQ":
|
||||
expected += " Please use IVF_HNSW_SQ instead."
|
||||
assert str(error.value) == expected
|
||||
|
||||
|
||||
def test_remote_unsupported_legacy_hnsw_pq():
|
||||
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
||||
table = db.open_table("test")
|
||||
with pytest.warns(DeprecationWarning, match="create_index"):
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match="IVF_HNSW_PQ is not supported for remote tables. "
|
||||
"Please use IVF_HNSW_SQ instead.",
|
||||
):
|
||||
table.create_index(
|
||||
vector_column_name="vector", index_type="IVF_HNSW_PQ"
|
||||
)
|
||||
|
||||
|
||||
def test_remote_python_only_messages(caplog):
|
||||
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
||||
with caplog.at_level("INFO"):
|
||||
table = db.open_table("test", storage_options={}, index_cache_size=1)
|
||||
assert "storage_options is ignored for remote tables" in caplog.text
|
||||
assert "index_cache_size is ignored for remote tables" in caplog.text
|
||||
for method in ["to_arrow", "to_pandas", "to_polars"]:
|
||||
with pytest.raises(NotImplementedError) as error:
|
||||
getattr(table, method)()
|
||||
assert str(error.value) == (
|
||||
f"{method}() is not supported for remote tables. "
|
||||
f"Use table.search().{method}() instead."
|
||||
)
|
||||
for method in ["compact_files", "cleanup_old_versions"]:
|
||||
with pytest.warns(UserWarning) as warning:
|
||||
getattr(table, method)()
|
||||
assert (
|
||||
str(warning[0].message) == f"{method}() is a no-op for remote tables."
|
||||
)
|
||||
|
||||
|
||||
def test_remote_embedding_config_does_not_warn_or_drop_metadata(caplog):
|
||||
received_tables = []
|
||||
|
||||
def handler(request):
|
||||
assert request.path == "/v1/table/test/create/?mode=create"
|
||||
if request.headers.get("Transfer-Encoding") == "chunked":
|
||||
body = bytearray()
|
||||
while size := int(request.rfile.readline().split(b";")[0], 16):
|
||||
body.extend(request.rfile.read(size))
|
||||
assert request.rfile.read(2) == b"\r\n"
|
||||
assert request.rfile.read(2) == b"\r\n"
|
||||
else:
|
||||
body = request.rfile.read(int(request.headers["Content-Length"]))
|
||||
received_tables.append(pa.ipc.open_stream(body).read_all())
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(b"{}")
|
||||
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text",
|
||||
vector_column="vector",
|
||||
function=MockNonNormTextEmbeddingFunction.create(),
|
||||
)
|
||||
schema = pa.schema(
|
||||
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
|
||||
)
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
db.create_table("test", schema=schema, embedding_functions=[config])
|
||||
assert caplog.messages == []
|
||||
assert len(received_tables) == 1
|
||||
metadata = EmbeddingFunctionRegistry.get_instance().get_table_metadata([config])
|
||||
assert (
|
||||
received_tables[0].schema.metadata == pa.schema([], metadata=metadata).metadata
|
||||
)
|
||||
|
||||
|
||||
def test_table_wait_for_index_timeout():
|
||||
@@ -1404,7 +1744,9 @@ def test_stats():
|
||||
table = db.create_table("test", [{"id": 1}])
|
||||
res = table.stats()
|
||||
print(f"{res=}")
|
||||
assert res == stats
|
||||
# The server does not report num_deleted_rows, and that unsupported
|
||||
# state is preserved as None.
|
||||
assert res == {**stats, "num_deleted_rows": None}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -1533,14 +1875,51 @@ def test_checkpoint_lsm_sync():
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def query_test_table(query_handler, *, server_version=Version("0.1.0")):
|
||||
def query_test_table(
|
||||
query_handler,
|
||||
*,
|
||||
server_version=Version("0.1.0"),
|
||||
vector_columns=None,
|
||||
embedding_functions=None,
|
||||
):
|
||||
if vector_columns is None:
|
||||
vector_columns = {"vector": 3}
|
||||
schema = {
|
||||
"fields": [
|
||||
{"name": "text", "type": {"type": "string"}, "nullable": False},
|
||||
*[
|
||||
{
|
||||
"name": name,
|
||||
"type": {
|
||||
"type": "fixed_size_list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"type": {"type": "float"},
|
||||
"nullable": True,
|
||||
}
|
||||
],
|
||||
"length": dim,
|
||||
},
|
||||
"nullable": False,
|
||||
}
|
||||
for name, dim in vector_columns.items()
|
||||
],
|
||||
]
|
||||
}
|
||||
if embedding_functions:
|
||||
metadata = EmbeddingFunctionRegistry.get_instance().get_table_metadata(
|
||||
embedding_functions
|
||||
)
|
||||
schema["metadata"] = {key: value.decode() for key, value in metadata.items()}
|
||||
|
||||
def handler(request):
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.send_header("phalanx-version", str(server_version))
|
||||
request.end_headers()
|
||||
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
||||
request.wfile.write(json.dumps({"version": 1, "schema": schema}).encode())
|
||||
elif request.path == "/v1/table/test/query/":
|
||||
content_len = int(request.headers.get("Content-Length"))
|
||||
body = request.rfile.read(content_len)
|
||||
@@ -1565,6 +1944,108 @@ def query_test_table(query_handler, *, server_version=Version("0.1.0")):
|
||||
yield table
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,search_kwargs,expected_column",
|
||||
[
|
||||
pytest.param([0.5] * 8, {}, "vector", id="vector-8"),
|
||||
pytest.param([0.5] * 4, {}, "vec2", id="vector-4"),
|
||||
pytest.param(np.array([0.5] * 4), {}, "vec2", id="numpy-vector"),
|
||||
pytest.param([[0.5] * 4, [0.2] * 4], {}, "vec2", id="batch-vectors"),
|
||||
pytest.param(
|
||||
[0.5] * 4, {"vector_column_name": "vec2"}, "vec2", id="explicit-column"
|
||||
),
|
||||
pytest.param(None, {}, None, id="scan"),
|
||||
pytest.param("hello", {"query_type": "fts"}, None, id="fts"),
|
||||
pytest.param(
|
||||
"hello", {"query_type": "fts", "fts_columns": "text"}, None, id="fts-column"
|
||||
),
|
||||
pytest.param(MatchQuery("hello", "text"), {}, None, id="structured-fts"),
|
||||
],
|
||||
)
|
||||
def test_query_object_sync_matches_local(mem_db, query, search_kwargs, expected_column):
|
||||
vector_columns = {"vector": 8, "vec2": 4}
|
||||
schema = pa.schema(
|
||||
[
|
||||
pa.field(name, pa.list_(pa.float32(), dim))
|
||||
for name, dim in vector_columns.items()
|
||||
]
|
||||
)
|
||||
local = mem_db.create_table("test", schema=schema)
|
||||
local_query = local.search(query, **search_kwargs).limit(2).to_query_object()
|
||||
assert local_query.vector_column == expected_column
|
||||
assert local_query.fast_search is None
|
||||
|
||||
with query_test_table(None, vector_columns=vector_columns) as remote:
|
||||
remote_query = remote.search(query, **search_kwargs).limit(2).to_query_object()
|
||||
assert remote_query == local_query
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fast_search", [None, False, True])
|
||||
@pytest.mark.parametrize("query,query_type", [([1, 2, 3], "vector"), ("hello", "fts")])
|
||||
def test_query_object_sync_preserves_fast_search(fast_search, query, query_type):
|
||||
vector_columns = {"vector": 3} if query_type == "vector" else {}
|
||||
with query_test_table(None, vector_columns=vector_columns) as table:
|
||||
query_obj = table.search(
|
||||
query, query_type=query_type, fast_search=fast_search
|
||||
).to_query_object()
|
||||
assert query_obj.fast_search is fast_search
|
||||
|
||||
|
||||
def test_query_sync_rejects_ambiguous_vector_column(mem_db):
|
||||
vector_columns = {"vector": 4, "vec2": 4}
|
||||
schema = pa.schema(
|
||||
[
|
||||
pa.field(name, pa.list_(pa.float32(), dim))
|
||||
for name, dim in vector_columns.items()
|
||||
]
|
||||
)
|
||||
local = mem_db.create_table("test", schema=schema)
|
||||
with pytest.raises(ValueError, match="Candidates:.*vector.*vec2") as local_error:
|
||||
local.search([0.5] * 4)
|
||||
|
||||
with query_test_table(None, vector_columns=vector_columns) as remote:
|
||||
with pytest.raises(ValueError) as remote_error:
|
||||
remote.search([0.5] * 4)
|
||||
assert str(remote_error.value) == str(local_error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vector_columns", [{}, {"v1": 4, "v2": 8}])
|
||||
@pytest.mark.parametrize("query_type", ["auto", "fts"])
|
||||
def test_query_sync_fts_without_embeddings(vector_columns, query_type):
|
||||
seen = []
|
||||
|
||||
def handler(body):
|
||||
seen.append(body)
|
||||
assert body["full_text_query"] == {"query": "hello", "columns": []}
|
||||
assert body["vector"] == []
|
||||
assert "vector_column" not in body
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
with query_test_table(handler, vector_columns=vector_columns) as table:
|
||||
assert table.search("hello", query_type=query_type).to_list() == [{"id": 1}]
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_query_sync_auto_with_embedding_function():
|
||||
def handler(body):
|
||||
assert body["vector_column"] == "vector"
|
||||
assert body["vector"] == [0.0] * 10
|
||||
assert "full_text_query" not in body
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text",
|
||||
vector_column="vector",
|
||||
function=MockTextEmbeddingFunction.create(),
|
||||
)
|
||||
with query_test_table(
|
||||
handler, vector_columns={"vector": 10}, embedding_functions=[config]
|
||||
) as table:
|
||||
query = table.search("hello")
|
||||
assert query.to_query_object().vector_column == "vector"
|
||||
assert query.to_list() == [{"id": 1}]
|
||||
|
||||
|
||||
def test_head():
|
||||
def handler(body):
|
||||
assert body == {
|
||||
@@ -1591,6 +2072,7 @@ def test_query_sync_minimal():
|
||||
"upper_bound": None,
|
||||
"ef": None,
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
"vector_column": "vector",
|
||||
"nprobes": 20,
|
||||
"minimum_nprobes": 20,
|
||||
"maximum_nprobes": 20,
|
||||
@@ -1605,7 +2087,84 @@ def test_query_sync_minimal():
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_query_sync_empty_query():
|
||||
@pytest.mark.parametrize("query_type", ["auto", "vector", "hybrid"])
|
||||
@pytest.mark.parametrize("vector_column_name", [None, "embedding"])
|
||||
def test_query_sync_text_embedding(query_type, vector_column_name):
|
||||
embedding_func = MockTextEmbeddingFunction.create()
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text", vector_column="embedding", function=embedding_func
|
||||
)
|
||||
query = "quick brown fox"
|
||||
expected_vector = embedding_func.compute_query_embeddings(query)[0]
|
||||
queries = []
|
||||
|
||||
def handler(body):
|
||||
queries.append(body)
|
||||
if "full_text_query" in body:
|
||||
assert query_type == "hybrid"
|
||||
assert body["full_text_query"]["query"] == query
|
||||
return pa.table({"id": [1], "_rowid": [1], "_score": [1.0]})
|
||||
assert body["vector_column"] == "embedding"
|
||||
assert body["vector"] == pytest.approx(expected_vector)
|
||||
return pa.table({"id": [1], "_rowid": [1], "_distance": [0.0]})
|
||||
|
||||
with query_test_table(
|
||||
handler,
|
||||
vector_columns={
|
||||
"embedding": embedding_func.ndims(),
|
||||
**({"other": embedding_func.ndims()} if vector_column_name else {}),
|
||||
},
|
||||
embedding_functions=[config],
|
||||
) as table:
|
||||
assert list(table.embedding_functions) == ["embedding"]
|
||||
builder = table.search(
|
||||
query, query_type=query_type, vector_column_name=vector_column_name
|
||||
)
|
||||
if query_type != "hybrid":
|
||||
assert isinstance(builder, LanceVectorQueryBuilder)
|
||||
results = builder.to_list()
|
||||
assert results[0]["id"] == 1
|
||||
if query_type != "hybrid":
|
||||
assert results[0]["_distance"] == 0.0
|
||||
assert len(queries) == (2 if query_type == "hybrid" else 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_type", ["auto", "vector", "hybrid"])
|
||||
def test_query_sync_ambiguous_vector_columns(query_type):
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text",
|
||||
vector_column="v1",
|
||||
function=MockTextEmbeddingFunction.create(),
|
||||
)
|
||||
queries = []
|
||||
|
||||
def handler(body):
|
||||
queries.append(body)
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
with query_test_table(
|
||||
handler,
|
||||
vector_columns={"v1": 10, "v2": 10},
|
||||
embedding_functions=[config],
|
||||
) as table:
|
||||
with pytest.raises(ValueError, match=r"Candidates: \['v1', 'v2'\]"):
|
||||
table.search("puppy", query_type=query_type).to_list()
|
||||
assert queries == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vector_column, dim", [("v1", 3), ("v2", 4)])
|
||||
def test_query_sync_infers_vector_column_by_dimension(vector_column, dim):
|
||||
def handler(body):
|
||||
assert body["vector_column"] == vector_column
|
||||
assert body["vector"] == [1.0] * dim
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
with query_test_table(handler, vector_columns={"v1": 3, "v2": 4}) as table:
|
||||
assert table.search([1.0] * dim).to_list() == [{"id": 1}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vector_columns", [{}, {"v1": 3, "v2": 3}])
|
||||
def test_query_sync_empty_query(vector_columns):
|
||||
def handler(body):
|
||||
assert body == {
|
||||
"k": 10,
|
||||
@@ -1618,12 +2177,66 @@ def test_query_sync_empty_query():
|
||||
|
||||
return pa.table({"id": [1, 2, 3]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
with query_test_table(handler, vector_columns=vector_columns) as table:
|
||||
data = table.search(None).where("true").select(["id"]).limit(10).to_list()
|
||||
expected = [{"id": 1}, {"id": 2}, {"id": 3}]
|
||||
assert data == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
|
||||
@pytest.mark.parametrize("vector_column_name", [None, "vector"])
|
||||
@pytest.mark.parametrize("query_type", ["auto", "vector"])
|
||||
def test_query_sync_empty_vector(query, vector_column_name, query_type):
|
||||
def handler(body):
|
||||
pytest.fail("An empty query vector must be rejected before sending a query")
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
|
||||
table.search(
|
||||
query, vector_column_name=vector_column_name, query_type=query_type
|
||||
).limit(3).to_arrow()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("search_kwargs", [{}, {"query": None}])
|
||||
async def test_async_search_without_query(search_kwargs):
|
||||
expected = pa.table({"id": [7, 8, 9]})
|
||||
|
||||
def handler(request):
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
||||
elif request.path == "/v1/table/test/query/":
|
||||
body = json.loads(
|
||||
request.rfile.read(int(request.headers["Content-Length"]))
|
||||
)
|
||||
assert body == {
|
||||
"k": 3,
|
||||
"filter": "id >= 7",
|
||||
"vector": [],
|
||||
"columns": ["id"],
|
||||
"prefilter": True,
|
||||
"version": None,
|
||||
}
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
||||
request.end_headers()
|
||||
with pa.ipc.new_file(request.wfile, schema=expected.schema) as writer:
|
||||
writer.write_table(expected)
|
||||
else:
|
||||
request.send_response(404)
|
||||
request.end_headers()
|
||||
|
||||
async with mock_lancedb_connection_async(handler) as db:
|
||||
table = await db.open_table("test")
|
||||
query = await table.search(**search_kwargs)
|
||||
assert isinstance(query, AsyncQuery)
|
||||
result = await query.where("id >= 7").select(["id"]).limit(3).to_arrow()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_query_sync_maximal():
|
||||
def handler(body):
|
||||
assert body == {
|
||||
@@ -1684,6 +2297,53 @@ def test_query_sync_maximal():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hybrid", [False, True])
|
||||
def test_query_sync_nprobes_zero(hybrid):
|
||||
query_requests = []
|
||||
|
||||
def handler(body):
|
||||
query_requests.append(body)
|
||||
return pa.table({"id": []})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
if hybrid:
|
||||
query = table.search(query_type="hybrid").vector([1, 2, 3]).text("dog")
|
||||
else:
|
||||
query = table.search([1, 2, 3])
|
||||
with pytest.raises(
|
||||
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
||||
):
|
||||
query.nprobes(0)
|
||||
|
||||
assert query_requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("hybrid", [False, True])
|
||||
async def test_query_async_nprobes_zero(hybrid):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request.path)
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
send_json(request, {"version": 1, "schema": {"fields": []}})
|
||||
else:
|
||||
request.send_response(404)
|
||||
request.end_headers()
|
||||
|
||||
async with mock_lancedb_connection_async(handler) as db:
|
||||
table = await db.open_table("test")
|
||||
query = table.query().nearest_to([1, 2, 3])
|
||||
if hybrid:
|
||||
query = query.nearest_to_text("dog")
|
||||
with pytest.raises(
|
||||
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
||||
):
|
||||
query.nprobes(0)
|
||||
|
||||
assert requests == ["/v1/table/test/describe/"]
|
||||
|
||||
|
||||
def test_query_sync_nprobes():
|
||||
def handler(body):
|
||||
assert body == {
|
||||
@@ -1767,7 +2427,9 @@ def test_query_sync_batch_queries(server_version):
|
||||
assert results == [{"id": 1, "query_index": 0}, {"id": 1, "query_index": 1}]
|
||||
|
||||
|
||||
def test_query_sync_fts():
|
||||
@pytest.mark.parametrize("vector_columns", [{}, {"vector": 3}, {"v1": 3, "v2": 3}])
|
||||
@pytest.mark.parametrize("query_type", ["auto", "fts"])
|
||||
def test_query_sync_fts(vector_columns, query_type):
|
||||
def handler(body):
|
||||
assert body == {
|
||||
"full_text_query": {
|
||||
@@ -1782,8 +2444,8 @@ def test_query_sync_fts():
|
||||
|
||||
return pa.table({"id": [1, 2, 3]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
(table.search("puppy", query_type="fts").to_list())
|
||||
with query_test_table(handler, vector_columns=vector_columns) as table:
|
||||
(table.search("puppy", query_type=query_type).to_list())
|
||||
|
||||
def handler(body):
|
||||
assert body == {
|
||||
@@ -1810,9 +2472,11 @@ def test_query_sync_fts():
|
||||
|
||||
return pa.table({"id": [1, 2, 3]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
with query_test_table(handler, vector_columns=vector_columns) as table:
|
||||
(
|
||||
table.search("puppy", query_type="fts", fts_columns=["name", "description"])
|
||||
table.search(
|
||||
"puppy", query_type=query_type, fts_columns=["name", "description"]
|
||||
)
|
||||
.with_row_id(True)
|
||||
.limit(42)
|
||||
.to_list()
|
||||
@@ -1850,7 +2514,9 @@ def test_query_sync_fts_document_granularity():
|
||||
}
|
||||
)
|
||||
|
||||
with query_test_table(handler, server_version=Version("0.6.0")) as table:
|
||||
with query_test_table(
|
||||
handler, server_version=Version("0.6.0"), vector_columns={}
|
||||
) as table:
|
||||
result = table.search(
|
||||
MatchQuery(
|
||||
"alpha",
|
||||
@@ -1885,6 +2551,7 @@ def test_query_sync_hybrid():
|
||||
"prefilter": True,
|
||||
"refine_factor": None,
|
||||
"vector": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"vector_column": "vector",
|
||||
"nprobes": 20,
|
||||
"minimum_nprobes": 20,
|
||||
"maximum_nprobes": 20,
|
||||
@@ -1896,15 +2563,14 @@ def test_query_sync_hybrid():
|
||||
}
|
||||
return pa.table({"_rowid": [1, 2, 3], "_distance": [0.1, 0.2, 0.3]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
embedding_func = MockTextEmbeddingFunction()
|
||||
embedding_config = MagicMock()
|
||||
embedding_config.function = embedding_func
|
||||
|
||||
embedding_funcs = MagicMock()
|
||||
embedding_funcs.get = MagicMock(return_value=embedding_config)
|
||||
table.embedding_functions = embedding_funcs
|
||||
|
||||
config = EmbeddingFunctionConfig(
|
||||
source_column="text",
|
||||
vector_column="vector",
|
||||
function=MockTextEmbeddingFunction.create(),
|
||||
)
|
||||
with query_test_table(
|
||||
handler, vector_columns={"vector": 10}, embedding_functions=[config]
|
||||
) as table:
|
||||
(table.search("puppy", query_type="hybrid").limit(42).to_list())
|
||||
|
||||
|
||||
@@ -2248,15 +2914,46 @@ async def test_header_provider_overrides_static_headers():
|
||||
|
||||
|
||||
def test_close():
|
||||
"""Test that close() works without AttributeError."""
|
||||
import asyncio
|
||||
|
||||
def handler(req):
|
||||
req.send_response(200)
|
||||
req.end_headers()
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
asyncio.run(db.close())
|
||||
assert db.close() is None
|
||||
assert not db.is_open()
|
||||
assert db.close() is None
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="table_names"):
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.table_names()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.list_tables()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.open_table("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raise_error", [False, True])
|
||||
def test_sync_context_manager(raise_error):
|
||||
def handler(req):
|
||||
req.send_response(200)
|
||||
req.send_header("Content-Type", "application/json")
|
||||
req.end_headers()
|
||||
req.wfile.write(b'{"tables": []}')
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
with contextlib.ExitStack() as stack:
|
||||
if raise_error:
|
||||
stack.enter_context(pytest.raises(ValueError, match="test error"))
|
||||
with db as conn:
|
||||
assert conn is db
|
||||
assert conn.is_open()
|
||||
assert conn.list_tables().tables == []
|
||||
if raise_error:
|
||||
raise ValueError("test error")
|
||||
|
||||
assert not db.is_open()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.list_tables()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception", [KeyboardInterrupt, SystemExit, GeneratorExit])
|
||||
@@ -2670,9 +3367,15 @@ def test_remote_blob_query_survives_a_server_that_ignores_the_row_id_request():
|
||||
def test_remote_blob_byte_apis_not_supported_on_old_server():
|
||||
with blob_remote_table(server_version=Version("0.1.0")) as table:
|
||||
assert table.blob_columns() == ["image"]
|
||||
with pytest.raises(NotImplementedError, match="not supported"):
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match="fetch_blobs is not supported by this LanceDB server",
|
||||
):
|
||||
table.fetch_blobs("image", [1])
|
||||
with pytest.raises(NotImplementedError, match="not supported"):
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match="fetch_blob_files requires LanceDB server 0.5.0 or newer",
|
||||
):
|
||||
table.fetch_blob_files("image", [1])
|
||||
|
||||
|
||||
@@ -2800,7 +3503,7 @@ def test_remote_connection_jobs_surface():
|
||||
request.end_headers()
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
jobs = db.list_jobs()
|
||||
jobs = list(db.list_jobs())
|
||||
assert [job.job_id for job in jobs] == ["job-1", "job-2"]
|
||||
assert jobs[0].state == "running"
|
||||
assert jobs[0].table == "t1"
|
||||
@@ -2960,7 +3663,7 @@ def test_view_crud_addresses_its_own_routes():
|
||||
described = db.describe_view("adults", namespace_path=["analytics"])
|
||||
assert described.schema == view.schema
|
||||
|
||||
assert db.list_views(namespace_path=["analytics"]) == ["adults"]
|
||||
assert list(db.list_views(namespace_path=["analytics"])) == ["adults"]
|
||||
db.drop_view("adults", namespace_path=["analytics"])
|
||||
|
||||
assert paths == [
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import threading
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import pyarrow as pa
|
||||
|
||||
@@ -11,7 +13,7 @@ from lancedb import _lancedb
|
||||
from lancedb.arrow import AsyncRecordBatchReader
|
||||
from lancedb.db import AsyncConnection
|
||||
from lancedb.remote.db import RemoteDBConnection
|
||||
from lancedb.sql import AsyncQuery, Query
|
||||
from lancedb.sql import AsyncQuery, Query, to_parameter_batch
|
||||
|
||||
NIL_QUERY_ID = UUID(int=0)
|
||||
|
||||
@@ -24,12 +26,20 @@ class FakeNativeQuery:
|
||||
|
||||
|
||||
class FakeNativeConnection:
|
||||
async def execute_query_async(self, query, *, default_namespace_path=None):
|
||||
def __init__(self):
|
||||
self.parameters = []
|
||||
|
||||
async def execute_query_async(
|
||||
self, query, *, default_namespace_path=None, parameters=None
|
||||
):
|
||||
self.parameters.append(parameters)
|
||||
return FakeNativeQuery()
|
||||
|
||||
|
||||
class FakeAsyncConnection:
|
||||
async def execute_query_async(self, query, *, default_namespace_path=None):
|
||||
async def execute_query_async(
|
||||
self, query, *, default_namespace_path=None, parameters=None
|
||||
):
|
||||
return AsyncQuery(FakeNativeQuery())
|
||||
|
||||
|
||||
@@ -160,3 +170,100 @@ def test_execute_query_async_rejects_invalid_namespace_components(
|
||||
remote_connection().execute_query_async(
|
||||
"SELECT 1", default_namespace_path=default_namespace_path
|
||||
)
|
||||
|
||||
|
||||
def test_positional_parameters_keep_their_types():
|
||||
vector = np.array([0.1, 0.2, 0.3], dtype=np.float32)
|
||||
batch = to_parameter_batch(
|
||||
[42, 1.5, "a", np.float32(0.25), vector, pa.scalar(7, pa.int16())]
|
||||
)
|
||||
assert batch.num_rows == 1
|
||||
assert batch.schema.names == ["1", "2", "3", "4", "5", "6"]
|
||||
assert batch.schema.types == [
|
||||
pa.int64(),
|
||||
pa.float64(),
|
||||
pa.string(),
|
||||
pa.float32(),
|
||||
pa.list_(pa.float32(), 3),
|
||||
pa.int16(),
|
||||
]
|
||||
sent = batch.column(4).flatten().to_numpy()
|
||||
assert sent.dtype == np.float32
|
||||
assert sent.tobytes() == vector.tobytes()
|
||||
|
||||
|
||||
def test_named_parameters_and_batches():
|
||||
half = pa.array(np.array([1.0, 2.0], dtype=np.float16))
|
||||
batch = to_parameter_batch({"vector": half, "k": 3})
|
||||
assert batch.schema.names == ["vector", "k"]
|
||||
assert batch.schema.field("vector").type == pa.list_(pa.float16(), 2)
|
||||
|
||||
row = pa.record_batch({"id": [1]})
|
||||
assert to_parameter_batch(row) is row
|
||||
assert to_parameter_batch(pa.table({"id": [1]})).equals(row)
|
||||
assert to_parameter_batch(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parameters",
|
||||
[pa.record_batch({"id": [1, 2]}), pa.table({"id": pa.array([], pa.int64())})],
|
||||
)
|
||||
def test_parameters_are_exactly_one_row(parameters):
|
||||
with pytest.raises(ValueError, match="exactly one row"):
|
||||
to_parameter_batch(parameters)
|
||||
|
||||
|
||||
def test_parameters_must_be_a_collection():
|
||||
with pytest.raises(TypeError, match="query parameters"):
|
||||
to_parameter_batch("SELECT 1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_execute_query_sends_parameters_as_a_batch():
|
||||
native = FakeNativeConnection()
|
||||
connection = AsyncConnection(native)
|
||||
await connection.execute_query("SELECT $1", parameters=[1])
|
||||
await connection.execute_query_async("SELECT 1")
|
||||
sent, unparameterized = native.parameters
|
||||
assert sent.equals(pa.record_batch({"1": [1]}))
|
||||
assert unparameterized is None
|
||||
|
||||
|
||||
def test_parameters_reach_the_server_as_the_values_that_were_sent():
|
||||
"""A round trip through the bindings against a Flight server that answers
|
||||
each exchange with the parameter row it received."""
|
||||
flight = pytest.importorskip("pyarrow.flight")
|
||||
|
||||
class EchoServer(flight.FlightServerBase):
|
||||
def __init__(self):
|
||||
super().__init__("grpc://127.0.0.1:0")
|
||||
self.commands = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def do_exchange(self, context, descriptor, reader, writer):
|
||||
parameters = reader.read_all()
|
||||
with self.lock:
|
||||
self.commands.append(descriptor.command)
|
||||
writer.begin(parameters.schema)
|
||||
writer.write_table(parameters)
|
||||
writer.close()
|
||||
|
||||
vector = np.array([0.1, 1e-45, -0.0], dtype=np.float32)
|
||||
with EchoServer() as server:
|
||||
db = lancedb.connect(
|
||||
"db://analytics",
|
||||
api_key="test-key",
|
||||
host_override="http://localhost:10024",
|
||||
sql_host_override=f"grpc://127.0.0.1:{server.port}",
|
||||
)
|
||||
echoed = db.execute_query(
|
||||
"SELECT echo", parameters={"vector": vector, "k": 10}
|
||||
).read_all()
|
||||
(command,) = server.commands
|
||||
|
||||
assert b"CommandStatementQuery" in command
|
||||
assert b"SELECT echo" in command
|
||||
assert echoed.schema.field("vector").type == pa.list_(pa.float32(), 3)
|
||||
received = echoed.column("vector").combine_chunks().flatten().to_numpy()
|
||||
assert received.view(np.uint32).tolist() == vector.view(np.uint32).tolist()
|
||||
assert echoed.column("k").to_pylist() == [10]
|
||||
|
||||
@@ -113,6 +113,32 @@ def test_basic(mem_db: DBConnection):
|
||||
assert table.to_arrow() == expected_data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_v2", [False, True])
|
||||
def test_migrate_v2_manifest_paths(tmp_path, enable_v2):
|
||||
db = lancedb.connect(
|
||||
tmp_path,
|
||||
storage_options={"new_table_enable_v2_manifest_paths": str(enable_v2).lower()},
|
||||
)
|
||||
table = db.create_table("calls", [{"id": 1, "vector": [1.0, 1.0]}])
|
||||
table.add([{"id": 2, "vector": [2.0, 2.0]}])
|
||||
expected_data = table.to_arrow()
|
||||
expected_versions = table.list_versions()
|
||||
assert table.uses_v2_manifest_paths() == enable_v2
|
||||
|
||||
# Migration is also safe to repeat on a table already using v2 paths.
|
||||
for _ in range(2):
|
||||
table.migrate_v2_manifest_paths()
|
||||
assert table.uses_v2_manifest_paths()
|
||||
reopened = db.open_table("calls")
|
||||
assert reopened.uses_v2_manifest_paths()
|
||||
assert reopened.to_arrow() == expected_data
|
||||
assert reopened.list_versions() == expected_versions
|
||||
|
||||
manifests = list((tmp_path / "calls.lance" / "_versions").glob("*.manifest"))
|
||||
assert len(manifests) == len(expected_versions)
|
||||
assert all(len(path.stem) == 20 and path.stem.isdigit() for path in manifests)
|
||||
|
||||
|
||||
def test_search_preserves_nulls_from_sliced_arrow_table(mem_db: DBConnection):
|
||||
data = pa.table(
|
||||
{
|
||||
@@ -967,6 +993,63 @@ def test_polars(mem_db: DBConnection):
|
||||
assert len(filtered_result) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_versions_timestamp_precision():
|
||||
# 2026-10-03T01:03:49.274Z in nanoseconds. Float math turns .274000 into
|
||||
# .273999, so make sure the conversion is exact.
|
||||
ts_nanos = 1790989429274000000
|
||||
|
||||
class FakeInner:
|
||||
async def list_versions(self):
|
||||
return [{"version": 1, "timestamp": ts_nanos, "metadata": {}}]
|
||||
|
||||
table = table_module.AsyncTable(FakeInner())
|
||||
versions = await table.list_versions()
|
||||
|
||||
expected = datetime.fromtimestamp(ts_nanos // 1_000_000_000)
|
||||
assert versions[0]["timestamp"] == expected + timedelta(microseconds=274000)
|
||||
assert versions[0]["timestamp"].microsecond == 274000
|
||||
|
||||
|
||||
def test_list_versions_timestamp_precision_sync(mem_db: DBConnection):
|
||||
# The sync LanceTable.list_versions delegates to AsyncTable.list_versions,
|
||||
# which is the path the parity report hit.
|
||||
ts_nanos = 1790989429274000000
|
||||
|
||||
class FakeInner:
|
||||
async def list_versions(self):
|
||||
return [{"version": 1, "timestamp": ts_nanos, "metadata": {}}]
|
||||
|
||||
table = mem_db.create_table("ts_precision", data=[{"id": 1}])
|
||||
table._table = table_module.AsyncTable(FakeInner())
|
||||
versions = table.list_versions()
|
||||
|
||||
expected = datetime.fromtimestamp(ts_nanos // 1_000_000_000)
|
||||
assert versions[0]["timestamp"] == expected + timedelta(microseconds=274000)
|
||||
assert versions[0]["timestamp"].microsecond == 274000
|
||||
|
||||
|
||||
def test_list_versions_timestamp_precision_remote():
|
||||
# RemoteTable.list_versions also delegates to AsyncTable.list_versions.
|
||||
from lancedb.remote.table import RemoteTable
|
||||
|
||||
ts_nanos = 1790989429274000000
|
||||
|
||||
class FakeInner:
|
||||
def name(self):
|
||||
return "ts_precision"
|
||||
|
||||
async def list_versions(self):
|
||||
return [{"version": 1, "timestamp": ts_nanos, "metadata": {}}]
|
||||
|
||||
table = RemoteTable(table_module.AsyncTable(FakeInner()), "dev")
|
||||
versions = table.list_versions()
|
||||
|
||||
expected = datetime.fromtimestamp(ts_nanos // 1_000_000_000)
|
||||
assert versions[0]["timestamp"] == expected + timedelta(microseconds=274000)
|
||||
assert versions[0]["timestamp"].microsecond == 274000
|
||||
|
||||
|
||||
def test_versioning(mem_db: DBConnection):
|
||||
table = mem_db.create_table(
|
||||
"test",
|
||||
@@ -3751,6 +3834,24 @@ def test_empty_query(mem_db: DBConnection):
|
||||
assert df.num_rows == 42
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
|
||||
@pytest.mark.parametrize("vector_column_name", [None, "vector"])
|
||||
@pytest.mark.parametrize("query_type", ["auto", "vector"])
|
||||
@pytest.mark.parametrize("multiple_vector_columns", [False, True])
|
||||
def test_search_empty_vector(
|
||||
mem_db, query, vector_column_name, query_type, multiple_vector_columns
|
||||
):
|
||||
fields = [pa.field("vector", pa.list_(pa.float32(), 8))]
|
||||
if multiple_vector_columns:
|
||||
fields.append(pa.field("vec2", pa.list_(pa.float32(), 4)))
|
||||
table = mem_db.create_table("empty_vector_query", schema=pa.schema(fields))
|
||||
|
||||
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
|
||||
table.search(
|
||||
query, vector_column_name=vector_column_name, query_type=query_type
|
||||
).limit(3).to_arrow()
|
||||
|
||||
|
||||
def test_search_with_schema_inf_single_vector(mem_db: DBConnection):
|
||||
class MyTable(LanceModel):
|
||||
text: str
|
||||
@@ -4144,14 +4245,74 @@ async def test_add_columns_with_schema(mem_db_async: AsyncConnection):
|
||||
)
|
||||
|
||||
|
||||
def test_alter_columns(mem_db: DBConnection):
|
||||
@pytest.mark.parametrize("rename_key", ["rename", "name"])
|
||||
def test_alter_columns(mem_db: DBConnection, rename_key):
|
||||
data = pa.table({"id": [0, 1]})
|
||||
table = mem_db.create_table("my_table", data=data)
|
||||
alter_columns_res = table.alter_columns({"path": "id", "rename": "new_id"})
|
||||
alter_columns_res = table.alter_columns({"path": "id", rename_key: "new_id"})
|
||||
assert alter_columns_res.version == 2
|
||||
assert table.to_arrow().column_names == ["new_id"]
|
||||
|
||||
|
||||
INVALID_COLUMN_ALTERATIONS = [
|
||||
(({"path": "id"},), "One of rename, nullable or data_type"),
|
||||
(
|
||||
({"path": "id", "nulable": False},), # spellchecker:disable-line
|
||||
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
||||
),
|
||||
(
|
||||
(
|
||||
{
|
||||
"path": "id",
|
||||
"rename": "new_id",
|
||||
"nulable": False, # spellchecker:disable-line
|
||||
},
|
||||
),
|
||||
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
||||
),
|
||||
(
|
||||
({"path": "id", "rename": "new_id"}, {"path": "id"}),
|
||||
"One of rename, nullable or data_type",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_alter_columns_nullable_false(mem_db: DBConnection):
|
||||
table = mem_db.create_table("my_table", data=pa.table({"id": [0, 1]}))
|
||||
result = table.alter_columns({"path": "id", "nullable": False})
|
||||
assert result.version == 2
|
||||
assert not table.schema.field("id").nullable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alterations, match", INVALID_COLUMN_ALTERATIONS)
|
||||
def test_alter_columns_rejects_invalid(mem_db: DBConnection, alterations, match):
|
||||
table = mem_db.create_table("my_table", data=pa.table({"id": [0, 1]}))
|
||||
initial_version = table.version
|
||||
initial_schema = table.schema
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
table.alter_columns(*alterations)
|
||||
|
||||
assert table.version == initial_version
|
||||
assert table.schema == initial_schema
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("alterations, match", INVALID_COLUMN_ALTERATIONS)
|
||||
async def test_alter_columns_rejects_invalid_async(
|
||||
mem_db_async: AsyncConnection, alterations, match
|
||||
):
|
||||
table = await mem_db_async.create_table("my_table", data=pa.table({"id": [0, 1]}))
|
||||
initial_version = await table.version()
|
||||
initial_schema = await table.schema()
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
await table.alter_columns(*alterations)
|
||||
|
||||
assert await table.version() == initial_version
|
||||
assert await table.schema() == initial_schema
|
||||
|
||||
|
||||
def test_update_field_metadata(mem_db: DBConnection):
|
||||
data = pa.table({"id": [0, 1], "category": ["a", "b"]})
|
||||
table = mem_db.create_table("my_table", data=data)
|
||||
@@ -4177,10 +4338,11 @@ def test_update_field_metadata(mem_db: DBConnection):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alter_columns_async(mem_db_async: AsyncConnection):
|
||||
@pytest.mark.parametrize("rename_key", ["rename", "name"])
|
||||
async def test_alter_columns_async(mem_db_async: AsyncConnection, rename_key):
|
||||
data = pa.table({"id": [0, 1]})
|
||||
table = await mem_db_async.create_table("my_table", data=data)
|
||||
alter_columns_res = await table.alter_columns({"path": "id", "rename": "new_id"})
|
||||
alter_columns_res = await table.alter_columns({"path": "id", rename_key: "new_id"})
|
||||
assert alter_columns_res.version == 2
|
||||
assert (await table.to_arrow()).column_names == ["new_id"]
|
||||
alter_columns_res = await table.alter_columns(
|
||||
@@ -4379,22 +4541,25 @@ def test_stats(mem_db: DBConnection):
|
||||
"my_table",
|
||||
data=[{"text": "foo", "id": 0}, {"text": "bar", "id": 1}],
|
||||
)
|
||||
assert len(table) == 2
|
||||
table.add([{"text": "baz", "id": 1}])
|
||||
assert len(table) == 3
|
||||
stats = table.stats()
|
||||
print(f"{stats=}")
|
||||
assert stats == {
|
||||
# Full on-disk size of the data file, footer and metadata included.
|
||||
"total_bytes": 637,
|
||||
"num_rows": 2,
|
||||
# Full on-disk size of the data files, footer and metadata included:
|
||||
# 637 bytes for the two-row fragment plus 415 for the one-row fragment.
|
||||
"total_bytes": 1052,
|
||||
"num_rows": 3,
|
||||
"num_deleted_rows": 0,
|
||||
"num_indices": 0,
|
||||
"fragment_stats": {
|
||||
"num_fragments": 1,
|
||||
"num_small_fragments": 1,
|
||||
"num_fragments": 2,
|
||||
"num_small_fragments": 2,
|
||||
"lengths": {
|
||||
"min": 2,
|
||||
"min": 1,
|
||||
"max": 2,
|
||||
"mean": 2,
|
||||
"p25": 2,
|
||||
"mean": 1,
|
||||
"p25": 1,
|
||||
"p50": 2,
|
||||
"p75": 2,
|
||||
"p99": 2,
|
||||
@@ -4402,12 +4567,19 @@ def test_stats(mem_db: DBConnection):
|
||||
},
|
||||
}
|
||||
|
||||
# Both rows with id = 1 are deleted, but that empties the second fragment,
|
||||
# which is dropped outright rather than kept with a deletion file, so only
|
||||
# the row marked in the surviving fragment is counted.
|
||||
table.delete("id = 1")
|
||||
# Index files count toward total_bytes too (only deletion files and
|
||||
# manifests are excluded).
|
||||
table.create_index("id", config=BTree())
|
||||
stats_with_index = table.stats()
|
||||
assert stats_with_index["num_indices"] == 1
|
||||
assert stats_with_index["total_bytes"] > stats["total_bytes"]
|
||||
assert stats_with_index["num_rows"] == 1
|
||||
assert stats_with_index["num_deleted_rows"] == 1
|
||||
assert stats_with_index["fragment_stats"]["num_fragments"] == 1
|
||||
|
||||
|
||||
def test_create_table_empty_list_with_schema(mem_db: DBConnection):
|
||||
|
||||
+45
-15
@@ -1,12 +1,13 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use lancedb::catalog::{
|
||||
CatalogConnection, CreateDatabaseRequest, DropDatabaseRequest, ListDatabasesRequest,
|
||||
};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use futures::{StreamExt, future::poll_fn};
|
||||
|
||||
use lancedb::catalog::{CatalogConnection, CreateDatabaseRequest, DropDatabaseRequest};
|
||||
use pyo3::exceptions::{PyStopAsyncIteration, PyValueError};
|
||||
use pyo3::{Bound, PyAny, PyRef, PyResult, Python, pyclass, pyfunction, pymethods};
|
||||
|
||||
use crate::connection::{Connection, PyClientConfig};
|
||||
@@ -67,19 +68,48 @@ impl Catalog {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (*, limit=None, page_token=None))]
|
||||
fn list_databases<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
limit: Option<u32>,
|
||||
page_token: Option<String>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
#[pyo3(signature = (*, page_token=None, page_limit=None))]
|
||||
fn list_databases(&self, page_token: Option<String>, page_limit: Option<u32>) -> DatabaseNames {
|
||||
DatabaseNames {
|
||||
inner: Arc::new(Mutex::new(
|
||||
self.inner.list_databases(page_token, page_limit),
|
||||
)),
|
||||
next_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
pub struct DatabaseNames {
|
||||
inner: Arc<Mutex<lancedb::catalog::DatabaseNames>>,
|
||||
next_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl DatabaseNames {
|
||||
fn num_page_results(&self, py: Python<'_>) -> usize {
|
||||
py.detach(|| self.inner.lock().unwrap().num_page_results())
|
||||
}
|
||||
|
||||
fn page_token(&self, py: Python<'_>) -> Option<String> {
|
||||
py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned))
|
||||
}
|
||||
|
||||
fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> {
|
||||
self_
|
||||
}
|
||||
|
||||
fn __anext__(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner.clone();
|
||||
let next_lock = self_.next_lock.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let mut request = ListDatabasesRequest::default();
|
||||
request.limit = limit;
|
||||
request.page_token = page_token;
|
||||
let response = inner.list_databases(request).await.infer_error()?;
|
||||
Ok((response.databases, response.page_token))
|
||||
// Serialize advances while allowing synchronous state inspection
|
||||
// between polls, including while a REST request is pending.
|
||||
let _guard = next_lock.lock().await;
|
||||
poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.ok_or_else(|| PyStopAsyncIteration::new_err(""))?
|
||||
.infer_error()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+63
-66
@@ -14,9 +14,10 @@ use crate::{
|
||||
table::Table,
|
||||
};
|
||||
use arrow::{
|
||||
array::RecordBatch,
|
||||
datatypes::Schema,
|
||||
ffi_stream::ArrowArrayStreamReader,
|
||||
pyarrow::{FromPyArrow, ToPyArrow},
|
||||
pyarrow::{FromPyArrow, PyArrowType, ToPyArrow},
|
||||
};
|
||||
use lancedb::{
|
||||
connection::Connection as LanceConnection,
|
||||
@@ -148,18 +149,22 @@ impl Connection {
|
||||
self.get_inner().map(|inner| inner.uri().to_string())
|
||||
}
|
||||
|
||||
#[pyo3(signature = (query, *, default_namespace_path=None))]
|
||||
#[pyo3(signature = (query, *, default_namespace_path=None, parameters=None))]
|
||||
pub fn execute_query_async<'a>(
|
||||
self_: PyRef<'a, Self>,
|
||||
query: String,
|
||||
default_namespace_path: Option<Bound<'_, PyAny>>,
|
||||
parameters: Option<PyArrowType<RecordBatch>>,
|
||||
) -> PyResult<Bound<'a, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
let default_namespace_path = parse_default_namespace_path(default_namespace_path)?;
|
||||
future_into_py(self_.py(), async move {
|
||||
let operation = inner
|
||||
let mut operation = inner
|
||||
.execute_query_async(query)
|
||||
.default_namespace_path(default_namespace_path);
|
||||
if let Some(PyArrowType(parameters)) = parameters {
|
||||
operation = operation.parameters(parameters);
|
||||
}
|
||||
operation
|
||||
.execute()
|
||||
.await
|
||||
@@ -407,8 +412,7 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false, *, vector_source_json=None))]
|
||||
#[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false))]
|
||||
pub fn create_materialized_view(
|
||||
self_: PyRef<'_, Self>,
|
||||
name: String,
|
||||
@@ -417,17 +421,10 @@ impl Connection {
|
||||
filter: Option<String>,
|
||||
limit: Option<u64>,
|
||||
with_no_data: bool,
|
||||
vector_source_json: Option<String>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let mut builder = inner.create_materialized_view(name, source);
|
||||
if let Some(source) = vector_source_json {
|
||||
builder = builder.vector_source(
|
||||
serde_json::from_str(&source)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
if let Some(projections) = projections {
|
||||
builder = builder.select(projections);
|
||||
}
|
||||
@@ -443,8 +440,7 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false, *, vector_source_json=None))]
|
||||
#[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false))]
|
||||
pub fn create_materialized_view_async(
|
||||
self_: PyRef<'_, Self>,
|
||||
name: String,
|
||||
@@ -453,17 +449,10 @@ impl Connection {
|
||||
filter: Option<String>,
|
||||
limit: Option<u64>,
|
||||
with_no_data: bool,
|
||||
vector_source_json: Option<String>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let mut builder = inner.create_materialized_view(name, source);
|
||||
if let Some(source) = vector_source_json {
|
||||
builder = builder.vector_source(
|
||||
serde_json::from_str(&source)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
if let Some(projections) = projections {
|
||||
builder = builder.select(projections);
|
||||
}
|
||||
@@ -480,12 +469,16 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_materialized_views(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let views = inner.list_materialized_views().await.infer_error()?;
|
||||
Ok(views)
|
||||
})
|
||||
#[pyo3(signature = (*, page_token=None, page_limit=None))]
|
||||
pub fn list_materialized_views(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?
|
||||
.list_materialized_views(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
@@ -792,22 +785,19 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (namespace_path=None))]
|
||||
#[pyo3(signature = (namespace_path=None, *, page_token=None, page_limit=None))]
|
||||
pub fn list_functions(
|
||||
self_: PyRef<'_, Self>,
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
let namespace_path = namespace_path.unwrap_or_default();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner
|
||||
.list_functions(&namespace_path)
|
||||
.await
|
||||
.infer_error()?
|
||||
.into_iter()
|
||||
.map(|function| function.to_canonical_json().infer_error())
|
||||
.collect::<PyResult<Vec<_>>>()
|
||||
})
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::FunctionListing> {
|
||||
Ok(crate::listing::FunctionListing::new(
|
||||
self.get_inner()?.list_functions(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, version, namespace_path=None))]
|
||||
@@ -879,16 +869,19 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (namespace_path=None))]
|
||||
#[pyo3(signature = (namespace_path=None, *, page_token=None, page_limit=None))]
|
||||
pub fn list_secrets(
|
||||
self_: PyRef<'_, Self>,
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
let namespace_path = namespace_path.unwrap_or_default();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.list_secrets(&namespace_path).await.infer_error()
|
||||
})
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?.list_secrets(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
@@ -989,27 +982,31 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (namespace_path=None))]
|
||||
#[pyo3(signature = (namespace_path=None, *, page_token=None, page_limit=None))]
|
||||
pub fn list_views(
|
||||
self_: PyRef<'_, Self>,
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
let namespace_path = namespace_path.unwrap_or_default();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.list_views(&namespace_path).await.infer_error()
|
||||
})
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?.list_views(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let jobs = inner.list_jobs().await.infer_error()?;
|
||||
Ok(jobs
|
||||
.into_iter()
|
||||
.map(crate::job::JobInfo::from)
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
#[pyo3(signature = (*, page_token=None, page_limit=None))]
|
||||
pub fn list_jobs(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::JobListing> {
|
||||
Ok(crate::listing::JobListing::new(
|
||||
self.get_inner()?
|
||||
.list_jobs(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn cancel_job(self_: PyRef<'_, Self>, job_id: String) -> PyResult<Bound<'_, PyAny>> {
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod expr;
|
||||
pub mod header;
|
||||
pub mod index;
|
||||
pub mod job;
|
||||
mod listing;
|
||||
pub mod namespace;
|
||||
pub mod oauth;
|
||||
pub mod otel;
|
||||
@@ -64,6 +65,11 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
env_logger::init_from_env(env);
|
||||
m.add_class::<Connection>()?;
|
||||
m.add_class::<catalog::Catalog>()?;
|
||||
m.add_class::<catalog::DatabaseNames>()?;
|
||||
m.add_class::<listing::NameListing>()?;
|
||||
m.add_class::<listing::FunctionListing>()?;
|
||||
m.add_class::<listing::JobListing>()?;
|
||||
|
||||
m.add_function(wrap_pyfunction!(catalog::connect_catalog, m)?)?;
|
||||
m.add_class::<Session>()?;
|
||||
m.add_class::<Table>()?;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::error::PythonErrorExt;
|
||||
use crate::runtime::future_into_py;
|
||||
use futures::{StreamExt, future::poll_fn};
|
||||
use lancedb::listing::{Listing, ListingOptions};
|
||||
use pyo3::exceptions::PyStopAsyncIteration;
|
||||
use pyo3::{Bound, PyAny, PyRef, PyResult, Python, pyclass, pymethods};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn options(page_token: Option<String>, page_limit: Option<u32>) -> ListingOptions {
|
||||
let mut options = ListingOptions::default();
|
||||
options.page_token = page_token;
|
||||
options.page_limit = page_limit;
|
||||
options
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
pub struct NameListing {
|
||||
inner: Arc<Mutex<Listing<String>>>,
|
||||
next_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
impl NameListing {
|
||||
pub fn new(inner: Listing<String>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
next_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[pymethods]
|
||||
impl NameListing {
|
||||
fn num_page_results(&self, py: Python<'_>) -> usize {
|
||||
py.detach(|| self.inner.lock().unwrap().num_page_results())
|
||||
}
|
||||
fn page_token(&self, py: Python<'_>) -> Option<String> {
|
||||
py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned))
|
||||
}
|
||||
fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> {
|
||||
self_
|
||||
}
|
||||
fn __anext__(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner.clone();
|
||||
let next_lock = self_.next_lock.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let _guard = next_lock.lock().await;
|
||||
let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.ok_or_else(|| PyStopAsyncIteration::new_err(""))?
|
||||
.infer_error()?;
|
||||
Ok(value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
pub struct FunctionListing {
|
||||
inner: Arc<Mutex<Listing<lancedb::function::FunctionVersion>>>,
|
||||
next_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
impl FunctionListing {
|
||||
pub fn new(inner: Listing<lancedb::function::FunctionVersion>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
next_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[pymethods]
|
||||
impl FunctionListing {
|
||||
fn num_page_results(&self, py: Python<'_>) -> usize {
|
||||
py.detach(|| self.inner.lock().unwrap().num_page_results())
|
||||
}
|
||||
fn page_token(&self, py: Python<'_>) -> Option<String> {
|
||||
py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned))
|
||||
}
|
||||
fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> {
|
||||
self_
|
||||
}
|
||||
fn __anext__(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner.clone();
|
||||
let next_lock = self_.next_lock.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let _guard = next_lock.lock().await;
|
||||
let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.ok_or_else(|| PyStopAsyncIteration::new_err(""))?
|
||||
.infer_error()?;
|
||||
value.to_canonical_json().infer_error()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
pub struct JobListing {
|
||||
inner: Arc<Mutex<Listing<lancedb::database::JobInfo>>>,
|
||||
next_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
impl JobListing {
|
||||
pub fn new(inner: Listing<lancedb::database::JobInfo>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
next_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[pymethods]
|
||||
impl JobListing {
|
||||
fn num_page_results(&self, py: Python<'_>) -> usize {
|
||||
py.detach(|| self.inner.lock().unwrap().num_page_results())
|
||||
}
|
||||
fn page_token(&self, py: Python<'_>) -> Option<String> {
|
||||
py.detach(|| self.inner.lock().unwrap().page_token().map(str::to_owned))
|
||||
}
|
||||
fn __aiter__(self_: PyRef<'_, Self>) -> PyRef<'_, Self> {
|
||||
self_
|
||||
}
|
||||
fn __anext__(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner.clone();
|
||||
let next_lock = self_.next_lock.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let _guard = next_lock.lock().await;
|
||||
let value = poll_fn(|cx| inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.ok_or_else(|| PyStopAsyncIteration::new_err(""))?
|
||||
.infer_error()?;
|
||||
Ok(crate::job::JobInfo::from(value))
|
||||
})
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -995,8 +995,9 @@ impl VectorQuery {
|
||||
self.inner = self.inner.clone().refine_factor(refine_factor);
|
||||
}
|
||||
|
||||
pub fn nprobes(&mut self, nprobe: u32) {
|
||||
self.inner = self.inner.clone().nprobes(nprobe as usize);
|
||||
pub fn nprobes(&mut self, nprobe: u32) -> PyResult<()> {
|
||||
self.inner = self.inner.clone().nprobes(nprobe as usize).infer_error()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn minimum_nprobes(&mut self, minimum_nprobes: u32) -> PyResult<()> {
|
||||
@@ -1188,8 +1189,8 @@ impl HybridQuery {
|
||||
self.inner_vec.refine_factor(refine_factor);
|
||||
}
|
||||
|
||||
pub fn nprobes(&mut self, nprobe: u32) {
|
||||
self.inner_vec.nprobes(nprobe);
|
||||
pub fn nprobes(&mut self, nprobe: u32) -> PyResult<()> {
|
||||
self.inner_vec.nprobes(nprobe)
|
||||
}
|
||||
|
||||
pub fn ef(&mut self, ef: u32) {
|
||||
|
||||
+31
-19
@@ -267,7 +267,7 @@ fn fmt_maintained(maintained: &Option<Vec<String>>) -> String {
|
||||
/// Constructed via the `bucket(...)`, `identity(...)`, or `unsharded()`
|
||||
/// classmethods, then optionally chain `with_maintained_indexes(...)` and
|
||||
/// `with_writer_config_defaults(...)`. A fresh spec maintains every index the
|
||||
/// MemWAL supports, resolved on install.
|
||||
/// table has, including ones created later.
|
||||
#[pyclass(module = "lancedb._lancedb", from_py_object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LsmWriteSpec {
|
||||
@@ -307,9 +307,9 @@ impl LsmWriteSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set which indexes the MemWAL maintains. `None` (the default)
|
||||
/// resolves every supported index on install; a list is verbatim,
|
||||
/// and an empty list maintains nothing.
|
||||
/// Set which indexes the MemWAL maintains. `None` (the default) is
|
||||
/// every index the table has, including ones created later; a list is
|
||||
/// verbatim, and an empty list maintains nothing.
|
||||
#[pyo3(signature = (indexes))]
|
||||
pub fn with_maintained_indexes(&self, indexes: Option<Vec<String>>) -> Self {
|
||||
Self {
|
||||
@@ -592,8 +592,6 @@ impl From<lancedb::RefreshMaterializedViewResult> for RefreshMaterializedViewRes
|
||||
fn from(result: lancedb::RefreshMaterializedViewResult) -> Self {
|
||||
let mode = match result.mode {
|
||||
lancedb::RefreshMode::Rebuild => "rebuild",
|
||||
lancedb::RefreshMode::Incremental => "incremental",
|
||||
lancedb::RefreshMode::NoOp => "no_op",
|
||||
};
|
||||
Self {
|
||||
mode: mode.to_string(),
|
||||
@@ -1268,6 +1266,7 @@ impl Table {
|
||||
let dict = PyDict::new(py);
|
||||
dict.set_item("total_bytes", stats.total_bytes)?;
|
||||
dict.set_item("num_rows", stats.num_rows)?;
|
||||
dict.set_item("num_deleted_rows", stats.num_deleted_rows)?;
|
||||
dict.set_item("num_indices", stats.num_indices)?;
|
||||
|
||||
let fragment_stats = PyDict::new(py);
|
||||
@@ -1709,7 +1708,10 @@ impl Table {
|
||||
future_into_py(self_.py(), async move {
|
||||
inner
|
||||
.as_native()
|
||||
.ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))?
|
||||
.ok_or_else(|| lancedb::Error::NotSupported {
|
||||
message: "uses_v2_manifest_paths is not supported for remote tables.".into(),
|
||||
})
|
||||
.infer_error()?
|
||||
.uses_v2_manifest_paths()
|
||||
.await
|
||||
.infer_error()
|
||||
@@ -1721,7 +1723,10 @@ impl Table {
|
||||
future_into_py(self_.py(), async move {
|
||||
inner
|
||||
.as_native()
|
||||
.ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))?
|
||||
.ok_or_else(|| lancedb::Error::NotSupported {
|
||||
message: "migrate_manifest_paths_v2 is not supported for remote tables.".into(),
|
||||
})
|
||||
.infer_error()?
|
||||
.migrate_manifest_paths_v2()
|
||||
.await
|
||||
.infer_error()
|
||||
@@ -1817,10 +1822,9 @@ impl Table {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (full=false, source_version=None))]
|
||||
#[pyo3(signature = (source_version=None))]
|
||||
pub fn refresh_materialized_view(
|
||||
self_: PyRef<'_, Self>,
|
||||
full: bool,
|
||||
source_version: Option<u64>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
@@ -1828,7 +1832,7 @@ impl Table {
|
||||
let view = lancedb::MaterializedView::from_table(inner)
|
||||
.await
|
||||
.infer_error()?;
|
||||
let mut builder = view.refresh().full(full);
|
||||
let mut builder = view.refresh();
|
||||
if let Some(version) = source_version {
|
||||
builder = builder.source_version(version);
|
||||
}
|
||||
@@ -1837,10 +1841,9 @@ impl Table {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (full=false, source_version=None))]
|
||||
#[pyo3(signature = (source_version=None))]
|
||||
pub fn refresh_materialized_view_async(
|
||||
self_: PyRef<'_, Self>,
|
||||
full: bool,
|
||||
source_version: Option<u64>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
@@ -1848,7 +1851,7 @@ impl Table {
|
||||
let view = lancedb::MaterializedView::from_table(inner)
|
||||
.await
|
||||
.infer_error()?;
|
||||
let mut builder = view.refresh().full(full);
|
||||
let mut builder = view.refresh();
|
||||
if let Some(version) = source_version {
|
||||
builder = builder.source_version(version);
|
||||
}
|
||||
@@ -1863,11 +1866,9 @@ impl Table {
|
||||
let view = lancedb::MaterializedView::from_table(inner)
|
||||
.await
|
||||
.infer_error()?;
|
||||
view.definition().to_json().map_err(|err| {
|
||||
PyRuntimeError::new_err(format!(
|
||||
"failed to serialize materialized-view definition: {err}"
|
||||
))
|
||||
})
|
||||
Ok(lancedb::materialized_view::definition_metadata_from_sql(
|
||||
view.definition_sql(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1897,6 +1898,17 @@ impl Table {
|
||||
let alterations = alterations
|
||||
.iter()
|
||||
.map(|alteration| {
|
||||
for key in alteration.keys().iter() {
|
||||
let key = key.extract::<String>()?;
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"path" | "rename" | "name" | "nullable" | "data_type"
|
||||
) {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Unknown column alteration key '{key}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let path = alteration
|
||||
.get_item("path")?
|
||||
.ok_or_else(|| PyValueError::new_err("Missing path"))?
|
||||
|
||||
Generated
+15
-15
@@ -1849,19 +1849,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "0.8.6"
|
||||
version = "0.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lance-namespace-urllib3-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/12/f7ab93b29be3edbf5fc3610714bf2d06088e7f4524bfb38dfd6852458b08/lance_namespace-0.8.6.tar.gz", hash = "sha256:18232e721c8188145f4ec9389cc2dfbeeabf54a619d94885ea1b3375bee9f4af", size = 11529, upload-time = "2026-06-12T17:36:41.651Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1b/5b1668ee2dc8910965f390640359112a31157092fcf8e000b89c79b58708/lance_namespace-0.8.6-py3-none-any.whl", hash = "sha256:571eae34f9aad70e5b05020416c2860889b9ec82993ccd0eb015e7b39c3ea309", size = 13383, upload-time = "2026-06-12T17:36:43.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace-urllib3-client"
|
||||
version = "0.8.6"
|
||||
version = "0.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1869,9 +1869,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/80/fb224b4a89c1c1638cde949cb6cce6c3aca7759effbfea46a3d9c3960b21/lance_namespace_urllib3_client-0.8.6.tar.gz", hash = "sha256:b6fb1d306e74a7576e5309919020be744527de484a63dbf5eed10f8b368548df", size = 228772, upload-time = "2026-06-12T17:36:42.609Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/90/1e27de15cd1b16785a1c7312beb0a59e75c8344a815f600f58173a565bd1/lance_namespace_urllib3_client-0.8.6-py3-none-any.whl", hash = "sha256:9d78249c3fb15aa3d15d668f78f04a275af3d08d800a7027492f37996ac4968b", size = 369950, upload-time = "2026-06-12T17:36:40.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2005,7 +2005,7 @@ requires-dist = [
|
||||
{ name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7.4,<3" },
|
||||
{ name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" },
|
||||
{ name = "pylance", marker = "extra == 'tests'", specifier = "==9.0.0" },
|
||||
{ name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b6" },
|
||||
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" },
|
||||
{ name = "pytest", marker = "extra == 'tests'", specifier = ">=7.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.21" },
|
||||
@@ -3912,8 +3912,8 @@ crypto = [
|
||||
|
||||
[[package]]
|
||||
name = "pylance"
|
||||
version = "9.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
version = "14.0.0b6"
|
||||
source = { registry = "https://pypi.fury.io/lance-format/" }
|
||||
dependencies = [
|
||||
{ name = "lance-namespace" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
@@ -3922,12 +3922,12 @@ dependencies = [
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/be/45733acd64801991852aac8e658601fd8fc12f76ceb81e57fca690896b90/pylance-9.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8257213501d3298c5b6a344d60938e4bbe4de9f00cd3265371a56d1dc3dd15ca", size = 68377982, upload-time = "2026-07-24T16:53:45.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/3e/1ef707cb215cc7268c63ad84a91344ad6313d3984b343eeb19a9b708698e/pylance-9.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804eedfa1fda2e703cca8580c76f0b44a1b849b725a8908c06f8acfca811732f", size = 71844362, upload-time = "2026-07-24T16:56:07.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fb/a499e5c53ddb75c7de44100fd2bb1f7cc735966200d20292eed7af9ef552/pylance-9.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0b75595e3766c1d5f4c90abdc52337b4de60f1a52d123a4f8c4e5bcbbbfa8f", size = 75663088, upload-time = "2026-07-24T17:10:31.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/80/0714e09f64a68dbdf62558955e737a7436df763b9834a6aba506861b5352/pylance-9.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d2f69c5c390ae3710c35a429905fe15f769951777fafb1b72a359e035ec121f7", size = 71866858, upload-time = "2026-07-24T16:56:35.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/3c/78d3a6d6ca0d843b7c3ac0c30d9cd2cf4635b0c2cabe6ec66583d5bfc1a1/pylance-9.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:836268a7832d62f3d5ccbe1c3fca239621971d90297bcfff14a70b3cb6842aa8", size = 75642656, upload-time = "2026-07-24T17:13:11.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c1/dc9c9a31e171530ec0add024d922488c046437d32a7087c29e254a7eacc7/pylance-9.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:96441d27a5ed3805300388ccf8f31835cd280c98751f80b6a1a11dcd6808fc43", size = 81668288, upload-time = "2026-07-24T17:05:38.707Z" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_DuQEn/pylance-14.0.0b6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3eade86fa30d9ff3248ee7ffb0b27ce29495394d12327da4d22b5c54bc2b59fe" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_K5sqj/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10e291974ee738a1d5d7e9dad17a634394a91cb60080b5ceac209077fbaec43c" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_EnbkT/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61bac10f5081c1210edd017f81c8c784d800b89446bd0ed05d4a24e7001965bc" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_19ffYg/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c627ad4a9870611becf84409d8e5ecddc6be64e1dd53bee4bd5fb10ab561e5c" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_2eLLY5/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bc603a7dd690233f1fe000600171e1c092f153bc9060cdf198825c4bbe1418aa" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_t8ggc/pylance-14.0.0b6-cp310-abi3-win_amd64.whl", hash = "sha256:1d32df32a3d157611450def07eae8ace3a7401286eac68cad9a24e0d5deed2db" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user