mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-30 00:45:37 +00:00
feat: add view CRUD APIs (#4236)
A view is a named query a database stores and plans on every read. It
holds no rows, which is the whole difference from a materialized view.
## API
| Verb | Route |
| --- | --- |
| `create_view(name, query, namespace_path)` | `POST
/v1/view/{id}/create` |
| `describe_view(name, namespace_path)` | `POST /v1/view/{id}/describe`
|
| `drop_view(name, namespace_path)` | `POST /v1/view/{id}/drop` |
| `list_views(namespace_path)` | `GET /v1/namespace/{id}/view/list` |
On `Connection` and the `Database` trait, with the remote client, Python
(sync and async) and Node bindings. Local databases return
`NotSupported`: the server side is Sophon's, where a view is an object
of the database manifest.
`ViewDescription` carries the defining query, the database *and
namespace path* unqualified names in it resolve against, and the schema
the query resolved to. `create_view` returns one, so a caller has the
schema without a second call.
Both defaults travel with the view because it outlives the session that
declared it: the server re-plans the stored query on every read, so a
reader resolving an unqualified name against its own defaults would read
a different table. `default_namespace_path` crosses the wire as
`default_namespace`, a path like `namespace`, absent for the root.
There is no replace: a name already taken is an error, and changing a
view is a drop followed by a create, each authorized against what it
actually touches.
Querying a view stays SQL's job. There are no rows behind a view, so
there is no `open_view` returning a `Table`.
This commit is contained in:
@@ -47,6 +47,7 @@ from .materialized_view import (
|
||||
MaterializedView,
|
||||
MaterializedViewDefinition,
|
||||
)
|
||||
from .view import ViewDescription as ViewDescription
|
||||
from .table import AsyncTable, Table
|
||||
from .types import BaseTokenizerType
|
||||
from ._lancedb import Session
|
||||
@@ -582,6 +583,7 @@ __all__ = [
|
||||
"AsyncMaterializedView",
|
||||
"MaterializedView",
|
||||
"MaterializedViewDefinition",
|
||||
"ViewDescription",
|
||||
"connect",
|
||||
"connect_async",
|
||||
"tokenize",
|
||||
|
||||
@@ -171,6 +171,18 @@ class Connection(object):
|
||||
async def describe_secret(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> Tuple[str, int, int]: ...
|
||||
async def create_view(
|
||||
self, name: str, query: str, namespace_path: Optional[List[str]] = None
|
||||
) -> Tuple[str, List[str], str, str, List[str], pa.Schema]: ...
|
||||
async def describe_view(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> Tuple[str, List[str], str, str, List[str], pa.Schema]: ...
|
||||
async def drop_view(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
async def list_views(
|
||||
self, namespace_path: Optional[List[str]] = None
|
||||
) -> List[str]: ...
|
||||
async def list_jobs(self) -> List[JobInfo]: ...
|
||||
async def cancel_job(self, job_id: str) -> bool: ...
|
||||
async def execute_query_async(
|
||||
|
||||
@@ -75,6 +75,7 @@ from .util import (
|
||||
get_uri_scheme,
|
||||
validate_table_name,
|
||||
)
|
||||
from .view import ViewDescription
|
||||
|
||||
import deprecation
|
||||
|
||||
@@ -96,6 +97,28 @@ from .namespace_utils import (
|
||||
)
|
||||
|
||||
|
||||
def _view_description(
|
||||
described: Tuple[str, List[str], str, str, List[str], "pa.Schema"],
|
||||
) -> ViewDescription:
|
||||
"""Name the fields the binding returns positionally."""
|
||||
(
|
||||
name,
|
||||
namespace_path,
|
||||
query,
|
||||
default_database,
|
||||
default_namespace_path,
|
||||
schema,
|
||||
) = described
|
||||
return ViewDescription(
|
||||
name=name,
|
||||
query=query,
|
||||
default_database=default_database,
|
||||
schema=schema,
|
||||
namespace_path=namespace_path,
|
||||
default_namespace_path=default_namespace_path,
|
||||
)
|
||||
|
||||
|
||||
class DBConnection(EnforceOverrides):
|
||||
"""An active LanceDB connection interface."""
|
||||
|
||||
@@ -918,6 +941,68 @@ class DBConnection(EnforceOverrides):
|
||||
"Secret operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def create_view(
|
||||
self, name: str, query: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
"""Create a view: a named query the database plans on every read.
|
||||
|
||||
The query is planned once, at creation, so one that cannot be planned
|
||||
is refused now rather than at the first read. A view holds no rows, and
|
||||
its readers see its sources as they are at read time.
|
||||
|
||||
There is no replace: a name already taken is an error, and changing a
|
||||
view is a drop followed by a create. Local connections raise
|
||||
``NotImplementedError``.
|
||||
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect("db://my_database") # doctest: +SKIP
|
||||
>>> view = db.create_view(
|
||||
... "adults", "SELECT name FROM people WHERE age >= 18"
|
||||
... ) # doctest: +SKIP
|
||||
>>> view.schema # doctest: +SKIP
|
||||
name: string
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"View operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def describe_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
"""What this database records about a view: its defining query and the
|
||||
schema that query resolved to.
|
||||
|
||||
The schema is the one recorded at creation; a source altered since then
|
||||
shows up when the view is read. Local connections raise
|
||||
``NotImplementedError``.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"View operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def drop_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""Drop a view.
|
||||
|
||||
The tables it reads are untouched: a view holds no rows of its own.
|
||||
Local connections raise ``NotImplementedError``.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"View operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[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``.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"View operations are not supported for this connection type"
|
||||
)
|
||||
|
||||
def open_job(self, job_id: str) -> Job:
|
||||
"""Open a server-side job by id, returning a handle with its record
|
||||
already populated.
|
||||
@@ -1731,6 +1816,30 @@ class LanceDBConnection(DBConnection):
|
||||
) -> SecretInfo:
|
||||
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def create_view(
|
||||
self, name: str, query: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
return LOOP.run(
|
||||
self._conn.create_view(name, query, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def describe_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
return LOOP.run(self._conn.describe_view(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def drop_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
LOOP.run(self._conn.drop_view(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_jobs(self) -> List[JobInfo]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
@@ -2689,6 +2798,38 @@ class AsyncConnection(object):
|
||||
updated_at_millis=updated_at_millis,
|
||||
)
|
||||
|
||||
async def create_view(
|
||||
self, name: str, query: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
"""Create a view: a named query the database plans on every read.
|
||||
|
||||
See
|
||||
[DBConnection.create_view][lancedb.DBConnection.create_view].
|
||||
"""
|
||||
return _view_description(
|
||||
await self._inner.create_view(name, query, list(namespace_path or []))
|
||||
)
|
||||
|
||||
async def describe_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
"""What this database records about a view: query and schema."""
|
||||
return _view_description(
|
||||
await self._inner.describe_view(name, list(namespace_path or []))
|
||||
)
|
||||
|
||||
async def drop_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""Drop a view. The tables it reads are untouched."""
|
||||
await self._inner.drop_view(name, list(namespace_path or []))
|
||||
|
||||
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 []))
|
||||
|
||||
async def list_jobs(self) -> List[JobInfo]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
return await self._inner.list_jobs()
|
||||
|
||||
@@ -41,6 +41,7 @@ from ..sql import Query as SqlQuery
|
||||
from ..sql import QueryDescription
|
||||
from ..materialized_view import MaterializedView, SelectArg
|
||||
from ..secrets import EnvVarSecret, SecretInfo
|
||||
from ..view import ViewDescription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._lancedb import JobInfo
|
||||
@@ -910,6 +911,30 @@ class RemoteDBConnection(DBConnection):
|
||||
) -> None:
|
||||
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def create_view(
|
||||
self, name: str, query: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
return LOOP.run(
|
||||
self._conn.create_view(name, query, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def describe_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> ViewDescription:
|
||||
return LOOP.run(self._conn.describe_view(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def drop_view(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
LOOP.run(self._conn.drop_view(name, namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
|
||||
|
||||
@override
|
||||
def list_jobs(self) -> List["JobInfo"]:
|
||||
"""List server-side jobs across the database's tables."""
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Views: a named query a database stores and plans on every read.
|
||||
|
||||
A view holds no rows. What it stores is the statement that defines it and the
|
||||
schema that statement resolved to, so a reader sees the sources as they are
|
||||
now. See ``DBConnection.create_view``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow as pa
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViewDescription:
|
||||
"""What a database records about one view."""
|
||||
|
||||
name: str
|
||||
"""The view's name within its namespace."""
|
||||
query: str
|
||||
"""The defining query, as the database stores it."""
|
||||
default_database: str
|
||||
"""The database that unqualified table names in ``query`` resolve against."""
|
||||
schema: "pa.Schema"
|
||||
"""The schema the defining query resolved to when the view was created."""
|
||||
namespace_path: List[str] = field(default_factory=list)
|
||||
"""The namespace holding the view; empty is the root namespace."""
|
||||
default_namespace_path: List[str] = field(default_factory=list)
|
||||
"""The namespace path those unqualified names resolve against.
|
||||
|
||||
Recorded with the view because it outlives the session that declared it: a
|
||||
reader resolving the query against its own default namespace could read a
|
||||
different table than the view was defined over.
|
||||
"""
|
||||
@@ -2747,3 +2747,57 @@ def test_remote_job_handle_reports_its_own_detail():
|
||||
"limit": 500,
|
||||
"filter": "state = 'claim_complete'",
|
||||
}
|
||||
|
||||
|
||||
def test_view_crud_addresses_its_own_routes():
|
||||
# The view verbs are their own routes, and the schema comes back in the
|
||||
# namespace spec's JSON encoding, decoded into a pyarrow schema.
|
||||
paths = []
|
||||
|
||||
def handler(request):
|
||||
paths.append((request.command, request.path))
|
||||
if request.path.endswith("/view/list"):
|
||||
body = {"views": ["adults"]}
|
||||
elif request.path.endswith("/drop"):
|
||||
body = {}
|
||||
else:
|
||||
body = {
|
||||
"name": "adults",
|
||||
"namespace": ["analytics"],
|
||||
"query": "SELECT name FROM people",
|
||||
"default_database": "dev",
|
||||
"default_namespace": ["analytics"],
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"name": "name", "nullable": True, "type": {"type": "utf8"}}
|
||||
]
|
||||
},
|
||||
}
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(json.dumps(body).encode())
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
view = db.create_view(
|
||||
"adults", "SELECT name FROM people", namespace_path=["analytics"]
|
||||
)
|
||||
assert view.name == "adults"
|
||||
assert view.namespace_path == ["analytics"]
|
||||
assert view.query == "SELECT name FROM people"
|
||||
assert view.default_database == "dev"
|
||||
assert view.default_namespace_path == ["analytics"]
|
||||
assert view.schema == pa.schema([pa.field("name", pa.utf8(), nullable=True)])
|
||||
|
||||
described = db.describe_view("adults", namespace_path=["analytics"])
|
||||
assert described.schema == view.schema
|
||||
|
||||
assert db.list_views(namespace_path=["analytics"]) == ["adults"]
|
||||
db.drop_view("adults", namespace_path=["analytics"])
|
||||
|
||||
assert paths == [
|
||||
("POST", "/v1/view/analytics$adults/create"),
|
||||
("POST", "/v1/view/analytics$adults/describe"),
|
||||
("GET", "/v1/namespace/analytics/view/list"),
|
||||
("POST", "/v1/view/analytics$adults/drop"),
|
||||
]
|
||||
|
||||
@@ -13,7 +13,11 @@ use crate::{
|
||||
runtime::future_into_py,
|
||||
table::Table,
|
||||
};
|
||||
use arrow::{datatypes::Schema, ffi_stream::ArrowArrayStreamReader, pyarrow::FromPyArrow};
|
||||
use arrow::{
|
||||
datatypes::Schema,
|
||||
ffi_stream::ArrowArrayStreamReader,
|
||||
pyarrow::{FromPyArrow, ToPyArrow},
|
||||
};
|
||||
use lancedb::{
|
||||
connection::Connection as LanceConnection,
|
||||
connection::NamespaceClientPushdownOperation,
|
||||
@@ -100,6 +104,28 @@ fn parse_default_namespace_path(path: Option<Bound<'_, PyAny>>) -> PyResult<Vec<
|
||||
}
|
||||
}
|
||||
|
||||
/// A view description on its way to Python: name, namespace, query, default
|
||||
/// database, and the schema as pyarrow renders it.
|
||||
type PyViewDescription = (String, Vec<String>, String, String, Vec<String>, Py<PyAny>);
|
||||
|
||||
/// A view description as a plain tuple, with the schema converted to the
|
||||
/// pyarrow schema the caller would get from any other lancedb API. The Python
|
||||
/// layer names the fields; this keeps the binding free of a class that would
|
||||
/// have to be kept in step with the Rust struct.
|
||||
fn view_description_to_py(view: lancedb::view::ViewDescription) -> PyResult<PyViewDescription> {
|
||||
Python::attach(|py| {
|
||||
let schema = view.schema.to_pyarrow(py)?.unbind();
|
||||
Ok((
|
||||
view.name,
|
||||
view.namespace_path,
|
||||
view.query,
|
||||
view.default_database,
|
||||
view.default_namespace_path,
|
||||
schema,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl Connection {
|
||||
fn __repr__(&self) -> String {
|
||||
@@ -862,6 +888,66 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, query, namespace_path=None))]
|
||||
pub fn create_view(
|
||||
self_: PyRef<'_, Self>,
|
||||
name: String,
|
||||
query: String,
|
||||
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 {
|
||||
let view = inner
|
||||
.create_view(name, query, &namespace_path)
|
||||
.await
|
||||
.infer_error()?;
|
||||
view_description_to_py(view)
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
pub fn describe_view(
|
||||
self_: PyRef<'_, Self>,
|
||||
name: String,
|
||||
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 {
|
||||
let view = inner
|
||||
.describe_view(name, &namespace_path)
|
||||
.await
|
||||
.infer_error()?;
|
||||
view_description_to_py(view)
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
pub fn drop_view(
|
||||
self_: PyRef<'_, Self>,
|
||||
name: String,
|
||||
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.drop_view(name, &namespace_path).await.infer_error()
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (namespace_path=None))]
|
||||
pub fn list_views(
|
||||
self_: PyRef<'_, 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()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.get_inner()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
|
||||
Reference in New Issue
Block a user