mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 23:32:27 +00:00
Merge main into gatekeeper/fix-4284-1
This commit is contained in:
@@ -52,9 +52,19 @@ 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":3,"query":${JSON.stringify(query)}}`,
|
||||
`{"format":5,"query":${JSON.stringify(query)}}`,
|
||||
'{"kind":"select_v3","source_table":"people"}',
|
||||
]) {
|
||||
expect(() => read(newer)).toThrow(/cannot refresh/);
|
||||
|
||||
@@ -85,6 +85,69 @@ async function withMockDatabase(
|
||||
}
|
||||
|
||||
describe("remote connection", () => {
|
||||
it("rejects the external blob opt-in without blocking regular adds", async () => {
|
||||
let insertRequests = 0;
|
||||
const describeRequests: string[] = [];
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const requestPath = req.url ?? "";
|
||||
if (requestPath.endsWith("/describe/")) {
|
||||
describeRequests.push(requestPath);
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "items",
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{
|
||||
name: "id",
|
||||
type: { type: "int64" },
|
||||
nullable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requestPath.endsWith("/insert/")) {
|
||||
insertRequests++;
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ version: insertRequests + 1 }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("items");
|
||||
|
||||
await expect(
|
||||
table.add([{ id: 1n }], {
|
||||
allowExternalBlobOutsideBases: true,
|
||||
}),
|
||||
).rejects.toThrow("only supported on local tables");
|
||||
expect(insertRequests).toBe(0);
|
||||
|
||||
await expect(table.add([{ id: 2n }])).resolves.toMatchObject({
|
||||
version: 2,
|
||||
});
|
||||
await expect(
|
||||
table.add([{ id: 3n }], {
|
||||
allowExternalBlobOutsideBases: false,
|
||||
}),
|
||||
).resolves.toMatchObject({ version: 3 });
|
||||
},
|
||||
);
|
||||
|
||||
expect(describeRequests.length).toBeGreaterThan(0);
|
||||
expect(insertRequests).toBe(2);
|
||||
});
|
||||
|
||||
it("lists materialized views through the namespace route", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as fs from "fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as path from "path";
|
||||
import * as tmp from "tmp";
|
||||
|
||||
@@ -416,6 +417,26 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
expect(await table.countRows("id == 10")).toBe(1);
|
||||
});
|
||||
|
||||
it("should let me update float values to NaN and Infinity", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const floats = await db.createTable("floats", [
|
||||
{ id: 1, price: 1.5 },
|
||||
{ id: 2, price: 2.5 },
|
||||
{ id: 3, price: 3.5 },
|
||||
{ id: 4, price: 4.5 },
|
||||
]);
|
||||
await floats.update({ values: { price: NaN }, where: "id = 1" });
|
||||
await floats.update({ values: { price: Infinity }, where: "id = 2" });
|
||||
await floats.update({ values: { price: -Infinity }, where: "id = 3" });
|
||||
|
||||
const rows = await floats.query().toArray();
|
||||
const price = (id: number) => rows.find((row) => row.id === id).price;
|
||||
expect(price(1)).toBeNaN();
|
||||
expect(price(2)).toBe(Infinity);
|
||||
expect(price(3)).toBe(-Infinity);
|
||||
expect(price(4)).toBe(4.5);
|
||||
});
|
||||
|
||||
// https://github.com/lancedb/lancedb/issues/1293
|
||||
test.each([new arrow.Float16(), new arrow.Float32(), new arrow.Float64()])(
|
||||
"can create empty table with non default float type: %s",
|
||||
@@ -2429,6 +2450,118 @@ describe("when dealing with blob columns", () => {
|
||||
await expect(table.countRows()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("requires an explicit opt-in for blob URIs outside registered bases", async () => {
|
||||
const { table, uri } = await openExternalBlobTable();
|
||||
|
||||
await expect(table.add([{ id: 1n, payload: uri }])).rejects.toThrow(
|
||||
"allow_external_blob_outside_bases",
|
||||
);
|
||||
await expect(table.countRows()).resolves.toBe(0);
|
||||
|
||||
await expect(
|
||||
table.add([{ id: 2n, payload: uri }], {
|
||||
allowExternalBlobOutsideBases: false,
|
||||
}),
|
||||
).rejects.toThrow("allow_external_blob_outside_bases");
|
||||
await expect(table.countRows()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["URI string", (uri: string) => uri],
|
||||
["URI struct", (uri: string) => ({ uri })],
|
||||
])(
|
||||
"round-trips an external blob from a %s after reopening",
|
||||
async (_label, blobValue) => {
|
||||
const { db, table, uri, payload } = await openExternalBlobTable();
|
||||
|
||||
const result = await table.add([{ id: 1n, payload: blobValue(uri) }], {
|
||||
allowExternalBlobOutsideBases: true,
|
||||
});
|
||||
expect(result.version).toBeGreaterThan(0);
|
||||
await expect(table.countRows()).resolves.toBe(1);
|
||||
|
||||
table.close();
|
||||
const reopened = await db.openTable("external_blobs");
|
||||
const rows = await reopened.query().withRowId().toArray();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].payload.blob_uri).toBe(uri);
|
||||
const bytes = await reopened.fetchBlobs("payload", [
|
||||
rows[0]._rowid as bigint,
|
||||
]);
|
||||
expect(bytes[0]).toEqual(payload);
|
||||
},
|
||||
);
|
||||
|
||||
it("applies the external blob opt-in to Arrow table input", async () => {
|
||||
const { table, schema, uri, payload } = await openExternalBlobTable();
|
||||
const input = makeArrowTable([{ id: 1n, payload: uri }], { schema });
|
||||
|
||||
const result = await table.add(input, {
|
||||
allowExternalBlobOutsideBases: true,
|
||||
});
|
||||
|
||||
expect(result.version).toBeGreaterThan(0);
|
||||
const rows = await table.query().withRowId().toArray();
|
||||
expect(rows[0].payload.blob_uri).toBe(uri);
|
||||
const bytes = await table.fetchBlobs("payload", [rows[0]._rowid as bigint]);
|
||||
expect(bytes[0]).toEqual(payload);
|
||||
});
|
||||
|
||||
it("applies the external blob opt-in only to the current add", async () => {
|
||||
const { table, uri } = await openExternalBlobTable();
|
||||
|
||||
await table.add([{ id: 1n, payload: uri }], {
|
||||
allowExternalBlobOutsideBases: true,
|
||||
});
|
||||
await expect(table.countRows()).resolves.toBe(1);
|
||||
|
||||
await expect(table.add([{ id: 2n, payload: uri }])).rejects.toThrow(
|
||||
"allow_external_blob_outside_bases",
|
||||
);
|
||||
await expect(table.countRows()).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("supports progress and overwrite with the external blob opt-in", async () => {
|
||||
const { table, uri, payload } = await openExternalBlobTable();
|
||||
await table.add([{ id: 1n, payload: Buffer.from("old") }]);
|
||||
|
||||
let resolveFinal!: (progress: import("../lancedb").WriteProgress) => void;
|
||||
let rejectFinal!: (error: Error) => void;
|
||||
const finalProgress = new Promise<import("../lancedb").WriteProgress>(
|
||||
(resolve, reject) => {
|
||||
resolveFinal = resolve;
|
||||
rejectFinal = reject;
|
||||
},
|
||||
);
|
||||
const timeout = setTimeout(
|
||||
() => rejectFinal(new Error("timed out waiting for final progress")),
|
||||
5_000,
|
||||
);
|
||||
try {
|
||||
const result = await table.add([{ id: 2n, payload: uri }], {
|
||||
mode: "overwrite",
|
||||
allowExternalBlobOutsideBases: true,
|
||||
progress: (progress) => {
|
||||
if (progress.done) {
|
||||
resolveFinal(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(result.version).toBeGreaterThan(0);
|
||||
const progress = await finalProgress;
|
||||
expect(progress.outputRows).toBe(1);
|
||||
expect(progress.totalRows).toBe(1);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const rows = await table.query().withRowId().toArray();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe(2n);
|
||||
const bytes = await table.fetchBlobs("payload", [rows[0]._rowid as bigint]);
|
||||
expect(bytes[0]).toEqual(payload);
|
||||
});
|
||||
|
||||
it("discovers blob columns", async () => {
|
||||
const { table } = await openBlobTable();
|
||||
expect(await table.blobColumns()).toEqual(["image"]);
|
||||
@@ -2700,6 +2833,20 @@ describe("when dealing with blob columns", () => {
|
||||
const rowIds = [1, 2, 3].map((id) => rowIdById.get(id)!);
|
||||
return { table, rowIds, alpha, beta };
|
||||
}
|
||||
|
||||
async function openExternalBlobTable() {
|
||||
const db = await connect(path.join(tmpDir.name, "db"));
|
||||
const payload = Buffer.from("external-payload");
|
||||
const payloadPath = path.join(tmpDir.name, "payload.bin");
|
||||
fs.writeFileSync(payloadPath, payload);
|
||||
const uri = pathToFileURL(payloadPath).href;
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
blob("payload"),
|
||||
]);
|
||||
const table = await db.createEmptyTable("external_blobs", schema);
|
||||
return { db, table, schema, uri, payload };
|
||||
}
|
||||
});
|
||||
|
||||
describe("when dealing with tags", () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ test.each([
|
||||
[new ArrayBuffer(8), "X'0000000000000000'"],
|
||||
[Buffer.from("hello"), "X'68656c6c6f'"],
|
||||
["Hello 'world'", "'Hello ''world'''"],
|
||||
[NaN, "CAST('NaN' AS DOUBLE)"],
|
||||
[Infinity, "CAST('Infinity' AS DOUBLE)"],
|
||||
[-Infinity, "CAST('-Infinity' AS DOUBLE)"],
|
||||
])("toSQL(%p) === %p", (value, expected) => {
|
||||
expect(toSQL(value)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -9,9 +9,10 @@ export const DEFINITION_META_KEY = "mv.definition";
|
||||
|
||||
/**
|
||||
* The newest stored layout this version reads: `{"format": N, "query": "<SQL>"}`,
|
||||
* format 2 being a query with `GROUP BY`.
|
||||
* format 2 being a query with `GROUP BY`, format 3 native pairs and format 4
|
||||
* native dedup results.
|
||||
*/
|
||||
export const DEFINITION_FORMAT = 2;
|
||||
export const DEFINITION_FORMAT = 4;
|
||||
|
||||
/**
|
||||
* The query that defines a materialized view, as stored:
|
||||
|
||||
+21
-1
@@ -124,6 +124,17 @@ export interface AddDataOptions {
|
||||
* ```
|
||||
*/
|
||||
progress: (progress: WriteProgress) => void;
|
||||
|
||||
/**
|
||||
* Whether blob URIs outside registered external bases may be written.
|
||||
*
|
||||
* Defaults to `false`. This option is supported only for local/native
|
||||
* tables; remote tables return an error when it is enabled. An enabled write
|
||||
* stores the absolute URI reference without registering a base or copying
|
||||
* the external object into the database. The object must remain accessible
|
||||
* when the blob is read later.
|
||||
*/
|
||||
allowExternalBlobOutsideBases?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateOptions {
|
||||
@@ -528,6 +539,10 @@ export abstract class Table {
|
||||
* duplicates. Null blobs are `null`. Empty blobs are empty buffers.
|
||||
* One missing or deleted row ID rejects the entire batch, including on remote
|
||||
* tables; no partial results are returned.
|
||||
* Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
|
||||
* The client splits requests automatically and reads an individual larger
|
||||
* blob through the Range route. This method still materializes all bytes in
|
||||
* memory; use {@link Table.fetchBlobFiles} for large values.
|
||||
*/
|
||||
abstract fetchBlobs(
|
||||
column: string,
|
||||
@@ -1095,7 +1110,12 @@ export class LocalTable extends Table {
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
return await this.inner.add(buffer, mode, progress);
|
||||
return await this.inner.add(
|
||||
buffer,
|
||||
mode,
|
||||
progress,
|
||||
options?.allowExternalBlobOutsideBases,
|
||||
);
|
||||
}
|
||||
|
||||
async update(
|
||||
|
||||
@@ -15,6 +15,14 @@ export function toSQL(value: IntoSql): string {
|
||||
if (typeof value === "string") {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
} else if (typeof value === "number") {
|
||||
// toString() gives "NaN" / "Infinity", which SQL reads as column names.
|
||||
if (Number.isNaN(value)) {
|
||||
return "CAST('NaN' AS DOUBLE)";
|
||||
} else if (!Number.isFinite(value)) {
|
||||
return value > 0
|
||||
? "CAST('Infinity' AS DOUBLE)"
|
||||
: "CAST('-Infinity' AS DOUBLE)";
|
||||
}
|
||||
return value.toString();
|
||||
} else if (typeof value === "boolean") {
|
||||
return value ? "TRUE" : "FALSE";
|
||||
|
||||
+5
-1
@@ -74,13 +74,14 @@ impl Table {
|
||||
|
||||
#[napi(
|
||||
catch_unwind,
|
||||
ts_args_type = "buf: Buffer, mode: string, progressCallback?: (progress: WriteProgressInfo) => void"
|
||||
ts_args_type = "buf: Buffer, mode: string, progressCallback?: (progress: WriteProgressInfo) => void, allowExternalBlobOutsideBases?: boolean"
|
||||
)]
|
||||
pub async fn add(
|
||||
&self,
|
||||
buf: Buffer,
|
||||
mode: String,
|
||||
progress_callback: Option<ProgressFn>,
|
||||
allow_external_blob_outside_bases: Option<bool>,
|
||||
) -> napi::Result<AddResult> {
|
||||
let batches = ipc_file_to_batches(buf.to_vec())
|
||||
.map_err(|e| napi::Error::from_reason(format!("Failed to read IPC file: {}", e)))?;
|
||||
@@ -105,6 +106,9 @@ impl Table {
|
||||
return Err(napi::Error::from_reason(format!("Invalid mode: {}", mode)));
|
||||
};
|
||||
|
||||
op = op
|
||||
.allow_external_blob_outside_bases(allow_external_blob_outside_bases.unwrap_or(false));
|
||||
|
||||
if let Some(tsfn) = progress_callback {
|
||||
op = op.progress(move |p| {
|
||||
// NonBlocking: dispatch onto the JS event loop without
|
||||
|
||||
Reference in New Issue
Block a user