mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-29 14:56:51 +00:00
Merge main into gatekeeper/fix-4299-1
This commit is contained in:
+1454
-1458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
// Every Apache Arrow release the package accepts as a peer dependency, as
|
||||
// separately installed copies so the tests can feed objects built by one
|
||||
// Arrow version into a LanceDB running on another. When a new Arrow major
|
||||
// is released, add the alias to `package.json` and extend this list; the
|
||||
// test files pick the change up from here.
|
||||
import * as arrow15 from "apache-arrow-15";
|
||||
import * as arrow16 from "apache-arrow-16";
|
||||
import * as arrow17 from "apache-arrow-17";
|
||||
import * as arrow18 from "apache-arrow-18";
|
||||
import * as arrow19 from "apache-arrow-19";
|
||||
import * as arrow20 from "apache-arrow-20";
|
||||
import * as arrow21 from "apache-arrow-21";
|
||||
|
||||
export { arrow15, arrow16, arrow17, arrow18, arrow19, arrow20, arrow21 };
|
||||
|
||||
export type ApacheArrow =
|
||||
| typeof arrow15
|
||||
| typeof arrow16
|
||||
| typeof arrow17
|
||||
| typeof arrow18
|
||||
| typeof arrow19
|
||||
| typeof arrow20
|
||||
| typeof arrow21;
|
||||
|
||||
export const arrowVersions: ApacheArrow[] = [
|
||||
arrow15,
|
||||
arrow16,
|
||||
arrow17,
|
||||
arrow18,
|
||||
arrow19,
|
||||
arrow20,
|
||||
arrow21,
|
||||
];
|
||||
|
||||
/** The newest supported Arrow release, for tests that only need one. */
|
||||
export const latestArrow = arrow21;
|
||||
+316
-13
@@ -1,9 +1,25 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { Field, Int64, List, Schema, Struct, Utf8 } from "apache-arrow";
|
||||
import { makeArrowTable } from "../lancedb/arrow";
|
||||
import {
|
||||
Field,
|
||||
Float,
|
||||
Float32,
|
||||
Int32,
|
||||
Int64,
|
||||
List,
|
||||
Schema,
|
||||
Struct,
|
||||
Utf8,
|
||||
} from "apache-arrow";
|
||||
import {
|
||||
convertToTable,
|
||||
makeArrowTable,
|
||||
resolveBlobInputs,
|
||||
} from "../lancedb/arrow";
|
||||
import { BlobFile, blob, coerceBlobValue, isBlobField } from "../lancedb/blob";
|
||||
import { EmbeddingFunction, LanceSchema } from "../lancedb/embedding";
|
||||
import { getRegistry } from "../lancedb/embedding/registry";
|
||||
|
||||
describe("blob()", () => {
|
||||
it("marks the field as lance.blob.v2", () => {
|
||||
@@ -46,6 +62,13 @@ describe("blob()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
class BlobStruct {
|
||||
constructor(
|
||||
public data?: unknown,
|
||||
public uri?: unknown,
|
||||
) {}
|
||||
}
|
||||
|
||||
describe("coerceBlobValue", () => {
|
||||
it.each([
|
||||
["Buffer", Buffer.from("x"), { data: Buffer.from("x"), uri: null }],
|
||||
@@ -65,6 +88,41 @@ describe("coerceBlobValue", () => {
|
||||
{ uri: "s3://bucket/key" },
|
||||
{ data: null, uri: "s3://bucket/key" },
|
||||
],
|
||||
[
|
||||
"ArrayBuffer",
|
||||
new Uint8Array([120]).buffer,
|
||||
{ data: new Uint8Array([120]), uri: null },
|
||||
],
|
||||
[
|
||||
"URL",
|
||||
new URL("https://example.com/blob"),
|
||||
{ data: null, uri: "https://example.com/blob" },
|
||||
],
|
||||
[
|
||||
"data struct with ArrayBuffer",
|
||||
{ data: new Uint8Array([121]).buffer },
|
||||
{ data: new Uint8Array([121]), uri: null },
|
||||
],
|
||||
[
|
||||
"uri struct with URL",
|
||||
{ uri: new URL("file:///tmp/a.png") },
|
||||
{ data: null, uri: "file:///tmp/a.png" },
|
||||
],
|
||||
[
|
||||
"class instance with Uint8Array data",
|
||||
new BlobStruct(new Uint8Array([122])),
|
||||
{ data: new Uint8Array([122]), uri: null },
|
||||
],
|
||||
[
|
||||
"class instance with ArrayBuffer data",
|
||||
new BlobStruct(new Uint8Array([122]).buffer),
|
||||
{ data: new Uint8Array([122]), uri: null },
|
||||
],
|
||||
[
|
||||
"class instance with URL uri",
|
||||
new BlobStruct(undefined, new URL("s3://bucket/key")),
|
||||
{ data: null, uri: "s3://bucket/key" },
|
||||
],
|
||||
["null", null, null],
|
||||
])("accepts %s", (_name, input, expected) => {
|
||||
expect(coerceBlobValue(input)).toEqual(expected);
|
||||
@@ -76,7 +134,7 @@ describe("coerceBlobValue", () => {
|
||||
[
|
||||
"Int16Array",
|
||||
new Int16Array([1]),
|
||||
/Blob data must be Buffer or Uint8Array/,
|
||||
/Blob data must be Buffer, Uint8Array, ArrayBuffer, or Blob/,
|
||||
],
|
||||
[
|
||||
"both data and uri",
|
||||
@@ -88,11 +146,160 @@ describe("coerceBlobValue", () => {
|
||||
{ data: null, uri: null },
|
||||
/exactly one of 'data' or 'uri'/,
|
||||
],
|
||||
["Blob", new Blob(["x"]), /must be read asynchronously/],
|
||||
["data struct with Blob", { data: new Blob(["x"]) }, /asynchronously/],
|
||||
[
|
||||
"class instance with Blob data",
|
||||
new BlobStruct(new Blob(["x"])),
|
||||
/asynchronously/,
|
||||
],
|
||||
[
|
||||
"unknown struct field",
|
||||
{ data: Buffer.from("y"), mime: "image/png" },
|
||||
/Blob struct values only support 'data' and 'uri', got 'mime'/,
|
||||
],
|
||||
[
|
||||
"misspelled struct field",
|
||||
{ uri: "s3://bucket/key", dat: Buffer.from("y") },
|
||||
/got 'dat'/,
|
||||
],
|
||||
[
|
||||
"SharedArrayBuffer",
|
||||
new SharedArrayBuffer(2),
|
||||
/Blob column values must be Buffer, Uint8Array, ArrayBuffer, Blob/,
|
||||
],
|
||||
["Date", new Date(0), /Blob column values must be/],
|
||||
])("rejects %s", (_name, input, message) => {
|
||||
expect(() => coerceBlobValue(input)).toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlobInputs", () => {
|
||||
it("reads Blob and File values in blob columns only", async () => {
|
||||
const schema = new Schema([
|
||||
new Field("name", new Utf8(), true),
|
||||
blob("image"),
|
||||
new Field("images", new List(blob("image")), true),
|
||||
]);
|
||||
const shared = new Blob(["same"]);
|
||||
const notABlobColumn = new Blob(["other"]);
|
||||
const data = [
|
||||
{
|
||||
name: notABlobColumn,
|
||||
image: new File(["file"], "a.txt"),
|
||||
images: [shared, { data: shared }, null],
|
||||
},
|
||||
{ name: "x", image: Buffer.from("buf"), images: null },
|
||||
];
|
||||
const resolved = await resolveBlobInputs(data, schema);
|
||||
|
||||
expect(resolved[0].name).toBe(notABlobColumn);
|
||||
expect(Buffer.from(resolved[0].image as Uint8Array).toString()).toBe(
|
||||
"file",
|
||||
);
|
||||
const images = resolved[0].images as [Uint8Array, { data: Uint8Array }];
|
||||
expect(Buffer.from(images[0]).toString()).toBe("same");
|
||||
expect(Buffer.from(images[1].data).toString()).toBe("same");
|
||||
// Rows without Blob values and the caller's records are left alone.
|
||||
expect(resolved[1]).toBe(data[1]);
|
||||
expect(data[0].images?.[0]).toBe(shared);
|
||||
});
|
||||
|
||||
it("reads each distinct Blob once, bare or inside data structs", async () => {
|
||||
class CountingBlob extends Blob {
|
||||
calls = 0;
|
||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
this.calls++;
|
||||
return super.arrayBuffer();
|
||||
}
|
||||
}
|
||||
const shared = new CountingBlob(["same"]);
|
||||
const schema = new Schema([blob("image")]);
|
||||
const resolved = await resolveBlobInputs(
|
||||
[
|
||||
{ image: { data: shared } },
|
||||
{ image: { data: shared } },
|
||||
{ image: shared },
|
||||
],
|
||||
schema,
|
||||
);
|
||||
expect(shared.calls).toBe(1);
|
||||
const first = (resolved[0].image as { data: Uint8Array }).data;
|
||||
expect(Buffer.from(first).toString()).toBe("same");
|
||||
expect((resolved[1].image as { data: Uint8Array }).data).toBe(first);
|
||||
expect(resolved[2].image).toBe(first);
|
||||
});
|
||||
|
||||
it("reads a Blob held by a class instance", async () => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const [row] = await resolveBlobInputs(
|
||||
[{ image: new BlobStruct(new Blob(["hi"])) }],
|
||||
schema,
|
||||
);
|
||||
expect(
|
||||
Buffer.from((row.image as { data: Uint8Array }).data).toString(),
|
||||
).toBe("hi");
|
||||
});
|
||||
|
||||
it("returns the input when there is nothing to read", async () => {
|
||||
const data = [{ image: Buffer.from("x") }];
|
||||
const schema = new Schema([blob("image")]);
|
||||
await expect(resolveBlobInputs(data, schema)).resolves.toBe(data);
|
||||
await expect(resolveBlobInputs(data)).resolves.toBe(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToTable blob columns with embedding functions", () => {
|
||||
// Embedding metadata routes rows through ensureNestedFieldsExist, which must
|
||||
// leave blob values for the blob coercion instead of treating them as structs.
|
||||
class LengthEmbedding extends EmbeddingFunction<string> {
|
||||
ndims() {
|
||||
return 2;
|
||||
}
|
||||
embeddingDataType(): Float {
|
||||
return new Float32();
|
||||
}
|
||||
async computeSourceEmbeddings(data: string[]) {
|
||||
return data.map((text) => [text.length, 1]);
|
||||
}
|
||||
}
|
||||
getRegistry().register("blob-test-length")(LengthEmbedding);
|
||||
const func = getRegistry().get<LengthEmbedding>("blob-test-length")!.create();
|
||||
const embeddingSchema = LanceSchema({
|
||||
text: func.sourceField(new Utf8()),
|
||||
vector: func.vectorField(),
|
||||
});
|
||||
const schema = new Schema(
|
||||
[...embeddingSchema.fields, blob("image")],
|
||||
embeddingSchema.metadata,
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Uint8Array", new Uint8Array([104, 105]), "hi", null],
|
||||
["ArrayBuffer", new Uint8Array([104, 105]).buffer, "hi", null],
|
||||
["Blob", new Blob(["hi"]), "hi", null],
|
||||
["URI string", "s3://b/k", null, "s3://b/k"],
|
||||
["URL", new URL("s3://b/k"), null, "s3://b/k"],
|
||||
["data struct", { data: new Uint8Array([104, 105]) }, "hi", null],
|
||||
])("accepts %s input", async (_name, image, data, uri) => {
|
||||
const table = await convertToTable([{ text: "a", image }], undefined, {
|
||||
schema,
|
||||
});
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(0);
|
||||
const bytes = column.getChild("data")!.get(0);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(0)).toBe(uri);
|
||||
});
|
||||
|
||||
it("writes a missing blob as null", async () => {
|
||||
const table = await convertToTable([{ text: "a" }], undefined, { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(1);
|
||||
expect(column.get(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlobFile", () => {
|
||||
it("rejects constructing BlobFile without a native handle", () => {
|
||||
expect(() => new (BlobFile as unknown as { new (): BlobFile })()).toThrow(
|
||||
@@ -103,26 +310,122 @@ describe("BlobFile", () => {
|
||||
|
||||
describe("makeArrowTable blob columns", () => {
|
||||
it.each([
|
||||
["ArrayBuffer", new Uint8Array([104]).buffer],
|
||||
["Blob", new Blob(["hello"])],
|
||||
["File", new File(["hello"], "hello.txt")],
|
||||
["URL", new URL("https://example.com/blob")],
|
||||
["ReadableStream", new ReadableStream()],
|
||||
["data struct with ArrayBuffer", { data: new Uint8Array([104]).buffer }],
|
||||
["uri struct with URL", { uri: new URL("https://example.com/blob") }],
|
||||
["Int16Array", new Int16Array([1])],
|
||||
])("rejects %s input rather than writing a null blob", (_name, image) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
expect(() => makeArrowTable([{ image }], { schema })).toThrow(/image/);
|
||||
});
|
||||
|
||||
it("rejects Blob instances with enumerable fields as one invalid value", () => {
|
||||
it.each([
|
||||
["Blob", new Blob(["hello"])],
|
||||
["File", new File(["hello"], "hello.txt")],
|
||||
["data struct with Blob", { data: new Blob(["hello"]) }],
|
||||
[
|
||||
"Blob with enumerable fields",
|
||||
Object.assign(new Blob(["hello"]), { position: 0 }),
|
||||
],
|
||||
])("points %s input at the async APIs", (_name, image) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const image = Object.assign(new Blob(["hello"]), { position: 0 });
|
||||
expect(() => makeArrowTable([{ image }], { schema })).toThrow(
|
||||
"Unsupported object value for field image at row 0.",
|
||||
expect(() => makeArrowTable([{ id: 1, image }], { schema })).toThrow(
|
||||
/blob field image at row 0: Blob and File values must be read asynchronously/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ArrayBuffer", new Uint8Array([104, 105]).buffer, "hi", null],
|
||||
[
|
||||
"data struct with ArrayBuffer",
|
||||
{ data: new Uint8Array([104, 105]).buffer },
|
||||
"hi",
|
||||
null,
|
||||
],
|
||||
["URL", new URL("https://example.com/a"), null, "https://example.com/a"],
|
||||
[
|
||||
"uri struct with URL",
|
||||
{ uri: new URL("file:///tmp/a.png") },
|
||||
null,
|
||||
"file:///tmp/a.png",
|
||||
],
|
||||
])("accepts %s input", (_name, image, data, uri) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const input = { image };
|
||||
const table = makeArrowTable([input], { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(0);
|
||||
const bytes = column.getChild("data")!.get(0);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(0)).toBe(uri);
|
||||
// The caller's record is left untouched.
|
||||
expect(input.image).toBe(image);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Uint8Array", new Uint8Array([104, 105]), "hi", null],
|
||||
["URI string", "s3://b/k", null, "s3://b/k"],
|
||||
["data struct", { data: new Uint8Array([104, 105]) }, "hi", null],
|
||||
])("accepts %s input after a null row", (_name, image, data, uri) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const table = makeArrowTable([{ image: null }, { image }], { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(1);
|
||||
expect(column.get(0)).toBeNull();
|
||||
const bytes = column.getChild("data")!.get(1);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(1)).toBe(uri);
|
||||
});
|
||||
|
||||
it("accepts a null struct before one holding a blob", () => {
|
||||
const schema = new Schema([
|
||||
new Field("info", new Struct([blob("image")]), true),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[{ info: null }, { info: { image: new Uint8Array([97]) } }],
|
||||
{ schema },
|
||||
);
|
||||
const info = table.getChild("info")!;
|
||||
expect(info.get(0)).toBeNull();
|
||||
const image = (info.get(1) as { image: { data: Uint8Array } }).image;
|
||||
expect(Buffer.from(image.data).toString()).toBe("a");
|
||||
});
|
||||
|
||||
it("skips a missing blob column named like an Object.prototype member", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int32(), true),
|
||||
blob("constructor"),
|
||||
]);
|
||||
const table = makeArrowTable([{ id: 1 }], { schema });
|
||||
expect(table.schema.fields.map((f) => f.name)).toEqual(["id"]);
|
||||
});
|
||||
|
||||
it("accepts ArrayBuffer and URL inside lists and structs", () => {
|
||||
const schema = new Schema([
|
||||
new Field("images", new List(blob("image")), true),
|
||||
new Field("info", new Struct([blob("image")]), true),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[
|
||||
{
|
||||
images: [new Uint8Array([97]).buffer, new URL("s3://b/k")],
|
||||
info: { image: new Uint8Array([98]).buffer },
|
||||
},
|
||||
],
|
||||
{ schema },
|
||||
);
|
||||
const images = Array.from(
|
||||
table.getChild("images")!.get(0) as Iterable<{
|
||||
data: Uint8Array | null;
|
||||
uri: string | null;
|
||||
}>,
|
||||
);
|
||||
expect(Buffer.from(images[0].data!).toString()).toBe("a");
|
||||
expect(images[1].uri).toBe("s3://b/k");
|
||||
const info = table.getChild("info")!.get(0) as {
|
||||
image: { data: Uint8Array };
|
||||
};
|
||||
expect(Buffer.from(info.image.data).toString()).toBe("b");
|
||||
});
|
||||
|
||||
it("coerces Buffer input onto a blob field", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
|
||||
@@ -80,9 +80,12 @@ describe("remote catalog", () => {
|
||||
const second = await catalog.connectDatabase("other");
|
||||
expect(await second.tableNames()).toEqual([]);
|
||||
expect(await first.tableNames()).toEqual([]);
|
||||
expect(
|
||||
await catalog.listDatabases({ limit: 1, pageToken: "a/b" }),
|
||||
).toEqual({ databases: ["team/search"], pageToken: "next" });
|
||||
const names = catalog.listDatabases({ pageLimit: 1, pageToken: "a/b" });
|
||||
expect(await names.next()).toEqual({
|
||||
done: false,
|
||||
value: "team/search",
|
||||
});
|
||||
expect(names.pageToken()).toBe("next");
|
||||
await catalog.dropDatabase("team/search", { ignoreMissing: true });
|
||||
expect(requests[0].url).toBe("/v1/namespace/team%2Fsearch/create");
|
||||
expect(requests[0].body).toEqual({ mode: "ExistOk" });
|
||||
@@ -129,11 +132,139 @@ describe("remote catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily traverses pages, including empty pages, and exposes cached state", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a", "b"], page_token: "empty" }],
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: [], page_token: "last" }],
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["c", "d"], page_token: "" }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases({ pageLimit: 2 });
|
||||
expect(names[Symbol.asyncIterator]()).toBe(names);
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
expect(await names.next()).toEqual({ done: false, value: "a" });
|
||||
expect(names.numPageResults()).toBe(1);
|
||||
expect(names.pageToken()).toBe("empty");
|
||||
expect(await names.next()).toEqual({ done: false, value: "b" });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(await names.next()).toEqual({ done: false, value: "c" });
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
expect(names.numPageResults()).toBe(1);
|
||||
const remaining = [];
|
||||
for await (const name of names) remaining.push(name);
|
||||
expect(remaining).toEqual(["d"]);
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(await names.next()).toEqual({ done: true, value: undefined });
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"/v1/namespace/$/list?limit=2",
|
||||
"/v1/namespace/$/list?limit=2&page_token=empty",
|
||||
"/v1/namespace/$/list?limit=2&page_token=last",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes from a saved token after draining the cache", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a", "b"], page_token: "next/token" }],
|
||||
[200, { namespaces: ["c"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases({
|
||||
pageToken: "start/token",
|
||||
pageLimit: 2,
|
||||
});
|
||||
expect(names.pageToken()).toBe("start/token");
|
||||
expect(await names.next()).toEqual({ done: false, value: "a" });
|
||||
while (names.numPageResults() > 0) await names.next();
|
||||
const resumed = catalog.listDatabases({
|
||||
pageToken: names.pageToken(),
|
||||
pageLimit: 2,
|
||||
});
|
||||
const results = [];
|
||||
for await (const name of resumed) results.push(name);
|
||||
expect(results).toEqual(["c"]);
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"/v1/namespace/$/list?limit=2&page_token=start%2Ftoken",
|
||||
"/v1/namespace/$/list?limit=2&page_token=next%2Ftoken",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates on errors and retains the failed request token", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a"], page_token: "retry" }],
|
||||
[400, {}],
|
||||
[200, { namespaces: ["b"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases();
|
||||
await names.next();
|
||||
await expect(names.next()).rejects.toThrow();
|
||||
expect(names.pageToken()).toBe("retry");
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(await names.next()).toEqual({ done: true, value: undefined });
|
||||
expect(requests).toHaveLength(2);
|
||||
const resumed = catalog.listDatabases({ pageToken: names.pageToken() });
|
||||
expect(await resumed.next()).toEqual({ done: false, value: "b" });
|
||||
expect(await resumed.next()).toEqual({ done: true, value: undefined });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("handles an empty listing and concurrent advances", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
[200, { namespaces: [] }],
|
||||
[200, { namespaces: ["a", "b"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const empty = catalog.listDatabases();
|
||||
expect(await empty.next()).toEqual({ done: true, value: undefined });
|
||||
expect(await empty.next()).toEqual({ done: true, value: undefined });
|
||||
const names = catalog.listDatabases();
|
||||
const pending = [names.next(), names.next(), names.next()];
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
const results = await Promise.all(pending);
|
||||
expect(
|
||||
results
|
||||
.filter((result) => !result.done)
|
||||
.map((result) => result.value)
|
||||
.sort(),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(results.filter((result) => result.done)).toHaveLength(1);
|
||||
expect(requests).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("validates endpoints and pagination", async () => {
|
||||
await expect(connectCatalog("/tmp/catalog")).rejects.toThrow();
|
||||
const catalog = await connectCatalog("http://127.0.0.1:1");
|
||||
for (const limit of [0, -1, 1.5, 2147483648]) {
|
||||
await expect(catalog.listDatabases({ limit })).rejects.toThrow("limit");
|
||||
for (const limit of [
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
2147483648,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]) {
|
||||
expect(() => catalog.listDatabases({ pageLimit: limit })).toThrow(
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
await expect(catalog.connectDatabase("a$b")).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
import { mkdirSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { Field, Float64, Schema } from "apache-arrow";
|
||||
import * as tmp from "tmp";
|
||||
import {
|
||||
@@ -180,6 +181,32 @@ describe("given a connection", () => {
|
||||
expect(seen).toEqual(created);
|
||||
});
|
||||
|
||||
it("should list all names and use a default page size of 100", async () => {
|
||||
const names = Array.from(
|
||||
{ length: 130 },
|
||||
(_, i) => `t${i.toString().padStart(3, "0")}`,
|
||||
);
|
||||
for (const name of names) {
|
||||
mkdirSync(join(tmpDir.name, `${name}.lance`));
|
||||
}
|
||||
await expect(db.tableNames()).resolves.toEqual(names);
|
||||
await expect(db.tableNames({ limit: 0 })).resolves.toEqual([]);
|
||||
const first = await db.listTables();
|
||||
expect(first.tables).toEqual(names.slice(0, 100));
|
||||
expect(first.pageToken).toBeDefined();
|
||||
const second = await db.listTables({ pageToken: first.pageToken });
|
||||
expect(second.tables).toEqual(names.slice(100));
|
||||
expect(second.pageToken).toBeUndefined();
|
||||
await expect(db.listTables({ limit: 200 })).resolves.toEqual({
|
||||
tables: names,
|
||||
pageToken: undefined,
|
||||
});
|
||||
await expect(db.listTables({ limit: 0 })).resolves.toEqual({
|
||||
tables: [],
|
||||
pageToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list tables in a namespace", async () => {
|
||||
const db = await connect(tmpDir.name, {
|
||||
// biome-ignore lint/style/useNamingConvention: opaque backend property key, must match Rust
|
||||
|
||||
@@ -16,15 +16,11 @@ describe("materialized views", () => {
|
||||
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 },
|
||||
],
|
||||
{ storageOptions: { newTableEnableStableRowIds: "true" } },
|
||||
);
|
||||
await db.createTable("people", [
|
||||
{ name: "ada", age: 36 },
|
||||
{ name: "kid", age: 7 },
|
||||
{ name: "grace", age: 85 },
|
||||
]);
|
||||
});
|
||||
afterEach(() => tmpDir.removeCallback());
|
||||
|
||||
@@ -52,19 +48,9 @@ describe("materialized views", () => {
|
||||
"SELECT * FROM people",
|
||||
);
|
||||
|
||||
for (const [format, fn] of [
|
||||
[3, "vector_duplicate_pairs"],
|
||||
[4, "vector_dedup"],
|
||||
]) {
|
||||
const native = `SELECT * FROM ${fn}('images', 3, 'phash', 4)`;
|
||||
expect(read(JSON.stringify({ format, query: native })).query).toBe(
|
||||
native,
|
||||
);
|
||||
}
|
||||
|
||||
// A newer writer's layout is reported, never guessed at.
|
||||
for (const newer of [
|
||||
`{"format":5,"query":${JSON.stringify(query)}}`,
|
||||
`{"format":3,"query":${JSON.stringify(query)}}`,
|
||||
'{"kind":"select_v3","source_table":"people"}',
|
||||
]) {
|
||||
expect(() => read(newer)).toThrow(/cannot refresh/);
|
||||
@@ -106,7 +92,7 @@ describe("materialized views", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes incrementally after an append", async () => {
|
||||
it("fully refreshes after an append", async () => {
|
||||
const view = await db.createMaterializedView("copy", "people", {
|
||||
withNoData: true,
|
||||
});
|
||||
@@ -115,18 +101,18 @@ describe("materialized views", () => {
|
||||
const people = await db.openTable("people");
|
||||
await people.add([{ name: "alan", age: 41 }]);
|
||||
const result = await view.refresh();
|
||||
expect(result.mode).toBe("incremental");
|
||||
expect(Number(result.rowsWritten)).toBe(1);
|
||||
expect(result.mode).toBe("rebuild");
|
||||
expect(Number(result.rowsWritten)).toBe(4);
|
||||
expect(await view.table().countRows()).toBe(4);
|
||||
|
||||
expect((await view.refresh()).mode).toBe("no_op");
|
||||
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 db.listMaterializedViews()).toEqual(["adults"]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual(["adults"]);
|
||||
await expect(db.openMaterializedView("people")).rejects.toThrow(
|
||||
"not a materialized view",
|
||||
);
|
||||
@@ -135,7 +121,7 @@ describe("materialized views", () => {
|
||||
);
|
||||
|
||||
await db.dropMaterializedView("adults");
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a job when dropping a view asynchronously", async () => {
|
||||
@@ -144,7 +130,7 @@ describe("materialized views", () => {
|
||||
const job = await db.dropMaterializedViewAsync("adults");
|
||||
expect(job.id).toBeNull();
|
||||
await job.wait();
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid expression at create time", async () => {
|
||||
@@ -161,7 +147,7 @@ describe("materialized views", () => {
|
||||
db.createMaterializedView("bad", "people", { limit }),
|
||||
).rejects.toThrow("non-negative integer");
|
||||
}
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
|
||||
const view = await db.createMaterializedView("copy", "people");
|
||||
for (const sourceVersion of [-1, 1.5, Infinity, NaN]) {
|
||||
@@ -172,9 +158,7 @@ describe("materialized views", () => {
|
||||
});
|
||||
|
||||
it("quotes bare select names", async () => {
|
||||
await db.createTable("odd_names", [{ "order item": "widget" }], {
|
||||
storageOptions: { newTableEnableStableRowIds: "true" },
|
||||
});
|
||||
await db.createTable("odd_names", [{ "order item": "widget" }]);
|
||||
const view = await db.createMaterializedView("quoted", "odd_names", {
|
||||
select: ["order item"],
|
||||
withNoData: true,
|
||||
@@ -183,10 +167,15 @@ describe("materialized views", () => {
|
||||
expect(Number(result.rowsWritten)).toBe(1);
|
||||
});
|
||||
|
||||
it("requires stable row ids on the source", async () => {
|
||||
it("does not require stable row ids on the source", async () => {
|
||||
await db.createTable("plain", [{ x: 1 }]);
|
||||
await expect(db.createMaterializedView("v", "plain")).rejects.toThrow(
|
||||
"stable row ids",
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,129 @@ describe("Query outputSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query empty results", () => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
let table: Table;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema(
|
||||
[
|
||||
new Field("id", new Int64(), false),
|
||||
new Field(
|
||||
"text",
|
||||
new Utf8(),
|
||||
true,
|
||||
new Map([["description", "searchable text"]]),
|
||||
),
|
||||
new Field(
|
||||
"vector",
|
||||
new FixedSizeList(2, new Field("item", new Float32())),
|
||||
true,
|
||||
),
|
||||
],
|
||||
new Map([["source", "empty-query-regression"]]),
|
||||
);
|
||||
table = await db.createTable(
|
||||
"test",
|
||||
makeArrowTable([{ id: 1n, text: "hello", vector: [1, 2] }], { schema }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tmpDir.removeCallback();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "filtered scan",
|
||||
query: (table: Table) => table.query().where("id < 0"),
|
||||
fields: ["id", "text", "vector"],
|
||||
},
|
||||
{
|
||||
name: "zero limit",
|
||||
query: (table: Table) => table.query().limit(0),
|
||||
fields: ["id", "text", "vector"],
|
||||
},
|
||||
{
|
||||
name: "column projection",
|
||||
query: (table: Table) =>
|
||||
table.query().where("id < 0").select(["text", "id"]),
|
||||
fields: ["text", "id"],
|
||||
},
|
||||
{
|
||||
name: "dynamic projection",
|
||||
query: (table: Table) =>
|
||||
table.query().where("id < 0").select({ doubled: "id * 2" }),
|
||||
fields: ["doubled"],
|
||||
},
|
||||
{
|
||||
name: "filtered vector search",
|
||||
query: (table: Table) =>
|
||||
table.search([0.5, 0.5]).where("id < 0").limit(3),
|
||||
fields: ["id", "text", "vector", "_distance"],
|
||||
},
|
||||
{
|
||||
name: "vector projection with row id",
|
||||
query: (table: Table) =>
|
||||
table
|
||||
.vectorSearch([0.5, 0.5])
|
||||
.where("id < 0")
|
||||
.select(["id"])
|
||||
.withRowId(),
|
||||
fields: ["id", "_distance", "_rowid"],
|
||||
},
|
||||
{
|
||||
name: "fast search without an index",
|
||||
query: (table: Table) => table.search([0.5, 0.5]).fastSearch(),
|
||||
fields: ["id", "text", "vector", "_distance"],
|
||||
},
|
||||
{
|
||||
name: "take with no offsets",
|
||||
query: (table: Table) => table.takeOffsets([]).select(["text"]),
|
||||
fields: ["text"],
|
||||
},
|
||||
])("preserves the output schema for $name", async ({ query, fields }) => {
|
||||
const result = await query(table).toArrow({ maxBatchLength: 1 });
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema.fields.map((field) => field.name)).toEqual(fields);
|
||||
for (const name of fields) {
|
||||
expect(result.getChild(name)).not.toBeNull();
|
||||
expect(result.getChild(name)?.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves types, nullability, and metadata", async () => {
|
||||
const result = await table.query().where("id < 0").toArrow();
|
||||
const schema = await table.schema();
|
||||
|
||||
expect(result.schema).toEqual(schema);
|
||||
expect(result.getChild("id")?.type.toString()).toBe("Int64");
|
||||
expect(result.schema.fields[0].nullable).toBe(false);
|
||||
expect(result.schema.fields[1].metadata.get("description")).toBe(
|
||||
"searchable text",
|
||||
);
|
||||
expect(result.schema.metadata.get("source")).toBe("empty-query-regression");
|
||||
});
|
||||
|
||||
it("preserves the score column for full-text search with no matches", async () => {
|
||||
await table.createIndex("text", { config: Index.fts() });
|
||||
const result = await table
|
||||
.search("missing", "fts")
|
||||
.select(["id"])
|
||||
.toArrow();
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema.fields.map((field) => field.name)).toEqual([
|
||||
"id",
|
||||
"_score",
|
||||
]);
|
||||
expect(result.getChild("_score")?.type.toString()).toBe("Float32");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search pagination", () => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
let table: Table;
|
||||
@@ -404,3 +527,106 @@ describe("Query orderBy", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IVF search effort", () => {
|
||||
let directory: tmp.DirResult;
|
||||
let table: Table;
|
||||
const vector = Array.from({ length: 16 }, (_, i) => (i === 0 ? 1 : 0));
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(directory.name);
|
||||
const rows = Array.from({ length: 512 }, (_, id) => ({
|
||||
id,
|
||||
vector: Array.from(
|
||||
{ length: 16 },
|
||||
(_, j) =>
|
||||
(Math.floor(id / 32) === j ? 1 : 0) + Math.sin(id * 17 + j) * 0.001,
|
||||
),
|
||||
}));
|
||||
table = await db.createTable("effort", rows.slice(0, 256));
|
||||
await table.add(rows.slice(256));
|
||||
await table.createIndex("vector", {
|
||||
config: Index.ivfFlat({ numPartitions: 16 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
directory.removeCallback();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 6],
|
||||
[0, 1],
|
||||
[0.25, 3],
|
||||
[0.5, 6],
|
||||
[0.75, 10],
|
||||
[1, 16],
|
||||
])(
|
||||
"preserves effort %s through execution and probe precedence",
|
||||
async (effort, expected) => {
|
||||
const partitions = async (query: ReturnType<Table["vectorSearch"]>) => {
|
||||
const plan = await query.limit(8).analyzePlan();
|
||||
const matches = [...plan.matchAll(/partitions_searched=(\d+)/g)];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.reduce((total, match) => total + Number(match[1]), 0);
|
||||
};
|
||||
expect(
|
||||
await partitions(table.vectorSearch(vector).searchEffort(effort)),
|
||||
).toBe(expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).nprobes(1).searchEffort(effort),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).nprobes(1),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.nprobes(1)
|
||||
.searchEffort(null),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).maximumNprobes(2),
|
||||
),
|
||||
).toBeLessThanOrEqual(2);
|
||||
const actual = await table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const exact = await table
|
||||
.vectorSearch(vector)
|
||||
.bypassVectorIndex()
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const ids = new Set(exact.map((row) => row.id));
|
||||
expect(
|
||||
actual.filter((row) => ids.has(row.id)).length / 8,
|
||||
).toBeGreaterThanOrEqual(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
-0.1,
|
||||
1.1,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
])("rejects invalid effort %s before execution", (effort) => {
|
||||
expect(() => table.vectorSearch(vector).searchEffort(effort)).toThrow(
|
||||
"search_effort must be finite and in [0, 1]",
|
||||
);
|
||||
expect(() =>
|
||||
table.vectorSearch(vector).fullTextSearch("dog").searchEffort(effort),
|
||||
).toThrow("search_effort must be finite and in [0, 1]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
|
||||
import * as apiArrow from "apache-arrow";
|
||||
|
||||
import * as arrow15 from "apache-arrow-15";
|
||||
import * as arrow16 from "apache-arrow-16";
|
||||
import * as arrow17 from "apache-arrow-17";
|
||||
import * as arrow18 from "apache-arrow-18";
|
||||
|
||||
import * as tmp from "tmp";
|
||||
|
||||
import { arrowVersions } from "./arrow_versions";
|
||||
|
||||
import { connect } from "../lancedb";
|
||||
import {
|
||||
EmbeddingFunction,
|
||||
@@ -18,7 +15,7 @@ import {
|
||||
} from "../lancedb/embedding";
|
||||
import { getRegistry, register } from "../lancedb/embedding/registry";
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])("LanceSchema", (arrow) => {
|
||||
describe.each(arrowVersions)("LanceSchema", (arrow) => {
|
||||
test("should preserve input order", async () => {
|
||||
const schema = LanceSchema({
|
||||
id: new arrow.Int32(),
|
||||
@@ -29,7 +26,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])("LanceSchema", (arrow) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])("Registry", (arrow) => {
|
||||
describe.each(arrowVersions)("Registry", (arrow) => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
beforeEach(() => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
@@ -240,7 +237,7 @@ describe("Registry.setVar", () => {
|
||||
return data.map(() => [1, 2, 3]);
|
||||
}
|
||||
embeddingDataType() {
|
||||
return new arrow18.Float32() as apiArrow.Float;
|
||||
return new apiArrow.Float32();
|
||||
}
|
||||
protected getSensitiveKeys() {
|
||||
return ["secretKey"];
|
||||
|
||||
@@ -14,12 +14,21 @@ import {
|
||||
TlsConfig,
|
||||
connect,
|
||||
} from "../lancedb";
|
||||
import {
|
||||
Table as ArrowTable,
|
||||
Field,
|
||||
Int64,
|
||||
RecordBatch,
|
||||
Schema,
|
||||
tableToIPC,
|
||||
} from "../lancedb/arrow";
|
||||
import {
|
||||
HeaderProvider,
|
||||
OAuthHeaderProvider,
|
||||
StaticHeaderProvider,
|
||||
} from "../lancedb/header";
|
||||
import { Index } from "../lancedb/indices";
|
||||
import { LocalTable } from "../lancedb/table";
|
||||
|
||||
// Test-only header providers
|
||||
class CustomProvider extends HeaderProvider {
|
||||
@@ -85,6 +94,150 @@ async function withMockDatabase(
|
||||
}
|
||||
|
||||
describe("remote connection", () => {
|
||||
it("reports unsupported operations with backend-neutral errors", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.url).toBe("/v1/table/test/describe/");
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "id", type: { type: "int64" }, nullable: false },
|
||||
{
|
||||
name: "vector",
|
||||
type: {
|
||||
type: "fixed_size_list",
|
||||
fields: [
|
||||
{ name: "item", type: { type: "float" }, nullable: true },
|
||||
],
|
||||
length: 2,
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
async (db) => {
|
||||
const table = (await db.openTable("test")) as LocalTable;
|
||||
const cases: [() => Promise<unknown>, string][] = [
|
||||
[() => table.usesV2ManifestPaths(), "uses_v2_manifest_paths"],
|
||||
[() => table.migrateManifestPathsV2(), "migrate_manifest_paths_v2"],
|
||||
[
|
||||
() => table.setUnenforcedPrimaryKey("id"),
|
||||
"set_unenforced_primary_key",
|
||||
],
|
||||
[() => table.optimize(), "optimize"],
|
||||
[
|
||||
() => table.createIndex("vector", { config: Index.hnswPq() }),
|
||||
"IVF_HNSW_PQ",
|
||||
],
|
||||
];
|
||||
for (const [run, operation] of cases) {
|
||||
let message = `LanceDBError: not supported: ${operation} is not supported for remote tables.`;
|
||||
if (operation === "IVF_HNSW_PQ") {
|
||||
message += " Please use IVF_HNSW_SQ instead.";
|
||||
}
|
||||
await expect(run()).rejects.toThrow(new Error(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects nprobes(0) before sending a query", async () => {
|
||||
const requests: string[] = [];
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
requests.push(req.url ?? "");
|
||||
if (req.url === "/v1/table/test/describe/") {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ version: 1, schema: { fields: [] } }));
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("test");
|
||||
expect(() => table.vectorSearch([0, 0]).nprobes(0)).toThrow(
|
||||
"Invalid input, nprobes must be greater than 0",
|
||||
);
|
||||
expect(() =>
|
||||
table.query().nearestTo([0, 0]).fullTextSearch("dog").nprobes(0),
|
||||
).toThrow("Invalid input, nprobes must be greater than 0");
|
||||
await expect(
|
||||
table
|
||||
.vectorSearch(Promise.resolve([0, 0]))
|
||||
.nprobes(0)
|
||||
.toArrow(),
|
||||
).rejects.toThrow("Invalid input, nprobes must be greater than 0");
|
||||
},
|
||||
);
|
||||
expect(requests).toEqual(["/v1/table/test/describe/"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves an empty query's schema with an empty batch: %s",
|
||||
async (withEmptyBatch) => {
|
||||
const schema = new Schema(
|
||||
[new Field("doubled", new Int64(), false)],
|
||||
new Map([["source", "query-output"]]),
|
||||
);
|
||||
const result = new ArrowTable(
|
||||
schema,
|
||||
withEmptyBatch ? [new RecordBatch(schema, undefined)] : [],
|
||||
);
|
||||
const response = Buffer.from(tableToIPC(result, "stream"));
|
||||
let queryRequests = 0;
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
if (req.url?.endsWith("/describe/")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "items",
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "id", type: { type: "int64" }, nullable: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (req.url?.endsWith("/query/")) {
|
||||
queryRequests++;
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res
|
||||
.writeHead(200, {
|
||||
"Content-Type": "application/vnd.apache.arrow.stream",
|
||||
})
|
||||
.end(response);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("items");
|
||||
const result = await table
|
||||
.query()
|
||||
.where("id < 0")
|
||||
.select({ doubled: "id * 2" })
|
||||
.toArrow();
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema).toEqual(schema);
|
||||
expect(result.getChild("doubled")?.length).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryRequests).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects the external blob opt-in without blocking regular adds", async () => {
|
||||
let insertRequests = 0;
|
||||
const describeRequests: string[] = [];
|
||||
@@ -158,7 +311,9 @@ describe("remote connection", () => {
|
||||
.end(JSON.stringify({ views: ["daily_sales"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await db.listMaterializedViews()).toEqual(["daily_sales"]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([
|
||||
"daily_sales",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -208,7 +363,7 @@ describe("remote connection", () => {
|
||||
.end(JSON.stringify({ views: ["adults"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await db.listViews()).toEqual(["adults"]);
|
||||
expect(await collect(db.listViews())).toEqual(["adults"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1277,7 +1432,7 @@ describe("remote connection jobs surface", () => {
|
||||
});
|
||||
},
|
||||
async (db) => {
|
||||
const jobs = await db.listJobs();
|
||||
const jobs = await collect(db.listJobs());
|
||||
expect(jobs.map((job) => job.jobId)).toEqual(["job-1", "job-2"]);
|
||||
expect(jobs[0].state).toEqual("running");
|
||||
expect(jobs[1].state).toEqual("finished");
|
||||
@@ -1349,4 +1504,171 @@ describe("remote connection jobs surface", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves numDeletedRows absent when the server omits it", async () => {
|
||||
// A server that predates num_deleted_rows says nothing about deleted rows
|
||||
const statsReply =
|
||||
'{"total_bytes":1,"num_rows":3,"num_indices":0,"fragment_stats":' +
|
||||
'{"num_fragments":1,"num_small_fragments":0,' +
|
||||
'"lengths":{"min":3,"max":3,"mean":3,"p25":3,"p50":3,"p75":3,"p99":3}}}';
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const path = req.url ?? "";
|
||||
const body = path.endsWith("/describe/")
|
||||
? JSON.stringify({ name: "t", version: 1, schema: { fields: [] } })
|
||||
: statsReply;
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
||||
},
|
||||
async (db) => {
|
||||
const stats = await (await db.openTable("t")).stats();
|
||||
expect("numDeletedRows" in stats).toBe(false);
|
||||
expect(stats.numDeletedRows).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of items) result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("lazy resource listings", () => {
|
||||
for (const kind of ["views", "materializedViews", "jobs"] as const) {
|
||||
const listing = (
|
||||
db: Connection,
|
||||
options: { pageToken?: string; pageLimit?: number } = {},
|
||||
) => {
|
||||
if (kind === "jobs") return db.listJobs(options);
|
||||
if (kind === "views") return db.listViews(["team"], options);
|
||||
return db.listMaterializedViews(options);
|
||||
};
|
||||
const page = (names: string[], token?: string) => {
|
||||
const items =
|
||||
kind === "jobs"
|
||||
? names.map((name) => ({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_id: name,
|
||||
table: "t",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_type: "create_index",
|
||||
state: "in_progress",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
created_at_millis: 1,
|
||||
}))
|
||||
: names;
|
||||
return {
|
||||
[kind === "jobs" ? "jobs" : "views"]: items,
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
};
|
||||
};
|
||||
|
||||
it(`${kind} fetches lazily, skips empty pages, and resumes`, async () => {
|
||||
const requests: { url: string; body: Record<string, unknown> }[] = [];
|
||||
const responses = [
|
||||
page(["a", "b"], "empty"),
|
||||
page([], "last"),
|
||||
page(["c", "d"], ""),
|
||||
];
|
||||
await withMockDatabase(
|
||||
async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
requests.push({
|
||||
url: req.url ?? "",
|
||||
body: body ? JSON.parse(body) : {},
|
||||
});
|
||||
res
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify(responses.shift()));
|
||||
},
|
||||
async (db) => {
|
||||
const items = listing(db, { pageToken: "start/token", pageLimit: 2 });
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(items.pageToken()).toBe("start/token");
|
||||
expect(items.numPageResults()).toBe(0);
|
||||
expect((await items.next()).done).toBe(false);
|
||||
expect(items.numPageResults()).toBe(1);
|
||||
expect(items.pageToken()).toBe("empty");
|
||||
await items.next();
|
||||
expect(requests).toHaveLength(1);
|
||||
const resumed = listing(db, {
|
||||
pageToken: items.pageToken(),
|
||||
pageLimit: 2,
|
||||
});
|
||||
await resumed.next();
|
||||
expect(resumed.numPageResults()).toBe(1);
|
||||
expect(resumed.pageToken()).toBeUndefined();
|
||||
expect(requests).toHaveLength(3);
|
||||
await resumed.next();
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
for (const [i, request] of requests.entries()) {
|
||||
const token = ["start/token", "empty", "last"][i];
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (kind === "jobs") {
|
||||
expect(url.pathname).toBe("/v1/jobs/list");
|
||||
expect(request.body).toEqual({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
limit: 2,
|
||||
});
|
||||
} else {
|
||||
expect(url.pathname).toBe(
|
||||
kind === "views"
|
||||
? "/v1/namespace/team/view/list"
|
||||
: "/v1/namespace/$/materialized_view/list",
|
||||
);
|
||||
expect(url.searchParams.get("page_token")).toBe(token);
|
||||
expect(url.searchParams.get("limit")).toBe("2");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it(`${kind} retains failed tokens and serializes concurrent advances`, async () => {
|
||||
let requests = 0;
|
||||
await withMockDatabase(
|
||||
(_req, res) => {
|
||||
requests += 1;
|
||||
res
|
||||
.writeHead(requests === 1 ? 401 : 200, {
|
||||
"content-type": "application/json",
|
||||
})
|
||||
.end(JSON.stringify(requests === 1 ? {} : page(["a", "b"])));
|
||||
},
|
||||
async (db) => {
|
||||
const failed = listing(db, { pageToken: "retry" });
|
||||
await expect(failed.next()).rejects.toThrow();
|
||||
expect(failed.pageToken()).toBe("retry");
|
||||
expect((await failed.next()).done).toBe(true);
|
||||
expect(requests).toBe(1);
|
||||
const resumed = listing(db, { pageToken: failed.pageToken() });
|
||||
const results = await Promise.all([
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
]);
|
||||
expect(results.filter((result) => result.done)).toHaveLength(1);
|
||||
expect(requests).toBe(2);
|
||||
for (const limit of [
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
2147483648,
|
||||
]) {
|
||||
expect(() => listing(db, { pageLimit: limit })).toThrow("limit");
|
||||
}
|
||||
expect(requests).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as arrow from "../lancedb/arrow";
|
||||
import { sanitizeField, sanitizeMap, sanitizeType } from "../lancedb/sanitize";
|
||||
import {
|
||||
sanitizeField,
|
||||
sanitizeMap,
|
||||
sanitizeSchema,
|
||||
sanitizeType,
|
||||
} from "../lancedb/sanitize";
|
||||
import { arrow21 } from "./arrow_versions";
|
||||
|
||||
describe("sanitize", function () {
|
||||
describe("sanitizeType function", function () {
|
||||
@@ -59,6 +65,8 @@ describe("sanitize", function () {
|
||||
["durationmicrosecond", new arrow.DurationMicrosecond()],
|
||||
["durationmillisecond", new arrow.DurationMillisecond()],
|
||||
["durationsecond", new arrow.DurationSecond()],
|
||||
["largebinary", new arrow.LargeBinary()],
|
||||
["largeutf8", new arrow.LargeUtf8()],
|
||||
] as const;
|
||||
|
||||
it.each(allTypeNameTestCases)(
|
||||
@@ -89,6 +97,110 @@ describe("sanitize", function () {
|
||||
"Unrecognized type name in schema: invalid_type",
|
||||
);
|
||||
});
|
||||
|
||||
// These types only exist in Arrow 20 (IntervalMonthDayNano) and 21 (the
|
||||
// rest), so the objects come from a second copy of the newest release. The
|
||||
// library itself may be running on an older Arrow (see the CI matrix), in
|
||||
// which case sanitizing them has to fail with a clear message instead.
|
||||
const hostArrow = arrow as unknown as Record<string, unknown>;
|
||||
const hostSupportsNewerTypes = typeof hostArrow.Utf8View === "function";
|
||||
const whenSupported = hostSupportsNewerTypes ? describe : describe.skip;
|
||||
const whenUnsupported = hostSupportsNewerTypes ? describe.skip : describe;
|
||||
|
||||
const newerTypeTestCases = [
|
||||
["Utf8View", () => new arrow21.Utf8View()],
|
||||
["BinaryView", () => new arrow21.BinaryView()],
|
||||
["IntervalMonthDayNano", () => new arrow21.IntervalMonthDayNano()],
|
||||
[
|
||||
"LargeList",
|
||||
() =>
|
||||
new arrow21.LargeList(new arrow21.Field("item", new arrow21.Utf8())),
|
||||
],
|
||||
] as const;
|
||||
|
||||
whenSupported("types added in Arrow 20 and 21", function () {
|
||||
it.each(newerTypeTestCases)(
|
||||
"should sanitize a %s type from another copy of Arrow",
|
||||
function (name, makeForeign) {
|
||||
const foreign = makeForeign();
|
||||
const result = sanitizeType(foreign);
|
||||
// Interval subclasses share `Type.Interval`, so the sanitized type
|
||||
// is the generic `Interval` carrying the same unit, as for the
|
||||
// other units.
|
||||
const expected = name === "IntervalMonthDayNano" ? "Interval" : name;
|
||||
expect(result).toBeInstanceOf(hostArrow[expected] as () => unknown);
|
||||
expect(result.typeId).toBe(foreign.typeId);
|
||||
expect(result.toString()).toBe(foreign.toString());
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["binaryview", "BinaryView"],
|
||||
["utf8view", "Utf8View"],
|
||||
["intervalmonthdaynano", "Interval"],
|
||||
])('should map type name "%s" to %s', function (name, expected) {
|
||||
expect(sanitizeType(name)).toBeInstanceOf(
|
||||
hostArrow[expected] as () => unknown,
|
||||
);
|
||||
});
|
||||
|
||||
it("should sanitize the child field of a LargeList", function () {
|
||||
const foreign = new arrow21.LargeList(
|
||||
new arrow21.Field("item", new arrow21.Int32(), false),
|
||||
);
|
||||
const result = sanitizeType(foreign) as arrow.List;
|
||||
expect(result.children[0]).toBeInstanceOf(arrow.Field);
|
||||
expect(result.children[0].name).toBe("item");
|
||||
expect(result.children[0].nullable).toBe(false);
|
||||
const child = result.children[0].type;
|
||||
expect(arrow.DataType.isInt(child)).toBe(true);
|
||||
expect((child as arrow.Int).bitWidth).toBe(32);
|
||||
expect((child as arrow.Int).isSigned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
whenUnsupported("types newer than the installed Arrow", function () {
|
||||
it.each(
|
||||
newerTypeTestCases.filter(([name]) => name !== "IntervalMonthDayNano"),
|
||||
)(
|
||||
"should explain that %s needs a newer apache-arrow",
|
||||
function (name, makeForeign) {
|
||||
expect(() => sanitizeType(makeForeign())).toThrow(
|
||||
`The Arrow type ${name} requires apache-arrow`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should sanitize IntervalMonthDayNano to a generic Interval", () => {
|
||||
// The subclass shares `Type.Interval`, which every supported Arrow
|
||||
// release has; only the unit is new.
|
||||
const result = sanitizeType(new arrow21.IntervalMonthDayNano());
|
||||
expect(result).toBeInstanceOf(arrow.Interval);
|
||||
expect((result as arrow.Interval).unit).toBe(
|
||||
arrow21.IntervalUnit.MONTH_DAY_NANO,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should rebuild a schema from a second copy of the same Arrow version", function () {
|
||||
// Arrow 21 makes `instanceof Schema` succeed across copies of the library
|
||||
// but not `instanceof <DataType>`; the schema must still be rebuilt.
|
||||
const foreign = new arrow21.Schema([
|
||||
new arrow21.Field("x", new arrow21.Float32(), true),
|
||||
]);
|
||||
// Only Arrow 21+ overrides `Symbol.hasInstance` this way.
|
||||
const crossCopyInstanceof = Object.getOwnPropertySymbols(
|
||||
arrow.Schema,
|
||||
).includes(Symbol.hasInstance);
|
||||
expect(foreign instanceof arrow.Schema).toBe(crossCopyInstanceof);
|
||||
const result = sanitizeSchema(foreign);
|
||||
expect(result).not.toBe(foreign);
|
||||
const type = result.fields[0].type;
|
||||
expect(type).toBeInstanceOf(arrow.DataType);
|
||||
expect(arrow.DataType.isFloat(type)).toBe(true);
|
||||
expect((type as arrow.Float).precision).toBe(arrow.Precision.SINGLE);
|
||||
expect(() => arrow.makeBuilder({ type })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeField function", function () {
|
||||
|
||||
+441
-14
@@ -2,17 +2,14 @@
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as fs from "fs";
|
||||
import { getEventListeners } from "node:events";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as path from "path";
|
||||
import * as tmp from "tmp";
|
||||
|
||||
import * as arrow15 from "apache-arrow-15";
|
||||
import * as arrow16 from "apache-arrow-16";
|
||||
import * as arrow17 from "apache-arrow-17";
|
||||
import * as arrow18 from "apache-arrow-18";
|
||||
|
||||
import {
|
||||
AutoQuery,
|
||||
type BlobInput,
|
||||
Connection,
|
||||
MatchQuery,
|
||||
PhraseQuery,
|
||||
@@ -55,19 +52,21 @@ import {
|
||||
instanceOfFullTextQuery,
|
||||
} from "../lancedb/query";
|
||||
import { LocalTable } from "../lancedb/table";
|
||||
import {
|
||||
type ApacheArrow,
|
||||
arrow18,
|
||||
arrowVersions,
|
||||
latestArrow,
|
||||
} from "./arrow_versions";
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
describe.each(arrowVersions)(
|
||||
"Given a table",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
(arrow: any) => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
let table: Table;
|
||||
|
||||
const schema:
|
||||
| import("apache-arrow-15").Schema
|
||||
| import("apache-arrow-16").Schema
|
||||
| import("apache-arrow-17").Schema
|
||||
| import("apache-arrow-18").Schema = new arrow.Schema([
|
||||
const schema: InstanceType<ApacheArrow["Schema"]> = new arrow.Schema([
|
||||
new arrow.Field("id", new arrow.Float64(), true),
|
||||
]);
|
||||
|
||||
@@ -92,6 +91,38 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
await expect(table.countRows()).resolves.toBe(3);
|
||||
});
|
||||
|
||||
it("creates and adds Arrow tables with large string and binary columns", async () => {
|
||||
const data = new arrow.Table({
|
||||
text: arrow.vectorFromArray(["alpha", "beta"], new arrow.LargeUtf8()),
|
||||
bytes: arrow.vectorFromArray(
|
||||
[Buffer.from("one"), Buffer.from("two")],
|
||||
new arrow.LargeBinary(),
|
||||
),
|
||||
});
|
||||
const conn = await connect(tmpDir.name);
|
||||
const largeTable = await conn.createTable("large_columns", data);
|
||||
await largeTable.add(data);
|
||||
|
||||
const rows = await largeTable.query().toArray();
|
||||
expect(rows.map((row) => row.text)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
expect(rows.map((row) => Buffer.from(row.bytes).toString())).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"one",
|
||||
"two",
|
||||
]);
|
||||
const fields = (await largeTable.schema()).fields;
|
||||
expect(fields.map((field) => field.type.typeId)).toEqual([
|
||||
arrow.Type.LargeUtf8,
|
||||
arrow.Type.LargeBinary,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should support a foreign Float64 vector schema end to end", async () => {
|
||||
const conn = await connect(tmpDir.name);
|
||||
const schema = new arrow.Schema([
|
||||
@@ -283,6 +314,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
},
|
||||
numIndices: 0,
|
||||
numRows: 3,
|
||||
numDeletedRows: 0,
|
||||
// Full on-disk size of the two data files, footers and metadata included.
|
||||
totalBytes: 550,
|
||||
});
|
||||
@@ -293,6 +325,15 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
const statsWithIndex = await table.stats();
|
||||
expect(statsWithIndex.numIndices).toBe(1);
|
||||
expect(statsWithIndex.totalBytes).toBeGreaterThan(550);
|
||||
|
||||
// Both rows with id = 1 are deleted, but that empties the second
|
||||
// fragment, which is dropped outright rather than kept with a deletion
|
||||
// file, so only the row marked in the surviving fragment is counted.
|
||||
await table.delete("id = 1");
|
||||
const stats = await table.stats();
|
||||
expect(stats.numRows).toBe(1);
|
||||
expect(stats.numDeletedRows).toBe(1);
|
||||
expect(stats.fragmentStats.numFragments).toBe(1);
|
||||
});
|
||||
|
||||
it("should overwrite data if asked", async () => {
|
||||
@@ -709,6 +750,114 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
);
|
||||
|
||||
// https://github.com/lancedb/lancedb/issues/1963
|
||||
// These types were added in Arrow 21, so they are built with a second copy of
|
||||
// that release rather than the per-version matrix above, and reading them back
|
||||
// needs the library's own Arrow to know them too.
|
||||
const hostArrowSupportsViewTypes =
|
||||
typeof (arrow as unknown as Record<string, unknown>).Utf8View === "function";
|
||||
(hostArrowSupportsViewTypes ? it : it.skip)(
|
||||
"creates and adds tables with LargeList, Utf8View and BinaryView columns",
|
||||
async () => {
|
||||
const tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(tmpDir.name);
|
||||
const makeData = () =>
|
||||
new latestArrow.Table({
|
||||
id: latestArrow.vectorFromArray([1, 2], new latestArrow.Int32()),
|
||||
tags: latestArrow.vectorFromArray(
|
||||
[["a", "b"], ["c"]],
|
||||
new latestArrow.LargeList(
|
||||
new latestArrow.Field("item", new latestArrow.Utf8(), true),
|
||||
),
|
||||
),
|
||||
text: latestArrow.vectorFromArray(
|
||||
["short", "a string that is longer than the twelve inline bytes"],
|
||||
new latestArrow.Utf8View(),
|
||||
),
|
||||
bytes: latestArrow.vectorFromArray(
|
||||
[new Uint8Array([1, 2, 3]), new Uint8Array(20).fill(7)],
|
||||
new latestArrow.BinaryView(),
|
||||
),
|
||||
});
|
||||
const table = await db.createTable("views", makeData());
|
||||
await table.add(makeData());
|
||||
|
||||
const schema = await table.schema();
|
||||
// Take the enum from the Arrow 21 copy: the host Arrow may predate
|
||||
// `Type.LargeList`, and this file still has to type-check there.
|
||||
expect(schema.fields.map((f) => f.type.typeId)).toEqual([
|
||||
latestArrow.Type.Int,
|
||||
latestArrow.Type.LargeList,
|
||||
// Lance stores the view types as their offset-based equivalents.
|
||||
latestArrow.Type.Utf8,
|
||||
latestArrow.Type.Binary,
|
||||
]);
|
||||
const rows = await table.query().toArrow();
|
||||
expect(rows.numRows).toBe(4);
|
||||
const row = rows.get(1)!.toJSON();
|
||||
expect(row.id).toBe(2);
|
||||
expect(row.tags.toJSON()).toEqual(["c"]);
|
||||
expect(row.text).toBe(
|
||||
"a string that is longer than the twelve inline bytes",
|
||||
);
|
||||
expect(Array.from(row.bytes)).toEqual(Array(20).fill(7));
|
||||
|
||||
// Arrow JS 21 serializes a *sliced* view column with a truncated views
|
||||
// buffer; the library rebuilds view columns as Utf8/Binary so this works.
|
||||
const sliced = makeData().slice(1, 2);
|
||||
expect(sliced.getChild("text")!.data[0].offset).toBe(1);
|
||||
const slicedTable = await db.createTable("views_sliced", sliced);
|
||||
await slicedTable.add(sliced);
|
||||
const slicedRows = await slicedTable.query().toArrow();
|
||||
expect(slicedRows.numRows).toBe(2);
|
||||
for (const i of [0, 1]) {
|
||||
const r = slicedRows.get(i)!.toJSON();
|
||||
expect(r.id).toBe(2);
|
||||
expect(r.text).toBe(
|
||||
"a string that is longer than the twelve inline bytes",
|
||||
);
|
||||
expect(Array.from(r.bytes)).toEqual(Array(20).fill(7));
|
||||
}
|
||||
|
||||
// Regression: a field with an integer-like name. JavaScript enumerates
|
||||
// such keys first, so a key-ordered rebuild would pair "1" with the text
|
||||
// column's values. The batches are built explicitly to pin field order.
|
||||
const numericSchema = new latestArrow.Schema([
|
||||
new latestArrow.Field("text", new latestArrow.Utf8View(), true),
|
||||
new latestArrow.Field("1", new latestArrow.Utf8View(), true),
|
||||
new latestArrow.Field("n", new latestArrow.Int32(), true),
|
||||
]);
|
||||
const column = (
|
||||
values: unknown[],
|
||||
type: InstanceType<typeof latestArrow.DataType>,
|
||||
) => latestArrow.vectorFromArray(values, type).data[0];
|
||||
const numericBatch = new latestArrow.RecordBatch(
|
||||
numericSchema,
|
||||
latestArrow.makeData({
|
||||
type: new latestArrow.Struct(numericSchema.fields),
|
||||
length: 3,
|
||||
nullCount: 0,
|
||||
children: [
|
||||
column(["t0", "t1", "t2"], new latestArrow.Utf8View()),
|
||||
column(["one0", "one1", "one2"], new latestArrow.Utf8View()),
|
||||
column([0, 1, 2], new latestArrow.Int32()),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const numericData = new latestArrow.Table(numericSchema, [numericBatch]);
|
||||
const numericTable = await db.createTable("views_numeric", numericData);
|
||||
await numericTable.add(numericData.slice(1));
|
||||
const numericRows = (await numericTable.query().toArrow()).toArray();
|
||||
expect(numericRows.map((r) => r.toJSON())).toEqual([
|
||||
{ text: "t0", "1": "one0", n: 0 },
|
||||
{ text: "t1", "1": "one1", n: 1 },
|
||||
{ text: "t2", "1": "one2", n: 2 },
|
||||
{ text: "t1", "1": "one1", n: 1 },
|
||||
{ text: "t2", "1": "one2", n: 2 },
|
||||
]);
|
||||
tmpDir.removeCallback();
|
||||
},
|
||||
);
|
||||
|
||||
it("should query documents with LangChain PDF metadata", async () => {
|
||||
const tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
try {
|
||||
@@ -1073,10 +1222,18 @@ describe("When creating an index", () => {
|
||||
.toArrow();
|
||||
expect(rst.numRows).toBe(2);
|
||||
|
||||
expect(() => tbl.vectorSearch(queryVec).nprobes(0)).toThrow(
|
||||
"Invalid input, nprobes must be greater than 0",
|
||||
);
|
||||
expect(() =>
|
||||
tbl.query().nearestTo(queryVec).fullTextSearch("dog").nprobes(0),
|
||||
).toThrow("Invalid input, nprobes must be greater than 0");
|
||||
expect(() => tbl.query().nearestTo(queryVec).minimumNprobes(0)).toThrow(
|
||||
"Invalid input, minimum_nprobes must be greater than 0",
|
||||
);
|
||||
expect(() => tbl.query().nearestTo(queryVec).maximumNprobes(5)).toThrow(
|
||||
expect(() =>
|
||||
tbl.query().nearestTo(queryVec).minimumNprobes(10).maximumNprobes(5),
|
||||
).toThrow(
|
||||
"Invalid input, maximum_nprobes must be greater than or equal to minimum_nprobes",
|
||||
);
|
||||
|
||||
@@ -2439,7 +2596,7 @@ describe("when dealing with blob columns", () => {
|
||||
new Field("id", new Int64(), true),
|
||||
blob("image"),
|
||||
]);
|
||||
const row = { id: 1n, image: new Uint8Array([104]).buffer };
|
||||
const row = { id: 1n, image: new ReadableStream() };
|
||||
|
||||
await expect(db.createTable("invalid", [row], { schema })).rejects.toThrow(
|
||||
/field image at row 0/,
|
||||
@@ -2469,6 +2626,8 @@ describe("when dealing with blob columns", () => {
|
||||
it.each([
|
||||
["URI string", (uri: string) => uri],
|
||||
["URI struct", (uri: string) => ({ uri })],
|
||||
["URL", (uri: string) => new URL(uri)],
|
||||
["URL struct", (uri: string) => ({ uri: new URL(uri) })],
|
||||
])(
|
||||
"round-trips an external blob from a %s after reopening",
|
||||
async (_label, blobValue) => {
|
||||
@@ -2562,6 +2721,63 @@ describe("when dealing with blob columns", () => {
|
||||
expect(bytes[0]).toEqual(payload);
|
||||
});
|
||||
|
||||
it("accepts ArrayBuffer, Blob, and File in createTable and add", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
blob("image"),
|
||||
]);
|
||||
const rows: { id: bigint; image: BlobInput }[] = [
|
||||
{ id: 1n, image: new TextEncoder().encode("array-buffer").buffer },
|
||||
{ id: 2n, image: new Blob(["blob"]) },
|
||||
{ id: 3n, image: { data: new File(["file"], "f.txt") } },
|
||||
];
|
||||
const table = await db.createTable("widened", rows.slice(0, 2), {
|
||||
schema,
|
||||
});
|
||||
await table.add(rows.slice(2));
|
||||
|
||||
const results = await table.query().select(["id"]).withRowId().toArray();
|
||||
results.sort((a, b) => Number(a.id - b.id));
|
||||
const bytes = await table.fetchBlobs(
|
||||
"image",
|
||||
results.map((row) => row._rowid as bigint),
|
||||
);
|
||||
expect(bytes.map((b) => b?.toString())).toEqual([
|
||||
"array-buffer",
|
||||
"blob",
|
||||
"file",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts Blob values in mergeInsert", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
blob("image"),
|
||||
]);
|
||||
const table = await db.createTable(
|
||||
"merge_blobs",
|
||||
[{ id: 1n, image: Buffer.from("old") }],
|
||||
{ schema },
|
||||
);
|
||||
await table
|
||||
.mergeInsert("id")
|
||||
.whenMatchedUpdateAll()
|
||||
.whenNotMatchedInsertAll()
|
||||
.execute([
|
||||
{ id: 1n, image: new Blob(["new"]) },
|
||||
{ id: 2n, image: new Uint8Array([104, 105]).buffer },
|
||||
]);
|
||||
const results = await table.query().select(["id"]).withRowId().toArray();
|
||||
results.sort((a, b) => Number(a.id - b.id));
|
||||
const bytes = await table.fetchBlobs(
|
||||
"image",
|
||||
results.map((row) => row._rowid as bigint),
|
||||
);
|
||||
expect(bytes.map((b) => b?.toString())).toEqual(["new", "hi"]);
|
||||
});
|
||||
|
||||
it("discovers blob columns", async () => {
|
||||
const { table } = await openBlobTable();
|
||||
expect(await table.blobColumns()).toEqual(["image"]);
|
||||
@@ -2601,6 +2817,39 @@ describe("when dealing with blob columns", () => {
|
||||
expect(Buffer.from(await files[1]!.read()).toString()).toBe("beta");
|
||||
});
|
||||
|
||||
it("rejects a whole blob read batch when one row ID was deleted", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
await table.delete("id = 2");
|
||||
|
||||
await expect(table.fetchBlobs("image", rowIds)).rejects.toThrow(
|
||||
`first missing row ids: [${rowIds[1]}]`,
|
||||
);
|
||||
await expect(table.fetchBlobFiles("image", rowIds)).rejects.toThrow(
|
||||
`first missing row ids: [${rowIds[1]}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a LargeBinary Arrow column to a blob table", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([new Field("id", new Int64()), blob("image")]);
|
||||
const table = await db.createTable(
|
||||
"blobs",
|
||||
[{ id: 1n, image: Buffer.from("alpha") }],
|
||||
{ schema },
|
||||
);
|
||||
const payload = Buffer.from("beta");
|
||||
const data = new arrow18.Table({
|
||||
id: arrow18.vectorFromArray([2n], new arrow18.Int64()),
|
||||
image: arrow18.vectorFromArray([payload], new arrow18.LargeBinary()),
|
||||
});
|
||||
await table.add(data);
|
||||
|
||||
const rows = await table.query().withRowId().toArray();
|
||||
const rowId = rows.find((row) => row.id === 2n)!._rowid as bigint;
|
||||
const [actual] = await table.fetchBlobs("image", [rowId]);
|
||||
expect(actual).toEqual(payload);
|
||||
});
|
||||
|
||||
it("reads a half-open range", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const files = await table.fetchBlobFiles("image", rowIds);
|
||||
@@ -2625,6 +2874,148 @@ describe("when dealing with blob columns", () => {
|
||||
).rejects.toThrow(/exceeds blob size/);
|
||||
});
|
||||
|
||||
it("reads at most maxBytes and advances the cursor", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
expect((await handle!.read(2n)).toString()).toBe("al");
|
||||
expect(await handle!.tell()).toBe(2n);
|
||||
expect((await handle!.read(2n)).toString()).toBe("ph");
|
||||
expect((await handle!.read(10n)).toString()).toBe("a");
|
||||
expect(await handle!.tell()).toBe(5n);
|
||||
expect(await handle!.read(1n)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it("seeks the cursor", async () => {
|
||||
const { table, rowIds, alpha } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
await handle!.seek(3n);
|
||||
expect(await handle!.tell()).toBe(3n);
|
||||
expect((await handle!.read()).toString()).toBe("ha");
|
||||
await handle!.seek(0n);
|
||||
expect(await handle!.read()).toEqual(alpha);
|
||||
});
|
||||
|
||||
it("rejects a negative seek position", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
await expect(handle!.seek(-1n)).rejects.toThrow(/cannot be negative/);
|
||||
});
|
||||
|
||||
it("readRanges returns one buffer per range in order", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
const buffers = await handle!.readRanges([
|
||||
{ start: 3n, end: 5n },
|
||||
{ start: 0n, end: 2n },
|
||||
{ start: 3n, end: 5n },
|
||||
]);
|
||||
expect(buffers.map((b) => b.toString())).toEqual(["ha", "al", "ha"]);
|
||||
expect(await handle!.tell()).toBe(0n);
|
||||
});
|
||||
|
||||
it("fails when a readRanges end is past the blob size", async () => {
|
||||
const { table, rowIds, alpha } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
await expect(
|
||||
handle!.readRanges([
|
||||
{ start: 0n, end: 1n },
|
||||
{ start: 0n, end: BigInt(alpha.length + 1) },
|
||||
]),
|
||||
).rejects.toThrow(/exceeds blob size/);
|
||||
});
|
||||
|
||||
it("fails reads after close and closes once", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
expect(handle!.isClosed()).toBe(false);
|
||||
await handle!.close();
|
||||
expect(handle!.isClosed()).toBe(true);
|
||||
await expect(handle!.close()).resolves.toBeUndefined();
|
||||
await expect(handle!.read()).rejects.toThrow(/already closed/);
|
||||
await expect(handle!.read(1n)).rejects.toThrow(/already closed/);
|
||||
await expect(handle!.readRange(0n, 1n)).rejects.toThrow(/already closed/);
|
||||
await expect(handle!.readRanges([{ start: 0n, end: 1n }])).rejects.toThrow(
|
||||
/already closed/,
|
||||
);
|
||||
await expect(handle!.seek(0n)).rejects.toThrow(/already closed/);
|
||||
await expect(handle!.tell()).rejects.toThrow(/already closed/);
|
||||
expect(handle!.size()).toBe(5n);
|
||||
});
|
||||
|
||||
it("rejects reads with an already aborted signal", async () => {
|
||||
const { table, rowIds, alpha } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
const signal = AbortSignal.abort();
|
||||
const abortError = { name: "AbortError" };
|
||||
await expect(handle!.read(undefined, { signal })).rejects.toMatchObject(
|
||||
abortError,
|
||||
);
|
||||
await expect(handle!.read(2n, { signal })).rejects.toMatchObject(
|
||||
abortError,
|
||||
);
|
||||
await expect(handle!.readRange(0n, 1n, { signal })).rejects.toMatchObject(
|
||||
abortError,
|
||||
);
|
||||
await expect(
|
||||
handle!.readRanges([{ start: 0n, end: 1n }], { signal }),
|
||||
).rejects.toMatchObject(abortError);
|
||||
await expect(
|
||||
table.fetchBlobs("image", rowIds, { signal }),
|
||||
).rejects.toMatchObject(abortError);
|
||||
await expect(
|
||||
table.fetchBlobFiles("image", rowIds, { signal }),
|
||||
).rejects.toMatchObject(abortError);
|
||||
expect(await handle!.read()).toEqual(alpha);
|
||||
});
|
||||
|
||||
it("rejects with the abort reason", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds);
|
||||
const reason = new Error("caller gave up");
|
||||
await expect(
|
||||
handle!.readRange(0n, 1n, { signal: AbortSignal.abort(reason) }),
|
||||
).rejects.toBe(reason);
|
||||
});
|
||||
|
||||
it("reads with a signal and removes its abort listener", async () => {
|
||||
const { table, rowIds, alpha, beta } = await openBlobTable();
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const [handle] = await table.fetchBlobFiles("image", rowIds, { signal });
|
||||
expect((await handle!.readRange(0n, 2n, { signal })).toString()).toBe("al");
|
||||
expect(await table.fetchBlobs("image", rowIds, { signal })).toEqual([
|
||||
alpha,
|
||||
beta,
|
||||
null,
|
||||
]);
|
||||
expect(getEventListeners(signal, "abort")).toHaveLength(0);
|
||||
controller.abort();
|
||||
expect(await handle!.read()).toEqual(alpha);
|
||||
});
|
||||
|
||||
it("cancels an in-flight read", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
blob("payload", { inlineSizeThreshold: 0, dedicatedSizeThreshold: 1 }),
|
||||
]);
|
||||
const payload = Buffer.alloc(32 * 1024 * 1024, 7);
|
||||
const table = await db.createTable("large_blobs", [{ id: 1n, payload }], {
|
||||
schema,
|
||||
});
|
||||
const [row] = await table.query().withRowId().toArray();
|
||||
const [handle] = await table.fetchBlobFiles("payload", [
|
||||
row._rowid as bigint,
|
||||
]);
|
||||
const controller = new AbortController();
|
||||
const pending = handle!.readRange(0n, handle!.size(), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(await handle!.readRange(0n, 4n)).toEqual(payload.subarray(0, 4));
|
||||
});
|
||||
|
||||
it("rejects fetchBlobs on a non-blob column", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
await expect(table.fetchBlobs("id", rowIds)).rejects.toThrow(/blob/i);
|
||||
@@ -3017,7 +3408,7 @@ it("passes cleanupOlderThan to the native binding as an absolute timestamp", asy
|
||||
expect(optimize).toHaveBeenCalledWith(cutoff.getTime(), true);
|
||||
});
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
describe.each(arrowVersions)(
|
||||
"when optimizing a dataset",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
(arrow: any) => {
|
||||
@@ -3683,6 +4074,28 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["Klingon", "english"])(
|
||||
"rejects unsupported full text language %s with a catchable error",
|
||||
(language) => {
|
||||
expect(() => Index.fts({ language })).toThrow(
|
||||
new Error(
|
||||
`LanceDB does not support the requested language: '${language}'`,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("full text search with a supported language", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("test", [{ text: "running" }]);
|
||||
await table.createIndex("text", {
|
||||
config: Index.fts({ language: "English", stem: true }),
|
||||
});
|
||||
|
||||
const results = await table.search("run").toArray();
|
||||
expect(results.map((row) => row.text)).toEqual(["running"]);
|
||||
});
|
||||
|
||||
test("full text search without lowercase", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const data = [
|
||||
@@ -3813,6 +4226,20 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
expect(mustNotResults.length).toBe(1);
|
||||
});
|
||||
|
||||
test("full text search code tokenizer", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("test", [
|
||||
{ id: 1, text: "def getUserName(user_id): return user_id" },
|
||||
{ id: 2, text: "def parseFile(file_path): return file_path" },
|
||||
]);
|
||||
await table.createIndex("text", {
|
||||
config: Index.fts({ baseTokenizer: "code" }),
|
||||
});
|
||||
|
||||
const results = await table.search("getUserName", "fts").toArray();
|
||||
expect(results.map((row) => row.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("full text search ngram", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const data = [
|
||||
|
||||
Reference in New Issue
Block a user