mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-26 05:26:14 +00:00
Remote materialized-view creation currently expects a REST response containing a registry job ID, while refresh already submits SQL. Route both through the SQL query backend and preserve Python's synchronous and asynchronous return types. ### Query to Job mapping `SqlClient::submit_as_job()` submits an `ExecuteQueryRequest` through the same backend as `execute_query_async()`. Once submission succeeds, it retains the returned `Query` and starts one client-side task that consumes its result reader to completion. `Job::from_query_task()` wraps that task, its cached outcome, a single ID and a cancellation callback. Python exposes this as `Job[T]` or `AsyncJob[T]`. The existing `job_type` property maps SQL-backed handles to the new `"query"` kind. The shared `id` contains that query's UUID string, so Python callers can inspect it with `db.describe_query(job.id)` on the submitting connection. No separate `query_id`, backend enum or `type()` API is added. | Handle | `job_type` | `id` | Available lookup | | --- | --- | --- | --- | | SQL-backed | `"query"`, known at submission | Connection-scoped query UUID string | `describe_query(job.id)` | | Registry-backed | Existing server-reported kind, after `status()` or `refresh()` | Registry job ID string | Registry job APIs, descriptions and events | | Local | `None` | `None` | The current in-process handle | | Job concept | SQL-backed behavior | | --- | --- | | Submission | Await initial SQL submission before returning the handle. Submission errors propagate immediately. | | `status()` | Tracks the adapter task: running during result consumption/finalization, finished after both succeed, failed if either fails, or cancelled after cancellation. | | `wait()` | Returns the operation's typed terminal result. One task consumes the query reader once; repeated waits reuse its cached outcome without resubmitting SQL or reopening the reader. | | `cancel()` | Delegates to `Query.cancel()`. Rejected or unsupported cancellation leaves the task running; an accepted request stops the task. Cancelling a finished operation is a no-op. | | Registry details | Query-backed handles expose no registry description, event history, pause/resume or reattachment after a client restart. `refresh()` updates cached lifecycle state. | The Job tracks command completion rather than exposing SQL batches. A wait timeout does not cancel the query. Query IDs remain scoped to the submitting connection and cannot be used with registry APIs or as durable resume tokens. Python `describe_query()` accepts both UUID objects and the string `id` exposed by query-backed jobs. Rust, Python and TypeScript expose the same classification through their existing job-type accessors. ### Materialized-view operations - **CREATE:** Render `CREATE MATERIALIZED VIEW` with the view namespace and optional auto-refresh settings. After command completion, resolve the job to `None`. Synchronous creation waits on the job, then opens and returns the materialized view. - **REFRESH:** Submit `REFRESH MATERIALIZED VIEW` through the same adapter. After command completion, read the refreshed table's version and row count to construct the existing typed refresh result. Synchronous refresh waits on the same path. Remote creation requires the SQL service. Execution placement remains a server decision. REST endpoints are unchanged. The job guide documents the ID/job-type contract and query-backed limitations. Regression coverage exercises SQL submission, namespaces, refresh options, job-type/ID preservation through typed result mapping, repeated waits, failures and cancellation rejection. No local builds or tests were run.
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import * as tmp from "tmp";
|
|
|
|
import { Connection, connect } from "../lancedb";
|
|
import {
|
|
DEFINITION_META_KEY,
|
|
definitionFromMetadata,
|
|
} from "../lancedb/materialized_view";
|
|
|
|
describe("materialized views", () => {
|
|
let tmpDir: tmp.DirResult;
|
|
let db: Connection;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
|
db = await connect(tmpDir.name);
|
|
await db.createTable("people", [
|
|
{ name: "ada", age: 36 },
|
|
{ name: "kid", age: 7 },
|
|
{ name: "grace", age: 85 },
|
|
]);
|
|
});
|
|
afterEach(() => tmpDir.removeCallback());
|
|
|
|
it("reads stored queries and legacy layouts", () => {
|
|
const read = (stored: string) =>
|
|
definitionFromMetadata(new Map([[DEFINITION_META_KEY, stored]]), "v");
|
|
const query =
|
|
"SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1";
|
|
expect(read(`{"format":1,"query":${JSON.stringify(query)}}`).query).toBe(
|
|
query,
|
|
);
|
|
|
|
// The structured layout written before the format number reads as the
|
|
// query it described, under either of its kind tags.
|
|
expect(
|
|
read(
|
|
'{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"],' +
|
|
'"projections":[{"output":"name","expression":"`name`"},' +
|
|
'{"output":"Shout","expression":"upper(name)"}],"filter":"age >= 18","limit":42}',
|
|
).query,
|
|
).toBe(
|
|
"SELECT `name`, upper(name) AS `Shout` FROM ns.people WHERE age >= 18 LIMIT 42",
|
|
);
|
|
expect(read('{"kind":"select","source_table":"people"}').query).toBe(
|
|
"SELECT * FROM people",
|
|
);
|
|
|
|
// A newer writer's layout is reported, never guessed at.
|
|
for (const newer of [
|
|
`{"format":3,"query":${JSON.stringify(query)}}`,
|
|
'{"kind":"select_v3","source_table":"people"}',
|
|
]) {
|
|
expect(() => read(newer)).toThrow(/cannot refresh/);
|
|
}
|
|
});
|
|
|
|
it("rejects a stored limit a number cannot carry", () => {
|
|
const big = new Map([
|
|
[
|
|
DEFINITION_META_KEY,
|
|
'{"kind":"select","source_table":"people","limit":9007199254740993}',
|
|
],
|
|
]);
|
|
expect(() => definitionFromMetadata(big, "v")).toThrow(
|
|
/too large to represent exactly/,
|
|
);
|
|
});
|
|
|
|
it("creates, refreshes and queries a view", async () => {
|
|
const view = await db.createMaterializedView("adults", "people", {
|
|
select: ["name", ["shout", "upper(name)"]],
|
|
where: "age >= 18",
|
|
});
|
|
expect(view.name).toBe("adults");
|
|
expect(await view.table().countRows()).toBe(2);
|
|
|
|
const rows = await view.table().query().toArray();
|
|
expect(rows.map((r) => r.shout).sort()).toEqual(["ADA", "GRACE"]);
|
|
});
|
|
|
|
it("round-trips the definition", async () => {
|
|
await db.createMaterializedView("adults", "people", {
|
|
where: "age >= 18",
|
|
});
|
|
const view = await db.openMaterializedView("adults");
|
|
const definition = await view.definition();
|
|
expect(definition.query).toBe(
|
|
"SELECT name, age FROM people WHERE age >= 18",
|
|
);
|
|
});
|
|
|
|
it("fully refreshes after an append", async () => {
|
|
const view = await db.createMaterializedView("copy", "people", {
|
|
withNoData: true,
|
|
});
|
|
await view.refresh();
|
|
|
|
const people = await db.openTable("people");
|
|
await people.add([{ name: "alan", age: 41 }]);
|
|
const result = await view.refresh();
|
|
expect(result.mode).toBe("rebuild");
|
|
expect(Number(result.rowsWritten)).toBe(4);
|
|
expect(await view.table().countRows()).toBe(4);
|
|
|
|
expect((await view.refresh()).mode).toBe("rebuild");
|
|
});
|
|
|
|
it("lists views and rejects non-views", async () => {
|
|
await db.createMaterializedView("adults", "people", {
|
|
where: "age >= 18",
|
|
});
|
|
expect(await collect(db.listMaterializedViews())).toEqual(["adults"]);
|
|
await expect(db.openMaterializedView("people")).rejects.toThrow(
|
|
"not a materialized view",
|
|
);
|
|
await expect(db.dropMaterializedView("people")).rejects.toThrow(
|
|
"not a materialized view",
|
|
);
|
|
|
|
await db.dropMaterializedView("adults");
|
|
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
|
});
|
|
|
|
it("returns a job when dropping a view asynchronously", async () => {
|
|
await db.createMaterializedView("adults", "people");
|
|
|
|
const job = await db.dropMaterializedViewAsync("adults");
|
|
expect(job.id).toBeNull();
|
|
expect(job.jobType).toBeNull();
|
|
await job.wait();
|
|
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
|
});
|
|
|
|
it("rejects an invalid expression at create time", async () => {
|
|
await expect(
|
|
db.createMaterializedView("bad", "people", {
|
|
select: [["x", "missing + 1"]],
|
|
}),
|
|
).rejects.toThrow("missing");
|
|
});
|
|
|
|
it("rejects invalid numeric options before creating anything", async () => {
|
|
for (const limit of [-5, 1.5, Infinity, NaN]) {
|
|
await expect(
|
|
db.createMaterializedView("bad", "people", { limit }),
|
|
).rejects.toThrow("non-negative integer");
|
|
}
|
|
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
|
|
|
const view = await db.createMaterializedView("copy", "people");
|
|
for (const sourceVersion of [-1, 1.5, Infinity, NaN]) {
|
|
await expect(view.refresh({ sourceVersion })).rejects.toThrow(
|
|
"non-negative integer",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("quotes bare select names", async () => {
|
|
await db.createTable("odd_names", [{ "order item": "widget" }]);
|
|
const view = await db.createMaterializedView("quoted", "odd_names", {
|
|
select: ["order item"],
|
|
withNoData: true,
|
|
});
|
|
const result = await view.refresh();
|
|
expect(Number(result.rowsWritten)).toBe(1);
|
|
});
|
|
|
|
it("does not require stable row ids on the source", async () => {
|
|
await db.createTable("plain", [{ x: 1 }]);
|
|
const view = await db.createMaterializedView("v", "plain");
|
|
expect(await view.table().countRows()).toBe(1);
|
|
});
|
|
});
|
|
|
|
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
|
const result: T[] = [];
|
|
for await (const item of items) result.push(item);
|
|
return result;
|
|
}
|