mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-23 20:22:37 +00:00
`blobMode: "bytes"` left blobs inside structs as descriptors because projection matching ignored parent structs and byte replacement only rebuilt top-level fields. Expand direct struct projections and aliases to their blob leaves, then recursively replace the corresponding Arrow fields and data with `LargeBinary`. Track literal output names separately from descendant paths so scalar aliases stay intact and every overlapping blob projection is converted. Preserve null structs, null and empty blobs, sibling fields, schema metadata, and the existing row ID and snapshot behavior. Blobs inside lists retain their descriptors. Update the API docs to describe struct support. Regression coverage includes full and parent-struct projections, leaf and struct aliases, dotted output names, overlapping projections in both orders, nested structs, nulls, empty blobs and results, multiple batches, list siblings, query reuse, row IDs, and concurrent writes. Regression tests reproduce the nested-blob and projection-collision failures before the corresponding changes. Validation: - `pnpm build` - `pnpm lint` - `pnpm run docs` - `pnpm test __test__/nested_blob.test.ts __test__/blob.test.ts __test__/query.test.ts --runInBand --verbose=false` — 142 passed - `pnpm test __test__/table.test.ts --runInBand --verbose=false -t 'when dealing with blob columns'` — 60 passed Fixes #4609 <!-- lance-gatekeeper-fix:v1 agent=76edaac77d044c468d569b58efa2104a generation=1 --> --------- Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> Co-authored-by: Xuanwo <github@xuanwo.io>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import * as tmp from "tmp";
|
|
import { type Table, blob, connect, isBlobField } from "../lancedb";
|
|
import {
|
|
DataType,
|
|
Field,
|
|
Int64,
|
|
List,
|
|
Schema,
|
|
Struct,
|
|
Utf8,
|
|
} from "../lancedb/arrow";
|
|
|
|
describe('nested blobs with blobMode "bytes"', () => {
|
|
let tmpDir: tmp.DirResult;
|
|
let table: Table;
|
|
|
|
const caption = new Field(
|
|
"caption",
|
|
new Utf8(),
|
|
false,
|
|
new Map([["label", "caption"]]),
|
|
);
|
|
const info = new Field(
|
|
"info",
|
|
new Struct([
|
|
blob("image"),
|
|
caption,
|
|
new Field("preview", new Struct([blob("image")]), true),
|
|
]),
|
|
true,
|
|
new Map([["label", "info"]]),
|
|
);
|
|
const schema = new Schema(
|
|
[new Field("id", new Int64(), false), info, blob("cover")],
|
|
new Map([["source", "nested-blobs"]]),
|
|
);
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
|
const db = await connect(tmpDir.name);
|
|
table = await db.createTable(
|
|
"nested_blobs",
|
|
[
|
|
{
|
|
id: 1n,
|
|
info: {
|
|
image: Buffer.from("hello"),
|
|
caption: "first",
|
|
preview: { image: Buffer.from("preview") },
|
|
},
|
|
cover: Buffer.from("cover"),
|
|
},
|
|
{ id: 2n, info: null, cover: null },
|
|
{
|
|
id: 3n,
|
|
info: { image: null, caption: "third", preview: null },
|
|
cover: null,
|
|
},
|
|
{
|
|
id: 4n,
|
|
info: {
|
|
image: Buffer.alloc(0),
|
|
caption: "fourth",
|
|
preview: { image: null },
|
|
},
|
|
cover: Buffer.alloc(0),
|
|
},
|
|
],
|
|
{ schema },
|
|
);
|
|
});
|
|
|
|
afterEach(() => tmpDir.removeCallback());
|
|
|
|
it.each([false, true])(
|
|
"reads struct leaves across batches (select parent: %s)",
|
|
async (selectParent) => {
|
|
const query = table
|
|
.query()
|
|
.orderBy({ columnName: "id", ascending: true });
|
|
if (selectParent) {
|
|
query.select(["info"]);
|
|
}
|
|
const result = await query.toArrow({
|
|
blobMode: "bytes",
|
|
maxBatchLength: 1,
|
|
});
|
|
expect(result.batches.length).toBeGreaterThan(1);
|
|
expect(result.schema.fields.map((field) => field.name)).toEqual(
|
|
selectParent ? ["info"] : ["id", "info", "cover"],
|
|
);
|
|
expect(result.schema.metadata).toEqual(schema.metadata);
|
|
const infoField = result.schema.fields.find(
|
|
(field) => field.name === "info",
|
|
)!;
|
|
expect(infoField.nullable).toBe(true);
|
|
expect(infoField.metadata).toEqual(info.metadata);
|
|
const fields = (infoField.type as Struct).children;
|
|
expect(DataType.isLargeBinary(fields[0].type)).toBe(true);
|
|
expect(isBlobField(fields[0])).toBe(false);
|
|
expect(fields[0].metadata.size).toBe(0);
|
|
expect(fields[1]).toEqual(caption);
|
|
expect(
|
|
DataType.isLargeBinary((fields[2].type as Struct).children[0].type),
|
|
).toBe(true);
|
|
|
|
const rows = result.toArray();
|
|
expect(Buffer.from(rows[0].info.image).toString()).toBe("hello");
|
|
expect(rows[0].info.caption).toBe("first");
|
|
expect(Buffer.from(rows[0].info.preview.image).toString()).toBe(
|
|
"preview",
|
|
);
|
|
expect(rows[1].info).toBeNull();
|
|
expect(rows[2].info.image).toBeNull();
|
|
expect(rows[2].info.caption).toBe("third");
|
|
expect(rows[2].info.preview).toBeNull();
|
|
expect(Buffer.from(rows[3].info.image)).toEqual(Buffer.alloc(0));
|
|
expect(rows[3].info.preview.image).toBeNull();
|
|
if (!selectParent) {
|
|
expect(Buffer.from(rows[0].cover).toString()).toBe("cover");
|
|
expect(rows[1].cover).toBeNull();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("reads direct leaf and struct aliases", async () => {
|
|
const rows = await table
|
|
.query()
|
|
.orderBy({ columnName: "id", ascending: true })
|
|
.select({
|
|
image: "info.image",
|
|
details: "info",
|
|
preview: "info.preview",
|
|
caption: "info.caption",
|
|
})
|
|
.toArray({ blobMode: "bytes", maxBatchLength: 1 });
|
|
expect(Object.keys(rows[0])).toEqual([
|
|
"image",
|
|
"details",
|
|
"preview",
|
|
"caption",
|
|
]);
|
|
expect(Buffer.from(rows[0].image).toString()).toBe("hello");
|
|
expect(Buffer.from(rows[0].details.image).toString()).toBe("hello");
|
|
expect(Buffer.from(rows[0].details.preview.image).toString()).toBe(
|
|
"preview",
|
|
);
|
|
expect(Buffer.from(rows[0].preview.image).toString()).toBe("preview");
|
|
expect(rows[0].caption).toBe("first");
|
|
expect(rows[1].details).toBeNull();
|
|
expect(rows[1].image).toBeNull();
|
|
expect(rows[2].image).toBeNull();
|
|
expect(Buffer.from(rows[3].image)).toEqual(Buffer.alloc(0));
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves a literal scalar alias matching a nested blob path (reverse: %s)",
|
|
async (reverse) => {
|
|
const projection: [string, string][] = [
|
|
["info", "info"],
|
|
["info.image", "id"],
|
|
];
|
|
if (reverse) {
|
|
projection.reverse();
|
|
}
|
|
const [row] = await table
|
|
.query()
|
|
.where("id = 1")
|
|
.select(new Map(projection))
|
|
.toArray({ blobMode: "bytes" });
|
|
expect(row["info.image"]).toBe(1n);
|
|
expect(Buffer.from(row.info.image).toString()).toBe("hello");
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"materializes every overlapping blob projection (reverse: %s)",
|
|
async (reverse) => {
|
|
const columns = ["info.image", "info"];
|
|
if (reverse) {
|
|
columns.reverse();
|
|
}
|
|
const result = await table
|
|
.query()
|
|
.orderBy({ columnName: "id", ascending: true })
|
|
.select(columns)
|
|
.toArrow({ blobMode: "bytes", maxBatchLength: 1 });
|
|
expect(result.schema.fields.map((field) => field.name)).toEqual(columns);
|
|
expect(DataType.isLargeBinary(result.getChild("info.image")!.type)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
DataType.isLargeBinary(
|
|
result.getChild("info")!.getChild("image")!.type,
|
|
),
|
|
).toBe(true);
|
|
const rows = result.toArray();
|
|
expect(Buffer.from(rows[0]["info.image"]).toString()).toBe("hello");
|
|
expect(Buffer.from(rows[0].info.image).toString()).toBe("hello");
|
|
expect(rows[0].info.caption).toBe("first");
|
|
expect(rows[1]["info.image"]).toBeNull();
|
|
expect(rows[1].info).toBeNull();
|
|
expect(rows[2]["info.image"]).toBeNull();
|
|
expect(rows[2].info.image).toBeNull();
|
|
expect(Buffer.from(rows[3]["info.image"])).toEqual(Buffer.alloc(0));
|
|
expect(Buffer.from(rows[3].info.image)).toEqual(Buffer.alloc(0));
|
|
},
|
|
);
|
|
|
|
it("materializes dotted aliases independently of nested fields", async () => {
|
|
const [row] = await table
|
|
.query()
|
|
.where("id = 1")
|
|
.select({
|
|
details: "info",
|
|
"details.image": "info.image",
|
|
"details.preview": "info.preview",
|
|
"details.preview.image": "info.preview.image",
|
|
})
|
|
.toArray({ blobMode: "bytes" });
|
|
expect(Buffer.from(row.details.image).toString()).toBe("hello");
|
|
expect(Buffer.from(row["details.image"]).toString()).toBe("hello");
|
|
expect(Buffer.from(row.details.preview.image).toString()).toBe("preview");
|
|
expect(Buffer.from(row["details.preview"].image).toString()).toBe(
|
|
"preview",
|
|
);
|
|
expect(Buffer.from(row["details.preview.image"]).toString()).toBe(
|
|
"preview",
|
|
);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"converts the empty result schema (alias: %s)",
|
|
async (alias) => {
|
|
const query = table.query().where("id < 0");
|
|
if (alias) {
|
|
query.select({ image: "info.image", details: "info" });
|
|
}
|
|
const result = await query.toArrow({ blobMode: "bytes" });
|
|
expect(result.numRows).toBe(0);
|
|
expect(result.schema.fields.map((field) => field.name)).toEqual(
|
|
alias ? ["image", "details"] : ["id", "info", "cover"],
|
|
);
|
|
const fields = (
|
|
result.schema.fields.find(
|
|
(field) => field.name === (alias ? "details" : "info"),
|
|
)!.type as Struct
|
|
).children;
|
|
expect(DataType.isLargeBinary(fields[0].type)).toBe(true);
|
|
expect(isBlobField(fields[0])).toBe(false);
|
|
expect(fields[1]).toEqual(caption);
|
|
expect(
|
|
DataType.isLargeBinary((fields[2].type as Struct).children[0].type),
|
|
).toBe(true);
|
|
if (alias) {
|
|
expect(DataType.isLargeBinary(result.schema.fields[0].type)).toBe(true);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("preserves requested row IDs and leaves subsequent descriptors intact", async () => {
|
|
const query = table.query().where("id = 1").select(["info"]);
|
|
const [bytes] = await query.toArray({ blobMode: "bytes" });
|
|
expect(Object.keys(bytes)).toEqual(["info"]);
|
|
const [description] = await query.toArray();
|
|
expect(Object.keys(description)).toEqual(["info"]);
|
|
expect(Number(description.info.image.size)).toBe(5);
|
|
const [withRowId] = await query.withRowId().toArray({ blobMode: "bytes" });
|
|
expect(typeof withRowId._rowid).toBe("bigint");
|
|
expect(await table.fetchBlobs("info.image", [withRowId._rowid])).toEqual([
|
|
Buffer.from(withRowId.info.image),
|
|
]);
|
|
});
|
|
|
|
it("keeps descriptors in description mode", async () => {
|
|
const [description] = await table
|
|
.query()
|
|
.where("id = 1")
|
|
.toArray({ blobMode: "descriptions" });
|
|
expect(Number(description.info.image.size)).toBe(5);
|
|
});
|
|
|
|
it("keeps list elements as descriptors alongside struct blob bytes", async () => {
|
|
const db = await connect(tmpDir.name);
|
|
const table = await db.createTable(
|
|
"list_blobs",
|
|
[
|
|
{
|
|
info: { image: Buffer.from("hello"), images: [Buffer.from("list")] },
|
|
},
|
|
],
|
|
{
|
|
schema: new Schema([
|
|
new Field(
|
|
"info",
|
|
new Struct([
|
|
blob("image"),
|
|
new Field("images", new List(blob("item")), true),
|
|
]),
|
|
true,
|
|
),
|
|
]),
|
|
},
|
|
);
|
|
const [bytes] = await table.query().toArray({ blobMode: "bytes" });
|
|
expect(Buffer.from(bytes.info.image).toString()).toBe("hello");
|
|
expect(Number(bytes.info.images.get(0).size)).toBe(4);
|
|
const [row] = await table
|
|
.query()
|
|
.select(["info.images"])
|
|
.toArray({ blobMode: "bytes" });
|
|
expect(Number(row["info.images"].get(0).size)).toBe(4);
|
|
expect(Object.keys(row)).toEqual(["info.images"]);
|
|
const result = await table.query().toArrow({ blobMode: "bytes" });
|
|
const fields = (result.schema.fields[0].type as Struct).children;
|
|
const descriptions = await table.query().toArrow();
|
|
expect(fields[1]).toEqual(
|
|
(descriptions.schema.fields[0].type as Struct).children[1],
|
|
);
|
|
expect(DataType.isStruct((fields[1].type as List).children[0].type)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|