From 2734e1076797969aa94d71dded5324da19753229 Mon Sep 17 00:00:00 2001 From: Dan Tasse <105866+dantasse@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:56:46 -0500 Subject: [PATCH] fix(node): update arrow to 21 (#4362) Thanks @vincentkoc for starting this! I was just looking into this myself. I started with your PR and added: - make arrow 21 the default - widen the peer range to <22 so if they release 21.3 that'll be included - have to do skipLibCheck in tsconfig.json, because of flatbuffers 25 which needs TypeScript 5.7 - fix override in Symbol.hasInstance - support new types in Arrow 20-21 (LargeList, Utf8View, BinaryView, IntervalMonthDayNano) - arrow-compat job in nodejs.yml that runs tests with arrow 19 and 20 (adds 2 sec) - put the list of arrow types in one place - update NODEJS_THIRD_PARTY_LICENSES.md - regenerated typescript docs (sorry about reorderings, one time cost) - reformat arrow.test.ts (sorry, almost all whitespace, thanks to biome. git diff -w shows 110 lines edited) - fixed Sliced Utf8View / BinaryView serialization error, by just converting them to Utf8 and Binary (as our rust code would anyway) --------- Co-authored-by: Vincent Koc Co-authored-by: Claude Fable 5.1 --- .github/workflows/nodejs.yml | 65 + docs/src/js/classes/AutoQuery.md | 2 +- docs/src/js/classes/Query.md | 2 +- docs/src/js/classes/QueryBase.md | 2 +- docs/src/js/classes/Table.md | 2 +- docs/src/js/classes/TakeQuery.md | 2 +- docs/src/js/classes/VectorQuery.md | 2 +- docs/src/js/globals.md | 1 + docs/src/js/interfaces/CherryPickResult.md | 6 +- .../src/js/interfaces/DropNamespaceOptions.md | 2 +- .../embedding/classes/EmbeddingFunction.md | 4 +- .../classes/TextEmbeddingFunction.md | 4 +- docs/src/js/type-aliases/DataTypeLike.md | 18 + nodejs/NODEJS_THIRD_PARTY_LICENSES.md | 8 + nodejs/__test__/arrow.test.ts | 2871 ++++++++--------- nodejs/__test__/arrow_versions.ts | 39 + nodejs/__test__/registry.test.ts | 13 +- nodejs/__test__/sanitize.test.ts | 114 +- nodejs/__test__/table.test.ts | 129 +- nodejs/lancedb/arrow.ts | 87 +- nodejs/lancedb/index.ts | 1 + nodejs/lancedb/sanitize.ts | 146 +- nodejs/package.json | 6 +- nodejs/pnpm-lock.yaml | 98 +- nodejs/tsconfig.json | 5 +- 25 files changed, 2110 insertions(+), 1519 deletions(-) create mode 100644 docs/src/js/type-aliases/DataTypeLike.md create mode 100644 nodejs/__test__/arrow_versions.ts diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 98acccf02..2e290166e 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -164,6 +164,71 @@ jobs: python ci/mock_openai.py & cd nodejs/examples node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose + arrow-compat: + # The main jobs resolve `apache-arrow` to the version pinned in + # devDependencies (the newest supported release). This job re-runs the + # suite with the library itself running on each older Arrow release the + # peer range still accepts, which is what a consumer on that version gets. + name: Linux (Arrow ${{ matrix.arrow-version }}) + timeout-minutes: 30 + strategy: + matrix: + arrow-version: [ "19.0.1", "20.0.0" ] + runs-on: "ubuntu-22.04" + defaults: + run: + shell: bash + working-directory: nodejs + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + lfs: true + - uses: pnpm/action-setup@v6 + with: + version: 11.1.1 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: nodejs/pnpm-lock.yaml + - uses: Swatinem/rust-cache@v2 + with: + # Same crate, profile and target as the `linux` job above, so reuse + # its cache: rust-cache keys on the job id by default, and this job + # would otherwise start cold on every run until it had saved its own. + shared-key: linux + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install dependencies + run: | + sudo apt update + sudo apt install -y protobuf-compiler libssl-dev + - name: Build + run: | + pnpm install --frozen-lockfile + # No `--` separator: pnpm forwards it literally, which would + # make napi-rs treat `--profile ci` as a cargo passthrough arg. + pnpm build:debug --profile ci + - name: Resolve apache-arrow to ${{ matrix.arrow-version }} + run: | + # The older releases are already installed as the `apache-arrow-NN` + # test aliases. With pnpm's hoisted linker each alias is a complete, + # self-contained directory, so swap it in for the library's own + # `apache-arrow` instead of running a second install, which has been + # seen to hang after printing "Done" in CI. A copy, not a symlink: the + # cross-version tests rely on the alias being a separate module + # instance from the one the library loads. + major="$(echo '${{ matrix.arrow-version }}' | cut -d. -f1)" + mv node_modules/apache-arrow node_modules/apache-arrow.pinned + cp -R "node_modules/apache-arrow-${major}" node_modules/apache-arrow + # `require("apache-arrow/package.json")` is blocked by the package's exports map. + node -p "'apache-arrow resolved to ' + JSON.parse(require('fs').readFileSync('node_modules/apache-arrow/package.json')).version" + pnpm tsc + - name: Test + env: + # Newer @smithy/core uses dynamic ESM imports. + NODE_OPTIONS: "--experimental-vm-modules" + run: node_modules/.bin/jest --verbose macos: timeout-minutes: 30 # macos-15 ships a newer linker; the older macos-14 linker fails to insert diff --git a/docs/src/js/classes/AutoQuery.md b/docs/src/js/classes/AutoQuery.md index 1d7ea6952..79d666124 100644 --- a/docs/src/js/classes/AutoQuery.md +++ b/docs/src/js/classes/AutoQuery.md @@ -350,7 +350,7 @@ input to this method would be: #### Parameters -* **columns**: `string` \| `string`[] \| `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **columns**: `string` \| `string`[] \| `Map`<`string`, `string`> \| `Record`<`string`, `string`> #### Returns diff --git a/docs/src/js/classes/Query.md b/docs/src/js/classes/Query.md index 6ebaebd75..0abf49755 100644 --- a/docs/src/js/classes/Query.md +++ b/docs/src/js/classes/Query.md @@ -424,7 +424,7 @@ input to this method would be: #### Parameters -* **columns**: `string` \| `string`[] \| `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **columns**: `string` \| `string`[] \| `Map`<`string`, `string`> \| `Record`<`string`, `string`> #### Returns diff --git a/docs/src/js/classes/QueryBase.md b/docs/src/js/classes/QueryBase.md index 35c071525..664337bad 100644 --- a/docs/src/js/classes/QueryBase.md +++ b/docs/src/js/classes/QueryBase.md @@ -193,7 +193,7 @@ input to this method would be: #### Parameters -* **columns**: `string` \| `string`[] \| `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **columns**: `string` \| `string`[] \| `Map`<`string`, `string`> \| `Record`<`string`, `string`> #### Returns diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index 0d813ff1e..ac16b8a81 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -1381,7 +1381,7 @@ repeatedly calling this method. ##### Parameters -* **updates**: `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **updates**: `Map`<`string`, `string`> \| `Record`<`string`, `string`> the columns to update diff --git a/docs/src/js/classes/TakeQuery.md b/docs/src/js/classes/TakeQuery.md index 6ae2f9c8c..79a53f753 100644 --- a/docs/src/js/classes/TakeQuery.md +++ b/docs/src/js/classes/TakeQuery.md @@ -200,7 +200,7 @@ input to this method would be: #### Parameters -* **columns**: `string` \| `string`[] \| `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **columns**: `string` \| `string`[] \| `Map`<`string`, `string`> \| `Record`<`string`, `string`> #### Returns diff --git a/docs/src/js/classes/VectorQuery.md b/docs/src/js/classes/VectorQuery.md index f9412c76d..29749c28f 100644 --- a/docs/src/js/classes/VectorQuery.md +++ b/docs/src/js/classes/VectorQuery.md @@ -673,7 +673,7 @@ input to this method would be: #### Parameters -* **columns**: `string` \| `string`[] \| `Record`<`string`, `string`> \| `Map`<`string`, `string`> +* **columns**: `string` \| `string`[] \| `Map`<`string`, `string`> \| `Record`<`string`, `string`> #### Returns diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index a82d9e334..edb0bacc3 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -162,6 +162,7 @@ - [BlobReadOptions](type-aliases/BlobReadOptions.md) - [Data](type-aliases/Data.md) - [DataLike](type-aliases/DataLike.md) +- [DataTypeLike](type-aliases/DataTypeLike.md) - [FieldLike](type-aliases/FieldLike.md) - [IntoSql](type-aliases/IntoSql.md) - [IntoVector](type-aliases/IntoVector.md) diff --git a/docs/src/js/interfaces/CherryPickResult.md b/docs/src/js/interfaces/CherryPickResult.md index c837ab143..fdec8eedd 100644 --- a/docs/src/js/interfaces/CherryPickResult.md +++ b/docs/src/js/interfaces/CherryPickResult.md @@ -38,9 +38,9 @@ preview: CherryPickPreview; ```ts status: - | "failed" - | "unknown" | "ready" + | "failed" | "notImplemented" - | "cherryPicked"; + | "cherryPicked" + | "unknown"; ``` diff --git a/docs/src/js/interfaces/DropNamespaceOptions.md b/docs/src/js/interfaces/DropNamespaceOptions.md index 1863bb0cd..6e5f01d34 100644 --- a/docs/src/js/interfaces/DropNamespaceOptions.md +++ b/docs/src/js/interfaces/DropNamespaceOptions.md @@ -21,7 +21,7 @@ Refuse to drop if non-empty (restrict) or drop recursively (cascade). ### mode? ```ts -optional mode: "fail" | "skip"; +optional mode: "skip" | "fail"; ``` Whether to skip if the namespace doesn't exist, or fail. diff --git a/docs/src/js/namespaces/embedding/classes/EmbeddingFunction.md b/docs/src/js/namespaces/embedding/classes/EmbeddingFunction.md index 574a0d71f..b6291e0be 100644 --- a/docs/src/js/namespaces/embedding/classes/EmbeddingFunction.md +++ b/docs/src/js/namespaces/embedding/classes/EmbeddingFunction.md @@ -52,7 +52,7 @@ new EmbeddingFunction(): EmbeddingFunction ### computeQueryEmbeddings() ```ts -computeQueryEmbeddings(data): Promise +computeQueryEmbeddings(data): Promise ``` Compute the embeddings for a single query @@ -63,7 +63,7 @@ Compute the embeddings for a single query #### Returns -`Promise`<`number`[] \| `Uint8Array` \| `Float32Array` \| `Float64Array`> +`Promise`<`Float32Array` \| `Float64Array` \| `Uint8Array` \| `number`[]> *** diff --git a/docs/src/js/namespaces/embedding/classes/TextEmbeddingFunction.md b/docs/src/js/namespaces/embedding/classes/TextEmbeddingFunction.md index 444c4c3f0..0a0cc619d 100644 --- a/docs/src/js/namespaces/embedding/classes/TextEmbeddingFunction.md +++ b/docs/src/js/namespaces/embedding/classes/TextEmbeddingFunction.md @@ -37,7 +37,7 @@ new TextEmbeddingFunction(): TextEmbeddingFunction ### computeQueryEmbeddings() ```ts -computeQueryEmbeddings(data): Promise +computeQueryEmbeddings(data): Promise ``` Compute the embeddings for a single query @@ -48,7 +48,7 @@ Compute the embeddings for a single query #### Returns -`Promise`<`number`[] \| `Uint8Array` \| `Float32Array` \| `Float64Array`> +`Promise`<`Float32Array` \| `Float64Array` \| `Uint8Array` \| `number`[]> #### Overrides diff --git a/docs/src/js/type-aliases/DataTypeLike.md b/docs/src/js/type-aliases/DataTypeLike.md new file mode 100644 index 000000000..1f0ee6b81 --- /dev/null +++ b/docs/src/js/type-aliases/DataTypeLike.md @@ -0,0 +1,18 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / DataTypeLike + +# Type Alias: DataTypeLike + +```ts +type DataTypeLike: DataType | object; +``` + +A `DataType` from any copy or version of apache-arrow. + +Arrow 21 brands its classes with `unique symbol` properties, so a type +object from a second copy of the library no longer satisfies the `DataType` +type of this one even though it is structurally identical. Inputs that only +need to be sanitized accept this looser shape instead. diff --git a/nodejs/NODEJS_THIRD_PARTY_LICENSES.md b/nodejs/NODEJS_THIRD_PARTY_LICENSES.md index c0881f61e..550d2888c 100644 --- a/nodejs/NODEJS_THIRD_PARTY_LICENSES.md +++ b/nodejs/NODEJS_THIRD_PARTY_LICENSES.md @@ -275,6 +275,7 @@ [@types/node@20.16.10](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT [@types/node@20.17.9](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT [@types/node@22.7.4](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT +[@types/node@25.9.8](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT [@types/semver@7.5.6](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT [@types/stack-utils@2.0.3](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT [@types/tmp@0.2.6](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT @@ -306,6 +307,9 @@ [apache-arrow@16.0.0](https://github.com/apache/arrow) - Apache-2.0 [apache-arrow@17.0.0](https://github.com/apache/arrow) - Apache-2.0 [apache-arrow@18.0.0](https://github.com/apache/arrow) - Apache-2.0 +[apache-arrow@19.0.1](https://github.com/apache/arrow) - Apache-2.0 +[apache-arrow@20.0.0](https://github.com/apache/arrow) - Apache-2.0 +[apache-arrow@21.2.0](https://github.com/apache/arrow-js) - Apache-2.0 [argparse@1.0.10](https://github.com/nodeca/argparse) - MIT [argparse@2.0.1](https://github.com/nodeca/argparse) - Python-2.0 [array-back@3.1.0](https://github.com/75lb/array-back) - MIT @@ -348,6 +352,7 @@ [color@4.2.3](https://github.com/Qix-/color) - MIT [combined-stream@1.0.8](https://github.com/felixge/node-combined-stream) - MIT [command-line-args@5.2.1](https://github.com/75lb/command-line-args) - MIT +[command-line-args@6.0.2](https://github.com/75lb/command-line-args) - MIT [command-line-usage@7.0.1](https://github.com/75lb/command-line-usage) - MIT [concat-map@0.0.1](https://github.com/substack/node-concat-map) - MIT [convert-source-map@2.0.0](https://github.com/thlorenz/convert-source-map) - MIT @@ -398,12 +403,14 @@ [file-entry-cache@6.0.1](https://github.com/royriojas/file-entry-cache) - MIT [fill-range@7.1.1](https://github.com/jonschlinkert/fill-range) - MIT [find-replace@3.0.0](https://github.com/75lb/find-replace) - MIT +[find-replace@5.0.2](https://github.com/75lb/find-replace) - MIT [find-up@4.1.0](https://github.com/sindresorhus/find-up) - MIT [find-up@5.0.0](https://github.com/sindresorhus/find-up) - MIT [flat-cache@3.2.0](https://github.com/jaredwray/flat-cache) - MIT [flatbuffers@1.12.0](https://github.com/google/flatbuffers) - Apache* [flatbuffers@23.5.26](https://github.com/google/flatbuffers) - Apache* [flatbuffers@24.3.25](https://github.com/google/flatbuffers) - Apache-2.0 +[flatbuffers@25.9.23](https://github.com/google/flatbuffers) - Apache-2.0 [flatted@3.2.9](https://github.com/WebReflection/flatted) - ISC [follow-redirects@1.15.6](https://github.com/follow-redirects/follow-redirects) - MIT [foreground-child@3.3.0](https://github.com/tapjs/foreground-child) - ISC @@ -644,6 +651,7 @@ [uc.micro@2.1.0](https://github.com/markdown-it/uc.micro) - MIT [undici-types@5.26.5](https://github.com/nodejs/undici) - MIT [undici-types@6.19.8](https://github.com/nodejs/undici) - MIT +[undici-types@7.24.6](https://github.com/nodejs/undici) - MIT [update-browserslist-db@1.0.13](https://github.com/browserslist/update-db) - MIT [uri-js@4.4.1](https://github.com/garycourt/uri-js) - BSD-2-Clause [uuid@9.0.1](https://github.com/uuidjs/uuid) - MIT diff --git a/nodejs/__test__/arrow.test.ts b/nodejs/__test__/arrow.test.ts index df02b4936..e054a5495 100644 --- a/nodejs/__test__/arrow.test.ts +++ b/nodejs/__test__/arrow.test.ts @@ -2,10 +2,8 @@ // SPDX-FileCopyrightText: Copyright The LanceDB Authors import * as fs from "node:fs"; import * as vm from "node:vm"; -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 { type ApacheArrow, arrow15, arrowVersions } from "./arrow_versions"; import { Field as CurrentField, @@ -110,1541 +108,1502 @@ it("preserves field metadata from a provided schema", async function () { expect(roundTripped.schema.fields[0].metadata).toEqual(jsonMetadata); }); -describe.each([arrow15, arrow16, arrow17, arrow18])( - "Arrow", - ( - arrow: typeof arrow15 | typeof arrow16 | typeof arrow17 | typeof arrow18, - ) => { - type ApacheArrow = - | typeof arrow15 - | typeof arrow16 - | typeof arrow17 - | typeof arrow18; - const { - Schema, - Field, - Binary, - Bool, - Utf8, - Float64, - Struct, - List, - Map_, - Int16, - Int32, - Int64, - Float, - Float16, - Float32, - FixedSizeList, - Precision, - tableFromIPC, - DataType, - Dictionary, - RecordBatch: ArrowRecordBatch, - Table: ArrowTable, - Uint8: ArrowUint8, - makeData: arrowMakeData, - vectorFromArray, +describe.each(arrowVersions)("Arrow %#", (arrow: ApacheArrow) => { + const { + Schema, + Field, + Binary, + Bool, + Utf8, + Float64, + Struct, + List, + Map_, + Int16, + Int32, + Int64, + Float, + Float16, + Float32, + FixedSizeList, + Precision, + tableFromIPC, + DataType, + Dictionary, + RecordBatch: ArrowRecordBatch, + Table: ArrowTable, + Uint8: ArrowUint8, + makeData: arrowMakeData, + vectorFromArray, + // biome-ignore lint/suspicious/noExplicitAny: + } = arrow; + type Schema = ApacheArrow["Schema"]; + type Table = ApacheArrow["Table"]; + + function expectValidMapField( + // biome-ignore lint/suspicious/noExplicitAny: Arrow Field types vary across supported versions + field: any, + ): void { + expect(DataType.isMap(field.type)).toBe(true); + expect(field.type.keysSorted).toBe(true); + expect(field.type.children).toHaveLength(1); + + const entries = field.type.children[0]; + expect(entries.name).toBe("entries"); + expect(entries.nullable).toBe(false); + expect(DataType.isStruct(entries.type)).toBe(true); + expect(entries.type.children).toHaveLength(2); + + const [key, value] = entries.type.children; + expect([key.name, value.name]).toEqual(["key", "value"]); + expect(key.nullable).toBe(false); + expect(DataType.isUtf8(key.type)).toBe(true); + expect(value.nullable).toBe(true); + expect(DataType.isInt(value.type)).toBe(true); + expect(value.type.bitWidth).toBe(32); + expect(value.type.isSigned).toBe(true); + } + + // Helper method to verify various ways to create a table + async function checkTableCreation( + tableCreationMethod: ( + records: Record[], + recordsReversed: Record[], + schema: Schema, + ) => Promise, + infersTypes: boolean, + ): Promise { + const records = sampleRecords(); + const recordsReversed = [ + { + list: ["anime", "action", "comedy"], + struct: { x: 0, y: 0 }, + string: "hello", + number: 7, + boolean: false, + binary: Buffer.alloc(5), + }, + ]; + const schema = new Schema([ + new Field("binary", new Binary(), false), + new Field("boolean", new Bool(), false), + new Field("number", new Float64(), false), + new Field("string", new Utf8(), false), + new Field( + "struct", + new Struct([ + new Field("x", new Float64(), false), + new Field("y", new Float64(), false), + ]), + ), + new Field("list", new List(new Field("item", new Utf8(), false)), false), + ]); + const table = (await tableCreationMethod( + records, + recordsReversed, + schema, // biome-ignore lint/suspicious/noExplicitAny: - } = arrow; - type Schema = ApacheArrow["Schema"]; - type Table = ApacheArrow["Table"]; + )) as any; - function expectValidMapField( - // biome-ignore lint/suspicious/noExplicitAny: Arrow Field types vary across supported versions - field: any, - ): void { - expect(DataType.isMap(field.type)).toBe(true); - expect(field.type.keysSorted).toBe(true); - expect(field.type.children).toHaveLength(1); + // We expect deterministic ordering of the fields + expect(table.schema.names).toEqual(schema.names); - const entries = field.type.children[0]; - expect(entries.name).toBe("entries"); - expect(entries.nullable).toBe(false); - expect(DataType.isStruct(entries.type)).toBe(true); - expect(entries.type.children).toHaveLength(2); + schema.fields.forEach( + ( + // biome-ignore lint/suspicious/noExplicitAny: + field: { name: any; type: { toString: () => any } }, + idx: string | number, + ) => { + const actualField = table.schema.fields[idx]; + // Type inference always assumes nullable=true + if (infersTypes) { + expect(actualField.nullable).toBe(true); + } else { + expect(actualField.nullable).toBe(false); + } + expect(table.getChild(field.name)?.type.toString()).toEqual( + field.type.toString(), + ); + expect(table.getChildAt(idx)?.type.toString()).toEqual( + field.type.toString(), + ); + }, + ); + } - const [key, value] = entries.type.children; - expect([key.name, value.name]).toEqual(["key", "value"]); - expect(key.nullable).toBe(false); - expect(DataType.isUtf8(key.type)).toBe(true); - expect(value.nullable).toBe(true); - expect(DataType.isInt(value.type)).toBe(true); - expect(value.type.bitWidth).toBe(32); - expect(value.type.isSigned).toBe(true); - } - - // Helper method to verify various ways to create a table - async function checkTableCreation( - tableCreationMethod: ( - records: Record[], - recordsReversed: Record[], - schema: Schema, - ) => Promise
, - infersTypes: boolean, - ): Promise { - const records = sampleRecords(); - const recordsReversed = [ - { - list: ["anime", "action", "comedy"], - struct: { x: 0, y: 0 }, - string: "hello", - number: 7, - boolean: false, - binary: Buffer.alloc(5), - }, + describe("The function makeArrowTable", function () { + it("accepts snake_case embedding metadata like camelCase", function () { + const spellings = [ + // biome-ignore lint/style/useNamingConvention: the Python wire spelling + { source_column: "text", vector_column: "vector" }, + { sourceColumn: "text", vectorColumn: "vector" }, ]; - const schema = new Schema([ - new Field("binary", new Binary(), false), - new Field("boolean", new Bool(), false), - new Field("number", new Float64(), false), - new Field("string", new Utf8(), false), - new Field( - "struct", - new Struct([ - new Field("x", new Float64(), false), - new Field("y", new Float64(), false), + for (const columns of spellings) { + const schema = new Schema( + [ + new Field("text", new Utf8(), false), + new Field( + "vector", + new FixedSizeList(3, new Field("item", new Float32(), true)), + false, + ), + ], + new Map([ + [ + "embedding_functions", + JSON.stringify([{ name: "mock", model: {}, ...columns }]), + ], ]), - ), + ); + // The vector field is non-nullable and absent from the data; only a + // recognized embedding config makes that acceptable. + const table = makeArrowTable([{ text: "hello" }], { schema }); + expect(table.numRows).toBe(1); + } + }); + + it("will use data types from a provided schema instead of inference", async function () { + const schema = new Schema([ + new Field("a", new Int32(), false), + new Field("b", new Float32(), true), + new Field("c", new FixedSizeList(3, new Field("item", new Float16()))), + new Field("d", new Int64(), true), + ]); + const table = makeArrowTable( + [ + { a: 1, b: 2, c: [1, 2, 3], d: 9 }, + { a: 4, b: 5, c: [4, 5, 6], d: 10 }, + { a: 7, b: 8, c: [7, 8, 9], d: null }, + ], + { schema }, + ); + + const buf = await fromTableToBuffer(table); + expect(buf.byteLength).toBeGreaterThan(0); + + const actual = tableFromIPC(buf); + expect(actual.numRows).toBe(3); + const actualSchema = actual.schema; + expect(actualSchema).toEqual(schema); + expect(table.getChild("a")?.toJSON()).toEqual([1, 4, 7]); + expect(table.getChild("b")?.toJSON()).toEqual([2, 5, 8]); + expect(table.getChild("d")?.toJSON()).toEqual([9n, 10n, null]); + }); + + it("will use a provided FixedSizeList schema with typed array values", function () { + const schema = new Schema([ + new Field("text", new Utf8(), false), new Field( - "list", - new List(new Field("item", new Utf8(), false)), + "vector", + new FixedSizeList(3, new Field("item", new Float32(), false)), false, ), ]); - const table = (await tableCreationMethod( - records, - recordsReversed, - schema, - // biome-ignore lint/suspicious/noExplicitAny: - )) as any; - // We expect deterministic ordering of the fields - expect(table.schema.names).toEqual(schema.names); + const table = makeArrowTable( + [ + { + text: "foo", + vector: new Float32Array([1, 2, 3]), + }, + ], + { schema }, + ); - schema.fields.forEach( - ( - // biome-ignore lint/suspicious/noExplicitAny: - field: { name: any; type: { toString: () => any } }, - idx: string | number, - ) => { - const actualField = table.schema.fields[idx]; - // Type inference always assumes nullable=true - if (infersTypes) { - expect(actualField.nullable).toBe(true); - } else { - expect(actualField.nullable).toBe(false); - } - expect(table.getChild(field.name)?.type.toString()).toEqual( - field.type.toString(), - ); - expect(table.getChildAt(idx)?.type.toString()).toEqual( - field.type.toString(), - ); + expect(table.getChild("text")?.toJSON()).toEqual(["foo"]); + expect( + table + .getChild("vector") + ?.toJSON() + .map((value) => value.toJSON()), + ).toEqual([[1, 2, 3]]); + }); + + it("will assume the column `vector` is FixedSizeList by default", async function () { + const schema = new Schema([ + new Field("a", new Float(Precision.DOUBLE), true), + new Field("b", new Int64(), true), + new Field( + "vector", + new FixedSizeList( + 3, + new Field("item", new Float(Precision.SINGLE), true), + ), + true, + ), + ]); + const table = makeArrowTable([ + { a: 1, b: 2n, vector: [1, 2, 3] }, + { a: 4, b: 5n, vector: [4, 5, 6] }, + { a: 7, b: 8n, vector: [7, 8, 9] }, + ]); + + const buf = await fromTableToBuffer(table); + expect(buf.byteLength).toBeGreaterThan(0); + + const actual = tableFromIPC(buf); + expect(actual.numRows).toBe(3); + const actualSchema = actual.schema; + expect(actualSchema).toEqual(schema); + + expect(table.getChild("a")?.toJSON()).toEqual([1, 4, 7]); + expect(table.getChild("b")?.toJSON()).toEqual([2n, 5n, 8n]); + expect( + table + .getChild("vector") + ?.toJSON() + .map((v) => v.toJSON()), + ).toEqual([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]); + }); + + it("can support multiple vector columns", async function () { + const schema = new Schema([ + new Field("a", new Float(Precision.DOUBLE), true), + new Field("b", new Float(Precision.DOUBLE), true), + new Field( + "vec1", + new FixedSizeList(3, new Field("item", new Float16(), true)), + true, + ), + new Field( + "vec2", + new FixedSizeList(3, new Field("item", new Float64(), true)), + true, + ), + ]); + const table = makeArrowTable( + [ + { a: 1, b: 2, vec1: [1, 2, 3], vec2: [2, 4, 6] }, + { a: 4, b: 5, vec1: [4, 5, 6], vec2: [8, 10, 12] }, + { a: 7, b: 8, vec1: [7, 8, 9], vec2: [14, 16, 18] }, + ], + { + vectorColumns: { + vec1: { type: new Float16() }, + vec2: { type: new Float64() }, + }, }, ); - } - describe("The function makeArrowTable", function () { - it("accepts snake_case embedding metadata like camelCase", function () { - const spellings = [ - // biome-ignore lint/style/useNamingConvention: the Python wire spelling - { source_column: "text", vector_column: "vector" }, - { sourceColumn: "text", vectorColumn: "vector" }, - ]; - for (const columns of spellings) { - const schema = new Schema( - [ - new Field("text", new Utf8(), false), - new Field( - "vector", - new FixedSizeList(3, new Field("item", new Float32(), true)), - false, - ), - ], - new Map([ - [ - "embedding_functions", - JSON.stringify([{ name: "mock", model: {}, ...columns }]), - ], - ]), - ); - // The vector field is non-nullable and absent from the data; only a - // recognized embedding config makes that acceptable. - const table = makeArrowTable([{ text: "hello" }], { schema }); - expect(table.numRows).toBe(1); - } - }); + const buf = await fromTableToBuffer(table); + expect(buf.byteLength).toBeGreaterThan(0); - it("will use data types from a provided schema instead of inference", async function () { - const schema = new Schema([ - new Field("a", new Int32(), false), - new Field("b", new Float32(), true), - new Field( - "c", - new FixedSizeList(3, new Field("item", new Float16())), - ), - new Field("d", new Int64(), true), - ]); - const table = makeArrowTable( - [ - { a: 1, b: 2, c: [1, 2, 3], d: 9 }, - { a: 4, b: 5, c: [4, 5, 6], d: 10 }, - { a: 7, b: 8, c: [7, 8, 9], d: null }, - ], - { schema }, - ); + const actual = tableFromIPC(buf); + expect(actual.numRows).toBe(3); + const actualSchema = actual.schema; + expect(actualSchema).toEqual(schema); + }); - const buf = await fromTableToBuffer(table); - expect(buf.byteLength).toBeGreaterThan(0); + it("will detect vector columns when name contains 'vector' or 'embedding'", async function () { + // Test various naming patterns that should be detected as vector columns + const floatVectorTable = makeArrowTable([ + { + // Float vectors (use decimal values to ensure they're treated as floats) + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + user_vector: [1.1, 2.2], + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + text_embedding: [3.3, 4.4], + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + doc_embeddings: [5.5, 6.6], + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + my_vector_field: [7.7, 8.8], + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + embedding_model: [9.9, 10.1], + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + VECTOR_COL: [11.1, 12.2], // uppercase + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + Vector_Mixed: [13.3, 14.4], // mixed case + }, + ]); - const actual = tableFromIPC(buf); - expect(actual.numRows).toBe(3); - const actualSchema = actual.schema; - expect(actualSchema).toEqual(schema); - expect(table.getChild("a")?.toJSON()).toEqual([1, 4, 7]); - expect(table.getChild("b")?.toJSON()).toEqual([2, 5, 8]); - expect(table.getChild("d")?.toJSON()).toEqual([9n, 10n, null]); - }); + // Check that columns with 'vector' or 'embedding' in name are converted to FixedSizeList + const floatVectorColumns = [ + "user_vector", + "text_embedding", + "doc_embeddings", + "my_vector_field", + "embedding_model", + "VECTOR_COL", + "Vector_Mixed", + ]; - it("will use a provided FixedSizeList schema with typed array values", function () { - const schema = new Schema([ - new Field("text", new Utf8(), false), - new Field( - "vector", - new FixedSizeList(3, new Field("item", new Float32(), false)), - false, - ), - ]); - - const table = makeArrowTable( - [ - { - text: "foo", - vector: new Float32Array([1, 2, 3]), - }, - ], - { schema }, - ); - - expect(table.getChild("text")?.toJSON()).toEqual(["foo"]); + for (const columnName of floatVectorColumns) { expect( - table - .getChild("vector") - ?.toJSON() - .map((value) => value.toJSON()), - ).toEqual([[1, 2, 3]]); - }); - - it("will assume the column `vector` is FixedSizeList by default", async function () { - const schema = new Schema([ - new Field("a", new Float(Precision.DOUBLE), true), - new Field("b", new Int64(), true), - new Field( - "vector", - new FixedSizeList( - 3, - new Field("item", new Float(Precision.SINGLE), true), - ), - true, - ), - ]); - const table = makeArrowTable([ - { a: 1, b: 2n, vector: [1, 2, 3] }, - { a: 4, b: 5n, vector: [4, 5, 6] }, - { a: 7, b: 8n, vector: [7, 8, 9] }, - ]); - - const buf = await fromTableToBuffer(table); - expect(buf.byteLength).toBeGreaterThan(0); - - const actual = tableFromIPC(buf); - expect(actual.numRows).toBe(3); - const actualSchema = actual.schema; - expect(actualSchema).toEqual(schema); - - expect(table.getChild("a")?.toJSON()).toEqual([1, 4, 7]); - expect(table.getChild("b")?.toJSON()).toEqual([2n, 5n, 8n]); + DataType.isFixedSizeList(floatVectorTable.getChild(columnName)?.type), + ).toBe(true); + // Check that float vectors use Float32 by default expect( - table - .getChild("vector") - ?.toJSON() - .map((v) => v.toJSON()), - ).toEqual([ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - ]); - }); + floatVectorTable + .getChild(columnName) + ?.type.children[0].type.toString(), + ).toEqual(new Float32().toString()); + } - it("can support multiple vector columns", async function () { - const schema = new Schema([ - new Field("a", new Float(Precision.DOUBLE), true), - new Field("b", new Float(Precision.DOUBLE), true), - new Field( - "vec1", - new FixedSizeList(3, new Field("item", new Float16(), true)), - true, - ), - new Field( - "vec2", - new FixedSizeList(3, new Field("item", new Float64(), true)), - true, - ), - ]); - const table = makeArrowTable( - [ - { a: 1, b: 2, vec1: [1, 2, 3], vec2: [2, 4, 6] }, - { a: 4, b: 5, vec1: [4, 5, 6], vec2: [8, 10, 12] }, - { a: 7, b: 8, vec1: [7, 8, 9], vec2: [14, 16, 18] }, - ], - { - vectorColumns: { - vec1: { type: new Float16() }, - vec2: { type: new Float64() }, - }, - }, - ); + // Test that regular integer arrays still get treated as float vectors + // (since JavaScript doesn't distinguish integers from floats at runtime) + const integerArrayTable = makeArrowTable([ + { + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + vector_int: [1, 2], // Regular array with integers - should be Float32 + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + embedding_int: [3, 4], // Regular array with integers - should be Float32 + }, + ]); - const buf = await fromTableToBuffer(table); - expect(buf.byteLength).toBeGreaterThan(0); - - const actual = tableFromIPC(buf); - expect(actual.numRows).toBe(3); - const actualSchema = actual.schema; - expect(actualSchema).toEqual(schema); - }); - - it("will detect vector columns when name contains 'vector' or 'embedding'", async function () { - // Test various naming patterns that should be detected as vector columns - const floatVectorTable = makeArrowTable([ - { - // Float vectors (use decimal values to ensure they're treated as floats) - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - user_vector: [1.1, 2.2], - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - text_embedding: [3.3, 4.4], - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - doc_embeddings: [5.5, 6.6], - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - my_vector_field: [7.7, 8.8], - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - embedding_model: [9.9, 10.1], - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - VECTOR_COL: [11.1, 12.2], // uppercase - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - Vector_Mixed: [13.3, 14.4], // mixed case - }, - ]); - - // Check that columns with 'vector' or 'embedding' in name are converted to FixedSizeList - const floatVectorColumns = [ - "user_vector", - "text_embedding", - "doc_embeddings", - "my_vector_field", - "embedding_model", - "VECTOR_COL", - "Vector_Mixed", - ]; - - for (const columnName of floatVectorColumns) { - expect( - DataType.isFixedSizeList( - floatVectorTable.getChild(columnName)?.type, - ), - ).toBe(true); - // Check that float vectors use Float32 by default - expect( - floatVectorTable - .getChild(columnName) - ?.type.children[0].type.toString(), - ).toEqual(new Float32().toString()); - } - - // Test that regular integer arrays still get treated as float vectors - // (since JavaScript doesn't distinguish integers from floats at runtime) - const integerArrayTable = makeArrowTable([ - { - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - vector_int: [1, 2], // Regular array with integers - should be Float32 - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - embedding_int: [3, 4], // Regular array with integers - should be Float32 - }, - ]); - - const integerArrayColumns = ["vector_int", "embedding_int"]; - - for (const columnName of integerArrayColumns) { - expect( - DataType.isFixedSizeList( - integerArrayTable.getChild(columnName)?.type, - ), - ).toBe(true); - // Regular integer arrays should use Float32 (avoiding false positives) - expect( - integerArrayTable - .getChild(columnName) - ?.type.children[0].type.toString(), - ).toEqual(new Float32().toString()); - } - - // Test normal list should NOT be converted to FixedSizeList - const normalListTable = makeArrowTable([ - { - // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns - normal_list: [15.5, 16.6], // should NOT be detected as vector - }, - ]); + const integerArrayColumns = ["vector_int", "embedding_int"]; + for (const columnName of integerArrayColumns) { expect( DataType.isFixedSizeList( - normalListTable.getChild("normal_list")?.type, + integerArrayTable.getChild(columnName)?.type, ), - ).toBe(false); - expect( - DataType.isList(normalListTable.getChild("normal_list")?.type), ).toBe(true); - }); - - it("will allow different vector column types", async function () { - const table = makeArrowTable([{ fp16: [1], fp32: [1], fp64: [1] }], { - vectorColumns: { - fp16: { type: new Float16() }, - fp32: { type: new Float32() }, - fp64: { type: new Float64() }, - }, - }); - + // Regular integer arrays should use Float32 (avoiding false positives) expect( - table.getChild("fp16")?.type.children[0].type.toString(), - ).toEqual(new Float16().toString()); - expect( - table.getChild("fp32")?.type.children[0].type.toString(), + integerArrayTable + .getChild(columnName) + ?.type.children[0].type.toString(), ).toEqual(new Float32().toString()); - expect( - table.getChild("fp64")?.type.children[0].type.toString(), - ).toEqual(new Float64().toString()); + } + + // Test normal list should NOT be converted to FixedSizeList + const normalListTable = makeArrowTable([ + { + // biome-ignore lint/style/useNamingConvention: Testing vector column detection patterns + normal_list: [15.5, 16.6], // should NOT be detected as vector + }, + ]); + + expect( + DataType.isFixedSizeList(normalListTable.getChild("normal_list")?.type), + ).toBe(false); + expect( + DataType.isList(normalListTable.getChild("normal_list")?.type), + ).toBe(true); + }); + + it("will allow different vector column types", async function () { + const table = makeArrowTable([{ fp16: [1], fp32: [1], fp64: [1] }], { + vectorColumns: { + fp16: { type: new Float16() }, + fp32: { type: new Float32() }, + fp64: { type: new Float64() }, + }, }); - it("will infer FixedSizeList from Float32Array values", async function () { - const table = makeArrowTable([ - { id: "a", vector: new Float32Array([0.1, 0.2, 0.3]) }, - { id: "b", vector: new Float32Array([0.4, 0.5, 0.6]) }, - ]); + expect(table.getChild("fp16")?.type.children[0].type.toString()).toEqual( + new Float16().toString(), + ); + expect(table.getChild("fp32")?.type.children[0].type.toString()).toEqual( + new Float32().toString(), + ); + expect(table.getChild("fp64")?.type.children[0].type.toString()).toEqual( + new Float64().toString(), + ); + }); - expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + it("will infer FixedSizeList from Float32Array values", async function () { + const table = makeArrowTable([ + { id: "a", vector: new Float32Array([0.1, 0.2, 0.3]) }, + { id: "b", vector: new Float32Array([0.4, 0.5, 0.6]) }, + ]); + + expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + true, + ); + const vectorType = table.getChild("vector")?.type; + expect(vectorType.listSize).toBe(3); + expect(vectorType.children[0].type.toString()).toEqual( + new Float32().toString(), + ); + }); + + it("will infer FixedSizeList from Uint8Array values", async function () { + const table = makeArrowTable([ + { id: "a", vector: new Uint8Array([1, 2, 3]) }, + { id: "b", vector: new Uint8Array([4, 5, 6]) }, + ]); + + expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + true, + ); + const vectorType = table.getChild("vector")?.type; + expect(vectorType.listSize).toBe(3); + expect(vectorType.children[0].type.toString()).toEqual( + new ArrowUint8().toString(), + ); + }); + + it("will use dictionary encoded strings if asked", async function () { + const table = makeArrowTable([{ str: "hello" }]); + expect(DataType.isUtf8(table.getChild("str")?.type)).toBe(true); + + const tableWithDict = makeArrowTable([{ str: "hello" }], { + dictionaryEncodeStrings: true, + }); + expect(DataType.isDictionary(tableWithDict.getChild("str")?.type)).toBe( + true, + ); + + const schema = new Schema([ + new Field("str", new Dictionary(new Utf8(), new Int32())), + ]); + + const tableWithDict2 = makeArrowTable([{ str: "hello" }], { schema }); + expect(DataType.isDictionary(tableWithDict2.getChild("str")?.type)).toBe( + true, + ); + }); + + it("will infer data types correctly", async function () { + await checkTableCreation( + // biome-ignore lint/suspicious/noExplicitAny: + async (records) => (makeArrowTable)(records), + true, + ); + }); + + it("will allow matching inferred types across records", function () { + expect(() => makeArrowTable([{ value: 1 }, { value: 2 }])).not.toThrow(); + }); + + it("will reject mismatched inferred types across records", function () { + expect(() => makeArrowTable([{ value: 1 }, { value: "two" }])).toThrow( + "Failed to infer schema for data. Previously inferred type Float64 but found Utf8 for field value at row 1. Consider providing an explicit schema.", + ); + }); + + 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, - ); - const vectorType = table.getChild("vector")?.type; - expect(vectorType.listSize).toBe(3); - expect(vectorType.children[0].type.toString()).toEqual( - new Float32().toString(), - ); - }); + ), + ]); + expect(() => + makeArrowTable( + [{ metadata: { bytes: new Uint8Array([104]).buffer } }], + { schema }, + ), + ).toThrow(/field metadata\.bytes at row 0/); + }); - it("will infer FixedSizeList from Uint8Array values", async function () { - const table = makeArrowTable([ - { id: "a", vector: new Uint8Array([1, 2, 3]) }, - { id: "b", vector: new Uint8Array([4, 5, 6]) }, - ]); - - expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + it("rejects unsupported objects in list elements", () => { + const schema = new Schema([ + new Field( + "images", + new List(new Field("item", new Binary(), true)), true, - ); - const vectorType = table.getChild("vector")?.type; - expect(vectorType.listSize).toBe(3); - expect(vectorType.children[0].type.toString()).toEqual( - new ArrowUint8().toString(), - ); - }); + ), + ]); + expect(() => + makeArrowTable([{ images: [new Blob(["abc"])] }], { schema }), + ).toThrow(/field images\[0\] at row 0/); + }); - it("will use dictionary encoded strings if asked", async function () { - const table = makeArrowTable([{ str: "hello" }]); - expect(DataType.isUtf8(table.getChild("str")?.type)).toBe(true); - - const tableWithDict = makeArrowTable([{ str: "hello" }], { - dictionaryEncodeStrings: true, - }); - expect(DataType.isDictionary(tableWithDict.getChild("str")?.type)).toBe( - true, - ); - - const schema = new Schema([ - new Field("str", new Dictionary(new Utf8(), new Int32())), - ]); - - const tableWithDict2 = makeArrowTable([{ str: "hello" }], { schema }); - expect( - DataType.isDictionary(tableWithDict2.getChild("str")?.type), - ).toBe(true); - }); - - it("will infer data types correctly", async function () { - await checkTableCreation( - // biome-ignore lint/suspicious/noExplicitAny: - async (records) => (makeArrowTable)(records), - true, - ); - }); - - it("will allow matching inferred types across records", function () { - expect(() => - makeArrowTable([{ value: 1 }, { value: 2 }]), - ).not.toThrow(); - }); - - it("will reject mismatched inferred types across records", function () { - expect(() => makeArrowTable([{ value: 1 }, { value: "two" }])).toThrow( - "Failed to infer schema for data. Previously inferred type Float64 but found Utf8 for field value at row 1. Consider providing an explicit schema.", - ); - }); - - 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, - ), + 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/); - }); + 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, - ), + 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/); + 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, }); - it("will ignore generated dictionary IDs when comparing inferred types", function () { - const table = makeArrowTable([{ str: "a" }, { str: "b" }], { - dictionaryEncodeStrings: true, - }); + expect(table.getChild("str")?.toJSON()).toEqual(["a", "b"]); + }); - expect(table.getChild("str")?.toJSON()).toEqual(["a", "b"]); - }); + it("will preserve null values without treating them as type mismatches", function () { + for (const records of [ + [{ vector: [1, 2, 3] }, { vector: null }], + [{ vector: null }, { vector: [1, 2, 3] }], + ]) { + const table = makeArrowTable(records); - it("will preserve null values without treating them as type mismatches", function () { - for (const records of [ - [{ vector: [1, 2, 3] }, { vector: null }], - [{ vector: null }, { vector: [1, 2, 3] }], - ]) { - const table = makeArrowTable(records); + expect(table.numRows).toBe(2); + expect(table.getChild("vector")?.nullCount).toBe(1); + } + }); - expect(table.numRows).toBe(2); - expect(table.getChild("vector")?.nullCount).toBe(1); - } - }); - - it("will preserve empty variable-size lists", function () { - for (const records of [ - [{ items: [1] }, { items: [] }], - [{ items: [] }, { items: [1] }], - ]) { - const table = makeArrowTable(records); - expect( - table - .getChild("items") - ?.toJSON() - .map((value) => value.toJSON()), - ).toEqual(records.map((record) => record.items)); - } - }); - - it("will propagate deferred evidence through nested lists", function () { - for (const records of [ - [{ items: [1] }, { items: [null] }], - [{ items: [null] }, { items: [1] }], - [{ items: [null, 1] }, { items: [2, null] }], - ]) { - const table = makeArrowTable(records); - expect( - table - .getChild("items") - ?.toJSON() - .map((value) => value.toJSON()), - ).toEqual(records.map((record) => record.items)); - } - - const nestedRecords = [{ items: [[1]] }, { items: [[null]] }]; - const nestedTable = makeArrowTable(nestedRecords); + it("will preserve empty variable-size lists", function () { + for (const records of [ + [{ items: [1] }, { items: [] }], + [{ items: [] }, { items: [1] }], + ]) { + const table = makeArrowTable(records); expect( - nestedTable + table .getChild("items") ?.toJSON() - .map((value) => - value - .toJSON() - .map((nestedValue: { toJSON: () => unknown[] }) => - nestedValue.toJSON(), - ), - ), - ).toEqual(nestedRecords.map((record) => record.items)); - }); + .map((value) => value.toJSON()), + ).toEqual(records.map((record) => record.items)); + } + }); - it("will reject incompatible deferred evidence within a list", function () { - for (const items of [ - [[], 1], - [1, []], - [[null], 1], - [1, [null]], - ]) { - expect(() => makeArrowTable([{ items }])).toThrow( - "Failed to infer data type for field items at row 0.", - ); - } - }); + it("will propagate deferred evidence through nested lists", function () { + for (const records of [ + [{ items: [1] }, { items: [null] }], + [{ items: [null] }, { items: [1] }], + [{ items: [null, 1] }, { items: [2, null] }], + ]) { + const table = makeArrowTable(records); + expect( + table + .getChild("items") + ?.toJSON() + .map((value) => value.toJSON()), + ).toEqual(records.map((record) => record.items)); + } - it("will reject empty fixed-size lists", function () { - expect(() => - makeArrowTable([{ vector: [1, 2, 3] }, { vector: [] }]), - ).toThrow( - "Failed to infer schema for data. Previously inferred type FixedSizeList[3] but found List[0] for field vector at row 1.", + const nestedRecords = [{ items: [[1]] }, { items: [[null]] }]; + const nestedTable = makeArrowTable(nestedRecords); + expect( + nestedTable + .getChild("items") + ?.toJSON() + .map((value) => + value + .toJSON() + .map((nestedValue: { toJSON: () => unknown[] }) => + nestedValue.toJSON(), + ), + ), + ).toEqual(nestedRecords.map((record) => record.items)); + }); + + it("will reject incompatible deferred evidence within a list", function () { + for (const items of [ + [[], 1], + [1, []], + [[null], 1], + [1, [null]], + ]) { + expect(() => makeArrowTable([{ items }])).toThrow( + "Failed to infer data type for field items at row 0.", ); - }); + } + }); - it("will reject inferred leaf and branch shape changes", function () { - expect(() => - makeArrowTable([{ value: 1 }, { value: { nested: 2 } }]), - ).toThrow( - "Failed to infer schema for data. Previously inferred type Float64 but found Struct for field value at row 1.", - ); - expect(() => - makeArrowTable([{ value: { nested: 1 } }, { value: 2 }]), - ).toThrow( - "Failed to infer schema for data. Previously inferred type Struct but found Float64 for field value at row 1.", - ); - }); + it("will reject empty fixed-size lists", function () { + expect(() => + makeArrowTable([{ vector: [1, 2, 3] }, { vector: [] }]), + ).toThrow( + "Failed to infer schema for data. Previously inferred type FixedSizeList[3] but found List[0] for field vector at row 1.", + ); + }); - it("will allow null values around inferred struct values", function () { - for (const { records, nullIndex } of [ - { - records: [{ value: null }, { value: { nested: 2 } }], - nullIndex: 0, - }, - { - records: [{ value: { nested: 1 } }, { value: null }], - nullIndex: 1, - }, - ]) { - const table = makeArrowTable(records); - const values = table.getChild("value"); + it("will reject inferred leaf and branch shape changes", function () { + expect(() => + makeArrowTable([{ value: 1 }, { value: { nested: 2 } }]), + ).toThrow( + "Failed to infer schema for data. Previously inferred type Float64 but found Struct for field value at row 1.", + ); + expect(() => + makeArrowTable([{ value: { nested: 1 } }, { value: 2 }]), + ).toThrow( + "Failed to infer schema for data. Previously inferred type Struct but found Float64 for field value at row 1.", + ); + }); - expect(values?.nullCount).toBe(1); - expect(values?.get(nullIndex)).toBeNull(); - } - }); + it("will allow null values around inferred struct values", function () { + for (const { records, nullIndex } of [ + { + records: [{ value: null }, { value: { nested: 2 } }], + nullIndex: 0, + }, + { + records: [{ value: { nested: 1 } }, { value: null }], + nullIndex: 1, + }, + ]) { + const table = makeArrowTable(records); + const values = table.getChild("value"); - it("will allow a schema to be provided", async function () { - await checkTableCreation( - async (records, _, schema) => - // biome-ignore lint/suspicious/noExplicitAny: - (makeArrowTable)(records, { schema }), - false, - ); - }); + expect(values?.nullCount).toBe(1); + expect(values?.get(nullIndex)).toBeNull(); + } + }); - it("will use the field order of any provided schema", async function () { - await checkTableCreation( - async (_, recordsReversed, schema) => - // biome-ignore lint/suspicious/noExplicitAny: - (makeArrowTable)(recordsReversed, { schema }), - false, - ); - }); - - it("will make an empty table", async function () { - await checkTableCreation( + it("will allow a schema to be provided", async function () { + await checkTableCreation( + async (records, _, schema) => // biome-ignore lint/suspicious/noExplicitAny: - async (_, __, schema) => (makeArrowTable)([], { schema }), - false, - ); - }); + (makeArrowTable)(records, { schema }), + false, + ); + }); - it("will allow subsets of columns if nullable", async function () { - const schema = new Schema([ - new Field("a", new Int64(), true), + it("will use the field order of any provided schema", async function () { + await checkTableCreation( + async (_, recordsReversed, schema) => + // biome-ignore lint/suspicious/noExplicitAny: + (makeArrowTable)(recordsReversed, { schema }), + false, + ); + }); + + it("will make an empty table", async function () { + await checkTableCreation( + // biome-ignore lint/suspicious/noExplicitAny: + async (_, __, schema) => (makeArrowTable)([], { schema }), + false, + ); + }); + + it("will allow subsets of columns if nullable", async function () { + const schema = new Schema([ + new Field("a", new Int64(), true), + new Field( + "s", + new Struct([ + new Field("x", new Int32(), true), + new Field("y", new Int32(), true), + ]), + true, + ), + new Field("d", new Int16(), true), + ]); + + const table = makeArrowTable([{ a: 1n }], { schema }); + expect(table.numCols).toBe(1); + expect(table.numRows).toBe(1); + + const table2 = makeArrowTable([{ a: 1n, d: 2 }], { schema }); + expect(table2.numCols).toBe(2); + + const table3 = makeArrowTable([{ s: { y: 3 } }], { schema }); + expect(table3.numCols).toBe(1); + const expectedSchema = new Schema([ + new Field("s", new Struct([new Field("y", new Int32(), true)]), true), + ]); + expect(table3.schema).toEqual(expectedSchema); + }); + + it("will work even if columns are sparsely provided", async function () { + const sparseRecords = [{ a: 1n }, { b: 2n }, { c: 3n }, { d: 4n }]; + const table = makeArrowTable(sparseRecords); + expect(table.numCols).toBe(4); + expect(table.numRows).toBe(4); + + const schema = new Schema([ + new Field("a", new Int64(), true), + new Field("b", new Int32(), true), + new Field("c", new Int64(), true), + new Field("d", new Int16(), true), + ]); + const table2 = makeArrowTable(sparseRecords, { schema }); + expect(table2.numCols).toBe(4); + expect(table2.numRows).toBe(4); + expect(table2.schema).toEqual(schema); + }); + + it("will handle missing columns in schema alignment when using embeddings", async function () { + const schema = new Schema( + [ + new Field("domain", new Utf8(), true), + new Field("name", new Utf8(), true), + new Field("description", new Utf8(), true), + ], + new Map([["embedding_functions", JSON.stringify([])]]), + ); + + const data = [ + { domain: "google.com", name: "Google" }, + { domain: "facebook.com", name: "Facebook" }, + ]; + + const table = await convertToTable(data, undefined, { schema }); + + expect(table.numCols).toBe(3); + expect(table.numRows).toBe(2); + + const descriptionColumn = table.getChild("description"); + expect(descriptionColumn).toBeDefined(); + expect(descriptionColumn?.nullCount).toBe(2); + expect(descriptionColumn?.toArray()).toEqual([null, null]); + + expect(table.getChild("domain")?.toArray()).toEqual([ + "google.com", + "facebook.com", + ]); + expect(table.getChild("name")?.toArray()).toEqual(["Google", "Facebook"]); + }); + + it("will handle completely missing nested struct columns", async function () { + const schema = new Schema( + [ + new Field("id", new Utf8(), true), + new Field("name", new Utf8(), true), new Field( - "s", + "metadata", new Struct([ - new Field("x", new Int32(), true), - new Field("y", new Int32(), true), + new Field("version", new Int32(), true), + new Field("author", new Utf8(), true), + new Field( + "tags", + new List(new Field("item", new Utf8(), true)), + true, + ), ]), true, ), - new Field("d", new Int16(), true), - ]); - - const table = makeArrowTable([{ a: 1n }], { schema }); - expect(table.numCols).toBe(1); - expect(table.numRows).toBe(1); - - const table2 = makeArrowTable([{ a: 1n, d: 2 }], { schema }); - expect(table2.numCols).toBe(2); - - const table3 = makeArrowTable([{ s: { y: 3 } }], { schema }); - expect(table3.numCols).toBe(1); - const expectedSchema = new Schema([ - new Field("s", new Struct([new Field("y", new Int32(), true)]), true), - ]); - expect(table3.schema).toEqual(expectedSchema); - }); - - it("will work even if columns are sparsely provided", async function () { - const sparseRecords = [{ a: 1n }, { b: 2n }, { c: 3n }, { d: 4n }]; - const table = makeArrowTable(sparseRecords); - expect(table.numCols).toBe(4); - expect(table.numRows).toBe(4); - - const schema = new Schema([ - new Field("a", new Int64(), true), - new Field("b", new Int32(), true), - new Field("c", new Int64(), true), - new Field("d", new Int16(), true), - ]); - const table2 = makeArrowTable(sparseRecords, { schema }); - expect(table2.numCols).toBe(4); - expect(table2.numRows).toBe(4); - expect(table2.schema).toEqual(schema); - }); - - it("will handle missing columns in schema alignment when using embeddings", async function () { - const schema = new Schema( - [ - new Field("domain", new Utf8(), true), - new Field("name", new Utf8(), true), - new Field("description", new Utf8(), true), - ], - new Map([["embedding_functions", JSON.stringify([])]]), - ); - - const data = [ - { domain: "google.com", name: "Google" }, - { domain: "facebook.com", name: "Facebook" }, - ]; - - const table = await convertToTable(data, undefined, { schema }); - - expect(table.numCols).toBe(3); - expect(table.numRows).toBe(2); - - const descriptionColumn = table.getChild("description"); - expect(descriptionColumn).toBeDefined(); - expect(descriptionColumn?.nullCount).toBe(2); - expect(descriptionColumn?.toArray()).toEqual([null, null]); - - expect(table.getChild("domain")?.toArray()).toEqual([ - "google.com", - "facebook.com", - ]); - expect(table.getChild("name")?.toArray()).toEqual([ - "Google", - "Facebook", - ]); - }); - - it("will handle completely missing nested struct columns", async function () { - const schema = new Schema( - [ - new Field("id", new Utf8(), true), - new Field("name", new Utf8(), true), - new Field( - "metadata", - new Struct([ - new Field("version", new Int32(), true), - new Field("author", new Utf8(), true), - new Field( - "tags", - new List(new Field("item", new Utf8(), true)), - true, - ), - ]), - true, - ), - ], - new Map([["embedding_functions", JSON.stringify([])]]), - ); - - const data = [ - { id: "doc1", name: "Document 1" }, - { id: "doc2", name: "Document 2" }, - ]; - - const table = await convertToTable(data, undefined, { schema }); - - expect(table.numCols).toBe(3); - expect(table.numRows).toBe(2); - - const buf = await fromTableToBuffer(table); - const retrievedTable = tableFromIPC(buf); - - const rows = []; - for (let i = 0; i < retrievedTable.numRows; i++) { - rows.push(retrievedTable.get(i)); - } - - expect(rows[0].metadata.version).toBe(null); - expect(rows[0].metadata.author).toBe(null); - expect(rows[0].metadata.tags).toBe(null); - expect(rows[0].id).toBe("doc1"); - expect(rows[0].name).toBe("Document 1"); - }); - - it("will handle partially missing nested struct fields", async function () { - const schema = new Schema( - [ - new Field("id", new Utf8(), true), - new Field( - "metadata", - new Struct([ - new Field("version", new Int32(), true), - new Field("author", new Utf8(), true), - new Field("created_at", new Utf8(), true), - ]), - true, - ), - ], - new Map([["embedding_functions", JSON.stringify([])]]), - ); - - const data = [ - { id: "doc1", metadata: { version: 1, author: "Alice" } }, - { id: "doc2", metadata: { version: 2 } }, - ]; - - const table = await convertToTable(data, undefined, { schema }); - - expect(table.numCols).toBe(2); - expect(table.numRows).toBe(2); - - const metadataColumn = table.getChild("metadata"); - expect(metadataColumn).toBeDefined(); - expect(metadataColumn?.type.toString()).toBe( - "Struct<{version:Int32, author:Utf8, created_at:Utf8}>", - ); - }); - - it("will handle multiple levels of nested structures", async function () { - const schema = new Schema( - [ - new Field("id", new Utf8(), true), - new Field( - "config", - new Struct([ - new Field("database", new Utf8(), true), - new Field( - "connection", - new Struct([ - new Field("host", new Utf8(), true), - new Field("port", new Int32(), true), - new Field( - "ssl", - new Struct([ - new Field("enabled", new Bool(), true), - new Field("cert_path", new Utf8(), true), - ]), - true, - ), - ]), - true, - ), - ]), - true, - ), - ], - new Map([["embedding_functions", JSON.stringify([])]]), - ); - - const data = [ - { - id: "config1", - config: { - database: "postgres", - connection: { host: "localhost" }, - }, - }, - { - id: "config2", - config: { database: "mysql" }, - }, - { - id: "config3", - }, - ]; - - const table = await convertToTable(data, undefined, { schema }); - - expect(table.numCols).toBe(2); - expect(table.numRows).toBe(3); - - const configColumn = table.getChild("config"); - expect(configColumn).toBeDefined(); - expect(configColumn?.type.toString()).toBe( - "Struct<{database:Utf8, connection:Struct<{host:Utf8, port:Int32, ssl:Struct<{enabled:Bool, cert_path:Utf8}>}>}>", - ); - }); - - it("will handle missing columns in Arrow table input when using embeddings", async function () { - const incompleteTable = makeArrowTable([ - { domain: "google.com", name: "Google" }, - { domain: "facebook.com", name: "Facebook" }, - ]); - - const schema = new Schema( - [ - new Field("domain", new Utf8(), true), - new Field("name", new Utf8(), true), - new Field("description", new Utf8(), true), - ], - new Map([["embedding_functions", JSON.stringify([])]]), - ); - - const buf = await fromDataToBuffer(incompleteTable, undefined, schema); - - expect(buf.byteLength).toBeGreaterThan(0); - - const retrievedTable = tableFromIPC(buf); - expect(retrievedTable.numCols).toBe(3); - expect(retrievedTable.numRows).toBe(2); - - const descriptionColumn = retrievedTable.getChild("description"); - expect(descriptionColumn).toBeDefined(); - expect(descriptionColumn?.nullCount).toBe(2); - expect(descriptionColumn?.toArray()).toEqual([null, null]); - - expect(retrievedTable.getChild("domain")?.toArray()).toEqual([ - "google.com", - "facebook.com", - ]); - expect(retrievedTable.getChild("name")?.toArray()).toEqual([ - "Google", - "Facebook", - ]); - }); - - it("should correctly retain values in nested struct fields", async function () { - const testData = [ - { - id: "doc1", - vector: [1, 2, 3], - metadata: { - filePath: "/path/to/file1.ts", - startLine: 10, - endLine: 20, - text: "function test() { return true; }", - }, - }, - { - id: "doc2", - vector: [4, 5, 6], - metadata: { - filePath: "/path/to/file2.ts", - startLine: 30, - endLine: 40, - text: "function test2() { return false; }", - }, - }, - ]; - - const table = makeArrowTable(testData); - - const metadataField = table.schema.fields.find( - (f) => f.name === "metadata", - ); - expect(metadataField).toBeDefined(); - // biome-ignore lint/suspicious/noExplicitAny: accessing fields in different Arrow versions - const childNames = metadataField?.type.children.map((c: any) => c.name); - expect(childNames).toEqual([ - "filePath", - "startLine", - "endLine", - "text", - ]); - - const buf = await fromTableToBuffer(table); - const retrievedTable = tableFromIPC(buf); - - const rows = []; - for (let i = 0; i < retrievedTable.numRows; i++) { - rows.push(retrievedTable.get(i)); - } - - const firstRow = rows[0]; - expect(firstRow.id).toBe("doc1"); - expect(firstRow.vector.toJSON()).toEqual([1, 2, 3]); - expect(firstRow.metadata.filePath).toBe("/path/to/file1.ts"); - expect(firstRow.metadata.startLine).toBe(10); - expect(firstRow.metadata.endLine).toBe(20); - expect(firstRow.metadata.text).toBe("function test() { return true; }"); - }); - }); - - class DummyEmbedding extends EmbeddingFunction { - toJSON(): Partial { - return {}; - } - - async computeSourceEmbeddings(data: string[]): Promise { - return data.map(() => [0.0, 0.0]); - } - - ndims(): number { - return 2; - } - - embeddingDataType() { - return new Float16(); - } - } - - class DummyEmbeddingWithNoDimension extends EmbeddingFunction { - toJSON(): Partial { - return {}; - } - - embeddingDataType() { - return new Float16(); - } - - async computeSourceEmbeddings(data: string[]): Promise { - return data.map(() => [0.0, 0.0]); - } - } - const dummyEmbeddingConfig: EmbeddingFunctionConfig = { - sourceColumn: "string", - function: new DummyEmbedding(), - }; - - const dummyEmbeddingConfigWithNoDimension: EmbeddingFunctionConfig = { - sourceColumn: "string", - function: new DummyEmbeddingWithNoDimension(), - }; - - describe("convertToTable", function () { - it("will infer data types correctly", async function () { - await checkTableCreation( - // biome-ignore lint/suspicious/noExplicitAny: - async (records) => await (convertToTable)(records), - true, - ); - }); - - it("will allow a schema to be provided", async function () { - await checkTableCreation( - async (records, _, schema) => - // biome-ignore lint/suspicious/noExplicitAny: - await (convertToTable)(records, undefined, { schema }), - false, - ); - }); - - it("will use the field order of any provided schema", async function () { - await checkTableCreation( - async (_, recordsReversed, schema) => - // biome-ignore lint/suspicious/noExplicitAny: - await (convertToTable)(recordsReversed, undefined, { schema }), - false, - ); - }); - - it("will make an empty table", async function () { - await checkTableCreation( - async (_, __, schema) => - // biome-ignore lint/suspicious/noExplicitAny: - await (convertToTable)([], undefined, { schema }), - false, - ); - }); - - it("will apply embeddings", async function () { - const records = sampleRecords(); - const table = await convertToTable(records, dummyEmbeddingConfig); - expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( - true, - ); - expect( - table.getChild("vector")?.type.children[0].type.toString(), - ).toEqual(new Float16().toString()); - }); - - it("will fail if missing the embedding source column", async function () { - await expect( - convertToTable([{ id: 1 }], dummyEmbeddingConfig), - ).rejects.toThrow("'string' was not present"); - }); - - it("use embeddingDimension if embedding missing from table", async function () { - const schema = new Schema([new Field("string", new Utf8(), false)]); - // Simulate getting an empty Arrow table (minus embedding) from some other source - // In other words, we aren't starting with records - const table = makeEmptyTable(schema); - - // If the embedding specifies the dimension we are fine - await fromTableToBuffer(table, dummyEmbeddingConfig); - - // We can also supply a schema and should be ok - const schemaWithEmbedding = new Schema([ - new Field("string", new Utf8(), false), - new Field( - "vector", - new FixedSizeList(2, new Field("item", new Float16(), false)), - false, - ), - ]); - await fromTableToBuffer( - table, - dummyEmbeddingConfigWithNoDimension, - schemaWithEmbedding, - ); - - // Otherwise we will get an error - await expect( - fromTableToBuffer(table, dummyEmbeddingConfigWithNoDimension), - ).rejects.toThrow("does not specify `embeddingDimension`"); - }); - - it("will apply embeddings to an empty table", async function () { - const schema = new Schema([ - new Field("string", new Utf8(), false), - new Field( - "vector", - new FixedSizeList(2, new Field("item", new Float16(), false)), - false, - ), - ]); - const table = await convertToTable([], dummyEmbeddingConfig, { - schema, - }); - expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( - true, - ); - expect( - table.getChild("vector")?.type.children[0].type.toString(), - ).toEqual(new Float16().toString()); - }); - - it("will complain if embeddings present but schema missing embedding column", async function () { - const schema = new Schema([new Field("string", new Utf8(), false)]); - await expect( - convertToTable([], dummyEmbeddingConfig, { schema }), - ).rejects.toThrow("column vector was missing"); - }); - - it("will skip embedding application if already applied", async function () { - const records = sampleRecords(); - const table = await convertToTable(records, dummyEmbeddingConfig); - - // fromTableToBuffer will try and apply the embeddings again - // but should skip since the column already has non-null values - const result = await fromTableToBuffer(table, dummyEmbeddingConfig); - expect(result.byteLength).toBeGreaterThan(0); - }); - }); - - describe("makeEmptyTable", function () { - it("will make an empty table", async function () { - await checkTableCreation( - // biome-ignore lint/suspicious/noExplicitAny: - async (_, __, schema) => (makeEmptyTable)(schema), - false, - ); - }); - - it("will make an empty table with a Map field", async function () { - const schema = new Schema([ - new Field( - "attributes", - new Map_( - new Field( - "entries", - new Struct([ - new Field("key", new Utf8(), false), - new Field("value", new Int32(), true), - ]), - false, - ), - true, - ), - ), - ]); - - const table = makeEmptyTable(schema); - - expectValidMapField(table.schema.fields[0]); - - const buffer = await fromTableToBuffer(table); - const roundTripped = tableFromIPC(buffer); - - expectValidMapField(roundTripped.schema.fields[0]); - }); - - it("preserves string schema metadata", function () { - const metadata = new Map([["source", "fixture"]]); - const schema = new Schema( - [new Field("value", new Int32(), true)], - metadata, - ); - - expect(makeEmptyTable(schema).schema.metadata.get("source")).toBe( - "fixture", - ); - }); - - it.each([ - ["non-string keys", new Map([[42, "fixture"]])], - ["non-string values", new Map([["source", 42]])], - [ - "non-string keys and values", - new Map([[42, false]]), ], - ])("rejects schema metadata with %s", function (_, metadataLike) { - const metadata = metadataLike as unknown as Map; - const schema = new Schema( - [new Field("value", new Int32(), true)], - metadata, - ); + new Map([["embedding_functions", JSON.stringify([])]]), + ); - expect(() => makeEmptyTable(schema)).toThrow( - "Expected metadata, if present, to be a Map but it had non-string keys or values", - ); - }); + const data = [ + { id: "doc1", name: "Document 1" }, + { id: "doc2", name: "Document 2" }, + ]; + + const table = await convertToTable(data, undefined, { schema }); + + expect(table.numCols).toBe(3); + expect(table.numRows).toBe(2); + + const buf = await fromTableToBuffer(table); + const retrievedTable = tableFromIPC(buf); + + const rows = []; + for (let i = 0; i < retrievedTable.numRows; i++) { + rows.push(retrievedTable.get(i)); + } + + expect(rows[0].metadata.version).toBe(null); + expect(rows[0].metadata.author).toBe(null); + expect(rows[0].metadata.tags).toBe(null); + expect(rows[0].id).toBe("doc1"); + expect(rows[0].name).toBe("Document 1"); }); - describe("when using two versions of arrow", function () { - it("preserves a dictionary shared by multiple fields", async function () { - const values = ["alpha", "beta", "alpha"]; - const dictionaryVector = vectorFromArray(values); - const batch = new ArrowRecordBatch({ - first: dictionaryVector.data[0], - second: dictionaryVector.data[0], - }); - const table = new ArrowTable([batch]); - - const sanitized = sanitizeTable(table); - expect([...sanitized.getChild("first")!]).toEqual(values); - expect([...sanitized.getChild("second")!]).toEqual(values); - const firstType = sanitized.schema.fields[0].type as { - dictionary: unknown; - }; - const secondType = sanitized.schema.fields[1].type as { - dictionary: unknown; - }; - expect(secondType.dictionary).toBe(firstType.dictionary); - expect(sanitized.batches[0].data.children[1].dictionary).toBe( - sanitized.batches[0].data.children[0].dictionary, - ); - - const buf = await fromDataToBuffer(table); - const actual = currentTableFromIPC(buf); - expect([...actual.getChild("first")!]).toEqual(values); - expect([...actual.getChild("second")!]).toEqual(values); - }); - - it("preserves shared dictionary data from another Arrow version", async function () { - const values = ["alpha", "beta", "alpha"]; - const dictionaryVector = vectorFromArray(values); - const firstBatch = new ArrowRecordBatch({ - label: dictionaryVector.slice(0, 2).data[0], - }); - const secondBatch = new ArrowRecordBatch({ - label: dictionaryVector.slice(2).data[0], - }); - const table = new ArrowTable([firstBatch, secondBatch]); - - const sanitized = sanitizeTable(table); - expect([...sanitized.getChild("label")!]).toEqual(values); - - const dictionaries = sanitized.batches.map( - (batch) => batch.data.children[0].dictionary, - ); - expect(dictionaries[0]).toBeInstanceOf(CurrentVector); - expect(dictionaries[1]).toBe(dictionaries[0]); - - const buf = await fromDataToBuffer(table); - const actual = currentTableFromIPC(buf); - expect([...actual.getChild("label")!]).toEqual(values); - }); - - it("preserves shared chunks in growing dictionaries", async function () { - const type = new Dictionary(new Utf8(), new Int32(), 42, false); - const firstDictionary = vectorFromArray(["alpha", "beta"], new Utf8()); - const secondDictionary = firstDictionary.concat( - vectorFromArray(["gamma"], new Utf8()), - ); - const firstData = arrowMakeData({ - type, - data: Int32Array.from([0, 1]), - dictionary: firstDictionary, - }); - const secondData = arrowMakeData({ - type, - data: Int32Array.from([2]), - dictionary: secondDictionary, - }); - const table = new ArrowTable([ - new ArrowRecordBatch({ label: firstData }), - new ArrowRecordBatch({ label: secondData }), - ]); - - const sanitized = sanitizeTable(table); - const expected = ["alpha", "beta", "gamma"]; - expect([...sanitized.getChild("label")!]).toEqual(expected); - const firstLocalDictionary = - sanitized.batches[0].data.children[0].dictionary!; - const secondLocalDictionary = - sanitized.batches[1].data.children[0].dictionary!; - expect(secondLocalDictionary.data[0]).toBe( - firstLocalDictionary.data[0], - ); - - const buf = await fromTableToBuffer(sanitized); - const actual = currentTableFromIPC(buf); - expect([...actual.getChild("label")!]).toEqual(expected); - }); - - it("can serialize list data from another Arrow version", async function () { - const values = [["anime", "action"], [], null]; - const vector = vectorFromArray( - values, - new List(new Field("item", new Utf8(), true)), - ); - const table = new ArrowTable({ tags: vector }); - - const buf = await fromDataToBuffer(table); - const actual = currentTableFromIPC(buf); - const actualTags = actual.getChild("tags"); - - expect(actualTags?.get(0)?.toJSON()).toEqual(values[0]); - expect(actualTags?.get(1)?.toJSON()).toEqual(values[1]); - expect(actualTags?.get(2)).toBeNull(); - }); - - it("can still import data", async function () { - const schema = new arrow15.Schema([ - new arrow15.Field("id", new arrow15.Int32()), - new arrow15.Field( - "vector", - new arrow15.FixedSizeList( - 1024, - new arrow15.Field("item", new arrow15.Float32(), true), - ), + it("will handle partially missing nested struct fields", async function () { + const schema = new Schema( + [ + new Field("id", new Utf8(), true), + new Field( + "metadata", + new Struct([ + new Field("version", new Int32(), true), + new Field("author", new Utf8(), true), + new Field("created_at", new Utf8(), true), + ]), + true, ), - new arrow15.Field( - "struct", - new arrow15.Struct([ - new arrow15.Field( - "nested", - new arrow15.Dictionary( - new arrow15.Utf8(), - new arrow15.Int32(), - 1, - true, - ), - ), - new arrow15.Field( - "ts_with_tz", - new arrow15.TimestampNanosecond("some_tz"), - ), - new arrow15.Field( - "ts_no_tz", - new arrow15.TimestampNanosecond(null), + ], + new Map([["embedding_functions", JSON.stringify([])]]), + ); + + const data = [ + { id: "doc1", metadata: { version: 1, author: "Alice" } }, + { id: "doc2", metadata: { version: 2 } }, + ]; + + const table = await convertToTable(data, undefined, { schema }); + + expect(table.numCols).toBe(2); + expect(table.numRows).toBe(2); + + const metadataColumn = table.getChild("metadata"); + expect(metadataColumn).toBeDefined(); + expect(metadataColumn?.type.toString()).toBe( + "Struct<{version:Int32, author:Utf8, created_at:Utf8}>", + ); + }); + + it("will handle multiple levels of nested structures", async function () { + const schema = new Schema( + [ + new Field("id", new Utf8(), true), + new Field( + "config", + new Struct([ + new Field("database", new Utf8(), true), + new Field( + "connection", + new Struct([ + new Field("host", new Utf8(), true), + new Field("port", new Int32(), true), + new Field( + "ssl", + new Struct([ + new Field("enabled", new Bool(), true), + new Field("cert_path", new Utf8(), true), + ]), + true, + ), + ]), + true, ), ]), + true, ), - // biome-ignore lint/suspicious/noExplicitAny: skip - ]) as any; - schema.metadataVersion = arrow15.MetadataVersion.V5; - const table = makeArrowTable([], { schema }); + ], + new Map([["embedding_functions", JSON.stringify([])]]), + ); - const buf = await fromTableToBuffer(table); - expect(buf.byteLength).toBeGreaterThan(0); - const actual = tableFromIPC(buf); - const actualSchema = actual.schema; - expect(actualSchema.fields.length).toBe(3); + const data = [ + { + id: "config1", + config: { + database: "postgres", + connection: { host: "localhost" }, + }, + }, + { + id: "config2", + config: { database: "mysql" }, + }, + { + id: "config3", + }, + ]; - // Deep equality gets hung up on some very minor unimportant differences - // between arrow version 13 and 15 which isn't really what we're testing for - // and so we do our own comparison that just checks name/type/nullability - function compareFields(lhs: arrow15.Field, rhs: arrow15.Field) { - expect(lhs.name).toEqual(rhs.name); - expect(lhs.nullable).toEqual(rhs.nullable); - expect(lhs.typeId).toEqual(rhs.typeId); - if ("children" in lhs.type && lhs.type.children !== null) { - const lhsChildren = lhs.type.children as arrow15.Field[]; - lhsChildren.forEach((child: arrow15.Field, idx) => { - compareFields(child, rhs.type.children[idx]); - }); - } - } + const table = await convertToTable(data, undefined, { schema }); + + expect(table.numCols).toBe(2); + expect(table.numRows).toBe(3); + + const configColumn = table.getChild("config"); + expect(configColumn).toBeDefined(); + expect(configColumn?.type.toString()).toBe( + "Struct<{database:Utf8, connection:Struct<{host:Utf8, port:Int32, ssl:Struct<{enabled:Bool, cert_path:Utf8}>}>}>", + ); + }); + + it("will handle missing columns in Arrow table input when using embeddings", async function () { + const incompleteTable = makeArrowTable([ + { domain: "google.com", name: "Google" }, + { domain: "facebook.com", name: "Facebook" }, + ]); + + const schema = new Schema( + [ + new Field("domain", new Utf8(), true), + new Field("name", new Utf8(), true), + new Field("description", new Utf8(), true), + ], + new Map([["embedding_functions", JSON.stringify([])]]), + ); + + const buf = await fromDataToBuffer(incompleteTable, undefined, schema); + + expect(buf.byteLength).toBeGreaterThan(0); + + const retrievedTable = tableFromIPC(buf); + expect(retrievedTable.numCols).toBe(3); + expect(retrievedTable.numRows).toBe(2); + + const descriptionColumn = retrievedTable.getChild("description"); + expect(descriptionColumn).toBeDefined(); + expect(descriptionColumn?.nullCount).toBe(2); + expect(descriptionColumn?.toArray()).toEqual([null, null]); + + expect(retrievedTable.getChild("domain")?.toArray()).toEqual([ + "google.com", + "facebook.com", + ]); + expect(retrievedTable.getChild("name")?.toArray()).toEqual([ + "Google", + "Facebook", + ]); + }); + + it("should correctly retain values in nested struct fields", async function () { + const testData = [ + { + id: "doc1", + vector: [1, 2, 3], + metadata: { + filePath: "/path/to/file1.ts", + startLine: 10, + endLine: 20, + text: "function test() { return true; }", + }, + }, + { + id: "doc2", + vector: [4, 5, 6], + metadata: { + filePath: "/path/to/file2.ts", + startLine: 30, + endLine: 40, + text: "function test2() { return false; }", + }, + }, + ]; + + const table = makeArrowTable(testData); + + const metadataField = table.schema.fields.find( + (f) => f.name === "metadata", + ); + expect(metadataField).toBeDefined(); + // biome-ignore lint/suspicious/noExplicitAny: accessing fields in different Arrow versions + const childNames = metadataField?.type.children.map((c: any) => c.name); + expect(childNames).toEqual(["filePath", "startLine", "endLine", "text"]); + + const buf = await fromTableToBuffer(table); + const retrievedTable = tableFromIPC(buf); + + const rows = []; + for (let i = 0; i < retrievedTable.numRows; i++) { + rows.push(retrievedTable.get(i)); + } + + const firstRow = rows[0]; + expect(firstRow.id).toBe("doc1"); + expect(firstRow.vector.toJSON()).toEqual([1, 2, 3]); + expect(firstRow.metadata.filePath).toBe("/path/to/file1.ts"); + expect(firstRow.metadata.startLine).toBe(10); + expect(firstRow.metadata.endLine).toBe(20); + expect(firstRow.metadata.text).toBe("function test() { return true; }"); + }); + }); + + class DummyEmbedding extends EmbeddingFunction { + toJSON(): Partial { + return {}; + } + + async computeSourceEmbeddings(data: string[]): Promise { + return data.map(() => [0.0, 0.0]); + } + + ndims(): number { + return 2; + } + + embeddingDataType() { + return new Float16(); + } + } + + class DummyEmbeddingWithNoDimension extends EmbeddingFunction { + toJSON(): Partial { + return {}; + } + + embeddingDataType() { + return new Float16(); + } + + async computeSourceEmbeddings(data: string[]): Promise { + return data.map(() => [0.0, 0.0]); + } + } + const dummyEmbeddingConfig: EmbeddingFunctionConfig = { + sourceColumn: "string", + function: new DummyEmbedding(), + }; + + const dummyEmbeddingConfigWithNoDimension: EmbeddingFunctionConfig = { + sourceColumn: "string", + function: new DummyEmbeddingWithNoDimension(), + }; + + describe("convertToTable", function () { + it("will infer data types correctly", async function () { + await checkTableCreation( // biome-ignore lint/suspicious/noExplicitAny: - actualSchema.fields.forEach((field: any, idx: string | number) => { - compareFields(field, actualSchema.fields[idx]); - }); - }); + async (records) => await (convertToTable)(records), + true, + ); }); - describe("converting record batches to buffers", function () { - it("can convert to buffered record batch and back again", async function () { - const records = [ - { text: "dog", vector: [0.1, 0.2] }, - { text: "cat", vector: [0.3, 0.4] }, - ]; - const table = await convertToTable(records); - const batch = table.batches[0]; - - const buffer = await fromRecordBatchToBuffer(batch); - const result = await fromBufferToRecordBatch(buffer); - - expect(JSON.stringify(batch.toArray())).toEqual( - JSON.stringify(result?.toArray()), - ); - }); - - it("converting from buffer returns null if buffer has no record batches", async function () { - const result = await fromBufferToRecordBatch(Buffer.from([0x01, 0x02])); // bad data - expect(result).toEqual(null); - }); + it("will allow a schema to be provided", async function () { + await checkTableCreation( + async (records, _, schema) => + // biome-ignore lint/suspicious/noExplicitAny: + await (convertToTable)(records, undefined, { schema }), + false, + ); }); - describe("boolean null handling", function () { - it("should handle null values in nullable boolean fields", () => { - const { makeArrowTable } = require("../lancedb/arrow"); - const schema = new Schema([new Field("test", new arrow.Bool(), true)]); - - // Test with all null values - const data = [{ test: null }]; - const table = makeArrowTable(data, { schema }); - - expect(table.numRows).toBe(1); - expect(table.schema.names).toEqual(["test"]); - expect(table.getChild("test")!.get(0)).toBeNull(); - }); - - it("should handle mixed null and non-null boolean values", () => { - const { makeArrowTable } = require("../lancedb/arrow"); - const schema = new Schema([new Field("test", new Bool(), true)]); - - // Test with mixed values - const data = [{ test: true }, { test: null }, { test: false }]; - const table = makeArrowTable(data, { schema }); - - expect(table.numRows).toBe(3); - expect(table.getChild("test")!.get(0)).toBe(true); - expect(table.getChild("test")!.get(1)).toBeNull(); - expect(table.getChild("test")!.get(2)).toBe(false); - }); + it("will use the field order of any provided schema", async function () { + await checkTableCreation( + async (_, recordsReversed, schema) => + // biome-ignore lint/suspicious/noExplicitAny: + await (convertToTable)(recordsReversed, undefined, { schema }), + false, + ); }); - // Test for the undefined values bug fix - describe("undefined values handling", () => { - it("should handle mixed undefined and actual values", () => { - const schema = new Schema([ - new Field("text", new Utf8(), true), // nullable - new Field("number", new Int32(), true), // nullable - new Field("bool", new Bool(), true), // nullable - ]); + it("will make an empty table", async function () { + await checkTableCreation( + async (_, __, schema) => + // biome-ignore lint/suspicious/noExplicitAny: + await (convertToTable)([], undefined, { schema }), + false, + ); + }); - const data = [ - { text: undefined, number: 42, bool: true }, - { text: "hello", number: undefined, bool: false }, - { text: "world", number: 123, bool: undefined }, - ]; - const table = makeArrowTable(data, { schema }); + it("will apply embeddings", async function () { + const records = sampleRecords(); + const table = await convertToTable(records, dummyEmbeddingConfig); + expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + true, + ); + expect( + table.getChild("vector")?.type.children[0].type.toString(), + ).toEqual(new Float16().toString()); + }); - const result = table.toArray(); - expect(result).toHaveLength(3); - expect(result[0].text).toBe(null); - expect(result[0].number).toBe(42); - expect(result[0].bool).toBe(true); - expect(result[1].text).toBe("hello"); - expect(result[1].number).toBe(null); - expect(result[1].bool).toBe(false); - expect(result[2].text).toBe("world"); - expect(result[2].number).toBe(123); - expect(result[2].bool).toBe(null); + it("will fail if missing the embedding source column", async function () { + await expect( + convertToTable([{ id: 1 }], dummyEmbeddingConfig), + ).rejects.toThrow("'string' was not present"); + }); + + it("use embeddingDimension if embedding missing from table", async function () { + const schema = new Schema([new Field("string", new Utf8(), false)]); + // Simulate getting an empty Arrow table (minus embedding) from some other source + // In other words, we aren't starting with records + const table = makeEmptyTable(schema); + + // If the embedding specifies the dimension we are fine + await fromTableToBuffer(table, dummyEmbeddingConfig); + + // We can also supply a schema and should be ok + const schemaWithEmbedding = new Schema([ + new Field("string", new Utf8(), false), + new Field( + "vector", + new FixedSizeList(2, new Field("item", new Float16(), false)), + false, + ), + ]); + await fromTableToBuffer( + table, + dummyEmbeddingConfigWithNoDimension, + schemaWithEmbedding, + ); + + // Otherwise we will get an error + await expect( + fromTableToBuffer(table, dummyEmbeddingConfigWithNoDimension), + ).rejects.toThrow("does not specify `embeddingDimension`"); + }); + + it("will apply embeddings to an empty table", async function () { + const schema = new Schema([ + new Field("string", new Utf8(), false), + new Field( + "vector", + new FixedSizeList(2, new Field("item", new Float16(), false)), + false, + ), + ]); + const table = await convertToTable([], dummyEmbeddingConfig, { + schema, + }); + expect(DataType.isFixedSizeList(table.getChild("vector")?.type)).toBe( + true, + ); + expect( + table.getChild("vector")?.type.children[0].type.toString(), + ).toEqual(new Float16().toString()); + }); + + it("will complain if embeddings present but schema missing embedding column", async function () { + const schema = new Schema([new Field("string", new Utf8(), false)]); + await expect( + convertToTable([], dummyEmbeddingConfig, { schema }), + ).rejects.toThrow("column vector was missing"); + }); + + it("will skip embedding application if already applied", async function () { + const records = sampleRecords(); + const table = await convertToTable(records, dummyEmbeddingConfig); + + // fromTableToBuffer will try and apply the embeddings again + // but should skip since the column already has non-null values + const result = await fromTableToBuffer(table, dummyEmbeddingConfig); + expect(result.byteLength).toBeGreaterThan(0); + }); + }); + + describe("makeEmptyTable", function () { + it("will make an empty table", async function () { + await checkTableCreation( + // biome-ignore lint/suspicious/noExplicitAny: + async (_, __, schema) => (makeEmptyTable)(schema), + false, + ); + }); + + it("will make an empty table with a Map field", async function () { + const schema = new Schema([ + new Field( + "attributes", + new Map_( + new Field( + "entries", + new Struct([ + new Field("key", new Utf8(), false), + new Field("value", new Int32(), true), + ]), + false, + ), + true, + ), + ), + ]); + + const table = makeEmptyTable(schema); + + expectValidMapField(table.schema.fields[0]); + + const buffer = await fromTableToBuffer(table); + const roundTripped = tableFromIPC(buffer); + + expectValidMapField(roundTripped.schema.fields[0]); + }); + + it("preserves string schema metadata", function () { + const metadata = new Map([["source", "fixture"]]); + const schema = new Schema( + [new Field("value", new Int32(), true)], + metadata, + ); + + expect(makeEmptyTable(schema).schema.metadata.get("source")).toBe( + "fixture", + ); + }); + + it.each([ + ["non-string keys", new Map([[42, "fixture"]])], + ["non-string values", new Map([["source", 42]])], + ["non-string keys and values", new Map([[42, false]])], + ])("rejects schema metadata with %s", function (_, metadataLike) { + const metadata = metadataLike as unknown as Map; + const schema = new Schema( + [new Field("value", new Int32(), true)], + metadata, + ); + + expect(() => makeEmptyTable(schema)).toThrow( + "Expected metadata, if present, to be a Map but it had non-string keys or values", + ); + }); + }); + + describe("when using two versions of arrow", function () { + it("preserves a dictionary shared by multiple fields", async function () { + const values = ["alpha", "beta", "alpha"]; + const dictionaryVector = vectorFromArray(values); + const batch = new ArrowRecordBatch({ + first: dictionaryVector.data[0], + second: dictionaryVector.data[0], + }); + const table = new ArrowTable([batch]); + + const sanitized = sanitizeTable(table); + expect([...sanitized.getChild("first")!]).toEqual(values); + expect([...sanitized.getChild("second")!]).toEqual(values); + const firstType = sanitized.schema.fields[0].type as { + dictionary: unknown; + }; + const secondType = sanitized.schema.fields[1].type as { + dictionary: unknown; + }; + expect(secondType.dictionary).toBe(firstType.dictionary); + expect(sanitized.batches[0].data.children[1].dictionary).toBe( + sanitized.batches[0].data.children[0].dictionary, + ); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("first")!]).toEqual(values); + expect([...actual.getChild("second")!]).toEqual(values); + }); + + it("preserves shared dictionary data from another Arrow version", async function () { + const values = ["alpha", "beta", "alpha"]; + const dictionaryVector = vectorFromArray(values); + const firstBatch = new ArrowRecordBatch({ + label: dictionaryVector.slice(0, 2).data[0], + }); + const secondBatch = new ArrowRecordBatch({ + label: dictionaryVector.slice(2).data[0], + }); + const table = new ArrowTable([firstBatch, secondBatch]); + + const sanitized = sanitizeTable(table); + expect([...sanitized.getChild("label")!]).toEqual(values); + + const dictionaries = sanitized.batches.map( + (batch) => batch.data.children[0].dictionary, + ); + expect(dictionaries[0]).toBeInstanceOf(CurrentVector); + expect(dictionaries[1]).toBe(dictionaries[0]); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("label")!]).toEqual(values); + }); + + it("preserves shared chunks in growing dictionaries", async function () { + const type = new Dictionary(new Utf8(), new Int32(), 42, false); + const firstDictionary = vectorFromArray(["alpha", "beta"], new Utf8()); + const secondDictionary = firstDictionary.concat( + vectorFromArray(["gamma"], new Utf8()), + ); + const firstData = arrowMakeData({ + type, + data: Int32Array.from([0, 1]), + dictionary: firstDictionary, + }); + const secondData = arrowMakeData({ + type, + data: Int32Array.from([2]), + dictionary: secondDictionary, + }); + const table = new ArrowTable([ + new ArrowRecordBatch({ label: firstData }), + new ArrowRecordBatch({ label: secondData }), + ]); + + const sanitized = sanitizeTable(table); + const expected = ["alpha", "beta", "gamma"]; + expect([...sanitized.getChild("label")!]).toEqual(expected); + const firstLocalDictionary = + sanitized.batches[0].data.children[0].dictionary!; + const secondLocalDictionary = + sanitized.batches[1].data.children[0].dictionary!; + expect(secondLocalDictionary.data[0]).toBe(firstLocalDictionary.data[0]); + + const buf = await fromTableToBuffer(sanitized); + const actual = currentTableFromIPC(buf); + expect([...actual.getChild("label")!]).toEqual(expected); + }); + + it("can serialize list data from another Arrow version", async function () { + const values = [["anime", "action"], [], null]; + const vector = vectorFromArray( + values, + new List(new Field("item", new Utf8(), true)), + ); + const table = new ArrowTable({ tags: vector }); + + const buf = await fromDataToBuffer(table); + const actual = currentTableFromIPC(buf); + const actualTags = actual.getChild("tags"); + + expect(actualTags?.get(0)?.toJSON()).toEqual(values[0]); + expect(actualTags?.get(1)?.toJSON()).toEqual(values[1]); + expect(actualTags?.get(2)).toBeNull(); + }); + + it("can still import data", async function () { + const schema = new arrow15.Schema([ + new arrow15.Field("id", new arrow15.Int32()), + new arrow15.Field( + "vector", + new arrow15.FixedSizeList( + 1024, + new arrow15.Field("item", new arrow15.Float32(), true), + ), + ), + new arrow15.Field( + "struct", + new arrow15.Struct([ + new arrow15.Field( + "nested", + new arrow15.Dictionary( + new arrow15.Utf8(), + new arrow15.Int32(), + 1, + true, + ), + ), + new arrow15.Field( + "ts_with_tz", + new arrow15.TimestampNanosecond("some_tz"), + ), + new arrow15.Field( + "ts_no_tz", + new arrow15.TimestampNanosecond(null), + ), + ]), + ), + // biome-ignore lint/suspicious/noExplicitAny: skip + ]) as any; + schema.metadataVersion = arrow15.MetadataVersion.V5; + const table = makeArrowTable([], { schema }); + + const buf = await fromTableToBuffer(table); + expect(buf.byteLength).toBeGreaterThan(0); + const actual = tableFromIPC(buf); + const actualSchema = actual.schema; + expect(actualSchema.fields.length).toBe(3); + + // Deep equality gets hung up on some very minor unimportant differences + // between arrow version 13 and 15 which isn't really what we're testing for + // and so we do our own comparison that just checks name/type/nullability + function compareFields(lhs: arrow15.Field, rhs: arrow15.Field) { + expect(lhs.name).toEqual(rhs.name); + expect(lhs.nullable).toEqual(rhs.nullable); + expect(lhs.typeId).toEqual(rhs.typeId); + if ("children" in lhs.type && lhs.type.children !== null) { + const lhsChildren = lhs.type.children as arrow15.Field[]; + lhsChildren.forEach((child: arrow15.Field, idx) => { + compareFields(child, rhs.type.children[idx]); + }); + } + } + // biome-ignore lint/suspicious/noExplicitAny: + actualSchema.fields.forEach((field: any, idx: string | number) => { + compareFields(field, actualSchema.fields[idx]); }); }); - }, -); + }); + + describe("converting record batches to buffers", function () { + it("can convert to buffered record batch and back again", async function () { + const records = [ + { text: "dog", vector: [0.1, 0.2] }, + { text: "cat", vector: [0.3, 0.4] }, + ]; + const table = await convertToTable(records); + const batch = table.batches[0]; + + const buffer = await fromRecordBatchToBuffer(batch); + const result = await fromBufferToRecordBatch(buffer); + + expect(JSON.stringify(batch.toArray())).toEqual( + JSON.stringify(result?.toArray()), + ); + }); + + it("converting from buffer returns null if buffer has no record batches", async function () { + const result = await fromBufferToRecordBatch(Buffer.from([0x01, 0x02])); // bad data + expect(result).toEqual(null); + }); + }); + + describe("boolean null handling", function () { + it("should handle null values in nullable boolean fields", () => { + const { makeArrowTable } = require("../lancedb/arrow"); + const schema = new Schema([new Field("test", new arrow.Bool(), true)]); + + // Test with all null values + const data = [{ test: null }]; + const table = makeArrowTable(data, { schema }); + + expect(table.numRows).toBe(1); + expect(table.schema.names).toEqual(["test"]); + expect(table.getChild("test")!.get(0)).toBeNull(); + }); + + it("should handle mixed null and non-null boolean values", () => { + const { makeArrowTable } = require("../lancedb/arrow"); + const schema = new Schema([new Field("test", new Bool(), true)]); + + // Test with mixed values + const data = [{ test: true }, { test: null }, { test: false }]; + const table = makeArrowTable(data, { schema }); + + expect(table.numRows).toBe(3); + expect(table.getChild("test")!.get(0)).toBe(true); + expect(table.getChild("test")!.get(1)).toBeNull(); + expect(table.getChild("test")!.get(2)).toBe(false); + }); + }); + + // Test for the undefined values bug fix + describe("undefined values handling", () => { + it("should handle mixed undefined and actual values", () => { + const schema = new Schema([ + new Field("text", new Utf8(), true), // nullable + new Field("number", new Int32(), true), // nullable + new Field("bool", new Bool(), true), // nullable + ]); + + const data = [ + { text: undefined, number: 42, bool: true }, + { text: "hello", number: undefined, bool: false }, + { text: "world", number: 123, bool: undefined }, + ]; + const table = makeArrowTable(data, { schema }); + + const result = table.toArray(); + expect(result).toHaveLength(3); + expect(result[0].text).toBe(null); + expect(result[0].number).toBe(42); + expect(result[0].bool).toBe(true); + expect(result[1].text).toBe("hello"); + expect(result[1].number).toBe(null); + expect(result[1].bool).toBe(false); + expect(result[2].text).toBe("world"); + expect(result[2].number).toBe(123); + expect(result[2].bool).toBe(null); + }); + }); +}); diff --git a/nodejs/__test__/arrow_versions.ts b/nodejs/__test__/arrow_versions.ts new file mode 100644 index 000000000..bfbbfa5ef --- /dev/null +++ b/nodejs/__test__/arrow_versions.ts @@ -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; diff --git a/nodejs/__test__/registry.test.ts b/nodejs/__test__/registry.test.ts index 973ad8a25..5c92941d6 100644 --- a/nodejs/__test__/registry.test.ts +++ b/nodejs/__test__/registry.test.ts @@ -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"]; diff --git a/nodejs/__test__/sanitize.test.ts b/nodejs/__test__/sanitize.test.ts index 587805d53..97856a55b 100644 --- a/nodejs/__test__/sanitize.test.ts +++ b/nodejs/__test__/sanitize.test.ts @@ -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; + 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 `; 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 () { diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 904c282d4..eaf59211c 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -7,11 +7,6 @@ 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, Connection, @@ -56,19 +51,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: (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 = new arrow.Schema([ new arrow.Field("id", new arrow.Float64(), true), ]); @@ -742,6 +739,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).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, + ) => 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 { @@ -3203,7 +3308,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: (arrow: any) => { diff --git a/nodejs/lancedb/arrow.ts b/nodejs/lancedb/arrow.ts index df875a808..3daee7d65 100644 --- a/nodejs/lancedb/arrow.ts +++ b/nodejs/lancedb/arrow.ts @@ -66,11 +66,20 @@ export type SchemaLike = export type FieldLike = | Field | { - type: string; + type: string | DataTypeLike; name: string; nullable: boolean; metadata?: Map; }; +/** + * A `DataType` from any copy or version of apache-arrow. + * + * Arrow 21 brands its classes with `unique symbol` properties, so a type + * object from a second copy of the library no longer satisfies the `DataType` + * type of this one even though it is structurally identical. Inputs that only + * need to be sanitized accept this looser shape instead. + */ +export type DataTypeLike = DataType | { readonly typeId: number }; /** * Create an Arrow field backed by LanceDB's JSON extension type. @@ -1059,6 +1068,71 @@ export async function fromRecordsToStreamBuffer( return Buffer.from(await writer.toUint8Array()); } +// `Type.Utf8View` / `Type.BinaryView` as numbers: the enum members only exist +// in Arrow 21+, and this module compiles against every supported release. +const UTF8_VIEW_TYPE_ID = 24; +const BINARY_VIEW_TYPE_ID = 23; + +/** + * Copy a Utf8View / BinaryView `Data` into a single `Data` of `type`. + */ +function materializeViewData(child: ArrowData, type: DataType): ArrowData { + const builder = makeBuilder({ type, nullValues: [null] }); + for (const value of new Vector([child])) { + builder.append(value); + } + return builder.finish().flush(); +} + +/** + * Rebuild any top-level Utf8View / BinaryView column as Utf8 / Binary. + * + * Lance stores the view types as their offset-based equivalents anyway, so + * nothing is lost. Doing it here also sidesteps an Arrow JS 21 bug: its IPC + * writer emits a truncated views buffer for a *sliced* view array, which the + * Rust reader rejects with "Need at least N bytes in buffers[0]". + * + * The record batches are rebuilt positionally rather than through a + * `Record`: JavaScript enumerates integer-like keys first, so + * a field named e.g. `"1"` would otherwise be paired with the wrong column. + * + * Tables without view columns are returned as-is. + */ +function materializeViewColumns(table: ArrowTable): ArrowTable { + const replacements = new Map(); + table.schema.fields.forEach((field, i) => { + if (field.type.typeId === UTF8_VIEW_TYPE_ID) { + replacements.set(i, new Utf8()); + } else if (field.type.typeId === BINARY_VIEW_TYPE_ID) { + replacements.set(i, new Binary()); + } + }); + if (replacements.size === 0) { + return table; + } + const fields = table.schema.fields.map((field, i) => { + const type = replacements.get(i); + return type === undefined + ? field + : new Field(field.name, type, field.nullable, field.metadata); + }); + const schema = new Schema(fields, table.schema.metadata); + const batches = table.batches.map((batch) => { + const children = batch.data.children.map((child, i) => { + const type = replacements.get(i); + return type === undefined ? child : materializeViewData(child, type); + }); + const data = makeData({ + type: new Struct(fields), + length: batch.numRows, + nullCount: 0, + children, + }); + return new RecordBatch(schema, data); + }); + return new ArrowTable(schema, batches); +} + /** * Serialize an Arrow Table into a buffer using the Arrow IPC File serialization * @@ -1075,7 +1149,9 @@ export async function fromTableToBuffer( if (schema !== undefined && schema !== null) { schema = sanitizeSchema(schema); } - const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema); + const tableWithEmbeddings = materializeViewColumns( + await applyEmbeddings(table, embeddings, schema), + ); validateBlobSchema(tableWithEmbeddings.schema); const writer = RecordBatchFileWriter.writeAll(tableWithEmbeddings); return Buffer.from(await writer.toUint8Array()); @@ -1146,7 +1222,8 @@ export async function fromRecordBatchToBuffer( export async function fromRecordBatchToStreamBuffer( batch: RecordBatch, ): Promise { - const writer = RecordBatchStreamWriter.writeAll([batch]); + const table = materializeViewColumns(new ArrowTable([batch])); + const writer = RecordBatchStreamWriter.writeAll(table); return Buffer.from(await writer.toUint8Array()); } @@ -1163,7 +1240,9 @@ export async function fromTableToStreamBuffer( embeddings?: EmbeddingFunctionConfig, schema?: SchemaLike, ): Promise { - const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema); + const tableWithEmbeddings = materializeViewColumns( + await applyEmbeddings(table, embeddings, schema), + ); const writer = RecordBatchStreamWriter.writeAll(tableWithEmbeddings); return Buffer.from(await writer.toUint8Array()); } diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index a1776e3f4..9d4e5608b 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -195,6 +195,7 @@ export * as rerankers from "./rerankers"; export { SchemaLike, TableLike, + DataTypeLike, FieldLike, RecordBatchLike, DataLike, diff --git a/nodejs/lancedb/sanitize.ts b/nodejs/lancedb/sanitize.ts index 98a144451..6ba4cc881 100644 --- a/nodejs/lancedb/sanitize.ts +++ b/nodejs/lancedb/sanitize.ts @@ -9,6 +9,7 @@ // comes from the exact same library instance. This is not always the case // and so we must sanitize the input to ensure that it is compatible. +import * as arrowModule from "apache-arrow"; import { BufferType, Data, Vector } from "apache-arrow"; import type { IntBitWidth, TKeys, TimeBitWidth } from "apache-arrow/type"; import { @@ -76,6 +77,66 @@ import { Utf8, } from "./arrow"; +/** + * Whether `value` was created by *this* copy of apache-arrow's `ctor`. + * + * Arrow 21 overrides `Symbol.hasInstance` on `Schema`, `Field`, `Table`, + * `RecordBatch`, `Vector` and `Data` so that `instanceof` also matches objects + * built by a different copy of the library. Those are exactly the objects + * this module has to rebuild: the `DataType` instances nested inside them are + * deliberately *not* given that treatment upstream, so Arrow's own builders and + * writers reject them. Walk the real prototype chain instead of trusting + * `instanceof`. + */ +function isOwnInstance( + value: unknown, + // biome-ignore lint/suspicious/noExplicitAny: matches any class constructor + ctor: abstract new (...args: any[]) => T, +): value is T { + return ( + typeof value === "object" && + value !== null && + Object.prototype.isPrototypeOf.call(ctor.prototype, value) + ); +} + +/** + * Types added to Arrow JS after release 18, keyed by the `Type` enum value + * they use. The library compiles and runs against every Arrow release in the + * peer range, so these are looked up by name at runtime instead of imported: + * an older Arrow simply does not have the class. + */ +type NewerTypeName = + | "IntervalMonthDayNano" + | "LargeList" + | "BinaryView" + | "Utf8View"; +const NEWER_TYPES: ReadonlyArray<{ + name: NewerTypeName; + typeId: number; + since: number; +}> = [ + { name: "IntervalMonthDayNano", typeId: -31, since: 20 }, + { name: "LargeList", typeId: 21, since: 21 }, + { name: "BinaryView", typeId: 23, since: 21 }, + { name: "Utf8View", typeId: 24, since: 21 }, +]; + +// biome-ignore lint/suspicious/noExplicitAny: constructor arity varies by type +type DataTypeConstructor = new (...args: any[]) => DataType; + +function newerTypeConstructor(name: NewerTypeName): DataTypeConstructor { + const ctor = (arrowModule as unknown as Record)[name]; + if (typeof ctor !== "function") { + const since = NEWER_TYPES.find((t) => t.name === name)?.since; + throw new Error( + `The Arrow type ${name} requires apache-arrow ${since} or newer, ` + + "but the installed apache-arrow does not provide it", + ); + } + return ctor as DataTypeConstructor; +} + type SanitizationContext = { types: WeakMap; vectors: WeakMap; @@ -212,19 +273,37 @@ export function sanitizeList(typeLike: object) { return sanitizeListWithContext(typeLike, createSanitizationContext()); } +function sanitizeListChildWithContext( + typeLike: object, + context: SanitizationContext, + typeName: string, +): Field { + if (!("children" in typeLike) || !Array.isArray(typeLike.children)) { + throw Error( + `Expected a ${typeName} type to have an array-like \`children\` property`, + ); + } + if (typeLike.children.length !== 1) { + throw Error(`Expected a ${typeName} type to have exactly one child`); + } + return sanitizeFieldWithContext(typeLike.children[0], context); +} + function sanitizeListWithContext( typeLike: object, context: SanitizationContext, ) { - if (!("children" in typeLike) || !Array.isArray(typeLike.children)) { - throw Error( - "Expected a List type to have an array-like `children` property", - ); - } - if (typeLike.children.length !== 1) { - throw Error("Expected a List type to have exactly one child"); - } - return new List(sanitizeFieldWithContext(typeLike.children[0], context)); + return new List(sanitizeListChildWithContext(typeLike, context, "List")); +} + +function sanitizeLargeListWithContext( + typeLike: object, + context: SanitizationContext, +) { + const LargeList = newerTypeConstructor("LargeList"); + return new LargeList( + sanitizeListChildWithContext(typeLike, context, "LargeList"), + ); } export function sanitizeStruct(typeLike: object) { @@ -451,11 +530,30 @@ function sanitizeTypeWithContext( return type; } +function sanitizeNewerTypeById( + typeLike: object, + typeId: number, + context: SanitizationContext, +): DataType | undefined { + const entry = NEWER_TYPES.find((t) => t.typeId === typeId); + if (entry === undefined) { + return undefined; + } + if (entry.name === "LargeList") { + return sanitizeLargeListWithContext(typeLike, context); + } + return new (newerTypeConstructor(entry.name))(); +} + function sanitizeTypeById( typeLike: object, typeId: Type, context: SanitizationContext, ): DataType { + const newer = sanitizeNewerTypeById(typeLike, typeId, context); + if (newer !== undefined) { + return newer; + } switch (typeId) { case Type.NONE: throw Error("Received a Type with a typeId of NONE"); @@ -572,7 +670,7 @@ function sanitizeFieldWithContext( fieldLike: unknown, context: SanitizationContext, ): Field { - if (fieldLike instanceof Field) { + if (isOwnInstance(fieldLike, Field)) { return fieldLike; } if (typeof fieldLike !== "object" || fieldLike === null) { @@ -626,7 +724,7 @@ function sanitizeSchemaWithContext( schemaLike: SchemaLike, context: SanitizationContext, ): Schema { - if (schemaLike instanceof Schema) { + if (isOwnInstance(schemaLike, Schema)) { return schemaLike; } if (typeof schemaLike !== "object" || schemaLike === null) { @@ -653,7 +751,7 @@ function sanitizeSchemaWithContext( } export function sanitizeTable(tableLike: TableLike): Table { - if (tableLike instanceof Table) { + if (isOwnInstance(tableLike, Table)) { return tableLike; } if (typeof tableLike !== "object" || tableLike === null) { @@ -681,7 +779,7 @@ function sanitizeRecordBatch( batchLike: RecordBatchLike, context: SanitizationContext, ): RecordBatch { - if (batchLike instanceof RecordBatch) { + if (isOwnInstance(batchLike, RecordBatch)) { return batchLike; } if (typeof batchLike !== "object" || batchLike === null) { @@ -710,11 +808,17 @@ type DictionaryDataLike = DataLike & { dictionary?: DictionaryVectorLike; }; +// Arrow 21 added `variadicBuffers` to `Data` for the Utf8View / BinaryView +// types. Older Arrow versions do not have the property, so it is optional. +type VariadicDataLike = DataLike & { + variadicBuffers?: ReadonlyArray; +}; + function sanitizeData( dataLike: DataLike, context: SanitizationContext, ): Data { - if (dataLike instanceof Data) { + if (isOwnInstance(dataLike, Data)) { return dataLike; } const cachedData = context.data.get(dataLike); @@ -732,7 +836,12 @@ function sanitizeData( context.vectors.set(dictionaryLike, dictionary); } } - const data = new Data( + // Arrow 21 added an eighth constructor parameter (`variadicBuffers`); older + // releases ignore the extra argument but their typings do not declare it. + const DataCtor = Data as unknown as new ( + ...args: unknown[] + ) => Data; + const data = new DataCtor( sanitizeTypeWithContext(dataLike.type, context), dataLike.offset, dataLike.length, @@ -745,6 +854,7 @@ function sanitizeData( }, dataLike.children.map((child) => sanitizeData(child, context)), dictionary, + (dataLike as VariadicDataLike).variadicBuffers, ); context.data.set(dataLike, data); return data; @@ -753,7 +863,11 @@ function sanitizeData( const constructorsByTypeName = { null: () => new Null(), binary: () => new Binary(), + largebinary: () => new LargeBinary(), + binaryview: () => new (newerTypeConstructor("BinaryView"))(), utf8: () => new Utf8(), + largeutf8: () => new LargeUtf8(), + utf8view: () => new (newerTypeConstructor("Utf8View"))(), bool: () => new Bool(), int8: () => new Int8(), int16: () => new Int16(), @@ -774,6 +888,8 @@ const constructorsByTypeName = { timesecond: () => new TimeSecond(), intervaldaytime: () => new IntervalDayTime(), intervalyearmonth: () => new IntervalYearMonth(), + intervalmonthdaynano: () => + new (newerTypeConstructor("IntervalMonthDayNano"))(), durationnanosecond: () => new DurationNanosecond(), durationmicrosecond: () => new DurationMicrosecond(), durationmillisecond: () => new DurationMillisecond(), diff --git a/nodejs/package.json b/nodejs/package.json index 29bbe4314..da5dc77b2 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -49,10 +49,14 @@ "@types/jest": "^29.1.2", "@types/node": "22.7.4", "@types/tmp": "^0.2.6", + "apache-arrow": "21.2.0", "apache-arrow-15": "npm:apache-arrow@15.0.0", "apache-arrow-16": "npm:apache-arrow@16.0.0", "apache-arrow-17": "npm:apache-arrow@17.0.0", "apache-arrow-18": "npm:apache-arrow@18.0.0", + "apache-arrow-19": "npm:apache-arrow@19.0.1", + "apache-arrow-20": "npm:apache-arrow@20.0.0", + "apache-arrow-21": "npm:apache-arrow@21.2.0", "eslint": "^8.57.0", "jest": "^29.7.0", "shx": "^0.3.4", @@ -102,7 +106,7 @@ }, "peerDependencies": { "@types/node": ">=22", - "apache-arrow": ">=15.0.0 <=18.1.0" + "apache-arrow": ">=15.0.0 <22.0.0" }, "peerDependenciesMeta": { "@types/node": { diff --git a/nodejs/pnpm-lock.yaml b/nodejs/pnpm-lock.yaml index a03838999..4c19305c8 100644 --- a/nodejs/pnpm-lock.yaml +++ b/nodejs/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 - apache-arrow: - specifier: '>=15.0.0 <=18.1.0' - version: 18.1.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -54,6 +51,9 @@ importers: '@types/tmp': specifier: ^0.2.6 version: 0.2.6 + apache-arrow: + specifier: 21.2.0 + version: 21.2.0 apache-arrow-15: specifier: npm:apache-arrow@15.0.0 version: apache-arrow@15.0.0 @@ -66,6 +66,15 @@ importers: apache-arrow-18: specifier: npm:apache-arrow@18.0.0 version: apache-arrow@18.0.0 + apache-arrow-19: + specifier: npm:apache-arrow@19.0.1 + version: apache-arrow@19.0.1 + apache-arrow-20: + specifier: npm:apache-arrow@20.0.0 + version: apache-arrow@20.0.0 + apache-arrow-21: + specifier: npm:apache-arrow@21.2.0 + version: apache-arrow@21.2.0 eslint: specifier: ^8.57.0 version: 8.57.1 @@ -1703,6 +1712,9 @@ packages: '@types/node@22.7.4': resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + '@types/node@25.9.8': + resolution: {integrity: sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1840,8 +1852,16 @@ packages: resolution: {integrity: sha512-gFlPaqN9osetbB83zC29AbbZqGiCuFH1vyyPseJ+B7SIbfBtESV62mMT/CkiIt77W6ykC/nTWFzTXFs0Uldg4g==} hasBin: true - apache-arrow@18.1.0: - resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==} + apache-arrow@19.0.1: + resolution: {integrity: sha512-APmMLzS4qbTivLrPdQXexGM4JRr+0g62QDaobzEvip/FdQIrv2qLy0mD5Qdmw4buydtVJgbFeKR8f59I6PPGDg==} + hasBin: true + + apache-arrow@20.0.0: + resolution: {integrity: sha512-JUeK0jFRUd7rbmrhhzR3O2KXjLaZ4YYYFOptyUfxOsMIoZCPi6bZR58gVi/xi3HTBMPseXm9PXyQ2V916930pA==} + hasBin: true + + apache-arrow@21.2.0: + resolution: {integrity: sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ==} hasBin: true argparse@1.0.10: @@ -2033,6 +2053,15 @@ packages: resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} engines: {node: '>=4.0.0'} + command-line-args@6.0.2: + resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} + engines: {node: '>=12.20'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + command-line-usage@7.0.4: resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} engines: {node: '>=12.20.0'} @@ -2281,6 +2310,15 @@ packages: resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} engines: {node: '>=4.0.0'} + find-replace@5.0.2: + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2302,6 +2340,9 @@ packages: flatbuffers@24.12.23: resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -3345,6 +3386,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -5505,6 +5549,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@25.9.8': + dependencies: + undici-types: 7.24.6 + '@types/stack-utils@2.0.3': {} '@types/tmp@0.2.6': {} @@ -5694,17 +5742,40 @@ snapshots: json-bignum: 0.0.3 tslib: 2.8.1 - apache-arrow@18.1.0: + apache-arrow@19.0.1: dependencies: '@swc/helpers': 0.5.21 '@types/command-line-args': 5.2.3 '@types/command-line-usage': 5.0.4 '@types/node': 20.19.41 - command-line-args: 5.2.1 + command-line-args: 6.0.2 command-line-usage: 7.0.4 flatbuffers: 24.12.23 json-bignum: 0.0.3 tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + + apache-arrow@20.0.0: + dependencies: + '@swc/helpers': 0.5.21 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 20.19.41 + command-line-args: 6.0.2 + command-line-usage: 7.0.4 + flatbuffers: 25.9.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + + apache-arrow@21.2.0: + dependencies: + '@types/node': 25.9.8 + flatbuffers: 25.9.23 + json-with-bigint: 3.5.8 + tslib: 2.8.1 argparse@1.0.10: dependencies: @@ -5906,6 +5977,13 @@ snapshots: lodash.camelcase: 4.3.0 typical: 4.0.0 + command-line-args@6.0.2: + dependencies: + array-back: 6.2.3 + find-replace: 5.0.2 + lodash.camelcase: 4.3.0 + typical: 7.3.0 + command-line-usage@7.0.4: dependencies: array-back: 6.2.3 @@ -6178,6 +6256,8 @@ snapshots: dependencies: array-back: 3.1.0 + find-replace@5.0.2: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6201,6 +6281,8 @@ snapshots: flatbuffers@24.12.23: {} + flatbuffers@25.9.23: {} + flatted@3.4.2: {} follow-redirects@1.16.0: {} @@ -7405,6 +7487,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.24.6: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json index 9b83da5ec..094fe5003 100644 --- a/nodejs/tsconfig.json +++ b/nodejs/tsconfig.json @@ -10,7 +10,10 @@ "resolveJsonModule": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "moduleResolution": "Node" + "moduleResolution": "Node", + // flatbuffers 25 (via apache-arrow 21) ships declarations that need + // TypeScript 5.7+ generic typed arrays; skip checking third-party .d.ts. + "skipLibCheck": true }, "exclude": ["./dist/*", "./examples/*"], "typedocOptions": {