feat: add view CRUD APIs

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.

Adds `create_view`, `describe_view`, `drop_view` and `list_views` on
`Connection` and the `Database` trait, the remote client that speaks
`/v1/view/{id}/*` and `/v1/namespace/{id}/view/list`, and the Python and
Node bindings. Local databases return `NotSupported`; the server side
lives in Sophon.

`ViewDescription` carries the defining query, the database 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. There is no replace: changing a view is a drop and a create.

Querying a view stays SQL's job -- there are no rows behind it, so there
is no `open_view` returning a `Table`.
This commit is contained in:
Jack Ye
2026-09-22 10:06:01 -07:00
parent e5683f82fe
commit aeea54ff48
17 changed files with 1004 additions and 4 deletions
+57
View File
@@ -100,6 +100,63 @@ describe("remote connection", () => {
);
});
it("creates a view and decodes the schema it resolved to", async () => {
await withMockDatabase(
(req, res) => {
expect(req.method).toBe("POST");
expect(req.url).toBe("/v1/view/analytics$adults/create");
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
name: "adults",
namespace: ["analytics"],
query: "SELECT name FROM people",
default_database: "db",
schema: {
fields: [{ name: "name", nullable: true, type: { type: "utf8" } }],
},
}),
);
},
async (db) => {
const view = await db.createView(
"adults",
"SELECT name FROM people",
["analytics"],
);
expect(view.name).toBe("adults");
expect(view.namespacePath).toEqual(["analytics"]);
expect(view.query).toBe("SELECT name FROM people");
expect(view.defaultDatabase).toBe("db");
expect(view.schema.fields.map((f) => f.name)).toEqual(["name"]);
},
);
});
it("lists and drops views through their own routes", async () => {
await withMockDatabase(
(req, res) => {
expect(req.url).toBe("/v1/namespace/$/view/list");
res
.writeHead(200, { "content-type": "application/json" })
.end(JSON.stringify({ views: ["adults"] }));
},
async (db) => {
expect(await db.listViews()).toEqual(["adults"]);
},
);
await withMockDatabase(
(req, res) => {
expect(req.method).toBe("POST");
expect(req.url).toBe("/v1/view/adults/drop");
res.writeHead(200, { "content-type": "application/json" }).end("{}");
},
async (db) => {
await db.dropView("adults");
},
);
});
it("should accept partial connection options", async () => {
await connect("db://test", {
apiKey: "fake",
+67
View File
@@ -23,6 +23,7 @@ import {
validateNonNegativeInteger,
} from "./materialized_view";
import { Connection as LanceDbConnection } from "./native";
import { ViewDescription, viewDescriptionFromNative } from "./view";
import type {
CreateNamespaceResponse,
DescribeNamespaceResponse,
@@ -377,6 +378,45 @@ export abstract class Connection {
namespacePath?: string[],
): Promise<Job>;
/**
* 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
* rejected 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.
*/
abstract createView(
name: string,
query: string,
namespacePath?: string[],
): Promise<ViewDescription>;
/**
* What this database records about the view named `name`: its defining
* query and the schema that query resolved to.
*/
abstract describeView(
name: string,
namespacePath?: string[],
): Promise<ViewDescription>;
/**
* Drop the view named `name`.
*
* The tables it reads are untouched: a view holds no rows of its own.
*/
abstract dropView(name: string, namespacePath?: string[]): Promise<void>;
/**
* The names of the views in one namespace.
*
* Names only; a definition comes from {@link describeView}.
*/
abstract listViews(namespacePath?: string[]): Promise<string[]>;
abstract openTable(
name: string,
namespacePath?: string[],
@@ -696,6 +736,33 @@ export class LocalConnection extends Connection {
);
}
async createView(
name: string,
query: string,
namespacePath?: string[],
): Promise<ViewDescription> {
return viewDescriptionFromNative(
await this.inner.createView(name, query, namespacePath ?? []),
);
}
async describeView(
name: string,
namespacePath?: string[],
): Promise<ViewDescription> {
return viewDescriptionFromNative(
await this.inner.describeView(name, namespacePath ?? []),
);
}
async dropView(name: string, namespacePath?: string[]): Promise<void> {
return this.inner.dropView(name, namespacePath ?? []);
}
async listViews(namespacePath?: string[]): Promise<string[]> {
return this.inner.listViews(namespacePath ?? []);
}
async listTables(
namespacePathOrOptions?: string[] | Partial<ListTablesOptions>,
options?: Partial<ListTablesOptions>,
+1
View File
@@ -26,6 +26,7 @@ export {
MaterializedViewDefinition,
MaterializedViewSelect,
} from "./materialized_view";
export { ViewDescription } from "./view";
export { JsHeaderProvider as NativeJsHeaderProvider } from "./native.js";
// OpenTelemetry metrics bridge. Only the high-level entry point is public; the
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { Schema, tableFromIPC } from "apache-arrow";
import { ViewDescription as NativeViewDescription } from "./native";
/**
* What a database records about one view.
*
* A view holds no rows: it stores the statement that defines it and the
* schema that statement resolved to, so a reader sees its sources as they
* are at read time.
*/
export interface ViewDescription {
/** The view's name within its namespace. */
name: string;
/** The namespace holding the view; empty is the root namespace. */
namespacePath: string[];
/** The defining query, as the database stores it. */
query: string;
/** The database that unqualified table names in {@link query} resolve against. */
defaultDatabase: string;
/** The schema the defining query resolved to when the view was created. */
schema: Schema;
}
/** Decode the schema the binding hands over as an Arrow IPC file. */
export function viewDescriptionFromNative(
view: NativeViewDescription,
): ViewDescription {
return {
name: view.name,
namespacePath: view.namespacePath,
query: view.query,
defaultDatabase: view.defaultDatabase,
schema: tableFromIPC(view.schema).schema,
};
}
+81
View File
@@ -55,6 +55,30 @@ pub struct DropNamespaceResponse {
pub transaction_id: Option<Vec<String>>,
}
/// What a database records about one view.
#[napi(object)]
pub struct ViewDescription {
pub name: String,
pub namespace_path: Vec<String>,
pub query: String,
pub default_database: String,
/// The view's schema as an empty Arrow IPC file, the way a table reports
/// its own.
pub schema: Buffer,
}
impl ViewDescription {
fn from_inner(view: lancedb::view::ViewDescription) -> napi::Result<Self> {
Ok(Self {
name: view.name,
namespace_path: view.namespace_path,
query: view.query,
default_database: view.default_database,
schema: crate::util::schema_to_buffer(&view.schema)?,
})
}
}
impl Connection {
pub(crate) fn inner_new(inner: LanceDBConnection) -> Self {
Self { inner: Some(inner) }
@@ -368,6 +392,63 @@ impl Connection {
.default_error()
}
/// Create a view: a named query planned on every read.
#[napi(catch_unwind)]
pub async fn create_view(
&self,
name: String,
query: String,
namespace_path: Option<Vec<String>>,
) -> napi::Result<ViewDescription> {
let ns = namespace_path.unwrap_or_default();
let view = self
.get_inner()?
.create_view(&name, &query, &ns)
.await
.default_error()?;
ViewDescription::from_inner(view)
}
/// What the database records about one view.
#[napi(catch_unwind)]
pub async fn describe_view(
&self,
name: String,
namespace_path: Option<Vec<String>>,
) -> napi::Result<ViewDescription> {
let ns = namespace_path.unwrap_or_default();
let view = self
.get_inner()?
.describe_view(&name, &ns)
.await
.default_error()?;
ViewDescription::from_inner(view)
}
/// Drop a view. The tables it reads are untouched.
#[napi(catch_unwind)]
pub async fn drop_view(
&self,
name: String,
namespace_path: Option<Vec<String>>,
) -> napi::Result<()> {
let ns = namespace_path.unwrap_or_default();
self.get_inner()?
.drop_view(&name, &ns)
.await
.default_error()
}
/// The names of the views in one namespace.
#[napi(catch_unwind)]
pub async fn list_views(
&self,
namespace_path: Option<Vec<String>>,
) -> napi::Result<Vec<String>> {
let ns = namespace_path.unwrap_or_default();
self.get_inner()?.list_views(&ns).await.default_error()
}
/// Start dropping a materialized view and return its cleanup job.
#[napi(catch_unwind)]
pub async fn drop_materialized_view_async(