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:
Jack Ye
2026-09-23 15:09:40 +08:00
committed by GitHub
parent dd2539c2ca
commit 8541d6d9df
22 changed files with 1283 additions and 4 deletions
+93 -1
View File
@@ -37,7 +37,11 @@ use crate::remote::{
},
};
use crate::secrets::SecretInfo;
use crate::utils::{validate_secret_component, validate_secret_reference};
use crate::utils::{
validate_namespace, validate_secret_component, validate_secret_reference,
validate_view_reference,
};
use crate::view::ViewDescription;
use lance::io::ObjectStoreParams;
pub use lance_file::version::LanceFileVersion;
#[cfg(feature = "remote")]
@@ -747,6 +751,94 @@ impl Connection {
.await
}
/// Create a view: a named query the database plans on every read.
///
/// The query is planned once, here, 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, each authorized against what it
/// actually touches. Local databases return [`Error::NotSupported`].
///
/// # Example
///
/// ```no_run
/// # async fn view_lifecycle(
/// # connection: &lancedb::Connection,
/// # ) -> Result<(), Box<dyn std::error::Error>> {
/// let namespace = vec!["analytics".to_string()];
///
/// let view = connection
/// .create_view(
/// "recent_orders",
/// "SELECT id, total FROM orders WHERE total > 100",
/// &namespace,
/// )
/// .await?;
/// println!("{} has {} columns", view.name, view.schema.fields().len());
///
/// // The query comes back as it was recorded, with the defaults its
/// // unqualified names resolve against.
/// let described = connection.describe_view("recent_orders", &namespace).await?;
/// println!("{} in {:?}", described.query, described.default_namespace_path);
///
/// let names = connection.list_views(&namespace).await?;
/// assert!(names.iter().any(|name| name == "recent_orders"));
///
/// // Dropping the view leaves `orders` untouched.
/// connection.drop_view("recent_orders", &namespace).await?;
/// # Ok(())
/// # }
/// ```
pub async fn create_view(
&self,
name: impl AsRef<str>,
query: impl AsRef<str>,
namespace_path: &[String],
) -> Result<ViewDescription> {
validate_view_reference(name.as_ref(), namespace_path)?;
self.internal
.create_view(name.as_ref(), query.as_ref(), namespace_path)
.await
}
/// What this database records about one 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, not here. Local databases return
/// [`Error::NotSupported`].
pub async fn describe_view(
&self,
name: impl AsRef<str>,
namespace_path: &[String],
) -> Result<ViewDescription> {
validate_view_reference(name.as_ref(), namespace_path)?;
self.internal
.describe_view(name.as_ref(), namespace_path)
.await
}
/// Drop a view.
///
/// The tables it reads are untouched: a view holds no rows of its own.
/// Local databases return [`Error::NotSupported`].
pub async fn drop_view(&self, name: impl AsRef<str>, namespace_path: &[String]) -> Result<()> {
validate_view_reference(name.as_ref(), namespace_path)?;
self.internal.drop_view(name.as_ref(), namespace_path).await
}
/// The names of the views in one namespace.
///
/// Names only; a definition is query metadata and comes from
/// [`Self::describe_view`]. The client walks all server pages before
/// returning. Local databases return [`Error::NotSupported`].
pub async fn list_views(&self, namespace_path: &[String]) -> Result<Vec<String>> {
validate_namespace(namespace_path)?;
self.internal.list_views(namespace_path).await
}
/// Rename a table in the database.
///
/// This is only supported in LanceDB Cloud.
+36
View File
@@ -32,6 +32,7 @@ use crate::job::Job;
use crate::materialized_view::CreateMaterializedViewRequest;
use crate::secrets::SecretInfo;
use crate::table::{BaseTable, WriteOptions};
use crate::view::ViewDescription;
pub mod listing;
pub mod namespace;
@@ -258,6 +259,12 @@ fn secret_catalog_not_supported<T>() -> Result<T> {
})
}
fn view_ops_not_supported<T>() -> Result<T> {
Err(crate::error::Error::NotSupported {
message: "View operations are not supported by this database".to_string(),
})
}
/// The `Database` trait defines the interface for database implementations.
///
/// A database is responsible for managing tables and their metadata.
@@ -443,6 +450,35 @@ pub trait Database:
async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<SecretInfo> {
secret_catalog_not_supported()
}
/// Create a view from a defining query. The query is planned once, by the
/// database, so one that cannot be planned is refused now rather than at
/// the first read.
async fn create_view(
&self,
_name: &str,
_query: &str,
_namespace_path: &[String],
) -> Result<ViewDescription> {
view_ops_not_supported()
}
/// What the database records about one view: its defining query and the
/// schema that query resolved to.
async fn describe_view(
&self,
_name: &str,
_namespace_path: &[String],
) -> Result<ViewDescription> {
view_ops_not_supported()
}
/// Drop a view. Its sources are untouched -- a view holds no rows of its
/// own.
async fn drop_view(&self, _name: &str, _namespace_path: &[String]) -> Result<()> {
view_ops_not_supported()
}
/// The names of the views in one namespace.
async fn list_views(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
view_ops_not_supported()
}
/// Open a job by id, returning a handle with its record already
/// populated. Fails with [`crate::Error::JobNotFound`] when the server has
/// no such job.
+1
View File
@@ -202,6 +202,7 @@ pub mod table;
#[cfg(test)]
pub mod test_utils;
pub mod utils;
pub mod view;
use std::{fmt::Display, str::FromStr};
+289 -2
View File
@@ -14,8 +14,8 @@ use reqwest::header::CONTENT_TYPE;
use lance_namespace::models::{
CreateNamespaceRequest, CreateNamespaceResponse, DescribeNamespaceRequest,
DescribeNamespaceResponse, DropNamespaceRequest, DropNamespaceResponse, ListNamespacesRequest,
ListNamespacesResponse, ListTablesRequest, ListTablesResponse,
DescribeNamespaceResponse, DropNamespaceRequest, DropNamespaceResponse, JsonArrowSchema,
ListNamespacesRequest, ListNamespacesResponse, ListTablesRequest, ListTablesResponse,
};
use crate::Error;
@@ -36,6 +36,7 @@ use crate::secrets::SecretBinding;
use crate::secrets::SecretInfo;
use crate::table::BaseTable;
use crate::utils::{reject_relative_segment, validate_table_name};
use crate::view::ViewDescription;
use super::client::{
ClientConfig, HeaderProvider, HttpSend, ID_DELIMITER, RequestResultExt, RestfulLanceDbClient,
@@ -784,6 +785,59 @@ struct RemoteListedSecret {
name: String,
}
/// Define a view from a query. The name and its namespace are the path
/// identifier, so neither appears here.
#[derive(serde::Serialize)]
struct RemoteCreateViewRequest<'a> {
query: &'a str,
}
/// What the service reports about one view. The schema arrives as the
/// namespace spec's JSON encoding, which is what `describe_table` uses too.
#[derive(serde::Deserialize)]
struct RemoteViewDescription {
name: String,
#[serde(default)]
namespace: Vec<String>,
query: String,
default_database: String,
/// A path, like `namespace`: the root is the absent field rather than a
/// spelling of its own.
#[serde(default)]
default_namespace: Vec<String>,
schema: JsonArrowSchema,
}
impl RemoteViewDescription {
fn into_description(self, request_id: String) -> Result<ViewDescription> {
let schema =
lance_namespace::schema::convert_json_arrow_schema(&self.schema).map_err(|source| {
Error::Http {
source: format!("View '{}' has an undecodable schema: {source}", self.name)
.into(),
request_id,
status_code: None,
}
})?;
Ok(ViewDescription {
name: self.name,
namespace_path: self.namespace,
query: self.query,
default_database: self.default_database,
default_namespace_path: self.default_namespace,
schema: Arc::new(schema),
})
}
}
#[derive(serde::Deserialize)]
struct RemoteListViewsResponse {
#[serde(default)]
views: Vec<String>,
#[serde(default)]
page_token: Option<String>,
}
/// Bound on `list_jobs` page walking; a warning is logged when the listing
/// is truncated at this many pages.
const MAX_LIST_JOBS_PAGES: usize = 100;
@@ -1122,6 +1176,79 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
response.json().await.err_to_http(request_id)
}
async fn create_view(
&self,
name: &str,
query: &str,
namespace_path: &[String],
) -> Result<ViewDescription> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/view/{view_id}/create"))
.json(&RemoteCreateViewRequest { query });
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
let description: RemoteViewDescription =
response.json().await.err_to_http(request_id.clone())?;
description.into_description(request_id)
}
async fn describe_view(
&self,
name: &str,
namespace_path: &[String],
) -> Result<ViewDescription> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let req = self.client.post(&format!("/v1/view/{view_id}/describe"));
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
let description: RemoteViewDescription =
response.json().await.err_to_http(request_id.clone())?;
description.into_description(request_id)
}
async fn drop_view(&self, name: &str, namespace_path: &[String]) -> Result<()> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let req = self.client.post(&format!("/v1/view/{view_id}/drop"));
let (request_id, response) = self.client.send(req).await?;
self.client.check_response(&request_id, response).await?;
Ok(())
}
async fn list_views(&self, namespace_path: &[String]) -> Result<Vec<String>> {
let namespace_id = build_namespace_identifier(namespace_path)?;
let path = format!("/v1/namespace/{namespace_id}/view/list");
let mut views = Vec::new();
let mut page_token: Option<String> = None;
let mut seen_page_tokens = HashSet::new();
loop {
let mut req = self.client.get(&path);
if let Some(token) = &page_token {
req = req.query(&[("page_token", token)]);
}
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
let status = response.status();
let response: RemoteListViewsResponse =
response.json().await.err_to_http(request_id.clone())?;
views.extend(response.views);
let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty())
else {
break;
};
if !seen_page_tokens.insert(next_page_token.clone()) {
return Err(Error::Http {
source: "View listing response repeated a page_token".into(),
request_id,
status_code: Some(status),
});
}
page_token = Some(next_page_token);
}
Ok(views)
}
async fn open_job(&self, job_id: &str) -> Result<Job> {
let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string());
match crate::job::JobHandle::describe(&handle).await {
@@ -3798,6 +3925,166 @@ mod tests {
.unwrap();
}
/// A view description carries the schema in the namespace spec's JSON
/// encoding, so the body a test asserts against is the one that encoding
/// produces rather than a hand-written guess at it.
fn view_description_body(name: &str, namespace: &[&str], query: &str) -> String {
let schema = Schema::new(vec![Field::new("id", DataType::Int32, true)]);
serde_json::json!({
"name": name,
"namespace": namespace,
"query": query,
"default_database": "db",
"default_namespace": namespace,
"schema": lance_namespace::schema::arrow_schema_to_json(&schema).unwrap(),
})
.to_string()
}
#[tokio::test]
async fn test_create_view_posts_the_query_and_returns_the_resolved_schema() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/view/analytics$adults/create");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
// The name and its namespace address the view in the path, so the
// body says only what they cannot.
assert_eq!(body, serde_json::json!({"query": "SELECT id FROM people"}));
http::Response::builder()
.status(200)
.body(view_description_body(
"adults",
&["analytics"],
"SELECT id FROM people",
))
.unwrap()
});
let view = conn
.create_view("adults", "SELECT id FROM people", &["analytics".into()])
.await
.unwrap();
assert_eq!(view.name, "adults");
assert_eq!(view.namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.query, "SELECT id FROM people");
assert_eq!(view.default_database, "db");
assert_eq!(view.default_namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.schema.field(0).name(), "id");
}
#[tokio::test]
async fn test_describe_and_drop_address_the_view_in_the_path() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/view/adults/describe");
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder()
.status(200)
.body(view_description_body(
"adults",
&[],
"SELECT id FROM people",
))
.unwrap()
});
let view = conn.describe_view("adults", &[]).await.unwrap();
assert!(view.namespace_path.is_empty());
assert_eq!(view.schema.fields().len(), 1);
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/view/analytics$adults/drop");
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_view("adults", &["analytics".into()])
.await
.unwrap();
}
/// A schema the client cannot decode is a broken response, not a view
/// with no columns: reporting it as an error keeps a caller from reading
/// an empty schema as the truth about the view.
#[tokio::test]
async fn test_describe_view_rejects_an_undecodable_schema() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"name":"adults","query":"SELECT 1","default_database":"db",
"schema":{"fields":[{"name":"id","type":{"type":"nonesuch"},
"nullable":true}]}}"#,
)
.unwrap()
});
let error = conn.describe_view("adults", &[]).await.unwrap_err();
assert!(error.to_string().contains("undecodable schema"), "{error}");
}
#[tokio::test]
async fn test_list_views_walks_pages() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/analytics/view/list");
let page = request
.url()
.query_pairs()
.find(|(key, _)| key == "page_token")
.map(|(_, value)| value.into_owned());
let body = match page.as_deref() {
None => r#"{"views":[],"page_token":"p2"}"#,
Some("p2") => r#"{"views":["adults"]}"#,
Some(other) => panic!("unexpected page token: {other}"),
};
http::Response::builder().status(200).body(body).unwrap()
});
assert_eq!(
conn.list_views(&["analytics".into()]).await.unwrap(),
vec!["adults".to_string()]
);
}
/// A server that keeps handing back the same token would otherwise spin
/// forever.
#[tokio::test]
async fn test_list_views_rejects_a_repeated_page_token() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"views":["adults"],"page_token":"same"}"#)
.unwrap()
});
let error = conn.list_views(&[]).await.unwrap_err();
assert!(
error.to_string().contains("repeated a page_token"),
"{error}"
);
}
/// A name carrying the delimiter would split back apart as a different
/// view, so it is refused before it reaches a route.
#[tokio::test]
async fn test_view_names_that_would_resplit_are_refused() {
let conn = Connection::new_with_handler(|_| -> http::Response<String> {
panic!("an invalid identifier must not reach the service")
});
for (name, namespace) in [
("analytics$adults", vec![]),
("adults", vec!["ana$lytics".to_string()]),
("", vec![]),
("..", vec![]),
] {
let error = match conn.describe_view(name, &namespace).await {
Ok(_) => panic!("accepted {name:?} in {namespace:?}"),
Err(error) => error.to_string(),
};
// A view is not a table, so the refusal says so.
assert!(
error.contains("view name") || error.contains("view namespace path segment"),
"{error}"
);
}
}
#[tokio::test]
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
const REQUEST: &str = include_str!(
+21
View File
@@ -174,6 +174,27 @@ pub fn validate_secret_reference(name: &str, namespace_path: &[String]) -> Resul
validate_secret_component("Secret name", name)
}
/// Validate a view name and every segment of the namespace path holding it.
///
/// Worded for a view rather than deferring to [`validate_table_name`]: a view
/// is not a table, and a caller who mistypes one should not be told their
/// table name is invalid. The rule is the same one every object name follows,
/// which is what lets the `$`-joined identifier split back apart.
pub fn validate_view_reference(name: &str, namespace_path: &[String]) -> Result<()> {
for segment in namespace_path {
validate_view_component("view namespace path segment", segment)?;
}
validate_view_component("view name", name)
}
/// Validate one component of a view identifier: a view name, or one segment of
/// the namespace path holding it.
pub fn validate_view_component(what: &str, value: &str) -> Result<()> {
check_object_name(value).map_err(|reason| Error::InvalidInput {
message: format!("invalid {what} '{value}': {reason}"),
})
}
/// Validate all components of a namespace
///
/// Iterates through all namespace components and validates each one.
+44
View File
@@ -0,0 +1,44 @@
// 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 rather than as they were when the view was created. That is the
//! whole difference from [`crate::materialized_view`], which holds rows and
//! moves them forward by refresh.
//!
//! The verbs live on [`crate::connection::Connection`]. A view is read through
//! SQL, by name, so there is no `open_view` returning a
//! [`crate::table::Table`]: there would be no rows behind it.
use arrow_schema::SchemaRef;
/// What a database records about one view.
///
/// Returned by [`crate::connection::Connection::describe_view`], and by
/// `create_view` so a caller has the resolved schema without a second call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewDescription {
/// The view's name within its namespace.
pub name: String,
/// The namespace holding the view; empty is the root namespace.
pub namespace_path: Vec<String>,
/// The defining query, as the database stores it.
pub query: String,
/// The database that unqualified table names in `query` resolve against.
/// A view created from a session connected elsewhere can name a database
/// other than its own, so this is part of what the query means rather
/// than a restatement of where the view lives.
pub default_database: String,
/// The namespace path those unqualified names resolve against; empty is
/// the root namespace.
///
/// Recorded with the view because it outlives the session that declared
/// it: a reader that resolved the query against its own default namespace
/// could read a different table than the view was defined over.
pub default_namespace_path: Vec<String>,
/// The schema the defining query resolved to when the view was created.
pub schema: SchemaRef,
}