mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-29 06:56:15 +00:00
fix: gate experimental local functions behind an opt-in feature (#4688)
Published SDK builds currently expose local SQL computed columns and materialized-view execution that are not ready for release. Add the opt-in `experimental-functions` Cargo feature to the Rust core and forward it through the Python and Node bindings; published packages leave it disabled. Local declarations and refreshes now return `NotSupported` before mutating tables or spawning jobs, including prepared materialized-view creation. Remote Cloud/Enterprise client operations, Python UDF packaging, ordinary eager `add_columns`, and access to existing data remain available. Stored metadata and write protections stay active, and shared server commit helpers remain usable. CI covers both feature configurations. Existing local execution tests run with the feature enabled; default-build regressions cover early rejection, unchanged table versions, existing metadata protections, and normal eager transforms. API and source-build documentation explain the opt-in. Validation: Rust library tests pass with the feature disabled (1,869 tests) and enabled (1,947 tests), plus three default-build integration regressions. Python and TypeScript bindings were rebuilt and tested in both configurations, including mocked remote requests; Rust formatting, Python package lint, TypeScript lint/build, and generated docs were checked. Repository-wide Ruff still reports 48 pre-existing errors in unchanged `docs/web-tests` files.
This commit is contained in:
@@ -50,6 +50,19 @@ pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Local SQL computed columns and materialized-view creation/refresh are experimental
|
||||
and disabled in published packages. To opt in when building from this directory:
|
||||
|
||||
```shell
|
||||
pnpm build:debug --features experimental-functions
|
||||
pnpm tsc
|
||||
pnpm test --runInBand table.test.ts materialized_view.test.ts
|
||||
```
|
||||
|
||||
Rebuild without `--features experimental-functions` to test the default package.
|
||||
The tests detect the compiled feature; remote operations, metadata protections,
|
||||
and ordinary eager `addColumns` transforms remain available in both builds.
|
||||
|
||||
Lint:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -48,3 +48,4 @@ napi-build = "2.3.1"
|
||||
default = ["remote", "lancedb/aws", "lancedb/gcs", "lancedb/azure", "lancedb/dynamodb", "lancedb/oss", "lancedb/huggingface", "lancedb/goosefs", "lancedb/metrics-otel"]
|
||||
fp16kernels = ["lancedb/fp16kernels"]
|
||||
remote = ["lancedb/remote"]
|
||||
experimental-functions = ["lancedb/experimental-functions"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { connect } from "../lancedb";
|
||||
import { experimentalFunctionsEnabled } from "../lancedb/native";
|
||||
|
||||
const defaultBuildTest = experimentalFunctionsEnabled() ? it.skip : it;
|
||||
|
||||
defaultBuildTest(
|
||||
"rejects local Functions before mutating the database",
|
||||
async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "lancedb-experimental-"));
|
||||
try {
|
||||
const db = await connect(directory);
|
||||
const table = await db.createTable("source", [{ x: 1 }, { x: 2 }]);
|
||||
const version = await table.version();
|
||||
const operations = [
|
||||
() =>
|
||||
table.addColumns({
|
||||
computed: [{ name: "doubled", valueSql: "x * 2" }],
|
||||
}),
|
||||
() => table.refreshColumn("x"),
|
||||
() => table.refreshColumnAsync("x"),
|
||||
() => db.createMaterializedView("blocked", "source"),
|
||||
() =>
|
||||
db.createMaterializedView("blocked", "source", { withNoData: true }),
|
||||
];
|
||||
for (const operation of operations) {
|
||||
await expect(operation()).rejects.toThrow("experimental-functions");
|
||||
}
|
||||
expect(await table.version()).toBe(version);
|
||||
expect(await db.tableNames()).toEqual(["source"]);
|
||||
await table.addColumns([{ name: "eager", valueSql: "x * 2" }]);
|
||||
expect((await table.query().toArray()).map((row) => row.eager)).toEqual([
|
||||
2, 4,
|
||||
]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
DEFINITION_META_KEY,
|
||||
definitionFromMetadata,
|
||||
} from "../lancedb/materialized_view";
|
||||
import { experimentalFunctionsEnabled } from "../lancedb/native";
|
||||
|
||||
describe("materialized views", () => {
|
||||
const experimentalTest = experimentalFunctionsEnabled() ? it : it.skip;
|
||||
let tmpDir: tmp.DirResult;
|
||||
let db: Connection;
|
||||
|
||||
@@ -69,7 +71,7 @@ describe("materialized views", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates, refreshes and queries a view", async () => {
|
||||
experimentalTest("creates, refreshes and queries a view", async () => {
|
||||
const view = await db.createMaterializedView("adults", "people", {
|
||||
select: ["name", ["shout", "upper(name)"]],
|
||||
where: "age >= 18",
|
||||
@@ -81,7 +83,7 @@ describe("materialized views", () => {
|
||||
expect(rows.map((r) => r.shout).sort()).toEqual(["ADA", "GRACE"]);
|
||||
});
|
||||
|
||||
it("round-trips the definition", async () => {
|
||||
experimentalTest("round-trips the definition", async () => {
|
||||
await db.createMaterializedView("adults", "people", {
|
||||
where: "age >= 18",
|
||||
});
|
||||
@@ -92,7 +94,7 @@ describe("materialized views", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fully refreshes after an append", async () => {
|
||||
experimentalTest("fully refreshes after an append", async () => {
|
||||
const view = await db.createMaterializedView("copy", "people", {
|
||||
withNoData: true,
|
||||
});
|
||||
@@ -108,7 +110,7 @@ describe("materialized views", () => {
|
||||
expect((await view.refresh()).mode).toBe("rebuild");
|
||||
});
|
||||
|
||||
it("lists views and rejects non-views", async () => {
|
||||
experimentalTest("lists views and rejects non-views", async () => {
|
||||
await db.createMaterializedView("adults", "people", {
|
||||
where: "age >= 18",
|
||||
});
|
||||
@@ -124,17 +126,20 @@ describe("materialized views", () => {
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a job when dropping a view asynchronously", async () => {
|
||||
await db.createMaterializedView("adults", "people");
|
||||
experimentalTest(
|
||||
"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([]);
|
||||
});
|
||||
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 () => {
|
||||
experimentalTest("rejects an invalid expression at create time", async () => {
|
||||
await expect(
|
||||
db.createMaterializedView("bad", "people", {
|
||||
select: [["x", "missing + 1"]],
|
||||
@@ -149,16 +154,21 @@ describe("materialized views", () => {
|
||||
).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 () => {
|
||||
experimentalTest(
|
||||
"rejects invalid source versions before refreshing",
|
||||
async () => {
|
||||
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",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
experimentalTest("quotes bare select names", async () => {
|
||||
await db.createTable("odd_names", [{ "order item": "widget" }]);
|
||||
const view = await db.createMaterializedView("quoted", "odd_names", {
|
||||
select: ["order item"],
|
||||
@@ -168,11 +178,14 @@ describe("materialized views", () => {
|
||||
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);
|
||||
});
|
||||
experimentalTest(
|
||||
"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[]> {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
register,
|
||||
} from "../lancedb/embedding";
|
||||
import { Index } from "../lancedb/indices";
|
||||
import { experimentalFunctionsEnabled } from "../lancedb/native";
|
||||
import {
|
||||
BooleanQuery,
|
||||
Occur,
|
||||
@@ -5477,13 +5478,14 @@ describe("LSM convergence and stats", () => {
|
||||
});
|
||||
|
||||
describe("computed columns", () => {
|
||||
const experimentalTest = experimentalFunctionsEnabled() ? it : it.skip;
|
||||
let tmpDir: tmp.DirResult;
|
||||
beforeEach(() => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
});
|
||||
afterEach(() => tmpDir.removeCallback());
|
||||
|
||||
it("declares a column and fills it on refresh", async () => {
|
||||
experimentalTest("declares a column and fills it on refresh", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("computed", [{ x: 1 }, { x: 2 }]);
|
||||
|
||||
@@ -5508,7 +5510,7 @@ describe("computed columns", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a job handle from refreshColumnAsync", async () => {
|
||||
experimentalTest("returns a job handle from refreshColumnAsync", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("computed_job", [{ x: 1 }, { x: 2 }]);
|
||||
|
||||
@@ -5530,7 +5532,7 @@ describe("computed columns", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fills rows added since the last refresh", async () => {
|
||||
experimentalTest("fills rows added since the last refresh", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("computed_append", [{ x: 1 }]);
|
||||
|
||||
|
||||
@@ -270,6 +270,10 @@ export abstract class Connection {
|
||||
/**
|
||||
* Define a materialized view named `name` over the table `source`.
|
||||
*
|
||||
* Local creation and refresh require a source build with the
|
||||
* `experimental-functions` Cargo feature, disabled in published packages.
|
||||
* Remote Cloud/Enterprise operations do not require this feature.
|
||||
*
|
||||
* The view is populated before creation returns. Set `withNoData` to create
|
||||
* only its definition and empty backing table. The view is a normal table:
|
||||
* it can be queried, indexed and searched, and it appears in `tableNames`.
|
||||
|
||||
@@ -186,6 +186,7 @@ export class MaterializedView {
|
||||
|
||||
/**
|
||||
* Recompute the view from its source.
|
||||
* Local refresh requires a source build with `experimental-functions`.
|
||||
*
|
||||
* Every refresh rebuilds the complete view. For local views,
|
||||
* `sourceVersion` refreshes to that source version instead of the latest;
|
||||
|
||||
@@ -594,6 +594,10 @@ export abstract class Table {
|
||||
/**
|
||||
* Add new columns with defined values.
|
||||
*
|
||||
* Local computed-column declaration and refresh require a source build with
|
||||
* the `experimental-functions` Cargo feature. Published packages disable it;
|
||||
* remote Cloud/Enterprise operations remain available.
|
||||
*
|
||||
* The `{ computed }` form stores the expression rather than evaluating it
|
||||
* now: the column is committed with no values, and rows get them from
|
||||
* {@link Table#refreshColumn}. Declaring one therefore costs the same on a
|
||||
@@ -632,6 +636,7 @@ export abstract class Table {
|
||||
|
||||
/**
|
||||
* Fill the rows of a computed column that hold no value yet.
|
||||
* Local refresh requires a source build with `experimental-functions`.
|
||||
*
|
||||
* Rows appended since the last refresh are filled by the next one, and
|
||||
* rows whose inputs changed since they were computed are recomputed;
|
||||
@@ -646,6 +651,8 @@ export abstract class Table {
|
||||
/**
|
||||
* Like {@link Table#refreshColumn}, but returns a handle to the refresh
|
||||
* job instead of blocking until it completes.
|
||||
* Local refresh requires a source build with `experimental-functions`.
|
||||
* Remote Cloud/Enterprise jobs do not require this feature.
|
||||
*
|
||||
* The job may already be complete when returned; callers must not assume
|
||||
* the column is filled until {@link Job.wait} resolves. Invalid input --
|
||||
|
||||
@@ -30,6 +30,12 @@ mod session;
|
||||
mod table;
|
||||
mod util;
|
||||
|
||||
/// Whether this native binding was built with local experimental Functions.
|
||||
#[napi]
|
||||
pub fn experimental_functions_enabled() -> bool {
|
||||
cfg!(feature = "experimental-functions")
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectionOptions {
|
||||
|
||||
Reference in New Issue
Block a user