mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user