From 1842c88f2c1c23bd688d6c23d12cddf519af459a Mon Sep 17 00:00:00 2001 From: "lancedb-gatefixer[bot]" <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:22:40 -0700 Subject: [PATCH] fix(node): reject unsupported object row values (#4307) ## Cause Schema inference treated unrecognized objects as nested structs. Objects without enumerable fields produced no field paths, so schema matching dropped their columns before Arrow or blob conversion. Blob instances with own fields were mistaken for struct fields. ## Fix Walk only nonempty plain records as structs. Reject unsupported object values in scalar fields, nested structs, and list elements with the field path and row number. Preserve the object types already passed through to Arrow. Support for converting these new input types is tracked in #4270. Nested class instances with enumerable fields previously became structs; this change rejects them and is labeled `breaking-change`. ## Validation - `pnpm build` - `pnpm lint` - `pnpm test __test__/arrow.test.ts __test__/blob.test.ts __test__/table.test.ts --runInBand --silent` (626 passed) - `pnpm run docs` Fixes #4269 --------- Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> --- nodejs/__test__/arrow.test.ts | 97 +++++++++++++++++++++++++++++++++++ nodejs/__test__/blob.test.ts | 21 ++++++++ nodejs/__test__/table.test.ts | 17 ++++++ nodejs/lancedb/schema.ts | 58 +++++++++++++++++---- 4 files changed, 184 insertions(+), 9 deletions(-) diff --git a/nodejs/__test__/arrow.test.ts b/nodejs/__test__/arrow.test.ts index 6c1d5ecba..df02b4936 100644 --- a/nodejs/__test__/arrow.test.ts +++ b/nodejs/__test__/arrow.test.ts @@ -601,6 +601,103 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( ); }); + it.each([ + ["ArrayBuffer", new Uint8Array([104]).buffer], + ["URL", new URL("https://example.com/")], + ["empty object", {}], + [ + "class instance with enumerable fields", + Object.assign(new Blob(["x"]), { position: 0 }), + ], + ])("rejects %s values without a schema", (_name, value) => { + expect(() => makeArrowTable([{ id: 1, value }])).toThrow( + /field value at row 0/, + ); + expect(() => makeArrowTable([{ value }])).toThrow( + /field value at row 0/, + ); + }); + + it.each([ + ["Binary", new Binary(), new Uint8Array([104]).buffer], + ["Utf8", new Utf8(), new URL("https://example.com/")], + ])("rejects %s values with a schema", (_name, type, value) => { + const schema = new Schema([new Field("value", type, true)]); + expect(() => makeArrowTable([{ value }], { schema })).toThrow( + /field value at row 0/, + ); + }); + + it("rejects unsupported values in nested struct fields", () => { + const schema = new Schema([ + new Field( + "metadata", + new Struct([new Field("bytes", new Binary(), true)]), + true, + ), + ]); + expect(() => + makeArrowTable( + [{ metadata: { bytes: new Uint8Array([104]).buffer } }], + { schema }, + ), + ).toThrow(/field metadata\.bytes at row 0/); + }); + + it("rejects unsupported objects in list elements", () => { + const schema = new Schema([ + new Field( + "images", + new List(new Field("item", new Binary(), true)), + true, + ), + ]); + expect(() => + makeArrowTable([{ images: [new Blob(["abc"])] }], { schema }), + ).toThrow(/field images\[0\] at row 0/); + }); + + it("rejects unsupported objects in nested lists", () => { + const schema = new Schema([ + new Field( + "images", + new List( + new Field( + "item", + new List(new Field("item", new Binary(), true)), + true, + ), + ), + true, + ), + ]); + expect(() => + makeArrowTable([{ images: [[new Blob(["abc"])]] }], { schema }), + ).toThrow(/field images\[0\]\[0\] at row 0/); + }); + + it("rejects unsupported nested struct fields in lists", () => { + const schema = new Schema([ + new Field( + "items", + new List( + new Field( + "item", + new Struct([new Field("bytes", new Binary(), true)]), + true, + ), + ), + true, + ), + ]); + expect(() => + makeArrowTable( + [{ items: [{ bytes: new Uint8Array([104]).buffer }] }], + { schema }, + ), + ).toThrow(/field items\[0\]\.bytes at row 0/); + }); + it("will ignore generated dictionary IDs when comparing inferred types", function () { const table = makeArrowTable([{ str: "a" }, { str: "b" }], { dictionaryEncodeStrings: true, diff --git a/nodejs/__test__/blob.test.ts b/nodejs/__test__/blob.test.ts index e8e9357e3..5c01897b2 100644 --- a/nodejs/__test__/blob.test.ts +++ b/nodejs/__test__/blob.test.ts @@ -102,6 +102,27 @@ 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") }], + ])("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", () => { + 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.", + ); + }); + it("coerces Buffer input onto a blob field", () => { const schema = new Schema([ new Field("id", new Int64(), true), diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 4142af696..ac8904c16 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -2412,6 +2412,23 @@ describe("when dealing with blob columns", () => { tmpDir.removeCallback(); }); + it("rejects unsupported blob values in createTable and add", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + blob("image"), + ]); + const row = { id: 1n, image: new Uint8Array([104]).buffer }; + + await expect(db.createTable("invalid", [row], { schema })).rejects.toThrow( + /field image at row 0/, + ); + + const table = await db.createEmptyTable("empty", schema); + await expect(table.add([row])).rejects.toThrow(/field image at row 0/); + await expect(table.countRows()).resolves.toBe(0); + }); + it("discovers blob columns", async () => { const { table } = await openBlobTable(); expect(await table.blobColumns()).toEqual(["image"]); diff --git a/nodejs/lancedb/schema.ts b/nodejs/lancedb/schema.ts index 3a3ee9316..724a81fc9 100644 --- a/nodejs/lancedb/schema.ts +++ b/nodejs/lancedb/schema.ts @@ -62,6 +62,8 @@ class SchemaInferrer { } private observe(path: string[], value: unknown, row: number): void { + assertSupportedValue(value, path.join("."), row); + const current = this.fields.get(path); if (current === undefined) { this.addField(path, value, row); @@ -430,19 +432,57 @@ function* recordPathsAndValues( } function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + const prototype = Object.getPrototypeOf(value); return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - !(value instanceof RegExp) && - !(value instanceof Date) && - !(value instanceof Set) && - !(value instanceof Map) && - !(value instanceof Buffer) && - !ArrayBuffer.isView(value) + (prototype === Object.prototype || prototype === null) && + Object.keys(value).length > 0 ); } +function isUnsupportedObject(value: unknown): boolean { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + isRecord(value) + ) { + return false; + } + // Preserve the object types already passed through to Arrow. Other objects + // must not disappear while walking struct fields. + return !( + value instanceof RegExp || + value instanceof Date || + value instanceof Set || + value instanceof Map || + Buffer.isBuffer(value) || + ArrayBuffer.isView(value) + ); +} + +function assertSupportedValue( + value: unknown, + field: string, + row: number, +): void { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + assertSupportedValue(item, `${field}[${index}]`, row); + } + } else if (isRecord(value)) { + for (const [name, child] of Object.entries(value)) { + assertSupportedValue(child, `${field}.${name}`, row); + } + } else if (isUnsupportedObject(value)) { + throw new Error( + `Unsupported object value for field ${field} at row ${row}.`, + ); + } +} + function fieldAtPath(schema: Schema, path: string[]): Field | undefined { let fields = schema.fields; let field: Field | undefined;