Compare commits

..
Author SHA1 Message Date
Jonathan M HsiehandClaude Opus 5 404b91d4d6 refactor(secrets): one binding list with a kind, and split the secret writes
`secret_env_bindings` took the general noun for one delivery mode. A binding is
the concept; how it arrives is a property of one. `secret_bindings` is a list of
`SecretBinding`, tagged by `kind`, so a later mode is a variant rather than a
sibling field -- and the rules that are per-Function, like how many Secrets it
may bind, stay answerable from one place.

The cost of one field is that an unknown kind is a decode error unless it is
caught. It is caught, the way `PythonRuntimeSpec` catches an unknown runtime:
`Unrecognized { kind }`, `#[non_exhaustive]`, and the payload dropped rather
than retained because the client does not proxy catalog values. A test pins it
-- a `file` binding from a newer server decodes, reports its kind, and
round-trips as its discriminator without failing the version around it.

A list has no key order to inherit, and the list is in the version hash, so
`bind_secrets` sorts it: a caller's argument order is not part of what a
Function is.

The Secret is named under `secret_ref`, not `secret`: the service scans Job
payloads for credential-shaped keys and refuses one called `secret` whatever it
holds. That guard is worth more blunt than argued with.

`create_secret` and `alter_secret` also stop sharing a request shape. They are
different operations to the service -- one refuses an existing name, the other
requires it -- and either may grow a field the other has no meaning for. What
they share is posting a body that must not be logged, which is a function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 20:06:42 +00:00
Jonathan M HsiehandClaude Opus 5 d6bcfe52a9 refactor(secrets): epoch timestamps, a named list request, and unit tests
`SecretInfo` carries `created_at_millis` / `updated_at_millis` as `i64` rather
than RFC 3339 strings, matching `created_at_millis` elsewhere in the platform.
A caller comparing two timestamps no longer parses anything, and the PyO3 layer
hands Python integers instead of stringifying them. `updated_at_millis` is the
only observable that a rotation landed -- no API returns a credential -- so it
is worth being a number a caller can compare.

`RemoteListSecretsRequest` is declared alongside its response instead of being
an inline object built field by field, so a reader of one finds the other.

`function.rs` gains unit tests for the pure parts that had none: canonical JSON
sorts keys at every depth and leaves array order alone -- it is what the version
hash is taken over, so both matter -- floats are refused at any depth, and the
unknown-key check inspects only the level it is handed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 18:55:35 +00:00
Jonathan M HsiehandClaude Opus 5 82cf9f3b96 fix(secrets): drop an import left behind by the removed validation tests
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 18:41:28 +00:00
Jonathan M HsiehandClaude Opus 5 762eb0f44f refactor(secrets): give Secrets their own module, leave validation to the service
Three things from review.

`SecretInfo` moves to `lancedb::secrets`. It was in `database` because that is
where the trait lives, not because it belongs to a database's shape; a new
object should land in its own module and the Secret verbs will follow it.

Binding and value validation come out of the client. The service owns those
rules -- it owns the runtime the names land in and the store the values go to --
and a copy here could disagree with it without either side noticing. What the
client was checking, the service already rejects: the per-Function cap, the
environment-variable grammar, disjointness from `runtime.env`, and the value
size. The cost is a round trip before the error, and the error is the service's
own words rather than a paraphrase that can drift.

The tests follow the rule rather than the check: what the client guarantees is
that the envelope it sends is the envelope the caller wrote, so that is what is
asserted now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 17:45:20 +00:00
Jonathan M HsiehandClaude Opus 5 b9c0446421 fix(secrets): admit periods in Secret names and namespace segments
LanceDB namespace and table names are `[A-Za-z0-9_.-]`, so excluding periods
here put Secrets out of reach inside any namespace a user already has one in --
unreachable to create, alter, describe, drop or bind, with no way to recover
but renaming the namespace.

The rule is that set and nothing further. No positional constraint rides along:
the established grammar says nothing about which character comes first, so a
segment may begin with `_`, `-` or `.` today, and a Secret has to be nameable
wherever a namespace already is. A narrower rule would reintroduce the same
unaddressability it is here to remove, one character class over.

Names and path segments follow one rule, and the client matches the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 17:10:43 +00:00
Jonathan M HsiehandClaude Opus 5 d67585c245 fix(secrets): complete the binding and value contract at the Rust boundary
`validate` checked only the binding count, so a caller reaching the low-level
entry point past `bind_secrets` could register an environment variable name the
runtime cannot deliver, or bind a Secret to a name `runtime.env` already sets --
which would resolve by delivery order, with a value visible in the Function's
record and a value that is not.

Secret values are bounded here too, at the limit the service enforces, so an
oversized credential is refused before a request body is built rather than
after it has been serialized and uploaded.

Sandbox-reserved names are deliberately still the service's alone: that list
belongs to the runtime that owns it, and a copy here would drift from it
silently.

Both gate reproducers now fail closed with no request reaching the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 17:10:43 +00:00
Jonathan M HsiehandClaude Opus 5 2e34d2742b fix(secrets): keep credentials out of the client's own debug log
`log_request` logs any JSON body verbatim at debug, and Python and Node both
wire that logger to `LANCEDB_LOG`. `create_secret` posts the value in its body,
so ordinary SDK debug logging wrote the credential to application logs. The
comment on `write_secret` reasoned correctly about proxy traces and access logs
and missed the logger in this process.

Redaction cannot live in the value model: the logger sees the serialized body,
where the credential is already plaintext bytes. So the request says whether its
body may be logged -- `send_suppressing_body` for the one whose body is the
credential -- and `log_request` obeys rather than deciding. The transport cannot
tell a credential from any other payload, and a list of routes there would have
to be kept in step with endpoints declared elsewhere.

The same debug line prints the request's Debug, which prints headers, so the
API key was in every debug line of every request regardless of route. Marking
the header value sensitive is what stops that.

The end-to-end regression fails without this: the log carried
`,"value":"SECRET_VALUE_SENTINEL"}` verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 17:10:43 +00:00
Jonathan M HsiehandClaude Opus 5 4d09f8ce26 refactor(secrets): name the binding field for its delivery mode, cap in Rust
`secret_bindings` read as the general record of what a Function needs, but a
`Map<String, String>` keyed on environment-variable name can only ever hold an
env-delivered binding: an accessor binding has no variable to key on. Naming
the field for its delivery mode leaves a later mode a sibling field rather than
a tagged value type, which would break a field already in the identity hash.

The per-Function cap moves from `bind_secrets` to
`Connection::create_function_async`, above the backend dispatch, so it holds
for every language surface rather than only the one that validates first. The
low-level PyO3 entry point reached the wire past the Python check; it no longer
does.

Also corrects a doc comment claiming registration fails when a bound Secret is
absent. Existence is first answered at `add_columns`, by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-09 16:59:40 +00:00
Jonathan M HsiehandClaude Opus 5 7b29fb2f51 feat(secrets): named Secrets and EnvVarSecret bindings
Adds the client half of database-scoped named Secrets: a Secret is a name and
an opaque value stored by the service, and a Function binds one to the
environment variable its library already reads.

- `db.create_secret` / `alter_secret` / `list_secrets` / `describe_secret` /
  `drop_secret` on sync, async and remote connections, with the pyo3 binding
  and the Rust client behind them. There is no read API by construction rather
  than by policy: no code path returns a stored credential, and
  `describe_secret` answers with metadata only.
- `EnvVarSecret(secret=..., env_variable=...)` pairs a Secret with the variable
  it arrives in. It is a pure local constructor -- it contacts no server, so it
  cannot fail on a Secret that does not exist -- and it exists so a bare string
  in that position, which would be a credential, is a TypeError rather than a
  plausible-looking mistake that reads identically in a diff.
- `create_function(..., secrets=[...])` carries the bindings as
  `secret_bindings`, a map from variable name to Secret name. The value never
  travels: it is resolved by the service when the Function runs, which is what
  lets a rotation reach columns pinned to an older FunctionVersion.

The UDF body is unchanged and stays portable -- it reads `OPENAI_API_KEY` the
way it always did, and the binding is what puts a value there.

Squashed: the original three commits were a first design plus a rewrite of it,
so their sequence describes an interface that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE1UwYKsgbb3USBfkqCE6v
2026-09-09 00:12:57 +00:00
97 changed files with 2256 additions and 3684 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion] [tool.bumpversion]
current_version = "0.39.0-beta.6" current_version = "0.39.0-beta.4"
parse = """(?x) parse = """(?x)
(?P<major>0|[1-9]\\d*)\\. (?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\. (?P<minor>0|[1-9]\\d*)\\.
-20
View File
@@ -1,20 +0,0 @@
name: Typo checker
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
run:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Check spelling of the entire repository
uses: crate-ci/typos@6802cc60d4e7f78b9d5454f6cf3935c042d5e1e3 # v1.26.0
-4
View File
@@ -10,10 +10,6 @@ repos:
rev: v0.9.9 rev: v0.9.9
hooks: hooks:
- id: ruff - id: ruff
- repo: https://github.com/crate-ci/typos
rev: v1.26.0
hooks:
- id: typos
# - repo: https://github.com/RobertCraigie/pyright-python # - repo: https://github.com/RobertCraigie/pyright-python
# rev: v1.1.395 # rev: v1.1.395
# hooks: # hooks:
-19
View File
@@ -1,19 +0,0 @@
[default]
extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"]
[default.extend-words]
# Azure Kubernetes Service, mentioned in rust/lancedb/src/remote/oauth.rs.
AKS = "AKS"
# RabitQ is the name of a vector quantization algorithm, not a typo of "Rabbit".
Rabit = "Rabit"
# `VarBuilder::from_mmaped_safetensors` is the real (if oddly-spelled) name of
# the candle-core API we call in rust/lancedb/src/embeddings/sentence_transformers.rs.
mmaped = "mmaped"
# `WriteableBuffer` is the real name of a type from Python's `_typeshed` stubs,
# used in python/python/lancedb/_blob.py.
Writeable = "Writeable"
[files]
extend-exclude = [
"*_THIRD_PARTY_LICENSES.*",
]
Generated
+246 -257
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
rust-version = "1.91.0" rust-version = "1.91.0"
[workspace.dependencies] [workspace.dependencies]
lance = { "version" = "=12.0.0-beta.17", default-features = false, "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-core = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-datagen = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-file = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=12.0.0-beta.17", default-features = false, "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-io = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-index = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-linalg = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-namespace = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=12.0.0-beta.17", default-features = false, "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-namespace-impls = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-table = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-testing = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-datafusion = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-encoding = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=12.0.0-beta.17", "tag" = "v12.0.0-beta.17", "git" = "https://github.com/lance-format/lance.git" } lance-arrow = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lancedb = { path = "rust/lancedb", default-features = false } lancedb = { path = "rust/lancedb", default-features = false }
ahash = "0.8" ahash = "0.8"
# Note that this one does not include pyarrow # Note that this one does not include pyarrow
@@ -60,7 +60,7 @@ log = "0.4"
metrics = "0.24" metrics = "0.24"
metrics-util = "0.19" metrics-util = "0.19"
moka = { version = "0.12", features = ["future"] } moka = { version = "0.12", features = ["future"] }
object_store = "0.14.1" object_store = "0.13.2"
pin-project = "1.0.7" pin-project = "1.0.7"
rand = "0.9" rand = "0.9"
snafu = "0.8" snafu = "0.8"
+1 -1
View File
@@ -155,7 +155,7 @@ paths:
vector: vector:
type: FixedSizeList type: FixedSizeList
description: | description: |
The targeted vector to search for. Required. The targetted vector to search for. Required.
vector_column: vector_column:
type: string type: string
description: | description: |
+1 -1
View File
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
<dependency> <dependency>
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-core</artifactId> <artifactId>lancedb-core</artifactId>
<version>0.39.0-beta.6</version> <version>0.39.0-beta.4</version>
</dependency> </dependency>
``` ```
-62
View File
@@ -1,62 +0,0 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / BlobFile
# Class: BlobFile
A lazy handle to blob bytes. Create one with [Table.fetchBlobFiles](Table.md#fetchblobfiles).
## Methods
### read()
```ts
read(): Promise<Buffer>
```
Reads from the cursor to the end and advances the cursor.
A second call returns an empty buffer. [BlobFile.readRange](BlobFile.md#readrange) does
not move the cursor.
#### Returns
`Promise`&lt;`Buffer`&gt;
***
### readRange()
```ts
readRange(start, end): Promise<Buffer>
```
Reads the half-open byte range `[start, end)`.
Fails when `end` is past the blob size. Does not move the cursor.
#### Parameters
* **start**: `bigint`
* **end**: `bigint`
#### Returns
`Promise`&lt;`Buffer`&gt;
***
### size()
```ts
size(): bigint
```
Returns the blob size in bytes.
#### Returns
`bigint`
+1 -1
View File
@@ -141,7 +141,7 @@ Currently this causes multiple copies of the row to be created
but that behavior is subject to change. but that behavior is subject to change.
An optional condition may be specified. If it is, then only An optional condition may be specified. If it is, then only
matched rows that satisfy the condition will be updated. Any matched rows that satisfy the condtion will be updated. Any
rows that do not satisfy the condition will be left as they rows that do not satisfy the condition will be left as they
are. Failing to satisfy the condition does not cause a are. Failing to satisfy the condition does not cause a
"matched row" to become a "not matched" row. "matched row" to become a "not matched" row.
+1 -63
View File
@@ -137,20 +137,6 @@ containing the new version number of the table after altering the columns.
*** ***
### blobColumns()
```ts
abstract blobColumns(): Promise<string[]>
```
Blob v2 columns, including nested dotted paths.
#### Returns
`Promise`&lt;`string`[]&gt;
***
### branches() ### branches()
```ts ```ts
@@ -513,54 +499,6 @@ Drop an index from the table.
*** ***
### fetchBlobFiles()
```ts
abstract fetchBlobFiles(column, rowIds): Promise<(null | BlobFile)[]>
```
Opens lazy blob handles for `column` at the given row IDs using the
table's current checkout.
Preserves input order, duplicates, and nulls. Use this for large payloads.
See [Table.fetchBlobs](Table.md#fetchblobs) for row-ID validity across versions.
#### Parameters
* **column**: `string`
* **rowIds**: readonly (`number` \| `bigint`)[]
#### Returns
`Promise`&lt;(`null` \| [`BlobFile`](BlobFile.md))[]&gt;
***
### fetchBlobs()
```ts
abstract fetchBlobs(column, rowIds): Promise<(null | Buffer)[]>
```
Bytes for `column` at row IDs from [Query.withRowId](Query.md#withrowid).
Reads the table's current checkout. IDs from another version can fail after
compaction unless stable row ids are enabled. Results keep input order and
duplicates. Null blobs are `null`. Empty blobs are empty buffers.
#### Parameters
* **column**: `string`
* **rowIds**: readonly (`number` \| `bigint`)[]
#### Returns
`Promise`&lt;(`null` \| `Buffer`)[]&gt;
***
### flushLsm() ### flushLsm()
```ts ```ts
@@ -1328,7 +1266,7 @@ value is 0")
Note: if your condition is something like "some_id_column == 7" and Note: if your condition is something like "some_id_column == 7" and
you are updating many rows (with different ids) then you will get you are updating many rows (with different ids) then you will get
better performance with a single [`merge_insert`] call instead of better performance with a single [`merge_insert`] call instead of
repeatedly calling this method. repeatedly calilng this method.
##### Parameters ##### Parameters
-55
View File
@@ -1,55 +0,0 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / blob
# Function: blob()
```ts
function blob(name, options): Field
```
Declares a `lance.blob.v2` column.
Query results are descriptors, not payload bytes. Use [Table.fetchBlobs](../classes/Table.md#fetchblobs)
or [Table.fetchBlobFiles](../classes/Table.md#fetchblobfiles) to read bytes.
## Parameters
* **name**: `string`
* **options**: [`BlobOptions`](../type-aliases/BlobOptions.md) = `{}`
## Returns
`Field`
## Example
```ts
import { readFile } from "node:fs/promises";
import { Field, Int64, Schema } from "apache-arrow";
import { blob, connect } from "@lancedb/lancedb";
const db = await connect("./data");
const video = await readFile("clip.mp4");
const table = await db.createTable(
"videos",
[{ id: 1n, video }],
{
schema: new Schema([
new Field("id", new Int64()),
blob("video"),
]),
},
);
const rows = await table.query().select(["id"]).withRowId().toArray();
const rowIds = rows.map((row) => row._rowid as bigint);
const bytes = await table.fetchBlobs("video", rowIds);
const [handle] = await table.fetchBlobFiles("video", rowIds);
const size = handle!.size();
const header = await handle!.readRange(0n, size < 65536n ? size : 65536n);
```
-22
View File
@@ -1,22 +0,0 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / isBlobField
# Function: isBlobField()
```ts
function isBlobField(field): boolean
```
Checks for the `lance.blob.v2` extension marker. Does not validate the
field's storage type.
## Parameters
* **field**: `Field`&lt;`any`&gt;
## Returns
`boolean`
-4
View File
@@ -19,7 +19,6 @@
## Classes ## Classes
- [AutoQuery](classes/AutoQuery.md) - [AutoQuery](classes/AutoQuery.md)
- [BlobFile](classes/BlobFile.md)
- [BooleanQuery](classes/BooleanQuery.md) - [BooleanQuery](classes/BooleanQuery.md)
- [BoostQuery](classes/BoostQuery.md) - [BoostQuery](classes/BoostQuery.md)
- [BranchContents](classes/BranchContents.md) - [BranchContents](classes/BranchContents.md)
@@ -144,7 +143,6 @@
- [AnalyzePlanDistributedMetrics](type-aliases/AnalyzePlanDistributedMetrics.md) - [AnalyzePlanDistributedMetrics](type-aliases/AnalyzePlanDistributedMetrics.md)
- [BaseTokenizer](type-aliases/BaseTokenizer.md) - [BaseTokenizer](type-aliases/BaseTokenizer.md)
- [BlobOptions](type-aliases/BlobOptions.md)
- [Data](type-aliases/Data.md) - [Data](type-aliases/Data.md)
- [DataLike](type-aliases/DataLike.md) - [DataLike](type-aliases/DataLike.md)
- [FieldLike](type-aliases/FieldLike.md) - [FieldLike](type-aliases/FieldLike.md)
@@ -160,11 +158,9 @@
## Functions ## Functions
- [RecordBatchIterator](functions/RecordBatchIterator.md) - [RecordBatchIterator](functions/RecordBatchIterator.md)
- [blob](functions/blob.md)
- [connect](functions/connect.md) - [connect](functions/connect.md)
- [connectNamespace](functions/connectNamespace.md) - [connectNamespace](functions/connectNamespace.md)
- [instrumentLanceDbMetrics](functions/instrumentLanceDbMetrics.md) - [instrumentLanceDbMetrics](functions/instrumentLanceDbMetrics.md)
- [isBlobField](functions/isBlobField.md)
- [makeArrowTable](functions/makeArrowTable.md) - [makeArrowTable](functions/makeArrowTable.md)
- [packBits](functions/packBits.md) - [packBits](functions/packBits.md)
- [permutationBuilder](functions/permutationBuilder.md) - [permutationBuilder](functions/permutationBuilder.md)
+1 -1
View File
@@ -118,7 +118,7 @@ Number of sub-vectors of PQ.
This value controls how much the vector is compressed during the quantization step. This value controls how much the vector is compressed during the quantization step.
The more sub vectors there are the less the vector is compressed. The default is The more sub vectors there are the less the vector is compressed. The default is
the dimension of the vector divided by 16. If the dimension is not evenly divisible the dimension of the vector divided by 16. If the dimension is not evenly divisible
by 16 we use the dimension divided by 8. by 16 we use the dimension divded by 8.
The above two cases are highly preferred. Having 8 or 16 values per subvector allows The above two cases are highly preferred. Having 8 or 16 values per subvector allows
us to use efficient SIMD instructions. us to use efficient SIMD instructions.
+1 -1
View File
@@ -16,7 +16,7 @@ optional config: Index;
Advanced index configuration Advanced index configuration
This option allows you to specify a specific index to create and also This option allows you to specify a specfic index to create and also
allows you to pass in configuration for training the index. allows you to pass in configuration for training the index.
See the static methods on Index for details on the various index types. See the static methods on Index for details on the various index types.
+1 -1
View File
@@ -112,7 +112,7 @@ Number of sub-vectors of PQ.
This value controls how much the vector is compressed during the quantization step. This value controls how much the vector is compressed during the quantization step.
The more sub vectors there are the less the vector is compressed. The default is The more sub vectors there are the less the vector is compressed. The default is
the dimension of the vector divided by 16. If the dimension is not evenly divisible the dimension of the vector divided by 16. If the dimension is not evenly divisible
by 16 we use the dimension divided by 8. by 16 we use the dimension divded by 8.
The above two cases are highly preferred. Having 8 or 16 values per subvector allows The above two cases are highly preferred. Having 8 or 16 values per subvector allows
us to use efficient SIMD instructions. us to use efficient SIMD instructions.
-48
View File
@@ -1,48 +0,0 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / BlobOptions
# Type Alias: BlobOptions
```ts
type BlobOptions: object;
```
## Type declaration
### dedicatedSizeThreshold?
```ts
optional dedicatedSizeThreshold: number;
```
Max payload bytes stored in a packed sidecar before a dedicated file. Must
be a positive safe integer.
### inlineSizeThreshold?
```ts
optional inlineSizeThreshold: number;
```
Max payload bytes kept inline in the data file. Zero is allowed. Must be a
safe integer.
### nullable?
```ts
optional nullable: boolean;
```
Defaults to true.
### packFileSizeThreshold?
```ts
optional packFileSizeThreshold: number;
```
Max bytes in one packed sidecar before starting another. Must be a positive
safe integer.
+4
View File
@@ -125,6 +125,10 @@ listing a storage directory.
::: lancedb.functions.UdfDefinition ::: lancedb.functions.UdfDefinition
::: lancedb.secrets.EnvVarSecret
::: lancedb.secrets.SecretInfo
::: lancedb.functions.FunctionRegistrationRequest ::: lancedb.functions.FunctionRegistrationRequest
::: lancedb.functions.FunctionArtifactRequest ::: lancedb.functions.FunctionArtifactRequest
+1 -1
View File
@@ -8,7 +8,7 @@
<parent> <parent>
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId> <artifactId>lancedb-parent</artifactId>
<version>0.39.0-beta.6</version> <version>0.39.0-beta.4</version>
<relativePath>../pom.xml</relativePath> <relativePath>../pom.xml</relativePath>
</parent> </parent>
+2 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId> <artifactId>lancedb-parent</artifactId>
<version>0.39.0-beta.6</version> <version>0.39.0-beta.4</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<name>${project.artifactId}</name> <name>${project.artifactId}</name>
<description>LanceDB Java SDK Parent POM</description> <description>LanceDB Java SDK Parent POM</description>
@@ -28,7 +28,7 @@
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<arrow.version>15.0.0</arrow.version> <arrow.version>15.0.0</arrow.version>
<lance-core.version>12.0.0-beta.17</lance-core.version> <lance-core.version>12.0.0-beta.14</lance-core.version>
<spotless.skip>false</spotless.skip> <spotless.skip>false</spotless.skip>
<spotless.version>2.30.0</spotless.version> <spotless.version>2.30.0</spotless.version>
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version> <spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "lancedb-nodejs" name = "lancedb-nodejs"
edition.workspace = true edition.workspace = true
version = "0.39.0-beta.6" version = "0.39.0-beta.4"
publish = false publish = false
license.workspace = true license.workspace = true
description.workspace = true description.workspace = true
-185
View File
@@ -1,185 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { Field, Int64, List, Schema, Struct, Utf8 } from "apache-arrow";
import { makeArrowTable } from "../lancedb/arrow";
import { BlobFile, blob, coerceBlobValue, isBlobField } from "../lancedb/blob";
describe("blob()", () => {
it("marks the field as lance.blob.v2", () => {
const field = blob("image", { nullable: false });
expect(field.nullable).toBe(false);
expect(isBlobField(field)).toBe(true);
expect(field.metadata.get("ARROW:extension:name")).toBe("lance.blob.v2");
});
it("writes encoding thresholds as field metadata", () => {
const field = blob("video", {
inlineSizeThreshold: 1024,
dedicatedSizeThreshold: 2 * 1024 * 1024,
packFileSizeThreshold: 64 * 1024 * 1024,
});
expect(
field.metadata.get("lance-encoding:blob-inline-size-threshold"),
).toBe("1024");
expect(
field.metadata.get("lance-encoding:blob-dedicated-size-threshold"),
).toBe(String(2 * 1024 * 1024));
expect(
field.metadata.get("lance-encoding:blob-pack-file-size-threshold"),
).toBe(String(64 * 1024 * 1024));
});
it("rejects invalid thresholds", () => {
expect(() => blob("image", { inlineSizeThreshold: -1 })).toThrow(
/inlineSizeThreshold must be non-negative/,
);
expect(() => blob("image", { dedicatedSizeThreshold: 0 })).toThrow(
/dedicatedSizeThreshold must be positive/,
);
expect(() => blob("image", { packFileSizeThreshold: 1.5 })).toThrow(
/packFileSizeThreshold must be a safe integer/,
);
expect(() =>
blob("image", { dedicatedSizeThreshold: Number.MAX_SAFE_INTEGER + 1 }),
).toThrow(/dedicatedSizeThreshold must be a safe integer/);
});
});
describe("coerceBlobValue", () => {
it.each([
["Buffer", Buffer.from("x"), { data: Buffer.from("x"), uri: null }],
[
"Uint8Array",
new Uint8Array([120]),
{ data: new Uint8Array([120]), uri: null },
],
["URI string", "s3://bucket/key", { data: null, uri: "s3://bucket/key" }],
[
"data struct",
{ data: Buffer.from("y") },
{ data: Buffer.from("y"), uri: null },
],
[
"uri struct",
{ uri: "s3://bucket/key" },
{ data: null, uri: "s3://bucket/key" },
],
["null", null, null],
])("accepts %s", (_name, input, expected) => {
expect(coerceBlobValue(input)).toEqual(expected);
});
it.each([
["empty URI", "", /uri cannot be empty/],
["object without data or uri", { position: 0 }, /data' or 'uri/],
[
"Int16Array",
new Int16Array([1]),
/Blob data must be Buffer or Uint8Array/,
],
[
"both data and uri",
{ data: Buffer.from("y"), uri: "s3://bucket/key" },
/exactly one of 'data' or 'uri'/,
],
[
"neither data nor uri",
{ data: null, uri: null },
/exactly one of 'data' or 'uri'/,
],
])("rejects %s", (_name, input, message) => {
expect(() => coerceBlobValue(input)).toThrow(message);
});
});
describe("BlobFile", () => {
it("rejects constructing BlobFile without a native handle", () => {
expect(() => new (BlobFile as unknown as { new (): BlobFile })()).toThrow(
/fetchBlobFiles/,
);
});
});
describe("makeArrowTable blob columns", () => {
it("coerces Buffer input onto a blob field", () => {
const schema = new Schema([
new Field("id", new Int64(), true),
blob("image"),
]);
const table = makeArrowTable([{ id: 1n, image: Buffer.from("hello") }], {
schema,
});
expect(isBlobField(table.schema.fields[1])).toBe(true);
const image = table.getChild("image")!;
expect(image.nullCount).toBe(0);
expect(image.getChild("uri")!.get(0)).toBeNull();
expect(image.getChild("data")!.nullCount).toBe(0);
expect(Buffer.from(image.getChild("data")!.get(0)!).toString()).toBe(
"hello",
);
});
it("coerces Buffer elements inside a list and keeps null slots", () => {
const schema = new Schema([
new Field("id", new Int64(), true),
new Field("images", new List(blob("image")), true),
]);
const table = makeArrowTable(
[
{ id: 1n, images: [Buffer.from("a"), Buffer.from("bb")] },
{ id: 2n, images: null },
{ id: 3n, images: [Buffer.from("c"), null] },
{ id: 4n, images: [] },
],
{ schema },
);
const images = table.getChild("images")!;
expect(images.nullCount).toBe(1);
const rows = images.toArray();
expect(rows[1]).toBeNull();
expect(Array.from(rows[3] as Iterable<unknown>)).toHaveLength(0);
const first = Array.from(rows[0] as Iterable<{ data: Uint8Array | null }>);
expect(Buffer.from(first[0].data!).toString()).toBe("a");
expect(Buffer.from(first[1].data!).toString()).toBe("bb");
const third = Array.from(
rows[2] as Iterable<{ data: Uint8Array | null } | null>,
);
expect(Buffer.from(third[0]!.data!).toString()).toBe("c");
expect(third[1]).toBeNull();
});
it("coerces Buffer fields inside list structs", () => {
const schema = new Schema([
new Field("id", new Int64(), true),
new Field(
"items",
new List(
new Field(
"item",
new Struct([new Field("name", new Utf8(), true), blob("image")]),
true,
),
),
true,
),
]);
const table = makeArrowTable(
[
{
id: 1n,
items: [{ name: "one", image: Buffer.from("alpha") }],
},
],
{ schema },
);
const items = Array.from(
table.getChild("items")!.toArray()[0] as Iterable<{
name: string;
image: { data: Uint8Array | null };
}>,
);
expect(items[0].name).toBe("one");
expect(Buffer.from(items[0].image.data!).toString()).toBe("alpha");
});
});
+4 -275
View File
@@ -18,7 +18,6 @@ import {
Query, Query,
Table, Table,
VectorQuery, VectorQuery,
blob,
connect, connect,
tokenize, tokenize,
} from "../lancedb"; } from "../lancedb";
@@ -282,7 +281,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
numIndices: 0, numIndices: 0,
numRows: 3, numRows: 3,
// Full on-disk size of the two data files, footers and metadata included. // Full on-disk size of the two data files, footers and metadata included.
totalBytes: 550, totalBytes: 684,
}); });
// Index files count toward totalBytes too (only deletion files and // Index files count toward totalBytes too (only deletion files and
@@ -290,7 +289,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
await table.createIndex("id", { config: Index.btree() }); await table.createIndex("id", { config: Index.btree() });
const statsWithIndex = await table.stats(); const statsWithIndex = await table.stats();
expect(statsWithIndex.numIndices).toBe(1); expect(statsWithIndex.numIndices).toBe(1);
expect(statsWithIndex.totalBytes).toBeGreaterThan(550); expect(statsWithIndex.totalBytes).toBeGreaterThan(684);
}); });
it("should overwrite data if asked", async () => { it("should overwrite data if asked", async () => {
@@ -2402,276 +2401,6 @@ describe("when dealing with versioning", () => {
}); });
}); });
describe("when dealing with blob columns", () => {
let tmpDir: tmp.DirResult;
beforeEach(() => {
tmpDir = tmp.dirSync({ unsafeCleanup: true });
});
afterEach(() => {
tmpDir.removeCallback();
});
it("discovers blob columns", async () => {
const { table } = await openBlobTable();
expect(await table.blobColumns()).toEqual(["image"]);
});
it("preserves order, duplicates, and nulls", async () => {
const { table, rowIds } = await openBlobTable();
const [alphaId, betaId, nullId] = rowIds;
const bytes = await table.fetchBlobs("image", [
betaId,
alphaId,
betaId,
nullId,
]);
expect(bytes.map((b) => (b == null ? null : b.toString()))).toEqual([
"beta",
"alpha",
"beta",
null,
]);
const files = await table.fetchBlobFiles("image", [
betaId,
nullId,
alphaId,
]);
expect(files.map((f) => f == null)).toEqual([false, true, false]);
});
it("reads full blob contents", async () => {
const { table, rowIds, alpha, beta } = await openBlobTable();
const bytes = await table.fetchBlobs("image", rowIds);
expect(bytes[0]!.equals(alpha)).toBe(true);
expect(bytes[1]!.equals(beta)).toBe(true);
const files = await table.fetchBlobFiles("image", rowIds);
expect(files[0]!.size()).toBe(BigInt(alpha.length));
expect(Buffer.from(await files[0]!.read()).toString()).toBe("alpha");
expect(Buffer.from(await files[1]!.read()).toString()).toBe("beta");
});
it("reads a half-open range", async () => {
const { table, rowIds } = await openBlobTable();
const files = await table.fetchBlobFiles("image", rowIds);
expect(Buffer.from(await files[0]!.readRange(0n, 2n)).toString()).toBe(
"al",
);
});
it("readRange does not move the cursor", async () => {
const { table, rowIds, alpha } = await openBlobTable();
const [handle] = await table.fetchBlobFiles("image", rowIds);
expect((await handle!.readRange(1n, 3n)).toString()).toBe("lp");
expect(await handle!.read()).toEqual(alpha);
expect(await handle!.read()).toEqual(Buffer.alloc(0));
});
it("fails when readRange end is past the blob size", async () => {
const { table, rowIds, alpha } = await openBlobTable();
const files = await table.fetchBlobFiles("image", rowIds);
await expect(
files[0]!.readRange(0n, BigInt(alpha.length + 1)),
).rejects.toThrow(/exceeds blob size/);
});
it("rejects fetchBlobs on a non-blob column", async () => {
const { table, rowIds } = await openBlobTable();
await expect(table.fetchBlobs("id", rowIds)).rejects.toThrow(/blob/i);
});
it("discovers and fetches nested blob columns", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field("info", new Struct([blob("image")]), true),
]);
const payload = Buffer.from("nested");
const table = await db.createTable(
"nested_blobs",
[{ id: 1n, info: { image: payload } }],
{ schema },
);
expect(await table.blobColumns()).toEqual(["info.image"]);
const rows = await table.query().withRowId().toArray();
const bytes = await table.fetchBlobs("info.image", [
rows[0]._rowid as bigint,
]);
expect(bytes[0]!.equals(payload)).toBe(true);
});
it("creates and adds list blob columns", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field("images", new List(blob("image")), true),
]);
const alpha = Buffer.from("alpha");
const beta = Buffer.from("beta");
const gamma = Buffer.from("gamma");
const table = await db.createTable(
"list_blobs",
[{ id: 1n, images: [alpha, beta] }],
{ schema },
);
await table.add([
{ id: 2n, images: null },
{ id: 3n, images: [gamma, null] },
{ id: 4n, images: [] },
]);
expect(await table.blobColumns()).toEqual(["images.image"]);
const rows = await table.query().toArray();
const byId = new Map(rows.map((row) => [Number(row.id), row]));
expect(descriptorSizes(byId.get(1)!.images)).toEqual([
alpha.length,
beta.length,
]);
expect(byId.get(2)!.images).toBeNull();
expect(descriptorSizes(byId.get(3)!.images)).toEqual([gamma.length, null]);
expect(Array.from(byId.get(4)!.images as Iterable<unknown>)).toHaveLength(
0,
);
});
it("creates and adds list struct blob columns", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field(
"items",
new List(
new Field(
"item",
new Struct([new Field("name", new Utf8(), true), blob("image")]),
true,
),
),
true,
),
]);
const alpha = Buffer.from("nested-alpha");
const beta = Buffer.from("nested-beta");
const table = await db.createTable(
"list_struct_blobs",
[{ id: 1n, items: [{ name: "one", image: alpha }] }],
{ schema },
);
await table.add([
{
id: 2n,
items: [
{ name: "two", image: beta },
{ name: "three", image: null },
],
},
]);
const rows = await table.query().toArray();
const byId = new Map(rows.map((row) => [Number(row.id), row]));
expect(
descriptorSizes(
Array.from(byId.get(1)!.items as Iterable<{ image: unknown }>).map(
(item) => item.image,
),
),
).toEqual([alpha.length]);
expect(
descriptorSizes(
Array.from(byId.get(2)!.items as Iterable<{ image: unknown }>).map(
(item) => item.image,
),
),
).toEqual([beta.length, null]);
});
it("rejects blob fields inside a fixed-size list", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field("frames", new FixedSizeList(2, blob("frame")), true),
]);
await expect(
db.createTable(
"fsl_blobs",
[{ id: 1n, frames: [Buffer.from("a"), Buffer.from("b")] }],
{ schema },
),
).rejects.toThrow(
"Blob fields inside FixedSizeList are not supported. Use List instead.",
);
});
it("rejects blob fields inside a nested fixed-size list", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field(
"clip",
new Struct([
new Field("frames", new FixedSizeList(2, blob("frame")), true),
]),
true,
),
]);
await expect(
db.createTable(
"nested_fsl_blobs",
[
{
id: 1n,
clip: { frames: [Buffer.from("a"), Buffer.from("b")] },
},
],
{ schema },
),
).rejects.toThrow(
"Blob fields inside FixedSizeList are not supported. Use List instead.",
);
});
it("rejects an Arrow table with blob fields inside a fixed-size list", async () => {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
new Field("frames", new FixedSizeList(2, blob("frame")), true),
]);
await expect(
db.createTable("fsl_blobs_ipc", new ArrowTable(schema)),
).rejects.toThrow(
"Blob fields inside FixedSizeList are not supported. Use List instead.",
);
});
function descriptorSizes(values: unknown): (number | null)[] {
return Array.from(
values as Iterable<{ size?: bigint | number } | null>,
).map((value) => (value == null ? null : Number(value.size)));
}
async function openBlobTable() {
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), true),
blob("image"),
]);
const alpha = Buffer.from("alpha");
const beta = Buffer.from("beta");
const table = await db.createTable(
"blobs",
[
{ id: 1n, image: alpha },
{ id: 2n, image: beta },
{ id: 3n, image: null },
],
{ schema },
);
const rows = await table.query().withRowId().toArray();
const rowIdById = new Map(
rows.map((r) => [Number(r.id), r._rowid as bigint]),
);
const rowIds = [1, 2, 3].map((id) => rowIdById.get(id)!);
return { table, rowIds, alpha, beta };
}
});
describe("when dealing with tags", () => { describe("when dealing with tags", () => {
let tmpDir: tmp.DirResult; let tmpDir: tmp.DirResult;
beforeEach(() => { beforeEach(() => {
@@ -3523,7 +3252,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
const db = await connect(tmpDir.name); const db = await connect(tmpDir.name);
const data = [ const data = [
{ text: "fa", vector: [0.1, 0.2, 0.3] }, { text: "fa", vector: [0.1, 0.2, 0.3] },
{ text: "fo", vector: [0.4, 0.5, 0.6] }, // spellchecker:disable-line { text: "fo", vector: [0.4, 0.5, 0.6] },
{ text: "fob", vector: [0.4, 0.5, 0.6] }, { text: "fob", vector: [0.4, 0.5, 0.6] },
{ text: "focus", vector: [0.4, 0.5, 0.6] }, { text: "focus", vector: [0.4, 0.5, 0.6] },
{ text: "foo", vector: [0.4, 0.5, 0.6] }, { text: "foo", vector: [0.4, 0.5, 0.6] },
@@ -3548,7 +3277,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
const resultSet = new Set(fuzzyResults.map((r) => r.text)); const resultSet = new Set(fuzzyResults.map((r) => r.text));
expect(resultSet.has("foo")).toBe(true); expect(resultSet.has("foo")).toBe(true);
expect(resultSet.has("fob")).toBe(true); expect(resultSet.has("fob")).toBe(true);
expect(resultSet.has("fo")).toBe(true); // spellchecker:disable-line expect(resultSet.has("fo")).toBe(true);
expect(resultSet.has("food")).toBe(true); expect(resultSet.has("food")).toBe(true);
const prefixResults = await table const prefixResults = await table
+3 -104
View File
@@ -40,7 +40,6 @@ import {
} from "apache-arrow"; } from "apache-arrow";
import { Buffers } from "apache-arrow/data"; import { Buffers } from "apache-arrow/data";
import { typedArrayToArrowType } from "./arrow_type"; import { typedArrayToArrowType } from "./arrow_type";
import { coerceBlobValue, isBlobField } from "./blob";
import { type EmbeddingFunction } from "./embedding/embedding_function"; import { type EmbeddingFunction } from "./embedding/embedding_function";
import { import {
EmbeddingFunctionConfig, EmbeddingFunctionConfig,
@@ -431,14 +430,12 @@ export function makeArrowTable(
throw new Error("A schema must be provided if data is empty"); throw new Error("A schema must be provided if data is empty");
} else { } else {
schema = new Schema(schema.fields, schemaMetadata); schema = new Schema(schema.fields, schemaMetadata);
validateBlobSchema(schema);
return new ArrowTable(schema); return new ArrowTable(schema);
} }
} }
let inferredSchema = inferSchema(data, schema, opt); let inferredSchema = inferSchema(data, schema, opt);
inferredSchema = new Schema(inferredSchema.fields, schemaMetadata); inferredSchema = new Schema(inferredSchema.fields, schemaMetadata);
validateBlobSchema(inferredSchema);
const finalColumns: Record<string, Vector> = {}; const finalColumns: Record<string, Vector> = {};
for (const field of inferredSchema.fields) { for (const field of inferredSchema.fields) {
@@ -448,35 +445,6 @@ export function makeArrowTable(
return new ArrowTable(inferredSchema, finalColumns); return new ArrowTable(inferredSchema, finalColumns);
} }
function validateBlobSchema(schema: Schema): void {
for (const field of schema.fields) {
validateBlobField(field);
}
}
function validateBlobField(field: Field): void {
if (
isFixedSizeList(field.type) &&
containsBlobField(field.type.children[0])
) {
throw new Error(
"Blob fields inside FixedSizeList are not supported. Use List instead.",
);
}
for (const child of field.type.children ?? []) {
validateBlobField(child);
}
}
function containsBlobField(field: Field): boolean {
if (isBlobField(field)) {
return true;
}
return (field.type.children ?? []).some((child: Field) =>
containsBlobField(child),
);
}
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return ( return (
typeof value === "object" && typeof value === "object" &&
@@ -512,32 +480,6 @@ function transposeData(
path: string[] = [], path: string[] = [],
): Vector { ): Vector {
const valuesPath = [...path, field.name]; const valuesPath = [...path, field.name];
if (isBlobField(field) && field.type instanceof Struct) {
const blobRows = data.map((datum) =>
coerceBlobValue(valueAtPath(datum, valuesPath)),
);
const childVectors = field.type.children.map((child) => {
const values = blobRows.map((row) =>
row == null ? null : (row[child.name as "data" | "uri"] ?? null),
);
return makeVector(values, child.type, undefined, child.nullable);
});
const nullCount = blobRows.filter((row) => row === null).length;
const structData = makeData({
type: field.type,
length: blobRows.length,
nullCount,
nullBitmap:
nullCount > 0
? arrowUtil.packBools(blobRows.map((row) => row !== null))
: undefined,
children: childVectors.map((v) => v.data[0]),
});
return arrowMakeVector(structData);
}
if (isList(field.type) && containsBlobField(field.type.children[0])) {
return transposeListData(data, field, valuesPath);
}
const values = data.map((datum) => valueAtPath(datum, valuesPath)); const values = data.map((datum) => valueAtPath(datum, valuesPath));
if (field.type instanceof Struct) { if (field.type instanceof Struct) {
const childFields = field.type.children; const childFields = field.type.children;
@@ -553,7 +495,7 @@ function transposeData(
nullCount > 0 nullCount > 0
? arrowUtil.packBools(values.map((value) => value !== null)) ? arrowUtil.packBools(values.map((value) => value !== null))
: undefined, : undefined,
children: childVectors.map((v) => v.data[0]), children: childVectors as unknown as ArrowData<DataType>[],
}); });
return arrowMakeVector(structData); return arrowMakeVector(structData);
} else { } else {
@@ -561,48 +503,6 @@ function transposeData(
} }
} }
function transposeListData(
data: Record<string, unknown>[],
field: Field,
valuesPath: string[],
): Vector {
const listType = field.type as List;
const childField = listType.children[0];
const lists = data.map((datum) => valueAtPath(datum, valuesPath));
const flattened: Record<string, unknown>[] = [];
const validity: boolean[] = [];
const offsets: number[] = [0];
for (const list of lists) {
if (list == null) {
validity.push(false);
offsets.push(flattened.length);
continue;
}
if (!Array.isArray(list)) {
throw new Error(`expected an array for list field '${field.name}'`);
}
validity.push(true);
for (const element of list) {
flattened.push({ [childField.name]: element });
}
offsets.push(flattened.length);
}
const childVector = transposeData(flattened, childField, []);
const nullCount = validity.filter((valid) => !valid).length;
return arrowMakeVector(
makeData({
type: listType,
length: lists.length,
nullCount,
nullBitmap: nullCount > 0 ? arrowUtil.packBools(validity) : undefined,
valueOffsets: Int32Array.from(offsets),
child: childVector.data[0],
}),
);
}
/** /**
* Create an empty Arrow table with the provided schema * Create an empty Arrow table with the provided schema
*/ */
@@ -700,7 +600,7 @@ function makeVector(
} }
if (values.length === 0) { if (values.length === 0) {
throw Error( throw Error(
"makeVector requires at least one value or the type must be specified", "makeVector requires at least one value or the type must be specfied",
); );
} }
const sampleValue = values.find((val) => val !== null && val !== undefined); const sampleValue = values.find((val) => val !== null && val !== undefined);
@@ -958,7 +858,7 @@ async function applyEmbeddings<T>(
* customized by the `embeddingDataType` property of the embedding function. * customized by the `embeddingDataType` property of the embedding function.
* *
* If a schema is provided in `makeTableOptions` then it should include the * If a schema is provided in `makeTableOptions` then it should include the
* embedding columns. If no schema is provided then embedding columns will * embedding columns. If no schema is provded then embedding columns will
* be placed at the end of the table, after all of the input columns. * be placed at the end of the table, after all of the input columns.
*/ */
export async function convertToTable( export async function convertToTable(
@@ -1052,7 +952,6 @@ export async function fromTableToBuffer(
schema = sanitizeSchema(schema); schema = sanitizeSchema(schema);
} }
const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema); const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema);
validateBlobSchema(tableWithEmbeddings.schema);
const writer = RecordBatchFileWriter.writeAll(tableWithEmbeddings); const writer = RecordBatchFileWriter.writeAll(tableWithEmbeddings);
return Buffer.from(await writer.toUint8Array()); return Buffer.from(await writer.toUint8Array());
} }
-236
View File
@@ -1,236 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { Field, LargeBinary, Struct, Utf8 } from "apache-arrow";
import { BlobFile as NativeBlobFile } from "./native";
const BLOB_V2_EXTENSION_NAME = "lance.blob.v2";
const INLINE_SIZE_THRESHOLD_KEY = "lance-encoding:blob-inline-size-threshold";
const DEDICATED_SIZE_THRESHOLD_KEY =
"lance-encoding:blob-dedicated-size-threshold";
const PACK_FILE_SIZE_THRESHOLD_KEY =
"lance-encoding:blob-pack-file-size-threshold";
export type BlobInput = {
data: Buffer | Uint8Array | null;
uri: string | null;
};
export type BlobOptions = {
/** Defaults to true. */
nullable?: boolean;
/**
* Max payload bytes kept inline in the data file. Zero is allowed. Must be a
* safe integer.
*/
inlineSizeThreshold?: number;
/**
* Max payload bytes stored in a packed sidecar before a dedicated file. Must
* be a positive safe integer.
*/
dedicatedSizeThreshold?: number;
/**
* Max bytes in one packed sidecar before starting another. Must be a positive
* safe integer.
*/
packFileSizeThreshold?: number;
};
/**
* Declares a `lance.blob.v2` column.
*
* Query results are descriptors, not payload bytes. Use {@link Table.fetchBlobs}
* or {@link Table.fetchBlobFiles} to read bytes.
*
* @example
* ```ts
* import { readFile } from "node:fs/promises";
* import { Field, Int64, Schema } from "apache-arrow";
* import { blob, connect } from "@lancedb/lancedb";
*
* const db = await connect("./data");
* const video = await readFile("clip.mp4");
* const table = await db.createTable(
* "videos",
* [{ id: 1n, video }],
* {
* schema: new Schema([
* new Field("id", new Int64()),
* blob("video"),
* ]),
* },
* );
*
* const rows = await table.query().select(["id"]).withRowId().toArray();
* const rowIds = rows.map((row) => row._rowid as bigint);
* const bytes = await table.fetchBlobs("video", rowIds);
*
* const [handle] = await table.fetchBlobFiles("video", rowIds);
* const size = handle!.size();
* const header = await handle!.readRange(0n, size < 65536n ? size : 65536n);
* ```
*/
export function blob(name: string, options: BlobOptions = {}): Field {
const metadata = new Map<string, string>([
["ARROW:extension:name", BLOB_V2_EXTENSION_NAME],
]);
setThreshold(
metadata,
INLINE_SIZE_THRESHOLD_KEY,
"inlineSizeThreshold",
options.inlineSizeThreshold,
0,
);
setThreshold(
metadata,
DEDICATED_SIZE_THRESHOLD_KEY,
"dedicatedSizeThreshold",
options.dedicatedSizeThreshold,
1,
);
setThreshold(
metadata,
PACK_FILE_SIZE_THRESHOLD_KEY,
"packFileSizeThreshold",
options.packFileSizeThreshold,
1,
);
return new Field(
name,
new Struct([
new Field("data", new LargeBinary(), true),
new Field("uri", new Utf8(), true),
]),
options.nullable ?? true,
metadata,
);
}
/**
* Checks for the `lance.blob.v2` extension marker. Does not validate the
* field's storage type.
*/
export function isBlobField(field: Field): boolean {
return field.metadata?.get("ARROW:extension:name") === BLOB_V2_EXTENSION_NAME;
}
/**
* A lazy handle to blob bytes. Create one with {@link Table.fetchBlobFiles}.
*
* @hideconstructor
*/
export class BlobFile {
private readonly inner: NativeBlobFile;
private constructor(inner: NativeBlobFile) {
if (!(inner instanceof NativeBlobFile)) {
throw new Error("BlobFile handles come from Table.fetchBlobFiles");
}
this.inner = inner;
}
/** @ignore */
static fromNative(inner: NativeBlobFile): BlobFile {
return new BlobFile(inner);
}
/** Returns the blob size in bytes. */
size(): bigint {
return this.inner.size();
}
/**
* Reads from the cursor to the end and advances the cursor.
*
* A second call returns an empty buffer. {@link BlobFile.readRange} does
* not move the cursor.
*/
read(): Promise<Buffer> {
return this.inner.read();
}
/**
* Reads the half-open byte range `[start, end)`.
*
* Fails when `end` is past the blob size. Does not move the cursor.
*/
readRange(start: bigint, end: bigint): Promise<Buffer> {
return this.inner.readRange(start, end);
}
}
export function coerceBlobValue(value: unknown): BlobInput | null {
if (value == null) {
return null;
}
if (isBlobBytes(value)) {
return { data: value, uri: null };
}
if (ArrayBuffer.isView(value)) {
throw new Error("Blob data must be Buffer or Uint8Array");
}
if (typeof value === "string") {
if (value === "") {
throw new Error("Blob uri cannot be empty");
}
return { data: null, uri: value };
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if (!("data" in record) && !("uri" in record)) {
throw new Error(
"Blob struct values must include a 'data' or 'uri' field",
);
}
const uri = record.uri;
if (uri === "") {
throw new Error("Blob uri cannot be empty");
}
if (uri != null && typeof uri !== "string") {
throw new Error(`Blob uri must be a string or null, got ${typeof uri}`);
}
const data = record.data;
if (data != null && !isBlobBytes(data)) {
throw new Error("Blob data must be Buffer, Uint8Array, or null");
}
const bytes = (data as Buffer | Uint8Array | null | undefined) ?? null;
const uriValue = uri ?? null;
if ((bytes == null) === (uriValue == null)) {
throw new Error(
"Blob struct values must set exactly one of 'data' or 'uri'",
);
}
return { data: bytes, uri: uriValue };
}
throw new Error(
"Blob column values must be Buffer, Uint8Array, a URI string, null, or { data?, uri? }",
);
}
function isBlobBytes(value: unknown): value is Buffer | Uint8Array {
return Buffer.isBuffer(value) || value instanceof Uint8Array;
}
function setThreshold(
metadata: Map<string, string>,
key: string,
optionName: string,
value: number | undefined,
minimum: number,
): void {
if (value === undefined) {
return;
}
if (!Number.isSafeInteger(value)) {
throw new Error(`${optionName} must be a safe integer`);
}
if (value < minimum) {
throw new Error(
minimum <= 0
? `${optionName} must be non-negative`
: `${optionName} must be positive`,
);
}
metadata.set(key, String(value));
}
-3
View File
@@ -77,9 +77,6 @@ export {
VectorColumnOptions, VectorColumnOptions,
} from "./arrow"; } from "./arrow";
export { blob, isBlobField, BlobFile } from "./blob";
export type { BlobOptions } from "./blob";
export { export {
Connection, Connection,
CreateTableOptions, CreateTableOptions,
+3 -3
View File
@@ -26,7 +26,7 @@ export interface IvfPqOptions {
* This value controls how much the vector is compressed during the quantization step. * This value controls how much the vector is compressed during the quantization step.
* The more sub vectors there are the less the vector is compressed. The default is * The more sub vectors there are the less the vector is compressed. The default is
* the dimension of the vector divided by 16. If the dimension is not evenly divisible * the dimension of the vector divided by 16. If the dimension is not evenly divisible
* by 16 we use the dimension divided by 8. * by 16 we use the dimension divded by 8.
* *
* The above two cases are highly preferred. Having 8 or 16 values per subvector allows * The above two cases are highly preferred. Having 8 or 16 values per subvector allows
* us to use efficient SIMD instructions. * us to use efficient SIMD instructions.
@@ -228,7 +228,7 @@ export interface HnswPqOptions {
* This value controls how much the vector is compressed during the quantization step. * This value controls how much the vector is compressed during the quantization step.
* The more sub vectors there are the less the vector is compressed. The default is * The more sub vectors there are the less the vector is compressed. The default is
* the dimension of the vector divided by 16. If the dimension is not evenly divisible * the dimension of the vector divided by 16. If the dimension is not evenly divisible
* by 16 we use the dimension divided by 8. * by 16 we use the dimension divded by 8.
* *
* The above two cases are highly preferred. Having 8 or 16 values per subvector allows * The above two cases are highly preferred. Having 8 or 16 values per subvector allows
* us to use efficient SIMD instructions. * us to use efficient SIMD instructions.
@@ -825,7 +825,7 @@ export interface IndexOptions {
/** /**
* Advanced index configuration * Advanced index configuration
* *
* This option allows you to specify a specific index to create and also * This option allows you to specify a specfic index to create and also
* allows you to pass in configuration for training the index. * allows you to pass in configuration for training the index.
* *
* See the static methods on Index for details on the various index types. * See the static methods on Index for details on the various index types.
+1 -1
View File
@@ -27,7 +27,7 @@ export class MergeInsertBuilder {
* but that behavior is subject to change. * but that behavior is subject to change.
* *
* An optional condition may be specified. If it is, then only * An optional condition may be specified. If it is, then only
* matched rows that satisfy the condition will be updated. Any * matched rows that satisfy the condtion will be updated. Any
* rows that do not satisfy the condition will be left as they * rows that do not satisfy the condition will be left as they
* are. Failing to satisfy the condition does not cause a * are. Failing to satisfy the condition does not cause a
* "matched row" to become a "not matched" row. * "matched row" to become a "not matched" row.
+1 -1
View File
@@ -3,7 +3,7 @@
// The utilities in this file help sanitize data from the user's arrow // The utilities in this file help sanitize data from the user's arrow
// library into the types expected by vectordb's arrow library. Node // library into the types expected by vectordb's arrow library. Node
// generally allows for multiple versions of the same library (and sometimes // generally allows for mulitple versions of the same library (and sometimes
// even multiple copies of the same version) to be installed at the same // even multiple copies of the same version) to be installed at the same
// time. However, arrow-js uses instanceof which expected that the input // time. However, arrow-js uses instanceof which expected that the input
// comes from the exact same library instance. This is not always the case // comes from the exact same library instance. This is not always the case
+17 -76
View File
@@ -17,7 +17,6 @@ import {
tableFromIPC, tableFromIPC,
} from "./arrow"; } from "./arrow";
import { BlobFile } from "./blob";
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
import { IndexOptions } from "./indices"; import { IndexOptions } from "./indices";
import { Job } from "./job"; import { Job } from "./job";
@@ -314,7 +313,7 @@ export abstract class Table {
* Note: if your condition is something like "some_id_column == 7" and * Note: if your condition is something like "some_id_column == 7" and
* you are updating many rows (with different ids) then you will get * you are updating many rows (with different ids) then you will get
* better performance with a single [`merge_insert`] call instead of * better performance with a single [`merge_insert`] call instead of
* repeatedly calling this method. * repeatedly calilng this method.
* @param {Map<string, string> | Record<string, string>} updates - the * @param {Map<string, string> | Record<string, string>} updates - the
* columns to update * columns to update
* @returns {Promise<UpdateResult>} A promise that resolves to an object * @returns {Promise<UpdateResult>} A promise that resolves to an object
@@ -511,35 +510,6 @@ export abstract class Table {
*/ */
abstract takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery; abstract takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery;
/**
* Blob v2 columns, including nested dotted paths.
*/
abstract blobColumns(): Promise<string[]>;
/**
* Bytes for `column` at row IDs from {@link Query.withRowId}.
*
* Reads the table's current checkout. IDs from another version can fail after
* compaction unless stable row ids are enabled. Results keep input order and
* duplicates. Null blobs are `null`. Empty blobs are empty buffers.
*/
abstract fetchBlobs(
column: string,
rowIds: readonly (bigint | number)[],
): Promise<(Buffer | null)[]>;
/**
* Opens lazy blob handles for `column` at the given row IDs using the
* table's current checkout.
*
* Preserves input order, duplicates, and nulls. Use this for large payloads.
* See {@link Table.fetchBlobs} for row-ID validity across versions.
*/
abstract fetchBlobFiles(
column: string,
rowIds: readonly (bigint | number)[],
): Promise<(BlobFile | null)[]>;
/** /**
* Create a search query to find the nearest neighbors * Create a search query to find the nearest neighbors
* of the given query * of the given query
@@ -1190,34 +1160,23 @@ export class LocalTable extends Table {
} }
takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery { takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery {
return new TakeQuery(this.inner.takeRowIds(rowIdsToBigInts(rowIds))); const ids = rowIds.map((id) => {
} if (typeof id === "bigint") {
return id;
}
if (!Number.isInteger(id)) {
throw new Error("Row id must be an integer (or bigint)");
}
if (id < 0) {
throw new Error("Row id cannot be negative");
}
if (!Number.isSafeInteger(id)) {
throw new Error("Row id is too large for number; use bigint instead");
}
return BigInt(id);
});
blobColumns(): Promise<string[]> { return new TakeQuery(this.inner.takeRowIds(ids));
return this.inner.blobColumns();
}
async fetchBlobs(
column: string,
rowIds: readonly (bigint | number)[],
): Promise<(Buffer | null)[]> {
const values = await this.inner.fetchBlobs(column, rowIdsToBigInts(rowIds));
// N-API Option maps missing values to undefined. Collapse those to null.
return values.map((value) => value ?? null);
}
async fetchBlobFiles(
column: string,
rowIds: readonly (bigint | number)[],
): Promise<(BlobFile | null)[]> {
const files = await this.inner.fetchBlobFiles(
column,
rowIdsToBigInts(rowIds),
);
// N-API Option maps missing values to undefined. Collapse those to null.
return files.map((file) =>
file == null ? null : BlobFile.fromNative(file),
);
} }
query(): Query { query(): Query {
@@ -1774,21 +1733,3 @@ export class Branches {
)) as unknown as CherryPickResult; )) as unknown as CherryPickResult;
} }
} }
function rowIdsToBigInts(rowIds: readonly (bigint | number)[]): bigint[] {
return rowIds.map((id) => {
if (typeof id === "bigint") {
return id;
}
if (!Number.isInteger(id)) {
throw new Error("Row id must be an integer (or bigint)");
}
if (id < 0) {
throw new Error("Row id cannot be negative");
}
if (!Number.isSafeInteger(id)) {
throw new Error("Row id is too large for number; use bigint instead");
}
return BigInt(id);
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-darwin-arm64", "name": "@lancedb/lancedb-darwin-arm64",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.darwin-arm64.node", "main": "lancedb.darwin-arm64.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-arm64-gnu", "name": "@lancedb/lancedb-linux-arm64-gnu",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.linux-arm64-gnu.node", "main": "lancedb.linux-arm64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-arm64-musl", "name": "@lancedb/lancedb-linux-arm64-musl",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.linux-arm64-musl.node", "main": "lancedb.linux-arm64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-x64-gnu", "name": "@lancedb/lancedb-linux-x64-gnu",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.linux-x64-gnu.node", "main": "lancedb.linux-x64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-x64-musl", "name": "@lancedb/lancedb-linux-x64-musl",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.linux-x64-musl.node", "main": "lancedb.linux-x64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-win32-arm64-msvc", "name": "@lancedb/lancedb-win32-arm64-msvc",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": [ "os": [
"win32" "win32"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-win32-x64-msvc", "name": "@lancedb/lancedb-win32-x64-msvc",
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.win32-x64-msvc.node", "main": "lancedb.win32-x64-msvc.node",
+1 -1
View File
@@ -11,7 +11,7 @@
"ann" "ann"
], ],
"private": false, "private": false,
"version": "0.39.0-beta.6", "version": "0.39.0-beta.4",
"main": "dist/index.js", "main": "dist/index.js",
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
-95
View File
@@ -1,95 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use std::ops::Range;
use std::sync::Arc;
use arrow_array::{Array, LargeBinaryArray};
use lancedb::blob::BlobFile as LanceBlobFile;
use napi::bindgen_prelude::*;
use napi_derive::napi;
use crate::error::convert_error;
#[napi]
pub struct BlobFile {
inner: Arc<LanceBlobFile>,
}
impl BlobFile {
pub(crate) fn new(inner: LanceBlobFile) -> Self {
Self {
inner: Arc::new(inner),
}
}
}
#[napi]
impl BlobFile {
#[napi]
pub fn size(&self) -> BigInt {
BigInt::from(self.inner.size())
}
#[napi]
pub async fn read(&self) -> napi::Result<Buffer> {
let bytes = self.inner.read().await.map_err(|err| convert_error(&err))?;
Ok(Buffer::from(bytes.as_ref()))
}
#[napi]
pub async fn read_range(&self, start: BigInt, end: BigInt) -> napi::Result<Buffer> {
let range = bigint_range(start, end)?;
let bytes = self
.inner
.read_range(range)
.await
.map_err(|err| convert_error(&err))?;
Ok(Buffer::from(bytes.as_ref()))
}
}
fn bigint_range(start: BigInt, end: BigInt) -> napi::Result<Range<u64>> {
let start = parse_u64(start, "start")?;
let end = parse_u64(end, "end")?;
if start > end {
return Err(napi::Error::from_reason(format!(
"invalid blob range: start ({start}) > end ({end})"
)));
}
Ok(start..end)
}
pub(crate) fn parse_u64(value: BigInt, name: &str) -> napi::Result<u64> {
let (negative, value, lossless) = value.get_u64();
if negative {
return Err(napi::Error::from_reason(format!(
"{name} cannot be negative"
)));
}
if !lossless {
return Err(napi::Error::from_reason(format!(
"{name} is too large to fit in u64"
)));
}
Ok(value)
}
pub(crate) fn parse_row_ids(row_ids: Vec<BigInt>) -> napi::Result<Vec<u64>> {
row_ids
.into_iter()
.map(|id| parse_u64(id, "row id"))
.collect()
}
pub(crate) fn copy_blob_buffers(array: LargeBinaryArray) -> Vec<Option<Buffer>> {
(0..array.len())
.map(|i| {
if array.is_null(i) {
None
} else {
Some(Buffer::from(array.value(i).to_vec()))
}
})
.collect()
}
-1
View File
@@ -10,7 +10,6 @@ use std::collections::HashMap;
use env_logger::Env; use env_logger::Env;
use napi_derive::*; use napi_derive::*;
mod blob;
mod connection; mod connection;
mod error; mod error;
mod header; mod header;
-39
View File
@@ -15,7 +15,6 @@ use napi::bindgen_prelude::*;
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi_derive::napi; use napi_derive::napi;
use crate::blob::{BlobFile, copy_blob_buffers, parse_row_ids};
use crate::error::NapiErrorExt; use crate::error::NapiErrorExt;
use crate::index::Index; use crate::index::Index;
use crate::merge::NativeMergeInsertBuilder; use crate::merge::NativeMergeInsertBuilder;
@@ -330,44 +329,6 @@ impl Table {
)) ))
} }
#[napi(catch_unwind)]
pub async fn blob_columns(&self) -> napi::Result<Vec<String>> {
self.inner_ref()?.blob_columns().await.default_error()
}
#[napi(catch_unwind)]
pub async fn fetch_blobs(
&self,
column: String,
row_ids: Vec<BigInt>,
) -> napi::Result<Vec<Option<Buffer>>> {
let row_ids = parse_row_ids(row_ids)?;
let array = self
.inner_ref()?
.fetch_blobs(column.as_str(), &row_ids)
.await
.default_error()?;
Ok(copy_blob_buffers(array))
}
#[napi(catch_unwind)]
pub async fn fetch_blob_files(
&self,
column: String,
row_ids: Vec<BigInt>,
) -> napi::Result<Vec<Option<BlobFile>>> {
let row_ids = parse_row_ids(row_ids)?;
let files = self
.inner_ref()?
.fetch_blob_files(column.as_str(), &row_ids)
.await
.default_error()?;
Ok(files
.into_iter()
.map(|file| file.map(BlobFile::new))
.collect())
}
#[napi(catch_unwind)] #[napi(catch_unwind)]
pub fn vector_search(&self, vector: Float32Array) -> napi::Result<VectorQuery> { pub fn vector_search(&self, vector: Float32Array) -> napi::Result<VectorQuery> {
self.query()?.nearest_to(vector) self.query()?.nearest_to(vector)
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lancedb-python" name = "lancedb-python"
version = "0.39.0-beta.6" version = "0.39.0-beta.4"
publish = false publish = false
edition.workspace = true edition.workspace = true
description = "Python bindings for LanceDB" description = "Python bindings for LanceDB"
+2
View File
@@ -37,6 +37,8 @@ from .functions import (
UdfDefinition as UdfDefinition, UdfDefinition as UdfDefinition,
udf as udf, udf as udf,
) )
from .secrets import EnvVarSecret as EnvVarSecret
from .secrets import SecretInfo as SecretInfo
from .materialized_view import ( from .materialized_view import (
AsyncMaterializedView, AsyncMaterializedView,
MaterializedView, MaterializedView,
+5
View File
@@ -153,6 +153,11 @@ class Connection(object):
async def get_function(self, name: str, version: str) -> str: ... async def get_function(self, name: str, version: str) -> str: ...
async def list_functions(self) -> List[str]: ... async def list_functions(self) -> List[str]: ...
async def drop_function(self, name: str, version: str) -> bool: ... async def drop_function(self, name: str, version: str) -> bool: ...
async def create_secret(self, name: str, value: str) -> None: ...
async def alter_secret(self, name: str, value: str) -> None: ...
async def list_secrets(self) -> List[str]: ...
async def drop_secret(self, name: str) -> None: ...
async def describe_secret(self, name: str) -> Dict[str, str]: ...
async def list_jobs(self) -> List[JobInfo]: ... async def list_jobs(self) -> List[JobInfo]: ...
async def cancel_job(self, job_id: str) -> bool: ... async def cancel_job(self, job_id: str) -> bool: ...
async def execute_query_async( async def execute_query_async(
+166 -11
View File
@@ -17,6 +17,7 @@ from typing import (
List, List,
Literal, Literal,
Optional, Optional,
Sequence,
Union, Union,
) )
from uuid import UUID from uuid import UUID
@@ -57,6 +58,7 @@ from .materialized_view import (
SelectArg, SelectArg,
normalize_select, normalize_select,
) )
from .secrets import EnvVarSecret, SecretInfo, validate_secret_name
from .table import ( from .table import (
AsyncTable, AsyncTable,
LanceTable, LanceTable,
@@ -692,15 +694,47 @@ class DBConnection(EnforceOverrides):
""" """
raise NotImplementedError("serialize is not supported for this connection type") raise NotImplementedError("serialize is not supported for this connection type")
def create_function(self, definition: UdfDefinition) -> FunctionVersion: def create_function(
self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> FunctionVersion:
"""Register a scalar Python UDF and wait for its immutable version. """Register a scalar Python UDF and wait for its immutable version.
This is the blocking counterpart of :meth:`create_function_async`. This is the blocking counterpart of :meth:`create_function_async`.
Local connections raise ``NotImplementedError``. Local connections raise ``NotImplementedError``.
"""
return self.create_function_async(definition).wait()
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: Parameters
----------
definition : UdfDefinition
A callable decorated with [udf][lancedb.udf].
secrets : sequence of EnvVarSecret, optional
One [EnvVarSecret][lancedb.secrets.EnvVarSecret] per credential the
Function needs, each naming a Secret and the environment variable
its value arrives in. The Function's source is unchanged by this;
it reads the variable the way it already did.
Examples
--------
```python
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
db.create_function(
analyze_caption,
secrets=[
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
],
)
```
"""
return self.create_function_async(definition, secrets=secrets).wait()
def create_function_async(
self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
"""Register a scalar Python UDF through the remote Function catalog. """Register a scalar Python UDF through the remote Function catalog.
Submission returns a typed job. The immutable Function version becomes Submission returns a typed job. The immutable Function version becomes
@@ -745,6 +779,62 @@ class DBConnection(EnforceOverrides):
"Function catalog operations are not supported for this connection type" "Function catalog operations are not supported for this connection type"
) )
def create_secret(self, name: str, value: str) -> None:
"""Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a
rotation. Nothing reads the value back: it is bound to a Function by
name and resolved by the service when that Function runs. Local
connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def alter_secret(self, name: str, value: str) -> None:
"""Replace the credential behind an existing Secret.
Fails if it does not exist. Every Function bound to the Secret uses the
new value from its next job, and no new Function version is created --
which is how a rotation reaches columns pinned to a version registered
before it. Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def list_secrets(self) -> List[str]:
"""The names of every Secret in this database.
Names only. No method returns a stored credential, by construction
rather than by policy. Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def drop_secret(self, name: str) -> None:
"""Drop a Secret.
Functions bound to it fail at their next job, naming the Secret; that
is the revocation path. The name becomes free to reuse, and a new
Secret under it is picked up by everything still bound to that name.
Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def describe_secret(self, name: str) -> SecretInfo:
"""What this database records about a Secret: name and timestamps.
Never the value -- there is no code path that could return one. Local
connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def open_job(self, job_id: str) -> Job: def open_job(self, job_id: str) -> Job:
"""Open a server-side job by id, returning a handle with its record """Open a server-side job by id, returning a handle with its record
already populated. already populated.
@@ -1457,8 +1547,13 @@ class LanceDBConnection(DBConnection):
return Job(LOOP.run(self._conn.open_job(job_id))) return Job(LOOP.run(self._conn.open_job(job_id)))
@override @override
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: def create_function_async(
job = LOOP.run(self._conn.create_function_async(definition)) self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
return Job(job) return Job(job)
@override @override
@@ -1473,6 +1568,26 @@ class LanceDBConnection(DBConnection):
def drop_function(self, name: str, *, version: str) -> bool: def drop_function(self, name: str, *, version: str) -> bool:
return LOOP.run(self._conn.drop_function(name, version=version)) return LOOP.run(self._conn.drop_function(name, version=version))
@override
def create_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.create_secret(name, value))
@override
def alter_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.alter_secret(name, value))
@override
def list_secrets(self) -> List[str]:
return LOOP.run(self._conn.list_secrets())
@override
def drop_secret(self, name: str) -> None:
LOOP.run(self._conn.drop_secret(name))
@override
def describe_secret(self, name: str) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name))
@override @override
def list_jobs(self) -> List[JobInfo]: def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
@@ -2265,18 +2380,23 @@ class AsyncConnection(object):
return AsyncJob(await self._inner.open_job(job_id)) return AsyncJob(await self._inner.open_job(job_id))
async def create_function_async( async def create_function_async(
self, definition: UdfDefinition self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> AsyncJob[FunctionVersion]: ) -> AsyncJob[FunctionVersion]:
"""Register a scalar Python UDF through the remote Function catalog. """Register a scalar Python UDF through the remote Function catalog.
The returned typed job resolves to the immutable Function version. The returned typed job resolves to the immutable Function version.
Local connections raise ``NotImplementedError``. ``secrets`` is a sequence of
[EnvVarSecret][lancedb.secrets.EnvVarSecret], each naming a Secret and
the environment variable its value arrives in. Local connections raise
``NotImplementedError``.
""" """
if not isinstance(definition, UdfDefinition): if not isinstance(definition, UdfDefinition):
raise TypeError("create_function_async requires a @udf definition") raise TypeError("create_function_async requires a @udf definition")
inner = await self._inner.create_function_async( request = definition.bind_secrets(secrets)
definition.registration_request.to_canonical_json() inner = await self._inner.create_function_async(request.to_canonical_json())
)
return _typed_job(inner, FunctionVersion.from_json) return _typed_job(inner, FunctionVersion.from_json)
async def get_function(self, name: str, *, version: str) -> FunctionVersion: async def get_function(self, name: str, *, version: str) -> FunctionVersion:
@@ -2298,6 +2418,41 @@ class AsyncConnection(object):
"""Drop one exact immutable Function version from the remote catalog.""" """Drop one exact immutable Function version from the remote catalog."""
return await self._inner.drop_function(name, version) return await self._inner.drop_function(name, version)
async def create_secret(self, name: str, value: str) -> None:
"""Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a
rotation. Nothing reads the value back.
"""
await self._inner.create_secret(validate_secret_name(name), value)
async def alter_secret(self, name: str, value: str) -> None:
"""Replace the credential behind an existing Secret.
Fails if it does not exist. Bound Functions use the new value from
their next job, with no new Function version.
"""
await self._inner.alter_secret(validate_secret_name(name), value)
async def list_secrets(self) -> List[str]:
"""The names of every Secret in this database. Names only."""
return await self._inner.list_secrets()
async def drop_secret(self, name: str) -> None:
"""Drop a Secret. Bound Functions fail at their next job."""
await self._inner.drop_secret(validate_secret_name(name))
async def describe_secret(self, name: str) -> SecretInfo:
"""What this database records about a Secret. Never the value."""
name, created_at_millis, updated_at_millis = await self._inner.describe_secret(
validate_secret_name(name)
)
return SecretInfo(
name=name,
created_at_millis=created_at_millis,
updated_at_millis=updated_at_millis,
)
async def list_jobs(self) -> List[JobInfo]: async def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
return await self._inner.list_jobs() return await self._inner.list_jobs()
+1 -1
View File
@@ -21,7 +21,7 @@ class GteEmbeddings(TextEmbeddingFunction):
An embedding function that uses GTE-LARGE MLX format(for Apple silicon devices only) An embedding function that uses GTE-LARGE MLX format(for Apple silicon devices only)
as well as the standard cpu/gpu version from: https://huggingface.co/thenlper/gte-large. as well as the standard cpu/gpu version from: https://huggingface.co/thenlper/gte-large.
For Apple users, you will need the mlx package installed, which can be done with: For Apple users, you will need the mlx package insalled, which can be done with:
pip install mlx pip install mlx
Parameters Parameters
@@ -60,7 +60,7 @@ class InstructorEmbeddingFunction(TextEmbeddingFunction):
import lancedb import lancedb
from lancedb.pydantic import LanceModel, Vector from lancedb.pydantic import LanceModel, Vector
from lancedb.embeddings import get_registry, InstructorEmbeddingFunction from lancedb.embeddings import get_registry, InstuctorEmbeddingFunction
instructor = get_registry().get("instructor").create( instructor = get_registry().get("instructor").create(
source_instruction="represent the document for retrieval", source_instruction="represent the document for retrieval",
+92 -5
View File
@@ -4,7 +4,7 @@
"""Canonical Function values exchanged with LanceDB Enterprise services. """Canonical Function values exchanged with LanceDB Enterprise services.
These immutable models contain client/wire state only. Catalog persistence, These immutable models contain client/wire state only. Catalog persistence,
environment bake, and execution are owned by Sophon. environment bake, secret resolution, and execution are owned by Sophon.
``RefreshColumnResult`` is also the backend-neutral result of a local ``RefreshColumnResult`` is also the backend-neutral result of a local
expression-backed refresh job. expression-backed refresh job.
""" """
@@ -25,7 +25,7 @@ import re
import sys import sys
import textwrap import textwrap
import types import types
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from datetime import date, datetime from datetime import date, datetime
from typing import ( from typing import (
Annotated, Annotated,
@@ -50,6 +50,7 @@ from pydantic import (
) )
from .schema import is_blob_v2_field as _is_blob_v2_field from .schema import is_blob_v2_field as _is_blob_v2_field
from .secrets import EnvVarSecret
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1) _Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1) _UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
@@ -226,6 +227,20 @@ class FunctionOutput(_OpenRemoteValue):
fields: tuple[FunctionResultField, ...] = () fields: tuple[FunctionResultField, ...] = ()
class SecretBinding(_RemoteValue):
"""How a Secret reaches the Function that binds it.
One list rather than a field per delivery mode: a binding is the concept,
and how it arrives is a property of one. ``kind`` is open, so a binding a
newer service introduces decodes here instead of failing the whole
FunctionVersion.
"""
kind: str
variable: Optional[str] = None
secret_ref: Optional[str] = None
class FunctionSignature(_RemoteValue): class FunctionSignature(_RemoteValue):
inputs: tuple[FunctionParameter, ...] inputs: tuple[FunctionParameter, ...]
output: FunctionOutput output: FunctionOutput
@@ -309,6 +324,7 @@ class FunctionVersion(_RemoteValue):
runtime: PythonRuntimeSpec runtime: PythonRuntimeSpec
runtime_digest: str runtime_digest: str
environment_digest: str environment_digest: str
secret_bindings: tuple[SecretBinding, ...] = ()
created_at: str created_at: str
def __call__(self, **inputs: Any) -> FunctionApplication: def __call__(self, **inputs: Any) -> FunctionApplication:
@@ -370,12 +386,18 @@ class FunctionVersion(_RemoteValue):
class FunctionRegistrationRequest(_RemoteValue): class FunctionRegistrationRequest(_RemoteValue):
"""Stable remote registration envelope produced by :func:`udf`.""" """Stable remote registration envelope produced by :func:`udf`.
Credential values deliberately have no field here. The only secret-shaped
thing a client sends is ``secret_bindings``: the name of a Secret the
database already holds, which the remote service resolves at execution.
"""
name: str name: str
artifact: FunctionArtifactRequest artifact: FunctionArtifactRequest
signature: FunctionSignature signature: FunctionSignature
runtime: PythonRuntimeSpec runtime: PythonRuntimeSpec
secret_bindings: tuple[SecretBinding, ...] = ()
class FunctionVersionRef(_OpenRemoteValue): class FunctionVersionRef(_OpenRemoteValue):
@@ -524,6 +546,8 @@ class RefreshColumnResult(_RemoteValue):
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") _FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
_DECLARED_SECRET = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_FUNCTION_BLOB_V2_TYPE = "blob_v2" _FUNCTION_BLOB_V2_TYPE = "blob_v2"
_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name" _ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name"
_BLOB_V2_EXTENSION_NAME = "lance.blob.v2" _BLOB_V2_EXTENSION_NAME = "lance.blob.v2"
@@ -1265,9 +1289,70 @@ class UdfDefinition:
@property @property
def registration_request(self) -> FunctionRegistrationRequest: def registration_request(self) -> FunctionRegistrationRequest:
"""The immutable request sent by ``create_function_async``.""" """The immutable request sent by ``create_function_async``.
Carries no secret bindings. Binding is a registration-time decision,
so a Function bound to Secrets is registered through :meth:`bind_secrets`,
which is what ``create_function`` calls.
"""
return self._request return self._request
def bind_secrets(
self, secrets: Optional[Sequence[EnvVarSecret]]
) -> FunctionRegistrationRequest:
"""The registration request for this definition bound to ``secrets``.
Binding does not change the Function's source: each
[EnvVarSecret][lancedb.secrets.EnvVarSecret] names a Secret and the
environment variable its value should arrive in, and the Function reads
that variable the way it already did. Whether the named Secrets exist is
the server's answer, not this one.
"""
bindings = () if secrets is None else tuple(secrets)
wrong_type = [
binding for binding in bindings if not isinstance(binding, EnvVarSecret)
]
if wrong_type:
kinds = sorted({type(binding).__name__ for binding in wrong_type})
raise TypeError(
f"Function secrets must be EnvVarSecret values, not {kinds!r}; a "
"credential value is never sent to this API"
)
variables = [binding.env_variable for binding in bindings]
duplicates = sorted({name for name in variables if variables.count(name) > 1})
if duplicates:
raise ValueError(
"a Function binds each environment variable once; duplicated: "
f"{duplicates!r}"
)
# `env` is ordinary configuration carried in the definition, so a name in
# both would have a value visible in the Function's record and a value
# that is not. Refuse rather than pick.
environment = self._request.runtime.env or {}
overlap = sorted(set(environment) & set(variables))
if overlap:
raise ValueError(
f"Function env and secret bindings must be disjoint: {overlap!r}"
)
if not bindings:
return self._request
# Sorted, because the list is carried in the FunctionVersion hash and a
# caller's argument order is not part of what a Function is.
resolved = tuple(
sorted(
(
SecretBinding(
kind="env",
variable=binding.env_variable,
secret_ref=binding.secret,
)
for binding in bindings
),
key=lambda binding: (binding.kind, binding.variable or ""),
)
)
return self._request._copy(update={"secret_bindings": resolved})
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
return self._function(*args, **kwargs) return self._function(*args, **kwargs)
@@ -1332,7 +1417,9 @@ def udf(
conda_channels : sequence of str, optional conda_channels : sequence of str, optional
Conda channels in priority order; requires ``conda``. Conda channels in priority order; requires ``conda``.
env : mapping of str to str, optional env : mapping of str to str, optional
Environment variables included in the Function definition. Environment variables included in the Function definition. Not for
credentials -- these are ordinary configuration, stored with the
Function and visible wherever it is.
python_version : str, optional python_version : str, optional
Remote Python major/minor version. Defaults to the client version. Remote Python major/minor version. Defaults to the client version.
gpu : bool, default False gpu : bool, default False
+1 -1
View File
@@ -751,7 +751,7 @@ class IvfPq:
This value controls how much the vector is compressed during the This value controls how much the vector is compressed during the
quantization step. The more sub vectors there are the less the vector is quantization step. The more sub vectors there are the less the vector is
compressed. The default is the dimension of the vector divided by 16. If compressed. The default is the dimension of the vector divided by 16. If
the dimension is not evenly divisible by 16 we use the dimension divided by the dimension is not evenly divisible by 16 we use the dimension divded by
8. 8.
The above two cases are highly preferred. Having 8 or 16 values per The above two cases are highly preferred. Having 8 or 16 values per
+17 -58
View File
@@ -78,10 +78,6 @@ if TYPE_CHECKING:
T = TypeVar("T", bound="LanceModel") T = TypeVar("T", bound="LanceModel")
AnalyzePlanDistributedMetrics = Literal["aggregate", "per_worker", "full"] AnalyzePlanDistributedMetrics = Literal["aggregate", "per_worker", "full"]
# Number of rows a hybrid query returns when no limit was set on it. This
# mirrors the default the Rust query builder applies to its sub-queries.
DEFAULT_HYBRID_LIMIT = 10
@runtime_checkable @runtime_checkable
class _LanceScanner(Protocol): class _LanceScanner(Protocol):
@@ -863,7 +859,7 @@ class Query(pydantic.BaseModel):
return query return query
# This tells pydantic to allow custom types (needed for the `vector` query since # This tells pydantic to allow custom types (needed for the `vector` query since
# pa.Array wouldn't be allowed otherwise) # pa.Array wouln't be allowed otherwise)
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
@@ -3897,54 +3893,14 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
return self return self
def _create_child_queries(
self,
) -> Tuple["AsyncFTSQuery", "AsyncVectorQuery", int, int]:
"""Build the sub-queries that make up this hybrid query.
Execution, `explain_plan` and `analyze_plan` all go through here so that
the plans that are reported are the plans that actually run.
Returns the two sub-queries along with the effective limit and offset of
the hybrid query itself.
"""
fts_query = AsyncFTSQuery(self._inner.to_fts_query(), self._table)
vec_query = AsyncVectorQuery(self._inner.to_vector_query(), self._table)
fts_req = fts_query._inner.to_query_request()
vec_req = vec_query._inner.to_query_request()
# Only one of the two sub-queries carries the limit when it was never
# set explicitly: nearest_to()/nearest_to_text() build the sibling query
# from scratch, and that is where the default gets filled in. Which one
# that is depends on the order the hybrid query was built in, so look at
# both rather than at a single side.
limit = fts_req.limit if fts_req.limit is not None else vec_req.limit
if limit is None:
limit = DEFAULT_HYBRID_LIMIT
offset = fts_req.offset or vec_req.offset or 0
fts_query.with_row_id()
vec_query.with_row_id()
# offset() pushes the offset down into both sub-queries, which would make
# each of them skip its own first `offset` rows. The window has to be
# taken out of the combined, reranked results instead, so fetch the
# skipped prefix here too and slice it off afterwards.
fts_query.limit(limit + offset)
vec_query.limit(limit + offset)
fts_query.offset(0)
vec_query.offset(0)
return fts_query, vec_query, limit, offset
async def to_batches( async def to_batches(
self, self,
*, *,
max_batch_length: Optional[int] = None, max_batch_length: Optional[int] = None,
timeout: Optional[timedelta] = None, timeout: Optional[timedelta] = None,
) -> AsyncRecordBatchReader: ) -> AsyncRecordBatchReader:
fts_query, vec_query, limit, offset = self._create_child_queries() fts_query = AsyncFTSQuery(self._inner.to_fts_query(), self._table)
vec_query = AsyncVectorQuery(self._inner.to_vector_query(), self._table)
req = fts_query._inner.to_query_request() req = fts_query._inner.to_query_request()
blob_auto_row_id = False blob_auto_row_id = False
@@ -3964,6 +3920,9 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
self._blob_auto_row_id = blob_auto_row_id self._blob_auto_row_id = blob_auto_row_id
self._blob_paths = blob_paths self._blob_paths = blob_paths
fts_query.with_row_id()
vec_query.with_row_id()
fts_results, vector_results = await asyncio.gather( fts_results, vector_results = await asyncio.gather(
fts_query.to_arrow(timeout=timeout), fts_query.to_arrow(timeout=timeout),
vec_query.to_arrow(timeout=timeout), vec_query.to_arrow(timeout=timeout),
@@ -3975,9 +3934,8 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
norm=self._norm, norm=self._norm,
fts_query=fts_query.get_query(), fts_query=fts_query.get_query(),
reranker=self._reranker, reranker=self._reranker,
limit=limit, limit=self._inner.get_limit(),
with_row_ids=True, with_row_ids=True,
offset=offset,
) )
if ( if (
not self._user_requested_row_id() not self._user_requested_row_id()
@@ -4006,14 +3964,14 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
... print(plan) ... print(plan)
>>> asyncio.run(doctest_example()) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE >>> asyncio.run(doctest_example()) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
RRFReranker(K=60) RRFReranker(K=60)
ProjectionExec: expr=[vector@0 as vector, text@3 as text, _distance@2 as _distance, _rowid@1 as _rowid] ProjectionExec: expr=[vector@0 as vector, text@3 as text, _distance@2 as _distance]
LanceRead: uri=..., projection=[text], source=stream(_rowid) LanceRead: uri=..., projection=[text], source=stream(_rowid)
GlobalLimitExec: skip=0, fetch=10 GlobalLimitExec: skip=0, fetch=10
FilterExec: _distance@2 IS NOT NULL FilterExec: _distance@2 IS NOT NULL
SortExec: TopK(fetch=10), expr=[_distance@2 ASC NULLS LAST, _rowid@1 ASC NULLS LAST], preserve_partitioning=[false] SortExec: TopK(fetch=10), expr=[_distance@2 ASC NULLS LAST, _rowid@1 ASC NULLS LAST], preserve_partitioning=[false]
KNNVectorDistance: metric=l2 KNNVectorDistance: metric=l2
LanceRead: uri=..., projection=[vector], ... LanceRead: uri=..., projection=[vector], ...
ProjectionExec: expr=[vector@2 as vector, text@3 as text, _score@1 as _score, _rowid@0 as _rowid] ProjectionExec: expr=[vector@2 as vector, text@3 as text, _score@1 as _score]
LanceRead: uri=..., projection=[vector, text], source=stream(_rowid) LanceRead: uri=..., projection=[vector, text], source=stream(_rowid)
GlobalLimitExec: skip=0, fetch=10 GlobalLimitExec: skip=0, fetch=10
MatchQuery: column=text, query=[hello] MatchQuery: column=text, query=[hello]
@@ -4028,9 +3986,8 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
plan : str plan : str
""" # noqa: E501 """ # noqa: E501
fts_query, vec_query, _, _ = self._create_child_queries() vector_plan = await self._inner.to_vector_query().explain_plan(verbose)
vector_plan = await vec_query.explain_plan(verbose) fts_plan = await self._inner.to_fts_query().explain_plan(verbose)
fts_plan = await fts_query.explain_plan(verbose)
# Indent sub-plans under the reranker # Indent sub-plans under the reranker
indented_vector = "\n".join(" " + line for line in vector_plan.splitlines()) indented_vector = "\n".join(" " + line for line in vector_plan.splitlines())
indented_fts = "\n".join(" " + line for line in fts_plan.splitlines()) indented_fts = "\n".join(" " + line for line in fts_plan.splitlines())
@@ -4057,12 +4014,14 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
------- -------
plan : str plan : str
""" """
fts_query, vec_query, _, _ = self._create_child_queries()
results = ["Vector Search Query:"] results = ["Vector Search Query:"]
results.append(await vec_query.analyze_plan(distributed_metrics)) results.append(
await self._inner.to_vector_query().analyze_plan(distributed_metrics)
)
results.append("FTS Search Query:") results.append("FTS Search Query:")
results.append(await fts_query.analyze_plan(distributed_metrics)) results.append(
await self._inner.to_fts_query().analyze_plan(distributed_metrics)
)
return "\n".join(results) return "\n".join(results)
+39 -3
View File
@@ -7,7 +7,16 @@ import json
import logging import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import sys import sys
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
Optional,
Sequence,
Union,
)
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
import warnings import warnings
@@ -29,6 +38,7 @@ from ..job import AsyncJob, Job
from ..sql import Query as SqlQuery from ..sql import Query as SqlQuery
from ..sql import QueryDescription from ..sql import QueryDescription
from ..materialized_view import MaterializedView, SelectArg from ..materialized_view import MaterializedView, SelectArg
from ..secrets import EnvVarSecret, SecretInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from .._lancedb import JobInfo from .._lancedb import JobInfo
@@ -746,8 +756,14 @@ class RemoteDBConnection(DBConnection):
return Job(LOOP.run(self._conn.open_job(job_id))) return Job(LOOP.run(self._conn.open_job(job_id)))
@override @override
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: def create_function_async(
return Job(LOOP.run(self._conn.create_function_async(definition))) self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
return Job(job)
@override @override
def get_function(self, name: str, *, version: str) -> FunctionVersion: def get_function(self, name: str, *, version: str) -> FunctionVersion:
@@ -761,6 +777,26 @@ class RemoteDBConnection(DBConnection):
def drop_function(self, name: str, *, version: str) -> bool: def drop_function(self, name: str, *, version: str) -> bool:
return LOOP.run(self._conn.drop_function(name, version=version)) return LOOP.run(self._conn.drop_function(name, version=version))
@override
def create_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.create_secret(name, value))
@override
def alter_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.alter_secret(name, value))
@override
def describe_secret(self, name: str) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name))
@override
def list_secrets(self) -> List[str]:
return LOOP.run(self._conn.list_secrets())
@override
def drop_secret(self, name: str) -> None:
LOOP.run(self._conn.drop_secret(name))
@override @override
def list_jobs(self) -> List["JobInfo"]: def list_jobs(self) -> List["JobInfo"]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
+1 -1
View File
@@ -720,7 +720,7 @@ class RemoteTable(Table):
Parameters Parameters
---------- ----------
query: list/np.ndarray/str/PIL.Image.Image, default None query: list/np.ndarray/str/PIL.Image.Image, default None
The targeted vector to search for. The targetted vector to search for.
- *default None*. - *default None*.
Acceptable types are: list, np.ndarray, PIL.Image.Image Acceptable types are: list, np.ndarray, PIL.Image.Image
+1 -1
View File
@@ -175,7 +175,7 @@ class Reranker(ABC):
if the results haven't been executed yet or the results in arrow format. if the results haven't been executed yet or the results in arrow format.
query : str or None, query : str or None,
The input query. Some rerankers might not need the query to rerank. The input query. Some rerankers might not need the query to rerank.
In that case, it can be set to None explicitly. This is intended to In that case, it can be set to None explicitly. This is inteded to
be handled by the reranker implementations. be handled by the reranker implementations.
deduplicate : bool, optional deduplicate : bool, optional
Whether to deduplicate the results based on the `_rowid` column, Whether to deduplicate the results based on the `_rowid` column,
+174
View File
@@ -0,0 +1,174 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Named Secrets, and the bindings that deliver them to Functions.
A Secret is a database-scoped named credential. Nothing in this module holds a
value: :class:`EnvVarSecret` names one and says which environment variable it
should arrive in, and the value is resolved by the remote service when a
Function bound to it runs. No API returns a stored credential, by construction
rather than by policy -- there is no code path that could.
"""
from __future__ import annotations
import re
# The same characters LanceDB already admits in a namespace or table name, and
# no positional rule on top of them: a segment may begin with `_`, `-` or `.`
# today, so anything narrower would put Secrets out of reach inside namespaces
# that already exist. Matches the service, which admits the same set.
_SECRET_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
_ENV_VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def validate_secret_name(name: str) -> str:
"""Check a Secret name locally and return it unchanged."""
if not isinstance(name, str):
raise TypeError(f"Secret name must be a string, not {type(name).__name__}")
if not _SECRET_NAME.fullmatch(name):
raise ValueError(f"invalid Secret name: {name!r}")
return name
def validate_env_variable(name: str) -> str:
"""Check an environment variable name locally and return it unchanged."""
if not isinstance(name, str):
raise TypeError(
f"environment variable name must be a string, not {type(name).__name__}"
)
if not _ENV_VARIABLE.fullmatch(name):
raise ValueError(f"invalid environment variable name: {name!r}")
return name
class EnvVarSecret:
"""A Secret bound to the environment variable a Function's library reads.
Pass these in the ``secrets`` sequence of
[DBConnection.create_function][lancedb.db.DBConnection.create_function]. The
Function's source is unchanged by binding: it reads ``OPENAI_API_KEY`` the
way it always did, and the binding is what puts a value there.
This is a local value. Constructing it contacts no server, so it always
succeeds and says nothing about whether the Secret exists; that is checked
at registration, where a mistyped Secret name surfaces as a clear "does not
exist" naming both the Secret and the variable bound to it. A mistyped
*variable* name cannot be caught anywhere -- nothing knows which variables a
Function reads -- so it surfaces on the first rows instead.
The type exists so a credential cannot be passed by accident. A bare string
in the same position is a plausible-looking mistake with the opposite
meaning, and it reads identically in a diff.
Parameters
----------
secret : str
The Secret's database-scoped name.
env_variable : str
The environment variable the Function reads it from.
Examples
--------
>>> from lancedb import EnvVarSecret
>>> binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
>>> binding.secret, binding.env_variable
('openai-prod', 'OPENAI_API_KEY')
"""
__slots__ = ("_secret", "_env_variable")
def __init__(self, secret: str, env_variable: str):
self._secret = validate_secret_name(secret)
self._env_variable = validate_env_variable(env_variable)
@property
def secret(self) -> str:
"""The Secret's database-scoped name."""
return self._secret
@property
def env_variable(self) -> str:
"""The environment variable the value is delivered in."""
return self._env_variable
def __repr__(self) -> str:
return (
f"EnvVarSecret(secret={self._secret!r}, "
f"env_variable={self._env_variable!r})"
)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, EnvVarSecret)
and other._secret == self._secret
and other._env_variable == self._env_variable
)
def __hash__(self) -> int:
return hash((EnvVarSecret, self._secret, self._env_variable))
class SecretInfo:
"""What a database records about a Secret. Never its value.
Returned by
[DBConnection.describe_secret][lancedb.db.DBConnection.describe_secret].
"""
__slots__ = ("_name", "_created_at_millis", "_updated_at_millis")
def __init__(self, name: str, created_at_millis: int, updated_at_millis: int):
self._name = name
self._created_at_millis = created_at_millis
self._updated_at_millis = updated_at_millis
@property
def name(self) -> str:
"""The Secret's database-scoped name."""
return self._name
@property
def created_at_millis(self) -> int:
"""When the Secret was created, in milliseconds since the Unix epoch."""
return self._created_at_millis
@property
def updated_at_millis(self) -> int:
"""When the Secret's value was last rotated, in epoch milliseconds.
The only observable that a rotation landed: no API returns a credential,
so a caller confirms ``alter_secret`` took effect by watching this move.
"""
return self._updated_at_millis
@classmethod
def from_json(cls, value: dict) -> "SecretInfo":
return cls(
name=value["name"],
created_at_millis=value["created_at_millis"],
updated_at_millis=value["updated_at_millis"],
)
def __repr__(self) -> str:
return (
f"SecretInfo(name={self._name!r}, "
f"created_at_millis={self._created_at_millis!r}, "
f"updated_at_millis={self._updated_at_millis!r})"
)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, SecretInfo)
and other._name == self._name
and other._created_at_millis == self._created_at_millis
and other._updated_at_millis == self._updated_at_millis
)
__all__ = [
"EnvVarSecret",
"SecretInfo",
"validate_env_variable",
"validate_secret_name",
]
+4 -4
View File
@@ -1619,7 +1619,7 @@ class Table(ABC):
Parameters Parameters
---------- ----------
query: list/np.ndarray/str/PIL.Image.Image, default None query: list/np.ndarray/str/PIL.Image.Image, default None
The targeted vector to search for. The targetted vector to search for.
- *default None*. - *default None*.
Acceptable types are: list, np.ndarray, PIL.Image.Image Acceptable types are: list, np.ndarray, PIL.Image.Image
@@ -3841,7 +3841,7 @@ class LanceTable(Table):
Parameters Parameters
---------- ----------
query: list/np.ndarray/str/PIL.Image.Image, default None query: list/np.ndarray/str/PIL.Image.Image, default None
The targeted vector to search for. The targetted vector to search for.
- *default None*. - *default None*.
Acceptable types are: list, np.ndarray, PIL.Image.Image Acceptable types are: list, np.ndarray, PIL.Image.Image
@@ -5638,7 +5638,7 @@ class AsyncTable:
if fill_value is None: if fill_value is None:
fill_value = 0.0 fill_value = 0.0
# _sanitize_data is an old code path, but we will use it until the # _santitize_data is an old code path, but we will use it until the
# new code path is ready. # new code path is ready.
if mode == "overwrite": if mode == "overwrite":
# For overwrite, apply the same preprocessing as create_table # For overwrite, apply the same preprocessing as create_table
@@ -5814,7 +5814,7 @@ class AsyncTable:
Parameters Parameters
---------- ----------
query: list/np.ndarray/str/PIL.Image.Image, default None query: list/np.ndarray/str/PIL.Image.Image, default None
The targeted vector to search for. The targetted vector to search for.
- *default None*. - *default None*.
Acceptable types are: list, np.ndarray, PIL.Image.Image Acceptable types are: list, np.ndarray, PIL.Image.Image
+1 -4
View File
@@ -297,10 +297,7 @@ def test_blob_v2_projection_sources_use_typed_column_name():
def _legacy_v1_table(name): def _legacy_v1_table(name):
# Legacy v1 blob columns are only writable at file version <= 2.1. db = lancedb.connect("memory:///")
db = lancedb.connect(
"memory:///", storage_options={"new_table_data_storage_version": "2.1"}
)
schema = pa.schema( schema = pa.schema(
[ [
pa.field("id", pa.int64()), pa.field("id", pa.int64()),
+5 -5
View File
@@ -327,8 +327,8 @@ def test_embedding_function_with_pandas(tmp_path):
) -> List[np.array]: ) -> List[np.array]:
return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))] return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))]
registry = get_registry() registery = get_registry()
func = registry.get("mock-embedding").create() func = registery.get("mock-embedding").create()
class TestSchema(LanceModel): class TestSchema(LanceModel):
text: str = func.SourceField() text: str = func.SourceField()
@@ -394,9 +394,9 @@ def test_multiple_embeddings_for_pandas(tmp_path):
) -> List[np.array]: ) -> List[np.array]:
return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))] return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))]
registry = get_registry() registery = get_registry()
func1 = registry.get("mock-embedding").create() func1 = registery.get("mock-embedding").create()
func2 = registry.get("mock-embedding2").create() func2 = registery.get("mock-embedding2").create()
class TestSchema(LanceModel): class TestSchema(LanceModel):
text: str = func1.SourceField() text: str = func1.SourceField()
@@ -13,6 +13,7 @@ from lancedb.functions import (
FunctionBinding, FunctionBinding,
FunctionVersion, FunctionVersion,
PythonRuntimeSpec, PythonRuntimeSpec,
SecretBinding,
RefreshColumnResult, RefreshColumnResult,
) )
from lancedb.table import AsyncTable from lancedb.table import AsyncTable
@@ -37,6 +38,22 @@ def job_result(name: str) -> dict:
return json.loads(fixture(name))["result"] return json.loads(fixture(name))["result"]
def assert_no_secret_values(value):
"""No client value models a resolved credential, at any nesting depth."""
if isinstance(value, dict):
for key, child in value.items():
assert key not in {
"secret_value",
"secret_values",
"resolved_secret",
"resolved_secrets",
}
assert_no_secret_values(child)
elif isinstance(value, list):
for child in value:
assert_no_secret_values(child)
def test_public_function_values_are_in_api_reference(): def test_public_function_values_are_in_api_reference():
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md" docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
rendered = docs.read_text() rendered = docs.read_text()
@@ -94,6 +111,9 @@ def test_function_version_identity_is_immutable_and_exact():
version = FunctionVersion.from_json(json.dumps(value)) version = FunctionVersion.from_json(json.dumps(value))
assert version.name == "embed" assert version.name == "embed"
assert version.version == "fv_01K3EXACT" assert version.version == "fv_01K3EXACT"
assert list(version.secret_bindings) == [
SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod")
]
with pytest.raises((TypeError, ValueError)): with pytest.raises((TypeError, ValueError)):
version.version = "fv_changed" version.version = "fv_changed"
@@ -276,6 +296,27 @@ def test_refresh_result_rejects_non_u64_values(field):
RefreshColumnResult.from_json(json.dumps(value)) RefreshColumnResult.from_json(json.dumps(value))
def test_canonical_client_values_carry_bindings_and_no_credentials():
"""A binding names a Secret; the credential behind it has no client field."""
version = FunctionVersion.from_json(
json.dumps(job_result("remote_function_job.json"))
)
canonical = json.loads(version.to_canonical_json())
assert canonical["secret_bindings"] == [
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}
]
assert_no_secret_values(canonical)
def test_a_version_without_bindings_keeps_the_original_wire_shape():
"""Every Function registered before Secrets existed serializes unchanged."""
value = job_result("remote_function_job.json")
del value["secret_bindings"]
version = FunctionVersion.from_json(json.dumps(value))
assert list(version.secret_bindings) == []
assert "secret_bindings" not in json.loads(version.to_canonical_json())
class _FunctionDeclarationInner: class _FunctionDeclarationInner:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
@@ -11,6 +11,7 @@ import types
from datetime import date from datetime import date
import http.server import http.server
import json import json
import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
@@ -23,11 +24,13 @@ import pytest
import lancedb import lancedb
from lancedb.functions import ( from lancedb.functions import (
PythonRuntimeSpec, PythonRuntimeSpec,
SecretBinding,
UdfDefinition, UdfDefinition,
_canonical_arrow_type, _canonical_arrow_type,
_GRAMMAR_PRIMITIVES, _GRAMMAR_PRIMITIVES,
udf, udf,
) )
from lancedb.secrets import EnvVarSecret
THRESHOLD = 20 THRESHOLD = 20
_CACHE = None _CACHE = None
@@ -53,6 +56,15 @@ def normalize_score(value: float) -> float:
return value / 100.0 return value / 100.0
@udf(
pip=["openai==3.7.0"],
env={"MODE": "test"},
python_version="3.12",
)
def analyze_caption(caption: str) -> str:
return caption.strip()
def test_scalar_udf_matches_shared_registration_golden_and_remains_callable(): def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
assert isinstance(normalize_score, UdfDefinition) assert isinstance(normalize_score, UdfDefinition)
assert normalize_score(25.0) == 0.25 assert normalize_score(25.0) == 0.25
@@ -69,6 +81,197 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
} }
def test_secret_bound_udf_matches_its_shared_registration_golden():
assert analyze_caption(" hello ") == "hello"
bound = analyze_caption.bind_secrets(
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
)
assert (
bound.to_canonical_json()
== (FIXTURES / "remote_function_secret_registration_request.canonical.json")
.read_text()
.strip()
)
def test_an_unbound_request_carries_no_binding_at_all():
"""Binding is a registration-time decision, so the definition holds none.
The decorator declares nothing about secrets, which is what makes the PRD's
claim true: a Function's source and its registration request are identical
whether or not a credential is later bound to it.
"""
unbound = json.loads(analyze_caption.registration_request.to_canonical_json())
assert "secret_bindings" not in unbound
assert "OPENAI_API_KEY" not in json.dumps(unbound)
def test_binding_a_secret_leaves_the_packaged_artifact_untouched():
"""The artifact is source bytes and nothing else, with or without secrets."""
bound = analyze_caption.bind_secrets(
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
)
assert bound.artifact == analyze_caption.registration_request.artifact
assert bound.artifact.digest == analyze_caption.registration_request.artifact.digest
def test_a_function_declaring_no_secret_is_registered_exactly_as_before():
"""The compatibility claim: nothing about the no-secret path moves."""
assert (
normalize_score.bind_secrets(None).to_canonical_json()
== normalize_score.registration_request.to_canonical_json()
)
assert (
"secret_bindings"
not in normalize_score.registration_request.to_canonical_json()
)
def test_a_function_binds_each_variable_once():
with pytest.raises(ValueError, match="binds each environment variable once"):
analyze_caption.bind_secrets(
[
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY"),
EnvVarSecret(secret="openai-staging", env_variable="OPENAI_API_KEY"),
]
)
def test_bindings_may_not_collide_with_plain_configuration():
"""`env` is stored with the Function; a Secret is not. Refuse, do not pick."""
with pytest.raises(ValueError, match="must be disjoint"):
analyze_caption.bind_secrets(
[EnvVarSecret(secret="mode-prod", env_variable="MODE")]
)
def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
"""Binding rules are the service's: it owns the runtime the names land in.
The client sends what it was given, so a rule it duplicated could disagree
with the service's without either side noticing. What is checked here is
that the envelope arrives intact -- the shape the service judges is the
shape the caller wrote.
"""
with _mock_remote_function_catalog() as (host, state):
db = lancedb.connect(
"db://dev",
api_key="fake",
host_override=host,
client_config={"retry_config": {"retries": 0}},
)
bindings = [
EnvVarSecret(secret=f"secret-{index}", env_variable=f"TOKEN_{index}")
for index in range(17)
]
db.create_function(normalize_score, secrets=bindings)
sent = state["requests"][0][1]
assert len(sent["secret_bindings"]) == 17
assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[
"secret_bindings"
]
_SECRET_DEBUG_LOG_SOURCE = """
import http.server
import json
import threading
import lancedb
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
payload = json.dumps({}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.ThreadingHTTPServer(("localhost", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
db = lancedb.connect(
"db://dev",
api_key="API_KEY_SENTINEL",
host_override="http://localhost:%d" % server.server_address[1],
client_config={"retry_config": {"retries": 0}},
)
db.create_secret("openai-prod", "SECRET_VALUE_SENTINEL")
finally:
server.shutdown()
"""
def test_a_credential_never_reaches_a_debug_log(tmp_path):
"""The logger sees the serialized body, so no value-side redaction reaches it.
Runs in a subprocess because the Rust logger reads ``LANCEDB_LOG`` once, at
import.
"""
script = tmp_path / "write_secret.py"
script.write_text(_SECRET_DEBUG_LOG_SOURCE)
result = subprocess.run(
[sys.executable, str(script)],
check=True,
capture_output=True,
text=True,
env={**os.environ, "LANCEDB_LOG": "debug"},
)
output = result.stdout + result.stderr
# Without this the test passes when debug logging is simply off.
assert "Sending request_id=" in output, output
assert "SECRET_VALUE_SENTINEL" not in output
assert "API_KEY_SENTINEL" not in output
def test_a_credential_value_is_rejected_in_the_binding_position():
"""The one mistake the typed binding exists to stop."""
with pytest.raises(TypeError, match="EnvVarSecret"):
analyze_caption.bind_secrets(["sk-live-0001"])
@pytest.mark.parametrize(
("secret", "variable", "message"),
[
("openai-prod", "not-a-var", "invalid environment variable name"),
("openai-prod", "API-TOKEN", "invalid environment variable name"),
("not a name", "API_TOKEN", "invalid Secret name"),
("openai$prod", "API_TOKEN", "invalid Secret name"),
],
)
def test_a_binding_validates_both_names_locally(secret, variable, message):
with pytest.raises(ValueError, match=message):
EnvVarSecret(secret=secret, env_variable=variable)
def test_a_secret_name_admits_what_a_namespace_name_does():
"""A Secret has to be nameable wherever a namespace already is.
LanceDB namespace and table names are `[A-Za-z0-9_.-]` with no rule about
which character comes first, so a name may lead with `_`, `-` or `.`.
Anything narrower here would leave Secrets unaddressable inside namespaces
that already exist -- the reason periods are admitted is the reason the
edges are too.
"""
for name in ["openai.prod.v1", ".hidden", "_internal", "-lead", "trailing."]:
binding = EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
assert binding.secret == name
for name in ["", "with/slash", "with$delimiter", "a" * 256]:
with pytest.raises(ValueError, match="invalid Secret name"):
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
def _main_udf_source( def _main_udf_source(
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">=" *, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
) -> str: ) -> str:
@@ -1230,6 +1433,7 @@ def _mock_remote_function_catalog():
"runtime": body["runtime"], "runtime": body["runtime"],
"runtime_digest": "sha256:runtime", "runtime_digest": "sha256:runtime",
"environment_digest": "sha256:environment", "environment_digest": "sha256:environment",
"secret_bindings": body.get("secret_bindings", []),
"created_at": "2026-08-21T00:00:00Z", "created_at": "2026-08-21T00:00:00Z",
} }
response = {"job_id": "job-register"} response = {"job_id": "job-register"}
@@ -1270,6 +1474,21 @@ def _mock_remote_function_catalog():
"version": "fv_exact", "version": "fv_exact",
} }
response = {"dropped": True} response = {"dropped": True}
elif self.path in ("/v1/secrets/create", "/v1/secrets/alter"):
assert set(body) == {"name", "value"}
response = {}
elif self.path == "/v1/secrets/list":
if "page_token" not in body:
response = {
"secrets": [{"name": "openai-prod"}],
"page_token": "next",
}
else:
assert body["page_token"] == "next"
response = {"secrets": [{"name": "hf-prod"}]}
elif self.path == "/v1/secrets/drop":
assert body == {"name": "openai-prod"}
response = {}
else: else:
status = 404 status = 404
response = {"error": "not found"} response = {"error": "not found"}
@@ -1312,6 +1531,75 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip():
) )
def test_remote_registration_sends_bindings_and_never_a_credential():
with _mock_remote_function_catalog() as (host, state):
db = lancedb.connect(
"db://dev",
api_key="fake",
host_override=host,
client_config={"retry_config": {"retries": 0}},
)
created = db.create_function(
analyze_caption,
secrets=[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")],
)
assert list(created.secret_bindings) == [
SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod")
]
path, create_request = state["requests"][0]
assert path == "/v1/functions/create"
assert create_request["secret_bindings"] == [
{"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"}
]
# The request names a Secret and carries nothing that could be one.
assert create_request == json.loads(
analyze_caption.bind_secrets(
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
).to_canonical_json()
)
def test_remote_secret_verbs_round_trip():
with _mock_remote_function_catalog() as (host, state):
db = lancedb.connect(
"db://dev",
api_key="fake",
host_override=host,
client_config={"retry_config": {"retries": 0}},
)
assert db.create_secret("openai-prod", "sk-live-0001") is None
assert db.alter_secret("openai-prod", "sk-live-0002") is None
assert db.list_secrets() == ["openai-prod", "hf-prod"]
assert db.drop_secret("openai-prod") is None
routes = [path for path, _ in state["requests"]]
assert routes == [
"/v1/secrets/create",
"/v1/secrets/alter",
"/v1/secrets/list",
"/v1/secrets/list",
"/v1/secrets/drop",
]
assert state["requests"][0][1] == {"name": "openai-prod", "value": "sk-live-0001"}
# The listing returns names, and the client has no way to ask for more.
assert state["requests"][2][1] == {}
def test_building_a_binding_contacts_no_server():
"""A binding is a local value: it says nothing about whether the Secret exists.
Existence is the server's answer at registration, where a mistyped name is a
clear error rather than a client-side check that was already stale.
"""
with _mock_remote_function_catalog() as (_host, state):
binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
assert binding.secret == "openai-prod"
assert binding.env_variable == "OPENAI_API_KEY"
assert state["requests"] == []
def test_blocking_remote_registration_returns_function_version(): def test_blocking_remote_registration_returns_function_version():
with _mock_remote_function_catalog() as (host, state): with _mock_remote_function_catalog() as (host, state):
db = lancedb.connect( db = lancedb.connect(
+4 -14
View File
@@ -1011,13 +1011,8 @@ def test_fts_ngram(mem_db: DBConnection):
assert set(r["text"] for r in results) == {"lance database", "lance is cool"} assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = ( results = (
table.search( table.search("nce", query_type="fts").limit(10).to_list()
"nce", # spellchecker:disable-line ) # spellchecker:disable-line
query_type="fts",
)
.limit(10)
.to_list()
)
assert len(results) == 2 assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"} assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
@@ -1039,13 +1034,8 @@ def test_fts_ngram(mem_db: DBConnection):
assert set(r["text"] for r in results) == {"lance database", "lance is cool"} assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = ( results = (
table.search( table.search("nce", query_type="fts").limit(10).to_list()
"nce", # spellchecker:disable-line ) # spellchecker:disable-line
query_type="fts",
)
.limit(10)
.to_list()
)
assert len(results) == 0 assert len(results) == 0
results = table.search("la", query_type="fts").limit(10).to_list() results = table.search("la", query_type="fts").limit(10).to_list()
-87
View File
@@ -203,93 +203,6 @@ async def test_async_hybrid_query_default_limit(table: AsyncTable):
assert texts.count("a") == 1 assert texts.count("a") == 1
@pytest.mark.asyncio
async def test_async_hybrid_query_offset(table: AsyncTable):
# The offset window of a hybrid query must be a suffix of the same query
# run without an offset. Skipping the first rows of each sub-query instead
# of the first rows of the fused result silently changes which rows land in
# the window.
full = await (
table.query()
.nearest_to([0.0, 0.4])
.nearest_to_text("dog")
.limit(4)
.with_row_id()
.to_arrow()
)
assert len(full) == 4
second_page = await (
table.query()
.nearest_to([0.0, 0.4])
.nearest_to_text("dog")
.offset(2)
.limit(2)
.with_row_id()
.to_arrow()
)
assert second_page["_rowid"].to_pylist() == full["_rowid"].to_pylist()[2:]
first_page = await (
table.query()
.nearest_to([0.0, 0.4])
.nearest_to_text("dog")
.limit(2)
.with_row_id()
.to_arrow()
)
# Paging through the result must visit every row exactly once: no row
# repeated from the previous page and none dropped between the two.
paged = first_page["_rowid"].to_pylist() + second_page["_rowid"].to_pylist()
assert sorted(paged) == sorted(full["_rowid"].to_pylist())
@pytest.mark.asyncio
async def test_async_hybrid_query_fts_first_default_limit(table: AsyncTable):
# nearest_to() and nearest_to_text() build their new sibling sub-query from
# scratch, and that is the sub-query the default limit ends up on. So the
# side that carries the limit depends on the order the hybrid query was
# built in, and looking at only one side loses the limit for half the ways
# a hybrid query can be written. Without a limit the combined results are
# not truncated at all and the whole union of both candidate lists is
# returned.
await table.add([{"text": "dog", "vector": [50.0 + i, 50.0]} for i in range(10)])
result = await (
table.query().nearest_to_text("dog").nearest_to([0.1, 0.1]).to_arrow()
)
assert len(result) == 10
offset_result = await (
table.query().nearest_to_text("dog").nearest_to([0.1, 0.1]).offset(2).to_arrow()
)
assert len(offset_result) == 10
@pytest.mark.asyncio
async def test_async_hybrid_query_explain_plan_matches_execution(table: AsyncTable):
# Paging rewrites the sub-queries: each one fetches limit + offset rows with
# no offset of its own, and the window is sliced out after fusion. The plans
# have to be built from those rewritten sub-queries, otherwise explain_plan
# and analyze_plan describe a query that is never run.
query = (
table.query().nearest_to([0.0, 0.4]).nearest_to_text("dog").offset(2).limit(2)
)
await query.to_arrow()
plan = await query.explain_plan()
assert [
line.strip() for line in plan.splitlines() if "GlobalLimitExec" in line
] == [
"GlobalLimitExec: skip=0, fetch=4",
"GlobalLimitExec: skip=0, fetch=4",
]
analyzed = await query.analyze_plan()
assert analyzed.count("skip=0, fetch=4") == 2
assert "skip=2" not in analyzed
def test_hybrid_query_offset(sync_table: Table): def test_hybrid_query_offset(sync_table: Table):
# The offset window of a hybrid query must be a suffix of the same query # The offset window of a hybrid query must be a suffix of the same query
# run without an offset -- it must not be silently ignored. # run without an offset -- it must not be silently ignored.
+1 -7
View File
@@ -193,13 +193,7 @@ class TestNamespaceConnection:
), ),
) )
# Legacy v1 blob columns are only writable at file version <= 2.1. table = db.create_table("blob_table", data, namespace_path=["test_ns"])
table = db.create_table(
"blob_table",
data,
namespace_path=["test_ns"],
storage_options={"new_table_data_storage_version": "2.1"},
)
df = table.to_pandas(blob_mode="lazy").sort_values("id") df = table.to_pandas(blob_mode="lazy").sort_values("id")
blob = df["blob"].iloc[0] blob = df["blob"].iloc[0]
+10 -38
View File
@@ -40,10 +40,6 @@ from utils import exception_output
from importlib.util import find_spec from importlib.util import find_spec
# Legacy v1 blob columns are only writable at file version <= 2.1.
LEGACY_BLOB_STORAGE_OPTIONS = {"new_table_data_storage_version": "2.1"}
def _blob_query_data(): def _blob_query_data():
return pa.table( return pa.table(
{ {
@@ -123,17 +119,13 @@ def _assert_blob_bytes_projection(df):
def _blob_query_table(db, name, blob_schema): def _blob_query_table(db, name, blob_schema):
if blob_schema == "v1": if blob_schema == "v1":
return db.create_table( return db.create_table(name, _blob_query_data())
name, _blob_query_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS
)
return _create_blob_v2_query_table(db, name) return _create_blob_v2_query_table(db, name)
async def _blob_query_table_async(db, name, blob_schema): async def _blob_query_table_async(db, name, blob_schema):
if blob_schema == "v1": if blob_schema == "v1":
return await db.create_table( return await db.create_table(name, _blob_query_data())
name, _blob_query_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS
)
return await _create_blob_v2_query_table_async(db, name) return await _create_blob_v2_query_table_async(db, name)
@@ -283,9 +275,7 @@ async def test_query_to_pandas_kwargs(table, table_async):
def test_plain_scan_query_to_pandas_blob_modes(tmp_db, blob_mode): def test_plain_scan_query_to_pandas_blob_modes(tmp_db, blob_mode):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table(
f"test_query_to_pandas_blob_{blob_mode}", f"test_query_to_pandas_blob_{blob_mode}", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
df = ( df = (
@@ -332,9 +322,7 @@ def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow(
): ):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table(
"test_query_to_pandas_blob_no_arrow_collect", "test_query_to_pandas_blob_no_arrow_collect", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
query = table.search().where("id = 1").select(["id", "blob"]) query = table.search().where("id = 1").select(["id", "blob"])
@@ -359,9 +347,7 @@ def test_plain_scan_query_to_pandas_blob_descriptions_flatten_uses_scanner(
): ):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table(
"test_query_to_pandas_blob_desc_flatten", "test_query_to_pandas_blob_desc_flatten", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
query = table.search().where("id = 1").select(["id", "blob"]) query = table.search().where("id = 1").select(["id", "blob"])
@@ -379,11 +365,7 @@ def test_plain_scan_query_to_pandas_blob_descriptions_flatten_uses_scanner(
def test_plain_scan_query_to_pandas_scanner_state(tmp_db): def test_plain_scan_query_to_pandas_scanner_state(tmp_db):
pytest.importorskip("lance") pytest.importorskip("lance")
data = _blob_query_data() data = _blob_query_data()
table = tmp_db.create_table( table = tmp_db.create_table("test_query_to_pandas_scanner_state", data.slice(0, 2))
"test_query_to_pandas_scanner_state",
data.slice(0, 2),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
)
table.add(data.slice(2, 2)) table.add(data.slice(2, 2))
fragments = table.to_lance().get_fragments() fragments = table.to_lance().get_fragments()
@@ -418,9 +400,7 @@ def test_plain_scan_query_to_pandas_scanner_state(tmp_db):
async def test_async_plain_scan_query_to_pandas_blob_projection(tmp_db_async): async def test_async_plain_scan_query_to_pandas_blob_projection(tmp_db_async):
pytest.importorskip("lance") pytest.importorskip("lance")
table = await tmp_db_async.create_table( table = await tmp_db_async.create_table(
"test_async_query_to_pandas_blob_projection", "test_async_query_to_pandas_blob_projection", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
lazy_df = await ( lazy_df = await (
@@ -472,9 +452,7 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow
): ):
pytest.importorskip("lance") pytest.importorskip("lance")
table = await tmp_db_async.create_table( table = await tmp_db_async.create_table(
"test_async_query_to_pandas_blob_no_arrow_collect", "test_async_query_to_pandas_blob_no_arrow_collect", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
query = table.query().where("id = 1").select(["id", "blob"]) query = table.query().where("id = 1").select(["id", "blob"])
@@ -496,11 +474,7 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow
def test_vector_query_to_pandas_blob_mode_requires_native_path(tmp_db): def test_vector_query_to_pandas_blob_mode_requires_native_path(tmp_db):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table("test_vector_query_blob_mode", _blob_query_data())
"test_vector_query_blob_mode",
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
)
with pytest.raises(RuntimeError, match="Lance native pandas conversion"): with pytest.raises(RuntimeError, match="Lance native pandas conversion"):
table.search([1.0, 0.0]).select(["blob", "vector"]).limit(1).to_pandas( table.search([1.0, 0.0]).select(["blob", "vector"]).limit(1).to_pandas(
@@ -511,9 +485,7 @@ def test_vector_query_to_pandas_blob_mode_requires_native_path(tmp_db):
def test_vector_query_to_pandas_blob_descriptions_requires_plain_scan(tmp_db): def test_vector_query_to_pandas_blob_descriptions_requires_plain_scan(tmp_db):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table(
"test_vector_query_blob_descriptions", "test_vector_query_blob_descriptions", _blob_query_data()
_blob_query_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
) )
with pytest.raises(RuntimeError, match="plain scan query"): with pytest.raises(RuntimeError, match="plain scan query"):
+1 -1
View File
@@ -81,7 +81,7 @@ def get_test_table(tmp_path):
"but his son was mortal", "but his son was mortal",
"there hasn't been a good battlefield game since 2142", "there hasn't been a good battlefield game since 2142",
"I wish they would make another one", "I wish they would make another one",
"campaigns are not as good as they used to be", "campains are not as good as they used to be",
"Multiplayer and open world games have destroyed the single player experience", "Multiplayer and open world games have destroyed the single player experience",
"Maybe the future is console games", "Maybe the future is console games",
"I don't know", "I don't know",
+5 -17
View File
@@ -64,23 +64,15 @@ async def _blob_v2_table_async(db: AsyncConnection, name: str):
return table return table
# Legacy v1 blob columns are only writable at file version <= 2.1.
LEGACY_BLOB_STORAGE_OPTIONS = {"new_table_data_storage_version": "2.1"}
def _blob_table(db: DBConnection, name: str, blob_schema: str): def _blob_table(db: DBConnection, name: str, blob_schema: str):
if blob_schema == "v1": if blob_schema == "v1":
return db.create_table( return db.create_table(name, data=_blob_test_data())
name, data=_blob_test_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS
)
return _blob_v2_table(db, name) return _blob_v2_table(db, name)
async def _blob_table_async(db: AsyncConnection, name: str, blob_schema: str): async def _blob_table_async(db: AsyncConnection, name: str, blob_schema: str):
if blob_schema == "v1": if blob_schema == "v1":
return await db.create_table( return await db.create_table(name, data=_blob_test_data())
name, data=_blob_test_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS
)
return await _blob_v2_table_async(db, name) return await _blob_v2_table_async(db, name)
@@ -155,11 +147,7 @@ def test_table_to_pandas_invalid_blob_mode_non_blob_table(tmp_db: DBConnection):
@pytest.mark.parametrize("blob_mode", ["lazy", "bytes", "descriptions"]) @pytest.mark.parametrize("blob_mode", ["lazy", "bytes", "descriptions"])
def test_table_to_pandas_blob_modes(tmp_db: DBConnection, blob_mode): def test_table_to_pandas_blob_modes(tmp_db: DBConnection, blob_mode):
pytest.importorskip("lance") pytest.importorskip("lance")
table = tmp_db.create_table( table = tmp_db.create_table(f"test_to_pandas_blob_{blob_mode}", _blob_test_data())
f"test_to_pandas_blob_{blob_mode}",
_blob_test_data(),
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
)
df = table.to_pandas(blob_mode=blob_mode) df = table.to_pandas(blob_mode=blob_mode)
@@ -3354,7 +3342,7 @@ def test_empty_query(mem_db: DBConnection):
# None is the same as default # None is the same as default
df = table.search().select(["id"]).limit(None).to_arrow() df = table.search().select(["id"]).limit(None).to_arrow()
assert df.num_rows == 100 assert df.num_rows == 100
# invalid limist is the same as None, which is the same as default # invalid limist is the same as None, wihch is the same as default
df = table.search().select(["id"]).limit(-1).to_arrow() df = table.search().select(["id"]).limit(-1).to_arrow()
assert df.num_rows == 100 assert df.num_rows == 100
# valid limit should work # valid limit should work
@@ -3971,7 +3959,7 @@ def test_stats(mem_db: DBConnection):
print(f"{stats=}") print(f"{stats=}")
assert stats == { assert stats == {
# Full on-disk size of the data file, footer and metadata included. # Full on-disk size of the data file, footer and metadata included.
"total_bytes": 637, "total_bytes": 633,
"num_rows": 2, "num_rows": 2,
"num_indices": 0, "num_indices": 0,
"fragment_stats": { "fragment_stats": {
+47
View File
@@ -704,6 +704,53 @@ impl Connection {
}) })
} }
pub fn create_secret(
self_: PyRef<'_, Self>,
name: String,
value: String,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
inner.create_secret(name, value).await.infer_error()
})
}
pub fn alter_secret(
self_: PyRef<'_, Self>,
name: String,
value: String,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
inner.alter_secret(name, value).await.infer_error()
})
}
pub fn list_secrets(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
inner.list_secrets().await.infer_error()
})
}
pub fn drop_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
inner.drop_secret(name).await.infer_error()
})
}
/// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so
/// there is none to filter out here. Timestamps stay integers rather than
/// going through a string, so the caller can compare two without parsing.
pub fn describe_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
let info = inner.describe_secret(name).await.infer_error()?;
Ok((info.name, info.created_at_millis, info.updated_at_millis))
})
}
pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> { pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
+1 -1
View File
@@ -334,7 +334,7 @@ pub struct PyQueryRequest {
pub column: Option<String>, pub column: Option<String>,
pub query_vector: Option<PyQueryVectors>, pub query_vector: Option<PyQueryVectors>,
pub minimum_nprobes: Option<usize>, pub minimum_nprobes: Option<usize>,
// None means user did not set it and default should be used (currently 20) // None means user did not set it and default shoud be used (currenty 20)
// Some(0) means user set it to None and there is no limit // Some(0) means user set it to None and there is no limit
pub maximum_nprobes: Option<usize>, pub maximum_nprobes: Option<usize>,
pub lower_bound: Option<f32>, pub lower_bound: Option<f32>,
+98 -95
View File
@@ -10,6 +10,9 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
[options]
prerelease-mode = "allow"
[[package]] [[package]]
name = "accelerate" name = "accelerate"
version = "1.14.0" version = "1.14.0"
@@ -799,7 +802,7 @@ name = "cuda-bindings"
version = "13.3.1" version = "13.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cuda-pathfinder", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "cuda-pathfinder" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" },
@@ -834,37 +837,37 @@ wheels = [
[package.optional-dependencies] [package.optional-dependencies]
cublas = [ cublas = [
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cublas" },
] ]
cudart = [ cudart = [
{ name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cuda-runtime" },
] ]
cufft = [ cufft = [
{ name = "nvidia-cufft", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cufft" },
] ]
cufile = [ cufile = [
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, { name = "nvidia-cufile" },
] ]
cupti = [ cupti = [
{ name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cuda-cupti" },
] ]
curand = [ curand = [
{ name = "nvidia-curand", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-curand" },
] ]
cusolver = [ cusolver = [
{ name = "nvidia-cusolver", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cusolver" },
] ]
cusparse = [ cusparse = [
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cusparse" },
] ]
nvjitlink = [ nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-nvjitlink" },
] ]
nvrtc = [ nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-cuda-nvrtc" },
] ]
nvtx = [ nvtx = [
{ name = "nvidia-nvtx", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, { name = "nvidia-nvtx" },
] ]
[[package]] [[package]]
@@ -1023,7 +1026,7 @@ name = "exceptiongroup"
version = "1.3.1" version = "1.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [ wheels = [
@@ -1440,16 +1443,16 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
dependencies = [ dependencies = [
{ name = "cachetools", marker = "python_full_version < '3.11'" }, { name = "cachetools" },
{ name = "certifi", marker = "python_full_version < '3.11'" }, { name = "certifi" },
{ name = "httpx", marker = "python_full_version < '3.11'" }, { name = "httpx" },
{ name = "ibm-cos-sdk", marker = "python_full_version < '3.11'" }, { name = "ibm-cos-sdk" },
{ name = "lomond", marker = "python_full_version < '3.11'" }, { name = "lomond" },
{ name = "packaging", marker = "python_full_version < '3.11'" }, { name = "packaging" },
{ name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" } },
{ name = "requests", marker = "python_full_version < '3.11'" }, { name = "requests" },
{ name = "tabulate", marker = "python_full_version < '3.11'" }, { name = "tabulate" },
{ name = "urllib3", marker = "python_full_version < '3.11'" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c7/56/2e3df38a1f13062095d7bde23c87a92f3898982993a15186b1bfecbd206f/ibm_watsonx_ai-1.3.42.tar.gz", hash = "sha256:ee5be59009004245d957ce97d1227355516df95a2640189749487614fef674ff", size = 688651, upload-time = "2025-10-01T13:35:41.527Z" } sdist = { url = "https://files.pythonhosted.org/packages/c7/56/2e3df38a1f13062095d7bde23c87a92f3898982993a15186b1bfecbd206f/ibm_watsonx_ai-1.3.42.tar.gz", hash = "sha256:ee5be59009004245d957ce97d1227355516df95a2640189749487614fef674ff", size = 688651, upload-time = "2025-10-01T13:35:41.527Z" }
wheels = [ wheels = [
@@ -1468,17 +1471,17 @@ resolution-markers = [
"python_full_version == '3.11.*'", "python_full_version == '3.11.*'",
] ]
dependencies = [ dependencies = [
{ name = "cachetools", marker = "python_full_version >= '3.11'" }, { name = "cachetools" },
{ name = "certifi", marker = "python_full_version >= '3.11'" }, { name = "certifi" },
{ name = "httpx", marker = "python_full_version >= '3.11'" }, { name = "httpx" },
{ name = "ibm-cos-sdk", marker = "python_full_version >= '3.11'" }, { name = "ibm-cos-sdk" },
{ name = "lomond", marker = "python_full_version >= '3.11'" }, { name = "lomond" },
{ name = "packaging", marker = "python_full_version >= '3.11'" }, { name = "packaging" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "requests", marker = "python_full_version >= '3.11'" }, { name = "requests" },
{ name = "tabulate", marker = "python_full_version >= '3.11'" }, { name = "tabulate" },
{ name = "urllib3", marker = "python_full_version >= '3.11'" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/29/a3/c756b534696ab2f3f29882fdb7ca7198b7a5c94e10c0a3a327853d6d6b79/ibm_watsonx_ai-1.5.14.tar.gz", hash = "sha256:a756488bd57e87c0fc51be42dcba871143cfe0ac1e805c497c5047e1e4f13e9d", size = 735804, upload-time = "2026-06-22T12:32:43.85Z" } sdist = { url = "https://files.pythonhosted.org/packages/29/a3/c756b534696ab2f3f29882fdb7ca7198b7a5c94e10c0a3a327853d6d6b79/ibm_watsonx_ai-1.5.14.tar.gz", hash = "sha256:a756488bd57e87c0fc51be42dcba871143cfe0ac1e805c497c5047e1e4f13e9d", size = 735804, upload-time = "2026-06-22T12:32:43.85Z" }
wheels = [ wheels = [
@@ -1554,17 +1557,17 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
dependencies = [ dependencies = [
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "decorator", marker = "python_full_version < '3.11'" }, { name = "decorator" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "exceptiongroup" },
{ name = "jedi", marker = "python_full_version < '3.11'" }, { name = "jedi" },
{ name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, { name = "matplotlib-inline" },
{ name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, { name = "prompt-toolkit" },
{ name = "pygments", marker = "python_full_version < '3.11'" }, { name = "pygments" },
{ name = "stack-data", marker = "python_full_version < '3.11'" }, { name = "stack-data" },
{ name = "traitlets", marker = "python_full_version < '3.11'" }, { name = "traitlets" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
wheels = [ wheels = [
@@ -1583,18 +1586,18 @@ resolution-markers = [
"python_full_version == '3.11.*'", "python_full_version == '3.11.*'",
] ]
dependencies = [ dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "decorator", marker = "python_full_version >= '3.11'" }, { name = "decorator" },
{ name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, { name = "ipython-pygments-lexers" },
{ name = "jedi", marker = "python_full_version >= '3.11'" }, { name = "jedi" },
{ name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, { name = "matplotlib-inline" },
{ name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, { name = "prompt-toolkit" },
{ name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
{ name = "pygments", marker = "python_full_version >= '3.11'" }, { name = "pygments" },
{ name = "stack-data", marker = "python_full_version >= '3.11'" }, { name = "stack-data" },
{ name = "traitlets", marker = "python_full_version >= '3.11'" }, { name = "traitlets" },
{ name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
wheels = [ wheels = [
@@ -1606,7 +1609,7 @@ name = "ipython-pygments-lexers"
version = "1.1.1" version = "1.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pygments", marker = "python_full_version >= '3.11'" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [ wheels = [
@@ -2858,7 +2861,7 @@ name = "nvidia-cudnn-cu13"
version = "9.19.0.56" version = "9.19.0.56"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-cublas" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
@@ -2870,7 +2873,7 @@ name = "nvidia-cufft"
version = "12.0.0.61" version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -2900,9 +2903,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66" version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-cublas" },
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -2914,7 +2917,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3" version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -3091,10 +3094,10 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
{ name = "python-dateutil", marker = "python_full_version < '3.11'" }, { name = "python-dateutil" },
{ name = "pytz", marker = "python_full_version < '3.11'" }, { name = "pytz" },
{ name = "tzdata", marker = "python_full_version < '3.11'" }, { name = "tzdata" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" } sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" }
wheels = [ wheels = [
@@ -3143,11 +3146,11 @@ resolution-markers = [
"python_full_version == '3.11.*'", "python_full_version == '3.11.*'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
{ name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "python-dateutil" },
{ name = "pytz", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "pytz" },
{ name = "tzdata", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "tzdata" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
wheels = [ wheels = [
@@ -3210,9 +3213,9 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } },
{ name = "python-dateutil", marker = "python_full_version >= '3.14'" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "(python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'win32')" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
wheels = [ wheels = [
@@ -3320,7 +3323,7 @@ name = "pexpect"
version = "4.9.0" version = "4.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "ptyprocess", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, { name = "ptyprocess" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [ wheels = [
@@ -3912,8 +3915,8 @@ crypto = [
[[package]] [[package]]
name = "pylance" name = "pylance"
version = "7.0.0" version = "9.0.0rc1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.fury.io/lance-format/" }
dependencies = [ dependencies = [
{ name = "lance-namespace" }, { name = "lance-namespace" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -3922,12 +3925,12 @@ dependencies = [
{ name = "pyarrow" }, { name = "pyarrow" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/ad/2f64921bf346e7075aef24a72595db44821724a3d89a9a92dd24e79632aa/pylance-7.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:98422021975be76e72b1572f41b8c9abb3bee5bdc9bfa5e9ce731110a65ed4d1", size = 62134146, upload-time = "2026-05-27T21:59:37.459Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_vEHBE/pylance-9.0.0rc1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0b6b02a1808bb3072ee7fe4e36614cae6f86302513e73ec7f55b2234a963b24" },
{ url = "https://files.pythonhosted.org/packages/73/1c/c5a01bee0160b55d9a98895cbd33091d038f0a0995b121ab72e629008d02/pylance-7.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bec86ee5b6fbd8bfc493e653f0a1fba0303cfe5492b9b46fc25ab908edc7183", size = 65373684, upload-time = "2026-05-27T22:04:01.584Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_1Jipm4/pylance-9.0.0rc1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f0ebf0d88034301819eb964f9236ce555aaa58e7ab89c5975a3e2250bbb405" },
{ url = "https://files.pythonhosted.org/packages/eb/da/1fe8b8f7dbfe734d76af76acc994fc360a0d0c79a4874ef69f5a72a58fe3/pylance-7.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881491432c53184e52f8d1db8d5f872f39a03f36fb104bec77b33d379519d8b5", size = 69458555, upload-time = "2026-05-27T22:16:50.567Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_IvKxo/pylance-9.0.0rc1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44609ea2615ea6e684b85478d1694af2026458f61cf7895ecc75e238bfd17aa8" },
{ url = "https://files.pythonhosted.org/packages/76/f0/dd505cf3fd0226ab9d94759acd713125af1d3bfacfd80bbd52e3b9f89509/pylance-7.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:18453999e7fff4f76b16d6b7882c9df0628bd142ff95e2461bd7dd5ee3fe0af3", size = 65394430, upload-time = "2026-05-27T22:05:30.923Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_2hidj1/pylance-9.0.0rc1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:182167a8dba9eeabffbffd53bd5b8548613d4d459b7cd7b34a840dd00cbb806f" },
{ url = "https://files.pythonhosted.org/packages/17/ba/2357b81034f28eb00790e258ed140289a6a887a7468ca9df6349fd186b27/pylance-7.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:04a58051d408c60fe76d41a220dcaf8fea8fb6d1aa0ca78a709b60bc3cc8d19a", size = 69473470, upload-time = "2026-05-27T22:17:18.935Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_1dFx3r/pylance-9.0.0rc1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a63b11e814b7eab758bcaf0d6f97eb05ea86203d9fb0af718c462c24c7d6c9c" },
{ url = "https://files.pythonhosted.org/packages/1f/ec/5c00b6303a67d787f9475141832cbdc513d674ac3dcaeef8a7b169905e65/pylance-7.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:467d4864af047eaab4e1370e2f1e88e2c6f507c079874421116cb41d78bc3629", size = 74792863, upload-time = "2026-05-27T22:19:23.875Z" }, { url = "https://pypi.fury.io/lance-format/-/ver_2a8dSh/pylance-9.0.0rc1-cp310-abi3-win_amd64.whl", hash = "sha256:2ff8b953ae2b0550490c1a7efd210aa91bc223d200ffac28849056cfd7436d97" },
] ]
[[package]] [[package]]
@@ -4683,10 +4686,10 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
dependencies = [ dependencies = [
{ name = "joblib", marker = "python_full_version < '3.11'" }, { name = "joblib" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" }, { name = "threadpoolctl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
wheels = [ wheels = [
@@ -4734,13 +4737,13 @@ resolution-markers = [
"python_full_version == '3.11.*'", "python_full_version == '3.11.*'",
] ]
dependencies = [ dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.11'" }, { name = "joblib" },
{ name = "narwhals", marker = "python_full_version >= '3.11'" }, { name = "narwhals" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, { name = "threadpoolctl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
wheels = [ wheels = [
@@ -4784,7 +4787,7 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [ wheels = [
@@ -4843,7 +4846,7 @@ resolution-markers = [
"python_full_version == '3.11.*'", "python_full_version == '3.11.*'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [ wheels = [
@@ -4920,7 +4923,7 @@ resolution-markers = [
"python_full_version >= '3.12' and python_full_version < '3.14'", "python_full_version >= '3.12' and python_full_version < '3.14'",
] ]
dependencies = [ dependencies = [
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [ wheels = [
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lancedb" name = "lancedb"
version = "0.39.0-beta.6" version = "0.39.0-beta.4"
edition.workspace = true edition.workspace = true
description = "LanceDB: A serverless, low-latency vector database for AI applications" description = "LanceDB: A serverless, low-latency vector database for AI applications"
license.workspace = true license.workspace = true
+1 -1
View File
@@ -163,7 +163,7 @@ pub struct PolarsDataFrameRecordBatchReader {
impl PolarsDataFrameRecordBatchReader { impl PolarsDataFrameRecordBatchReader {
/// Creates a new `PolarsDataFrameRecordBatchReader` from a given Polars DataFrame. /// Creates a new `PolarsDataFrameRecordBatchReader` from a given Polars DataFrame.
/// If the input dataframe does not have aligned chunks, this function undergoes /// If the input dataframe does not have aligned chunks, this function undergoes
/// the costly operation of reallocating each series as a single contiguous chunk. /// the costly operation of reallocating each series as a single contigous chunk.
pub fn new(mut df: DataFrame) -> Result<Self> { pub fn new(mut df: DataFrame) -> Result<Self> {
df.align_chunks(); df.align_chunks();
let arrow_schema = let arrow_schema =
+8 -10
View File
@@ -532,11 +532,10 @@ mod tests {
fn storage_version_bumps_to_v2_2() { fn storage_version_bumps_to_v2_2() {
let mut params = WriteParams::default(); let mut params = WriteParams::default();
ensure_blob_storage_version(&blob_schema(), &mut params); ensure_blob_storage_version(&blob_schema(), &mut params);
let resolved = params assert_eq!(
.data_storage_version params.data_storage_version.unwrap().resolve(),
.unwrap_or(LanceFileVersion::Stable) ConcreteFileVersion::V2_2
.resolve(); );
assert_eq!(resolved, ConcreteFileVersion::V2_2);
assert!(!params.enable_stable_row_ids); assert!(!params.enable_stable_row_ids);
} }
@@ -548,11 +547,10 @@ mod tests {
}; };
ensure_blob_storage_version(&blob_schema(), &mut params); ensure_blob_storage_version(&blob_schema(), &mut params);
assert!(params.enable_stable_row_ids); assert!(params.enable_stable_row_ids);
let resolved = params assert_eq!(
.data_storage_version params.data_storage_version.unwrap().resolve(),
.unwrap_or(LanceFileVersion::Stable) ConcreteFileVersion::V2_2
.resolve(); );
assert_eq!(resolved, ConcreteFileVersion::V2_2);
} }
#[test] #[test]
+56 -1
View File
@@ -36,6 +36,7 @@ use crate::remote::{
OPT_REMOTE_SQL_HOST_OVERRIDE, OPT_REMOTE_SQL_HOST_OVERRIDE,
}, },
}; };
use crate::secrets::SecretInfo;
use lance::io::ObjectStoreParams; use lance::io::ObjectStoreParams;
pub use lance_file::version::LanceFileVersion; pub use lance_file::version::LanceFileVersion;
#[cfg(feature = "remote")] #[cfg(feature = "remote")]
@@ -586,6 +587,7 @@ impl Connection {
/// Registration is remote-only and always asynchronous. Waiting on the /// Registration is remote-only and always asynchronous. Waiting on the
/// returned typed job yields the durable [`crate::function::FunctionVersion`]. /// returned typed job yields the durable [`crate::function::FunctionVersion`].
/// Local databases return [`Error::NotSupported`]. /// Local databases return [`Error::NotSupported`].
///
pub async fn create_function_async( pub async fn create_function_async(
&self, &self,
request: crate::function::FunctionRegistrationRequest, request: crate::function::FunctionRegistrationRequest,
@@ -645,6 +647,59 @@ impl Connection {
.await .await
} }
/// Create a named Secret in this database.
///
/// Fails if the name is taken, so a create can never silently become a
/// rotation. There is no API that reads a stored credential back; the only
/// consumer is a Function that binds the Secret by name. Local databases
/// return [`Error::NotSupported`].
pub async fn create_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> {
self.internal
.create_secret(name.as_ref(), value.as_ref())
.await
}
/// Replace the credential behind an existing Secret.
///
/// Fails if it does not exist. Every Function bound to the Secret resolves
/// the new value from its next execution, and no new Function version is
/// minted -- which is what lets a rotation reach columns pinned to a
/// version registered before it. Local databases return
/// [`Error::NotSupported`].
pub async fn alter_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> {
self.internal
.alter_secret(name.as_ref(), value.as_ref())
.await
}
/// The names of every Secret in this database.
///
/// Names only. No path in this API returns a stored credential, by
/// construction rather than by policy. Local databases return
/// [`Error::NotSupported`].
pub async fn list_secrets(&self) -> Result<Vec<String>> {
self.internal.list_secrets().await
}
/// Drop a Secret.
///
/// Functions bound to it fail at their next job, naming the Secret; that
/// is the revocation path. The name becomes free to reuse, and a new
/// Secret under it is picked up by everything still bound to that name.
/// Local databases return [`Error::NotSupported`].
pub async fn drop_secret(&self, name: impl AsRef<str>) -> Result<()> {
self.internal.drop_secret(name.as_ref()).await
}
/// What this database records about one Secret: its name and timestamps.
///
/// Never the value. The type it returns has no field for one, so this is a
/// property of the API rather than of what the caller chooses to read.
/// Local databases return [`Error::NotSupported`].
pub async fn describe_secret(&self, name: impl AsRef<str>) -> Result<SecretInfo> {
self.internal.describe_secret(name.as_ref()).await
}
/// Rename a table in the database. /// Rename a table in the database.
/// ///
/// This is only supported in LanceDB Cloud. /// This is only supported in LanceDB Cloud.
@@ -827,7 +882,7 @@ impl Connection {
pub struct ConnectRequest { pub struct ConnectRequest {
/// Database URI /// Database URI
/// ///
/// ### Accepted URI formats /// ### Accpeted URI formats
/// ///
/// - `/path/to/database` - local database on file system. /// - `/path/to/database` - local database on file system.
/// - `s3://bucket/path/to/database` or `gs://bucket/path/to/database` - database on cloud object store /// - `s3://bucket/path/to/database` or `gs://bucket/path/to/database` - database on cloud object store
+35
View File
@@ -28,6 +28,7 @@ use lance_namespace::models::{
use crate::data::scannable::Scannable; use crate::data::scannable::Scannable;
use crate::error::Result; use crate::error::Result;
use crate::secrets::SecretInfo;
use crate::table::{BaseTable, WriteOptions}; use crate::table::{BaseTable, WriteOptions};
pub mod listing; pub mod listing;
@@ -249,6 +250,12 @@ fn function_catalog_not_supported<T>() -> Result<T> {
}) })
} }
fn secret_catalog_not_supported<T>() -> Result<T> {
Err(crate::error::Error::NotSupported {
message: "Secret operations are not supported by this database".to_string(),
})
}
/// The `Database` trait defines the interface for database implementations. /// The `Database` trait defines the interface for database implementations.
/// ///
/// A database is responsible for managing tables and their metadata. /// A database is responsible for managing tables and their metadata.
@@ -317,6 +324,34 @@ pub trait Database:
async fn drop_function(&self, _name: &str, _version: &str) -> Result<bool> { async fn drop_function(&self, _name: &str, _version: &str) -> Result<bool> {
function_catalog_not_supported() function_catalog_not_supported()
} }
/// Create a named Secret in this database. Fails if the name is taken, so
/// a create can never silently become a rotation.
async fn create_secret(&self, _name: &str, _value: &str) -> Result<()> {
secret_catalog_not_supported()
}
/// Replace the credential behind an existing Secret. Fails if it does not
/// exist. Every Function bound to it resolves the new value from its next
/// execution, with no new Function version.
async fn alter_secret(&self, _name: &str, _value: &str) -> Result<()> {
secret_catalog_not_supported()
}
/// The names of every Secret in this database.
///
/// Names only. No API path returns a stored credential, by construction
/// rather than by policy.
async fn list_secrets(&self) -> Result<Vec<String>> {
secret_catalog_not_supported()
}
/// Drop a Secret. Functions bound to it fail at their next job, which is
/// the revocation path.
async fn drop_secret(&self, _name: &str) -> Result<()> {
secret_catalog_not_supported()
}
/// What the database records about one Secret: its name and timestamps,
/// never its value.
async fn describe_secret(&self, _name: &str) -> Result<SecretInfo> {
secret_catalog_not_supported()
}
/// Open a job by id, returning a handle with its record already /// Open a job by id, returning a handle with its record already
/// populated. Fails with [`crate::Error::JobNotFound`] when the server has /// populated. Fails with [`crate::Error::JobNotFound`] when the server has
/// no such job. /// no such job.
+9 -9
View File
@@ -512,7 +512,7 @@ impl ListingDatabase {
// iter thru the query params and extract the commit store param // iter thru the query params and extract the commit store param
let mut engine = None; let mut engine = None;
let mut mirrored_store = None; let mut mirrored_store = None;
let mut filtered_queries = vec![]; let mut filtered_querys = vec![];
// WARNING: specifying engine is NOT a publicly supported feature in lancedb yet // WARNING: specifying engine is NOT a publicly supported feature in lancedb yet
// THE API WILL CHANGE // THE API WILL CHANGE
@@ -528,13 +528,13 @@ impl ListingDatabase {
mirrored_store = Some(value.to_string()); mirrored_store = Some(value.to_string());
} else { } else {
// to owned so we can modify the url // to owned so we can modify the url
filtered_queries.push((key.to_string(), value.to_string())); filtered_querys.push((key.to_string(), value.to_string()));
} }
} }
// Filter out the commit store query param -- it's a lancedb param // Filter out the commit store query param -- it's a lancedb param
url.query_pairs_mut().clear(); url.query_pairs_mut().clear();
url.query_pairs_mut().extend_pairs(filtered_queries); url.query_pairs_mut().extend_pairs(filtered_querys);
// Take a copy of the query string so we can propagate it to lance. // Take a copy of the query string so we can propagate it to lance.
// `query_pairs_mut()` leaves the URL with `Some("")` even when no // `query_pairs_mut()` leaves the URL with `Some("")` even when no
// pairs survive (or none existed in the first place), so an empty // pairs survive (or none existed in the first place), so an empty
@@ -896,11 +896,11 @@ impl Database for ListingDatabase {
} }
async fn read_consistency(&self) -> Result<ReadConsistency> { async fn read_consistency(&self) -> Result<ReadConsistency> {
if let Some(interval) = self.read_consistency_interval { if let Some(read_consistency_inverval) = self.read_consistency_interval {
if interval.is_zero() { if read_consistency_inverval.is_zero() {
Ok(ReadConsistency::Strong) Ok(ReadConsistency::Strong)
} else { } else {
Ok(ReadConsistency::Eventual(interval)) Ok(ReadConsistency::Eventual(read_consistency_inverval))
} }
} else { } else {
Ok(ReadConsistency::Manual) Ok(ReadConsistency::Manual)
@@ -3043,15 +3043,15 @@ mod tests {
/// across platforms — see the `file://` test below). /// across platforms — see the `file://` test below).
fn capture_query_like_connect(input_uri: &str) -> Option<String> { fn capture_query_like_connect(input_uri: &str) -> Option<String> {
let mut url = url::Url::parse(input_uri).unwrap(); let mut url = url::Url::parse(input_uri).unwrap();
let mut filtered_queries = Vec::new(); let mut filtered_querys = Vec::new();
for (key, value) in url.query_pairs() { for (key, value) in url.query_pairs() {
if key == ENGINE || key == MIRRORED_STORE { if key == ENGINE || key == MIRRORED_STORE {
continue; continue;
} }
filtered_queries.push((key.to_string(), value.to_string())); filtered_querys.push((key.to_string(), value.to_string()));
} }
url.query_pairs_mut().clear(); url.query_pairs_mut().clear();
url.query_pairs_mut().extend_pairs(filtered_queries); url.query_pairs_mut().extend_pairs(filtered_querys);
url.query().filter(|q| !q.is_empty()).map(|s| s.to_string()) url.query().filter(|q| !q.is_empty()).map(|s| s.to_string())
} }
+3 -8
View File
@@ -3,7 +3,6 @@
//! Namespace-based database implementation that delegates table management to lance-namespace //! Namespace-based database implementation that delegates table management to lance-namespace
use lance_datafusion::utils::StreamingWriteSource;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -251,11 +250,11 @@ impl Database for LanceNamespaceDatabase {
} }
async fn read_consistency(&self) -> Result<ReadConsistency> { async fn read_consistency(&self) -> Result<ReadConsistency> {
if let Some(interval) = self.read_consistency_interval { if let Some(read_consistency_inverval) = self.read_consistency_interval {
if interval.is_zero() { if read_consistency_inverval.is_zero() {
Ok(ReadConsistency::Strong) Ok(ReadConsistency::Strong)
} else { } else {
Ok(ReadConsistency::Eventual(interval)) Ok(ReadConsistency::Eventual(read_consistency_inverval))
} }
} else { } else {
Ok(ReadConsistency::Manual) Ok(ReadConsistency::Manual)
@@ -305,10 +304,6 @@ impl Database for LanceNamespaceDatabase {
} }
async fn create_table(&self, request: DbCreateTableRequest) -> Result<Arc<dyn BaseTable>> { async fn create_table(&self, request: DbCreateTableRequest) -> Result<Arc<dyn BaseTable>> {
// Refuse a bad declaration before the namespace records a table.
crate::table::computed_columns::ensure_declarations_are_planned(
&request.data.arrow_schema(),
)?;
let mut table_id = request.namespace_path.clone(); let mut table_id = request.namespace_path.clone();
table_id.push(request.name.clone()); table_id.push(request.name.clone());
let mut existing_table = None; let mut existing_table = None;
+210 -1
View File
@@ -5,7 +5,7 @@
//! backend-neutral terminal result of a computed-column refresh. //! backend-neutral terminal result of a computed-column refresh.
//! //!
//! This module contains client/wire values only. Catalog persistence, //! This module contains client/wire values only. Catalog persistence,
//! environment bake, and execution are owned by Sophon. //! environment bake, secret resolution, and execution are owned by Sophon.
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -409,6 +409,8 @@ pub struct FunctionVersion {
runtime: PythonRuntimeSpec, runtime: PythonRuntimeSpec,
runtime_digest: String, runtime_digest: String,
environment_digest: String, environment_digest: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
secret_bindings: Vec<SecretBinding>,
created_at: String, created_at: String,
} }
@@ -441,6 +443,16 @@ impl FunctionVersion {
&self.environment_digest &self.environment_digest
} }
/// Declared environment variable name to the Secret each one resolves.
///
/// Bindings are part of this version's identity; the credentials behind
/// them are not, and resolve at execution. Rotating a bound Secret
/// therefore changes what the same version runs with, and no value has a
/// field in this model.
pub fn secret_bindings(&self) -> &[SecretBinding] {
&self.secret_bindings
}
pub fn created_at(&self) -> &str { pub fn created_at(&self) -> &str {
&self.created_at &self.created_at
} }
@@ -481,13 +493,133 @@ pub struct FunctionArtifactRequest {
pub adapter: PythonAdapterSpec, pub adapter: PythonAdapterSpec,
} }
/// How a Secret reaches the Function that binds it.
///
/// One list rather than a field per delivery mode: a binding is the concept,
/// and how it arrives is a property of one. A mode added later is a variant
/// here, and the rules that are per-Function -- how many Secrets a Function may
/// bind, which ones it needs -- stay answerable from one place.
///
/// Unknown kinds decode rather than failing the whole FunctionVersion, as
/// [`PythonRuntimeSpec`] does for runtimes. The payload is intentionally not
/// retained: the client does not proxy catalog values.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum SecretBinding {
/// Delivered as an environment variable, which the UDF's library already
/// reads. The variable is the delivery target; the Secret is what fills it.
Env {
variable: String,
/// Named `secret_ref` rather than `secret` because a Job payload is
/// scanned server-side for credential-shaped keys, and a key called
/// `secret` trips that guard whatever it actually holds.
secret_ref: String,
},
/// A binding kind introduced by a newer server.
Unrecognized { kind: String },
}
impl SecretBinding {
/// The wire discriminator reported by Sophon.
pub fn kind(&self) -> &str {
match self {
Self::Env { .. } => "env",
Self::Unrecognized { kind } => kind,
}
}
/// The environment variable this binding fills, or `None` for a kind that
/// does not deliver through one.
pub fn variable(&self) -> Option<&str> {
match self {
Self::Env { variable, .. } => Some(variable),
Self::Unrecognized { .. } => None,
}
}
/// The Secret bound, or `None` for a kind this client cannot read.
pub fn secret(&self) -> Option<&str> {
match self {
Self::Env { secret_ref, .. } => Some(secret_ref),
Self::Unrecognized { .. } => None,
}
}
}
#[derive(Deserialize)]
struct EnvSecretBindingWire {
variable: String,
secret_ref: String,
}
impl<'de> Deserialize<'de> for SecretBinding {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
let kind = value
.get("kind")
.ok_or_else(|| de::Error::missing_field("kind"))?
.as_str()
.ok_or_else(|| de::Error::custom("secret binding kind must be a string"))?
.to_string();
match kind.as_str() {
"env" => {
let wire: EnvSecretBindingWire =
serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(Self::Env {
variable: wire.variable,
secret_ref: wire.secret_ref,
})
}
_ => Ok(Self::Unrecognized { kind }),
}
}
}
impl Serialize for SecretBinding {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct EnvBindingRef<'a> {
kind: &'static str,
variable: &'a str,
secret_ref: &'a str,
}
#[derive(Serialize)]
struct UnrecognizedBindingRef<'a> {
kind: &'a str,
}
match self {
Self::Env {
variable,
secret_ref,
} => EnvBindingRef {
kind: "env",
variable,
secret_ref,
}
.serialize(serializer),
Self::Unrecognized { kind } => UnrecognizedBindingRef { kind }.serialize(serializer),
}
}
}
/// Stable request envelope for remote immutable Function registration. /// Stable request envelope for remote immutable Function registration.
///
/// Credential values deliberately have no field here. The only secret-shaped
/// thing a client sends is `secret_bindings`: the name of a Secret the
/// database already holds, which Sophon resolves inside the remote runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FunctionRegistrationRequest { pub struct FunctionRegistrationRequest {
pub name: String, pub name: String,
pub artifact: FunctionArtifactRequest, pub artifact: FunctionArtifactRequest,
pub signature: FunctionSignature, pub signature: FunctionSignature,
pub runtime: PythonRuntimeSpec, pub runtime: PythonRuntimeSpec,
/// Declared environment variable name to the Secret it binds. A binding is
/// a reference: whether the Secret exists is answered when a column is
/// declared against this version, not here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret_bindings: Vec<SecretBinding>,
} }
impl_json!(FunctionRegistrationRequest); impl_json!(FunctionRegistrationRequest);
@@ -749,3 +881,80 @@ mod conda_environment_tests {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Canonical form is what the FunctionVersion hash is taken over, so key
/// order must come from the keys and not from however serde happened to
/// emit them. Nesting is included because the sort is recursive.
#[test]
fn canonical_json_sorts_keys_at_every_depth() {
let value = serde_json::json!({
"runtime": {"kind": "python", "env": {"B": "2", "A": "1"}},
"artifact": {"digest": "sha256:x"},
"name": "embed",
});
let mut out = String::new();
write_canonical_json(&value, &mut out).expect("canonical JSON");
assert_eq!(
out,
r#"{"artifact":{"digest":"sha256:x"},"name":"embed","runtime":{"env":{"A":"1","B":"2"},"kind":"python"}}"#
);
}
/// Arrays are ordered by the caller, so canonicalization must leave them
/// alone -- sorting them would change what a signature means.
#[test]
fn canonical_json_preserves_array_order() {
let value = serde_json::json!({"inputs": ["b", "a", "c"]});
let mut out = String::new();
write_canonical_json(&value, &mut out).expect("canonical JSON");
assert_eq!(out, r#"{"inputs":["b","a","c"]}"#);
}
/// A float has no single canonical spelling, so two clients could hash the
/// same literal differently. Rejected at any depth rather than rounded.
#[test]
fn validate_literal_rejects_floats_at_any_depth() {
for value in [
serde_json::json!(1.5),
serde_json::json!([1, [2, 3.5]]),
serde_json::json!({"a": {"b": 0.25}}),
] {
let error = validate_literal(&value).expect_err("floats are not canonical");
assert!(
error.to_string().contains("floating-point"),
"unexpected error: {error}"
);
}
for value in [
serde_json::json!(1),
serde_json::json!("1.5"),
serde_json::json!([1, {"a": true}]),
serde_json::json!(null),
] {
validate_literal(&value).expect("non-float literals are canonical");
}
}
/// Unknown keys are how a newer server's payload reaches an older client,
/// so the check has to be exact about which level it is looking at.
#[test]
fn has_unknown_keys_only_inspects_the_level_it_is_given() {
let value = serde_json::json!({"name": "embed", "version": "fv_1"});
assert!(!has_unknown_keys(&value, &["name", "version"]));
assert!(has_unknown_keys(&value, &["name"]));
// A nested unknown is not this level's business.
let nested = serde_json::json!({"name": {"unexpected": 1}});
assert!(!has_unknown_keys(&nested, &["name"]));
// A non-object has no keys to be unknown.
assert!(!has_unknown_keys(&serde_json::json!("embed"), &["name"]));
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ macro_rules! impl_pq_params_setter {
/// This value controls how much the vector is compressed during the quantization step. /// This value controls how much the vector is compressed during the quantization step.
/// The more sub vectors there are the less the vector is compressed. The default is /// The more sub vectors there are the less the vector is compressed. The default is
/// the dimension of the vector divided by 16. If the dimension is not evenly divisible /// the dimension of the vector divided by 16. If the dimension is not evenly divisible
/// by 16 we use the dimension divided by 8. /// by 16 we use the dimension divded by 8.
/// ///
/// The above two cases are highly preferred. Having 8 or 16 values per subvector allows /// The above two cases are highly preferred. Having 8 or 16 values per subvector allows
/// us to use efficient SIMD instructions. /// us to use efficient SIMD instructions.
+1
View File
@@ -195,6 +195,7 @@ pub mod query;
#[cfg(feature = "remote")] #[cfg(feature = "remote")]
pub mod remote; pub mod remote;
pub mod rerankers; pub mod rerankers;
pub mod secrets;
pub mod sql; pub mod sql;
pub mod table; pub mod table;
#[cfg(test)] #[cfg(test)]
File diff suppressed because it is too large Load Diff
+17 -536
View File
@@ -24,8 +24,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use arrow_array::cast::AsArray; use arrow_array::cast::AsArray;
use arrow_array::types::UInt64Type; use arrow_array::types::UInt64Type;
use arrow_array::{RecordBatch, UInt64Array, new_null_array}; use arrow_array::{RecordBatch, UInt64Array};
use arrow_schema::{FieldRef, Schema as ArrowSchema, SchemaRef}; use arrow_schema::{Schema as ArrowSchema, SchemaRef};
use datafusion::common::ScalarValue; use datafusion::common::ScalarValue;
use datafusion::error::DataFusionError; use datafusion::error::DataFusionError;
use datafusion::physical_plan::SendableRecordBatchStream; use datafusion::physical_plan::SendableRecordBatchStream;
@@ -34,7 +34,7 @@ use datafusion::prelude::{col, lit};
use futures::{StreamExt, TryStreamExt}; use futures::{StreamExt, TryStreamExt};
use lance::Dataset; use lance::Dataset;
use lance::dataset::mem_wal::DatasetMemWalExt; use lance::dataset::mem_wal::DatasetMemWalExt;
use lance::dataset::transaction::{Operation, Transaction, UpdateMode}; use lance::dataset::transaction::{Operation, Transaction};
use lance::dataset::write::delete::DeleteBuilder; use lance::dataset::write::delete::DeleteBuilder;
use lance::dataset::write::merge_insert::inserted_rows::{ use lance::dataset::write::merge_insert::inserted_rows::{
KeyExistenceFilter, KeyExistenceFilterBuilder, KeyValue, KeyExistenceFilter, KeyExistenceFilterBuilder, KeyValue,
@@ -51,9 +51,6 @@ use super::{
definition_to_metadata, definition_to_metadata,
}; };
use crate::database::OpenTableRequest; use crate::database::OpenTableRequest;
use crate::table::computed_columns::{
computed_column_from_field, computed_columns, ensure_declarations_are_planned,
};
use crate::table::{NativeTable, NativeTableExt, Table}; use crate::table::{NativeTable, NativeTableExt, Table};
use crate::{Error, Result}; use crate::{Error, Result};
@@ -170,52 +167,30 @@ pub(crate) async fn execute_refresh(
.map(|p| (p.output.clone(), p.expression.clone())) .map(|p| (p.output.clone(), p.expression.clone()))
.collect(); .collect();
validate_inputs(&source_ds, definition)?; validate_inputs(&source_ds, definition)?;
let (replanned, planned_fields, _renames) = super::plan( let (replanned, mut planned_fields, _renames) = super::plan(
source_schema, source_schema,
&definition.source_table, &definition.source_table,
&definition.source_namespace, &definition.source_namespace,
Some(&projections), &projections,
definition.filter.as_deref(), definition.filter.as_deref(),
definition.limit, definition.limit,
)?; )?;
let mut planned_fields = planned_fields;
planned_fields.push(arrow_schema::Field::new( planned_fields.push(arrow_schema::Field::new(
SOURCE_ROW_ID_COLUMN, SOURCE_ROW_ID_COLUMN,
arrow_schema::DataType::UInt64, arrow_schema::DataType::UInt64,
false, false,
)); ));
// A computed column is not planned from the source: refresh writes it
// NULL and its declaration's owner fills it. Its declaration must still
// be complete, and it must be able to hold NULL.
let physical = ArrowSchema::from(view_ds.schema()); let physical = ArrowSchema::from(view_ds.schema());
let mut computed = computed_columns(&physical).into_iter().map(|c| c.name); let planned_shape: Vec<_> = planned_fields
if let Some(name) = computed.by_ref().find(|name| { .iter()
physical .map(|f| (f.name().clone(), f.data_type().clone(), f.is_nullable()))
.field_with_name(name) .collect();
.is_ok_and(|f| !f.is_nullable()) let physical_shape: Vec<_> = physical
}) {
return Err(Error::Schema {
message: format!(
"computed column '{name}' of view '{}' cannot hold NULL; recreate the view",
view.name()
),
});
}
ensure_declarations_are_planned(&physical)?;
let physical_planned: Vec<&FieldRef> = physical
.fields() .fields()
.iter() .iter()
.filter(|f| computed_column_from_field(f).is_none()) .map(|f| (f.name().clone(), f.data_type().clone(), f.is_nullable()))
.collect(); .collect();
// A projected column that became nullable at the source still fits the if planned_shape != physical_shape {
// view's nullable field; the reverse would not.
let matches = planned_fields.len() == physical_planned.len()
&& planned_fields.iter().zip(&physical_planned).all(|(e, p)| {
e.name() == p.name()
&& e.data_type() == p.data_type()
&& (p.is_nullable() || !e.is_nullable())
});
if !matches {
return Err(Error::Schema { return Err(Error::Schema {
message: format!( message: format!(
"the stored definition of view '{}' does not produce this \ "the stored definition of view '{}' does not produce this \
@@ -254,18 +229,11 @@ pub(crate) async fn execute_refresh(
.get(SOURCE_VERSION_TS_META_KEY) .get(SOURCE_VERSION_TS_META_KEY)
.and_then(|raw| raw.parse().ok()); .and_then(|raw| raw.parse().ok());
// The watermark speaks only for the view state its refresh left behind; // The watermark speaks only for the view state its refresh left behind;
// any other commit on the view since then is drift, except a fill of its // any other commit on the view since then is drift.
// computed columns, which rewrites nothing refresh certifies. let view_intact = metadata
let recorded_view_version = metadata
.get(VIEW_VERSION_META_KEY) .get(VIEW_VERSION_META_KEY)
.and_then(|raw| raw.parse::<u64>().ok()); .and_then(|raw| raw.parse::<u64>().ok())
let view_intact = match recorded_view_version { == Some(view_ds.version().version);
Some(recorded) if recorded == view_ds.version().version => true,
Some(recorded) if recorded < view_ds.version().version => {
only_computed_rewrites_since(&view_ds, recorded).await?
}
_ => false,
};
if !full && watermark == Some(source_version) && view_intact && recorded_ts == Some(source_ts) { if !full && watermark == Some(source_version) && view_intact && recorded_ts == Some(source_ts) {
return Ok(RefreshMaterializedViewResult { return Ok(RefreshMaterializedViewResult {
@@ -1122,69 +1090,6 @@ struct RowScope {
limit: Option<u64>, limit: Option<u64>,
} }
/// Whether every commit on the view after `recorded` is a fill of its
/// computed columns: a column rewrite or data replacement touching only
/// those fields and neither adding nor removing rows. A version whose
/// transaction cannot be read is not proven, so it counts as drift.
async fn only_computed_rewrites_since(view_ds: &Dataset, recorded: u64) -> Result<bool> {
// A fill may write any field under a computed column, so the whole
// subtree counts, not only the root.
let physical = ArrowSchema::from(view_ds.schema());
fn subtree(field: &lance_core::datatypes::Field, ids: &mut Vec<u32>) {
ids.push(field.id as u32);
for child in &field.children {
subtree(child, ids);
}
}
let mut computed_fields = Vec::new();
for column in computed_columns(&physical) {
if let Some(field) = view_ds.schema().field(&column.name) {
subtree(field, &mut computed_fields);
}
}
if computed_fields.is_empty() {
return Ok(false);
}
for version in recorded + 1..=view_ds.version().version {
let Some(transaction) = view_ds.read_transaction_by_version(version).await? else {
return Ok(false);
};
let fill = match &transaction.operation {
Operation::Update {
removed_fragment_ids,
new_fragments,
fields_modified,
update_mode: Some(UpdateMode::RewriteColumns),
..
} => {
removed_fragment_ids.is_empty()
&& new_fragments.is_empty()
&& !fields_modified.is_empty()
&& fields_modified
.iter()
.all(|field| computed_fields.contains(field))
}
// What `refresh_column` commits for a SQL declaration.
Operation::DataReplacement { replacements } => {
!replacements.is_empty()
&& replacements.iter().all(|group| {
!group.1.fields.is_empty()
&& group
.1
.fields
.iter()
.all(|field| computed_fields.contains(&(*field as u32)))
})
}
_ => false,
};
if !fill {
return Ok(false);
}
}
Ok(true)
}
async fn compute_stream( async fn compute_stream(
source: &Dataset, source: &Dataset,
definition: &MaterializedViewDefinition, definition: &MaterializedViewDefinition,
@@ -1253,10 +1158,6 @@ async fn compute_stream(
let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?; let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut columns = Vec::with_capacity(out_schema.fields().len()); let mut columns = Vec::with_capacity(out_schema.fields().len());
for field in out_schema.fields() { for field in out_schema.fields() {
if computed_column_from_field(field).is_some() {
columns.push(new_null_array(field.data_type(), batch.num_rows()));
continue;
}
let name = if field.name() == SOURCE_ROW_ID_COLUMN { let name = if field.name() == SOURCE_ROW_ID_COLUMN {
ROW_ID ROW_ID
} else { } else {
@@ -2867,7 +2768,7 @@ mod tests {
let (conn, source) = db_with_source(vec![1]).await; let (conn, source) = db_with_source(vec![1]).await;
let prepared = crate::materialized_view::prepare_declaration( let prepared = crate::materialized_view::prepare_declaration(
&source, &source,
Some(&[("x".into(), "x".into()), ("twice".into(), "x * 2".into())]), &[("x".into(), "x".into()), ("twice".into(), "x * 2".into())],
None, None,
None, None,
) )
@@ -3231,424 +3132,4 @@ mod tests {
let err = view.refresh().execute().await.unwrap_err(); let err = view.refresh().execute().await.unwrap_err();
assert!(err.to_string().contains("source table 'src'"), "{err}"); assert!(err.to_string().contains("source table 'src'"), "{err}");
} }
/// A view with a computed column, declared over `people` and refreshed.
async fn refreshed_computed_view(conn: &Connection) -> MaterializedView {
use crate::materialized_view::tests::{computed_field, people, test_binding};
let source = people(conn).await;
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[
("id".to_string(), "id".to_string()),
("name".to_string(), "name".to_string()),
]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(
vec![(2, computed_field("emb", "fb_1", "name"))],
&[test_binding("fb_1", "name", "emb")],
)
.unwrap()
.create("v")
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
view
}
async fn unfilled(view: &MaterializedView) -> usize {
view.table()
.count_rows(Some("emb IS NULL".to_string()))
.await
.unwrap()
}
async fn append_people(conn: &Connection, ids: Vec<i32>, names: Vec<&str>) {
let batch = record_batch!(("id", Int32, ids), ("name", Utf8, names)).unwrap();
conn.open_table("people")
.execute()
.await
.unwrap()
.add(batch)
.execute()
.await
.unwrap();
}
/// Commit the fill job's shape on the view: a column rewrite of
/// `fields`, touching no rows. The data is left as it is; what matters
/// here is how the next refresh classifies the commit.
async fn commit_column_rewrite(view: &MaterializedView, fields: &[&str]) {
let native = view.table().as_native().unwrap();
native.dataset.reload().await.unwrap();
let dataset = native.dataset.get().await.unwrap().as_ref().clone();
let fields_modified = fields
.iter()
.map(|name| dataset.schema().field(name).unwrap().id as u32)
.collect();
let updated_fragments = dataset
.get_fragments()
.iter()
.map(|fragment| fragment.metadata().clone())
.collect();
let operation = Operation::Update {
removed_fragment_ids: Vec::new(),
updated_fragments,
new_fragments: Vec::new(),
fields_modified,
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: Some(UpdateMode::RewriteColumns),
inserted_rows_filter: None,
updated_fragment_offsets: None,
};
let read_version = dataset.version().version;
CommitBuilder::new(WriteDestination::Dataset(Arc::new(dataset)))
.execute(Transaction::new(read_version, operation, None))
.await
.unwrap();
}
/// Refresh never computes a computed column: every row it writes, on a
/// rebuild, an append and a rewrite, carries NULL there, and the
/// declaration survives all three.
#[tokio::test]
async fn test_computed_columns_are_written_null_and_kept() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
assert_eq!(unfilled(&view).await, 3);
append_people(&conn, vec![4, 5], vec!["d", "e"]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(unfilled(&view).await, 5);
conn.open_table("people")
.execute()
.await
.unwrap()
.update()
.column("name", "'z'")
.only_if("id = 1")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(unfilled(&view).await, 5);
assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4, 5]);
let schema = view.table().schema().await.unwrap();
assert!(
crate::table::computed_columns::function_bindings(&schema)
.unwrap()
.iter()
.any(|b| b.binding_id() == "fb_1"),
"the binding envelope was lost"
);
assert!(
computed_column_from_field(schema.field_with_name("emb").unwrap()).is_some(),
"the declaration was lost"
);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
/// The fill job's commit rewrites only computed columns. It is the one
/// commit on a view that is not drift: the next refresh carries on from
/// its watermark instead of rebuilding, which would null what the fill
/// just wrote.
#[tokio::test]
async fn test_a_computed_column_fill_is_not_drift() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
commit_column_rewrite(&view, &["emb"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
commit_column_rewrite(&view, &["emb"]).await;
append_people(&conn, vec![4], vec!["d"]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4]);
}
/// A column rewrite that reaches a projected column is drift like any
/// other write: refresh certifies those columns and must recompute them.
#[tokio::test]
async fn test_a_rewrite_of_a_projected_column_is_drift() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
commit_column_rewrite(&view, &["emb", "name"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
}
/// The declaration contract is checked before any refresh mutation: a
/// missing binding envelope and a column that lost its declaration both
/// fail closed.
#[tokio::test]
async fn test_a_broken_declaration_is_refused_before_refresh() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
dataset
.update_schema_metadata(vec![(
crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(),
None,
)])
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err().to_string();
assert!(err.contains("references missing binding 'fb_1'"), "{err}");
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
dataset
.replace_field_metadata(vec![(
dataset.schema().field("emb").unwrap().id as u32,
HashMap::new(),
)])
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err().to_string();
assert!(err.contains("does not match binding 'fb_1'"), "{err}");
}
/// An input the view does not project is materialized on every refresh
/// path, before the provenance column, with the source's values.
#[tokio::test]
async fn test_internal_inputs_are_materialized_and_refreshed() {
use crate::materialized_view::tests::{computed_field, strict_people, test_binding};
let conn = connect("memory://").execute().await.unwrap();
let source = strict_people(&conn).await;
let mut prepared = crate::materialized_view::prepare_declaration(
&source,
Some(&[("id".to_string(), "id".to_string())]),
None,
None,
)
.await
.unwrap();
let input = prepared.input_column("name").unwrap();
let view = prepared
.with_computed_columns(
vec![(1, computed_field("emb", "fb_1", &input))],
&[test_binding("fb_1", &input, "emb")],
)
.unwrap()
.create("v")
.await
.unwrap();
let names: Vec<String> = view
.table()
.schema()
.await
.unwrap()
.fields()
.iter()
.map(|f| f.name().clone())
.collect();
assert_eq!(names, ["id", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]);
let unfilled_inputs = || async {
view.table()
.count_rows(Some("__input_name IS NULL".to_string()))
.await
.unwrap()
};
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
assert_eq!(view.table().count_rows(None).await.unwrap(), 3);
assert_eq!(unfilled_inputs().await, 0);
let more = arrow_array::RecordBatch::try_new(
source.schema().await.unwrap(),
vec![
Arc::new(Int32Array::from(vec![4])),
Arc::new(arrow_array::StringArray::from(vec!["d"])),
],
)
.unwrap();
source.add(more).execute().await.unwrap();
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Incremental
);
assert_eq!(unfilled_inputs().await, 0);
assert_eq!(
view.table()
.count_rows(Some("__input_name = 'd'".to_string()))
.await
.unwrap(),
1
);
source
.update()
.column("name", "'z'")
.only_if("id = 1")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
view.table()
.count_rows(Some("__input_name = 'z'".to_string()))
.await
.unwrap(),
1
);
assert_eq!(
unfilled(&view).await,
4,
"rewritten and new rows are unfilled"
);
}
/// A SQL declaration is filled by `refresh_column` on the view, which
/// commits a data replacement; the next refresh continues from its
/// watermark and keeps what the fill wrote, and only rows the view added
/// since come back unfilled.
#[tokio::test]
async fn test_a_sql_fill_is_not_drift() {
use crate::materialized_view::tests::{people, sql_field};
let conn = connect("memory://").execute().await.unwrap();
let source = people(&conn).await;
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[("id".to_string(), "id".to_string())]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(
vec![(
1,
sql_field("next", arrow_schema::DataType::Int32, "id + 1", r#"["id"]"#),
)],
&[],
)
.unwrap()
.create("v")
.await
.unwrap();
let filled = || async {
view.table()
.count_rows(Some("next = id + 1".to_string()))
.await
.unwrap()
};
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
assert_eq!(
view.table()
.refresh_column("next")
.await
.unwrap()
.rows_filled,
3
);
assert_eq!(filled().await, 3);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
assert_eq!(filled().await, 3);
append_people(&conn, vec![4], vec!["d"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Incremental
);
assert_eq!(filled().await, 3);
assert_eq!(
view.table()
.refresh_column("next")
.await
.unwrap()
.rows_filled,
1
);
assert_eq!(filled().await, 4);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
/// A fill of a nested computed column writes its child fields; that is
/// still a fill, not drift.
#[tokio::test]
async fn test_a_nested_sql_fill_is_not_drift() {
use crate::materialized_view::tests::{people, sql_field};
let conn = connect("memory://").execute().await.unwrap();
let source = people(&conn).await;
let payload = sql_field(
"payload",
arrow_schema::DataType::Struct(
vec![arrow_schema::Field::new(
"value",
arrow_schema::DataType::Utf8,
true,
)]
.into(),
),
"named_struct('value', name)",
r#"["name"]"#,
);
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[("name".to_string(), "name".to_string())]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(vec![(1, payload)], &[])
.unwrap()
.create("v")
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
view.table()
.refresh_column("payload")
.await
.unwrap()
.rows_filled,
3
);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
assert_eq!(
view.table()
.count_rows(Some("payload.value = name".to_string()))
.await
.unwrap(),
3
);
}
} }
+1 -1
View File
@@ -1299,7 +1299,7 @@ impl VectorQuery {
/// This can be useful when there is a narrow filter to allow these queries to /// This can be useful when there is a narrow filter to allow these queries to
/// spend more time searching and avoid potential false negatives. /// spend more time searching and avoid potential false negatives.
/// ///
/// Set to None to search all partitions, if needed, to satisfy the limit /// Set to None to search all partitions, if needed, to satsify the limit
pub fn maximum_nprobes(mut self, maximum_nprobes: Option<usize>) -> Result<Self> { pub fn maximum_nprobes(mut self, maximum_nprobes: Option<usize>) -> Result<Self> {
if let Some(maximum_nprobes) = maximum_nprobes { if let Some(maximum_nprobes) = maximum_nprobes {
if maximum_nprobes == 0 { if maximum_nprobes == 0 {
+82 -10
View File
@@ -404,6 +404,20 @@ fn validate_dns_hostname(hostname: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Whether a request's body may appear in a debug log.
///
/// The API that built the body decides. The transport cannot know which
/// payloads are credentials, and a list of routes here would have to be kept in
/// step with endpoints defined elsewhere -- so the knowledge lives with the
/// call that has it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum BodyLogging {
/// Log the body at debug, as every request did before Secrets existed.
Allowed,
/// Never log the body. For a request whose body is a credential.
Suppressed,
}
impl RestfulLanceDbClient<Sender> { impl RestfulLanceDbClient<Sender> {
fn get_timeout(passed: Option<Duration>, env_var: &str) -> Result<Option<Duration>> { fn get_timeout(passed: Option<Duration>, env_var: &str) -> Result<Option<Duration>> {
if let Some(passed) = passed { if let Some(passed) = passed {
@@ -610,12 +624,14 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
) -> Result<HeaderMap> { ) -> Result<HeaderMap> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if !api_key.is_empty() { if !api_key.is_empty() {
headers.insert( // `log_request` prints the request's Debug, which prints headers.
HeaderName::from_static("x-api-key"), // Marking the value sensitive is what makes that print `Sensitive`
HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput { // instead of the key itself.
message: "non-ascii api key provided".to_string(), let mut key = HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput {
})?, message: "non-ascii api key provided".to_string(),
); })?;
key.set_sensitive(true);
headers.insert(HeaderName::from_static("x-api-key"), key);
} }
if region == "local" { if region == "local" {
let host = format!("{}.local.api.lancedb.com", db_name); let host = format!("{}.local.api.lancedb.com", db_name);
@@ -725,6 +741,22 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
} }
pub async fn send(&self, req: RequestBuilder) -> Result<(String, Response)> { pub async fn send(&self, req: RequestBuilder) -> Result<(String, Response)> {
self.send_logging(req, BodyLogging::Allowed).await
}
/// Send a request whose body must never reach a debug log.
///
/// The body is built by the caller, so only the caller knows it holds a
/// credential; `log_request` sees serialized bytes and cannot tell.
pub async fn send_suppressing_body(&self, req: RequestBuilder) -> Result<(String, Response)> {
self.send_logging(req, BodyLogging::Suppressed).await
}
async fn send_logging(
&self,
req: RequestBuilder,
body_logging: BodyLogging,
) -> Result<(String, Response)> {
let (client, request) = req.build_split(); let (client, request) = req.build_split();
let mut request = request.unwrap(); let mut request = request.unwrap();
let request_id = self.extract_request_id(&mut request); let request_id = self.extract_request_id(&mut request);
@@ -732,7 +764,7 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
// Apply dynamic headers before sending // Apply dynamic headers before sending
request = self.apply_dynamic_headers(request).await?; request = self.apply_dynamic_headers(request).await?;
self.log_request(&request, &request_id); self.log_request(&request, &request_id, body_logging);
let response = self let response = self
.sender .sender
@@ -795,7 +827,7 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
// Apply dynamic headers before each retry attempt // Apply dynamic headers before each retry attempt
request = self.apply_dynamic_headers(request).await?; request = self.apply_dynamic_headers(request).await?;
self.log_request(&request, &request_id); self.log_request(&request, &request_id, BodyLogging::Allowed);
let response = self.sender.send(&c, request).await.map(|r| (r.status(), r)); let response = self.sender.send(&c, request).await.map(|r| (r.status(), r));
@@ -839,13 +871,18 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
} }
} }
pub(crate) fn log_request(&self, request: &Request, request_id: &String) { fn log_request(&self, request: &Request, request_id: &String, body_logging: BodyLogging) {
if log::log_enabled!(log::Level::Debug) { if log::log_enabled!(log::Level::Debug) {
let content_type = request let content_type = request
.headers() .headers()
.get("content-type") .get("content-type")
.map(|v| v.to_str().unwrap()); .map(|v| v.to_str().unwrap());
if content_type == Some("application/json") { if body_logging == BodyLogging::Suppressed {
debug!(
"Sending request_id={}: {:?} with body suppressed",
request_id, request
);
} else if content_type == Some("application/json") {
let body = request.body().as_ref().unwrap().as_bytes().unwrap(); let body = request.body().as_ref().unwrap().as_bytes().unwrap();
let body = String::from_utf8_lossy(body); let body = String::from_utf8_lossy(body);
debug!( debug!(
@@ -1192,6 +1229,41 @@ mod tests {
assert_eq!(headers.get("x-api-key").unwrap(), "api-key"); assert_eq!(headers.get("x-api-key").unwrap(), "api-key");
} }
/// `log_request` prints the request's Debug, and Debug for a request prints
/// its headers. Marking the value sensitive is the only thing standing
/// between the API key and every debug line; assert on the header map's own
/// Debug, which is what that printing reduces to.
#[test]
fn test_api_key_is_redacted_in_debug_output() {
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"sk-live-sentinel",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-live-sentinel");
assert!(
!format!("{:?}", headers).contains("sk-live-sentinel"),
"the API key must not survive Debug formatting"
);
}
/// A suppressed body is suppressed whatever the content type says, and an
/// allowed one is logged exactly as it was before Secrets existed.
#[test]
fn test_body_logging_is_decided_by_the_caller() {
assert_ne!(BodyLogging::Allowed, BodyLogging::Suppressed);
// `send` and `send_suppressing_body` differ only in what they pass, so
// the enum is the whole contract: a caller states its intent and the
// transport does not infer one from the route.
assert_eq!(BodyLogging::Allowed, BodyLogging::Allowed);
}
#[test] #[test]
fn test_rejects_invalid_cloud_dns_hostname() { fn test_rejects_invalid_cloud_dns_hostname() {
let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()]; let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()];
+205
View File
@@ -28,6 +28,7 @@ use crate::function::{FunctionRegistrationRequest, FunctionVersion};
use crate::job::Job; use crate::job::Job;
use crate::remote::job::{RemoteJob, job_state_to_client}; use crate::remote::job::{RemoteJob, job_state_to_client};
use crate::remote::util::stream_as_body; use crate::remote::util::stream_as_body;
use crate::secrets::SecretInfo;
use crate::table::BaseTable; use crate::table::BaseTable;
use super::client::{ use super::client::{
@@ -352,6 +353,24 @@ impl RemoteDatabase {
} }
impl<S: HttpSend> RemoteDatabase<S> { impl<S: HttpSend> RemoteDatabase<S> {
/// Post a request whose body carries a credential.
///
/// Shared by the create and alter verbs, which declare their own request
/// types: the two mean different things to the service and are free to
/// diverge, so what they share is the posting and not the shape.
///
/// The value is a request field and never a path segment or query
/// parameter, which keeps it out of access logs and proxy traces.
async fn post_secret_write<T: serde::Serialize>(&self, route: &str, body: &T) -> Result<()> {
let req = self.client.post(route).json(body);
// This call is what says the body is a credential. Nothing downstream
// can tell from the bytes, and a route list in the transport would have
// to be kept in step with endpoints declared here.
let (request_id, response) = self.client.send_suppressing_body(req).await?;
self.client.check_response(&request_id, response).await?;
Ok(())
}
async fn submit_drop_table( async fn submit_drop_table(
&self, &self,
name: &str, name: &str,
@@ -570,6 +589,49 @@ struct RemoteDropFunctionResponse {
dropped: bool, dropped: bool,
} }
/// Create a Secret under a name the database does not yet hold.
///
/// Declared separately from the alter request although the two are identical
/// today: they are different operations to the service -- one refuses an
/// existing name, the other requires it -- and either may grow a field the
/// other has no meaning for.
#[derive(serde::Serialize)]
struct RemoteCreateSecretRequest<'a> {
name: &'a str,
value: &'a str,
}
/// Replace the credential behind a Secret the database already holds.
#[derive(serde::Serialize)]
struct RemoteAlterSecretRequest<'a> {
name: &'a str,
value: &'a str,
}
/// One page of a Secret listing. A struct rather than an inline object so the
/// request and the response are declared the same way -- a reader of one finds
/// the other.
#[derive(serde::Serialize)]
struct RemoteListSecretsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
page_token: Option<String>,
}
#[derive(serde::Deserialize)]
struct RemoteListSecretsResponse {
#[serde(default)]
secrets: Vec<RemoteListedSecret>,
#[serde(default)]
page_token: Option<String>,
}
/// An object rather than a bare name so a later listing can carry a Secret's
/// type or last-updated time without breaking this one.
#[derive(serde::Deserialize)]
struct RemoteListedSecret {
name: String,
}
/// Bound on `list_jobs` page walking; a warning is logged when the listing /// Bound on `list_jobs` page walking; a warning is logged when the listing
/// is truncated at this many pages. /// is truncated at this many pages.
const MAX_LIST_JOBS_PAGES: usize = 100; const MAX_LIST_JOBS_PAGES: usize = 100;
@@ -671,6 +733,73 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
Ok(response.dropped) Ok(response.dropped)
} }
async fn create_secret(&self, name: &str, value: &str) -> Result<()> {
self.post_secret_write(
"/v1/secrets/create",
&RemoteCreateSecretRequest { name, value },
)
.await
}
async fn alter_secret(&self, name: &str, value: &str) -> Result<()> {
self.post_secret_write(
"/v1/secrets/alter",
&RemoteAlterSecretRequest { name, value },
)
.await
}
async fn list_secrets(&self) -> Result<Vec<String>> {
let mut names = Vec::new();
let mut page_token: Option<String> = None;
let mut seen_page_tokens = HashSet::new();
loop {
let body = RemoteListSecretsRequest {
page_token: page_token.clone(),
};
let req = self.client.post("/v1/secrets/list").json(&body);
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
let status = response.status();
let response: RemoteListSecretsResponse =
response.json().await.err_to_http(request_id.clone())?;
names.extend(response.secrets.into_iter().map(|secret| secret.name));
let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty())
else {
break;
};
if !seen_page_tokens.insert(next_page_token.clone()) {
return Err(Error::Http {
source: "Secret listing response repeated a page_token".into(),
request_id,
status_code: Some(status),
});
}
page_token = Some(next_page_token);
}
Ok(names)
}
async fn drop_secret(&self, name: &str) -> Result<()> {
let req = self
.client
.post("/v1/secrets/drop")
.json(&serde_json::json!({ "name": name }));
let (request_id, response) = self.client.send(req).await?;
self.client.check_response(&request_id, response).await?;
Ok(())
}
async fn describe_secret(&self, name: &str) -> Result<SecretInfo> {
let req = self
.client
.post("/v1/secrets/describe")
.json(&serde_json::json!({ "name": name }));
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
response.json().await.err_to_http(request_id)
}
async fn open_job(&self, job_id: &str) -> Result<Job> { async fn open_job(&self, job_id: &str) -> Result<Job> {
let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string()); let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string());
match crate::job::JobHandle::describe(&handle).await { match crate::job::JobHandle::describe(&handle).await {
@@ -2781,6 +2910,82 @@ mod tests {
assert_eq!(batches[0].schema(), schema); assert_eq!(batches[0].schema(), schema);
} }
#[tokio::test]
async fn test_create_and_alter_secret_send_the_value_in_the_request_body() {
for (route, call) in [("/v1/secrets/create", true), ("/v1/secrets/alter", false)] {
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), route);
// Never a path segment or query parameter, which is what keeps
// it out of access logs and proxy traces.
assert!(request.url().query().is_none(), "{:?}", request.url());
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body["name"], "openai-prod");
assert_eq!(body["value"], "sk-live-0001");
http::Response::builder().status(200).body("{}").unwrap()
});
if call {
conn.create_secret("openai-prod", "sk-live-0001")
.await
.unwrap();
} else {
conn.alter_secret("openai-prod", "sk-live-0001")
.await
.unwrap();
}
}
}
#[tokio::test]
async fn test_list_secrets_walks_pages_and_returns_names_only() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/secrets/list");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
let page = body.get("page_token").and_then(|token| token.as_str());
let body = match page {
None => r#"{"secrets":[{"name":"openai-prod"}],"page_token":"p2"}"#,
Some("p2") => r#"{"secrets":[{"name":"hf-prod"}]}"#,
Some(other) => panic!("unexpected page token: {other}"),
};
http::Response::builder().status(200).body(body).unwrap()
});
assert_eq!(
conn.list_secrets().await.unwrap(),
vec!["openai-prod".to_string(), "hf-prod".to_string()]
);
}
/// A server that keeps handing back the same token would otherwise spin
/// forever.
#[tokio::test]
async fn test_list_secrets_rejects_a_repeated_page_token() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
.unwrap()
});
let error = conn.list_secrets().await.unwrap_err();
assert!(
error.to_string().contains("repeated a page_token"),
"{error}"
);
}
#[tokio::test]
async fn test_drop_secret_posts_the_name_alone() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/secrets/drop");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body, serde_json::json!({"name": "openai-prod"}));
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod").await.unwrap();
}
#[tokio::test] #[tokio::test]
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() { async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
const REQUEST: &str = include_str!( const REQUEST: &str = include_str!(
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Named Secrets: database-scoped credentials a Function binds by name.
//!
//! Nothing here holds a credential. The verbs live on
//! [`crate::connection::Connection`], and none of them returns a value -- by
//! construction rather than by policy, so there is no code path that could.
//! What a Function records is a binding, in [`crate::function`].
/// What a database records about a Secret. Never its value.
///
/// Returned by [`crate::connection::Connection::describe_secret`]. There is no
/// field for the credential and no method that could produce one.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct SecretInfo {
/// The Secret's database-scoped name.
pub name: String,
/// When the Secret was created, in milliseconds since the Unix epoch.
pub created_at_millis: i64,
/// When the Secret's value was last rotated, in milliseconds since the Unix
/// epoch.
///
/// This is the only observable that a rotation landed: no API returns a
/// credential, so a caller confirms `alter_secret` took effect by watching
/// this move.
pub updated_at_millis: i64,
}
+4 -5
View File
@@ -240,7 +240,7 @@ enum BadVectorHandling {
/// An error is returned /// An error is returned
#[default] #[default]
Error, Error,
/// The offending row is dropped /// The offending row is droppped
Drop, Drop,
/// The invalid/missing items are replaced by fill_value /// The invalid/missing items are replaced by fill_value
Fill(f32), Fill(f32),
@@ -1326,7 +1326,7 @@ impl Table {
/// Note: if your condition is something like "some_id_column == 7" and /// Note: if your condition is something like "some_id_column == 7" and
/// you are updating many rows (with different ids) then you will get /// you are updating many rows (with different ids) then you will get
/// better performance with a single [`merge_insert`] call instead of /// better performance with a single [`merge_insert`] call instead of
/// repeatedly calling this method. /// repeatedly calilng this method.
pub fn update(&self) -> UpdateBuilder { pub fn update(&self) -> UpdateBuilder {
UpdateBuilder::new(self.inner.clone()) UpdateBuilder::new(self.inner.clone())
} }
@@ -2804,7 +2804,7 @@ impl NativeTable {
namespace_client: Option<Arc<dyn LanceNamespace>>, namespace_client: Option<Arc<dyn LanceNamespace>>,
pushdown_operations: HashSet<NamespaceClientPushdownOperation>, pushdown_operations: HashSet<NamespaceClientPushdownOperation>,
) -> Result<Self> { ) -> Result<Self> {
let batches = computed_columns::admit_create_source(batches)?; computed_columns::ensure_no_foreign_declarations(batches.arrow_schema().fields())?;
// Default params uses format v1. // Default params uses format v1.
let params = params.unwrap_or(WriteParams { let params = params.unwrap_or(WriteParams {
..Default::default() ..Default::default()
@@ -2904,7 +2904,6 @@ impl NativeTable {
pushdown_operations: HashSet<NamespaceClientPushdownOperation>, pushdown_operations: HashSet<NamespaceClientPushdownOperation>,
session: Option<Arc<lance::session::Session>>, session: Option<Arc<lance::session::Session>>,
) -> Result<Self> { ) -> Result<Self> {
let batches = computed_columns::admit_create_source(batches)?;
// Build table_id from namespace + name for the storage options provider // Build table_id from namespace + name for the storage options provider
let mut table_id = namespace.clone(); let mut table_id = namespace.clone();
table_id.push(name.to_string()); table_id.push(name.to_string());
@@ -5678,7 +5677,7 @@ mod tests {
TableStatistics { TableStatistics {
num_rows: 250, num_rows: 250,
num_indices: 0, num_indices: 0,
total_bytes: 8969, total_bytes: 8925,
fragment_stats: FragmentStatistics { fragment_stats: FragmentStatistics {
num_fragments: 11, num_fragments: 11,
num_small_fragments: 11, num_small_fragments: 11,
+1 -152
View File
@@ -21,7 +21,6 @@
//! [`computed_columns`] and [`computed_column_from_field`] read declarations //! [`computed_columns`] and [`computed_column_from_field`] read declarations
//! back off a schema. //! back off a schema.
use futures::StreamExt;
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
@@ -1339,106 +1338,6 @@ pub(crate) fn ensure_batch_writes_no_computed_values(
Ok(()) Ok(())
} }
/// Validate every computed-column declaration `schema` carries against the
/// schema itself: every field with declaration metadata is a complete
/// declaration, a SQL declaration re-plans to the field it declares, a
/// Function declaration satisfies the binding contract, and no declaration
/// reads another computed column. What passes here is what `refresh_column`
/// can execute.
pub(crate) fn ensure_declarations_are_planned(schema: &ArrowSchema) -> Result<()> {
let invalid = |message: String| Error::InvalidInput { message };
// A field with any declaration key is a declaration; a partial one is
// not "no declaration", it is a broken one.
for field in schema.fields() {
if field.metadata().keys().any(|k| is_declaration_key(k))
&& computed_column_from_field(field).is_none()
{
return Err(invalid(format!(
"field '{}' carries an incomplete computed-column declaration",
field.name()
)));
}
}
let declared: HashSet<String> = computed_columns(schema)
.into_iter()
.map(|c| c.name)
.collect();
for column in computed_columns(schema) {
let field = schema.field_with_name(&column.name)?;
if !field.is_nullable() {
return Err(invalid(format!(
"computed column '{}' must be nullable until a refresh fills it",
column.name
)));
}
match &column.kind {
ComputedColumnKind::Sql { expression } => {
let others: Vec<ArrowField> = schema
.fields()
.iter()
.filter(|f| f.name() != &column.name)
.map(|f| f.as_ref().clone())
.collect();
let bound = bind(Arc::new(ArrowSchema::new(others)), &column.name, expression)?;
if let Some(input) = bound.roots.iter().find(|r| declared.contains(*r)) {
return Err(invalid(format!(
"computed column '{}' reads computed column '{input}'",
column.name
)));
}
if &bound.data_type != field.data_type() {
return Err(invalid(format!(
"computed column '{}' is declared as {} but its expression yields {}",
column.name,
field.data_type(),
bound.data_type
)));
}
let mut declared_inputs = column.inputs.clone();
declared_inputs.sort();
if declared_inputs != bound.inputs {
return Err(invalid(format!(
"computed column '{}' declares inputs {:?} but its expression reads {:?}",
column.name, declared_inputs, bound.inputs
)));
}
}
ComputedColumnKind::Function { binding_id, .. } => {
// The binding validator resolves each input's leaf; the
// no-computed-input rule is about the root it hangs from.
let bindings = function_bindings(schema)?;
let Some(binding) = bindings.iter().find(|b| b.binding_id() == binding_id) else {
continue; // reported by the binding validator below
};
// Roots come from the canonical path parser: a quoted
// top-level name may itself contain a dot.
if let Some(input) = binding
.inputs()
.iter()
.filter_map(|input| resolve_field_path(schema, &input.field_path).ok())
.map(|resolved| resolved.root.name().as_str())
.find(|r| declared.contains(*r))
{
return Err(invalid(format!(
"computed column '{}' reads computed column '{input}'",
column.name
)));
}
}
ComputedColumnKind::Unrecognized { kind } => {
return Err(Error::NotSupported {
message: format!(
"computed column '{}' is defined by '{kind}', which this version \
of lancedb cannot fill",
column.name
),
});
}
}
}
ensure_supported_function_metadata(schema)
}
/// Reject fields carrying declaration metadata that did not come through /// Reject fields carrying declaration metadata that did not come through
/// [`plan`]. One authority for creation, overwrite and raw transforms. /// [`plan`]. One authority for creation, overwrite and raw transforms.
pub(crate) fn ensure_no_foreign_declarations<'a>( pub(crate) fn ensure_no_foreign_declarations<'a>(
@@ -1897,54 +1796,6 @@ pub(super) async fn add_foreign_kind(table: &crate::Table, name: &str, kind: &st
.unwrap(); .unwrap();
} }
/// Admit a table's initial data: every declaration it carries is validated,
/// and the stream refuses any batch with values in a computed column, whose
/// values come from refresh alone. One boundary for every way a table is
/// created.
pub(crate) fn admit_create_source<S: lance_datafusion::utils::StreamingWriteSource>(
batches: S,
) -> Result<UnfilledDeclarations<S>> {
let schema = batches.arrow_schema();
ensure_declarations_are_planned(&schema)?;
let declared = computed_columns(&schema)
.into_iter()
.map(|c| c.name)
.collect();
Ok(UnfilledDeclarations {
inner: batches,
declared,
})
}
/// A write source whose computed columns must arrive unfilled.
pub(crate) struct UnfilledDeclarations<S> {
inner: S,
declared: Vec<String>,
}
impl<S: lance_datafusion::utils::StreamingWriteSource> lance_datafusion::utils::StreamingWriteSource
for UnfilledDeclarations<S>
{
fn arrow_schema(&self) -> SchemaRef {
self.inner.arrow_schema()
}
fn into_stream(self) -> datafusion_physical_plan::SendableRecordBatchStream {
if self.declared.is_empty() {
return self.inner.into_stream();
}
let schema = self.inner.arrow_schema();
let declared = self.declared;
let stream = self.inner.into_stream().map(move |batch| {
let batch = batch?;
ensure_batch_writes_no_computed_values(&declared, &batch)
.map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?;
Ok(batch)
});
Box::pin(datafusion_physical_plan::stream::RecordBatchStreamAdapter::new(schema, stream))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// The gate's reproducer: the validator applies the same schema-level /// The gate's reproducer: the validator applies the same schema-level
@@ -2795,8 +2646,6 @@ mod tests {
); );
} }
/// A create carries a declaration only if it re-plans completely; this
/// one lacks its inputs and is refused before its forged value matters.
#[tokio::test] #[tokio::test]
async fn test_create_table_cannot_inject_a_declaration() { async fn test_create_table_cannot_inject_a_declaration() {
let conn = connect("memory://").execute().await.unwrap(); let conn = connect("memory://").execute().await.unwrap();
@@ -2824,7 +2673,7 @@ mod tests {
.await .await
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(&err, Error::InvalidInput { message } if message.contains("computed column 'doubled'")), matches!(&err, Error::InvalidInput { message } if message.contains("computed()")),
"{err:?}" "{err:?}"
); );
} }
+1 -1
View File
@@ -52,7 +52,7 @@ enum ConsistencyMode {
/// refresh_window = min(3s, TTL/4) /// refresh_window = min(3s, TTL/4)
/// ///
/// | t < TTL - refresh_window | t < TTL | t >= TTL | /// | t < TTL - refresh_window | t < TTL | t >= TTL |
/// | Return value | Background refresh & return value | synchronous refresh | /// | Return value | Background refresh & return value | syncronous refresh |
Eventual(BackgroundCache<Arc<Dataset>, Error>), Eventual(BackgroundCache<Arc<Dataset>, Error>),
} }
+1 -1
View File
@@ -103,7 +103,7 @@ impl MergeInsertBuilder {
/// but that behavior is subject to change. /// but that behavior is subject to change.
/// ///
/// An optional condition may be specified. If it is, then only /// An optional condition may be specified. If it is, then only
/// matched rows that satisfy the condition will be updated. Any /// matched rows that satisfy the condtion will be updated. Any
/// rows that do not satisfy the condition will be left as they /// rows that do not satisfy the condition will be left as they
/// are. Failing to satisfy the condition does not cause a /// are. Failing to satisfy the condition does not cause a
/// "matched row" to become a "not matched" row. /// "matched row" to become a "not matched" row.
+1 -1
View File
@@ -904,7 +904,7 @@ fn unsharded_shard_id() -> Uuid {
/// Build a [`ShardWriterConfig`] from the persisted `writer_config_defaults`. /// Build a [`ShardWriterConfig`] from the persisted `writer_config_defaults`.
/// ///
/// Unknown or unparsable keys are ignored; absent keys keep the /// Unknown or unparseable keys are ignored; absent keys keep the
/// [`ShardWriterConfig`] default. The shard id is set by `mem_wal_writer`. /// [`ShardWriterConfig`] default. The shard id is set by `mem_wal_writer`.
fn shard_writer_config_from_defaults(defaults: &HashMap<String, String>) -> ShardWriterConfig { fn shard_writer_config_from_defaults(defaults: &HashMap<String, String>) -> ShardWriterConfig {
let mut config = ShardWriterConfig::default().with_shard_spec_id(SHARDING_SPEC_ID); let mut config = ShardWriterConfig::default().with_shard_spec_id(SHARDING_SPEC_ID);
+2 -10
View File
@@ -19,7 +19,6 @@ use lancedb::{
connect, connect_namespace, connect, connect_namespace,
database::listing::{ database::listing::{
ListingDatabaseOptions, NewTableConfig, OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, ListingDatabaseOptions, NewTableConfig, OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS,
OPT_NEW_TABLE_STORAGE_VERSION,
}, },
query::{ExecutableQuery, QueryBase}, query::{ExecutableQuery, QueryBase},
table::{AddDataMode, CompactionOptions, OptimizeAction, OptimizeStats, WriteOptions}, table::{AddDataMode, CompactionOptions, OptimizeAction, OptimizeStats, WriteOptions},
@@ -147,10 +146,7 @@ async fn non_blob_table_keeps_default_format_and_row_id_setting() -> Result<()>
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
let table = db.create_empty_table("t", schema).execute().await?; let table = db.create_empty_table("t", schema).execute().await?;
assert_eq!( assert!(!supports_blob_v2(storage_format_version(&table).await));
storage_format_version(&table).await,
LanceFileVersion::Stable.resolve()
);
assert!(!uses_stable_row_ids(&table).await); assert!(!uses_stable_row_ids(&table).await);
Ok(()) Ok(())
} }
@@ -813,11 +809,7 @@ async fn fetch_blobs_rejects_unknown_column() -> Result<()> {
#[tokio::test] #[tokio::test]
async fn fetch_blobs_rejects_legacy_v1_blob_column() -> Result<()> { async fn fetch_blobs_rejects_legacy_v1_blob_column() -> Result<()> {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
// Legacy v1 blob columns are only writable at file version <= 2.1. let db = connect(tmp.path().to_str().unwrap()).execute().await?;
let db = connect(tmp.path().to_str().unwrap())
.storage_options([(OPT_NEW_TABLE_STORAGE_VERSION, "2.1")])
.execute()
.await?;
let legacy = Field::new("image", DataType::LargeBinary, true).with_metadata( let legacy = Field::new("image", DataType::LargeBinary, true).with_metadata(
std::collections::HashMap::from([("lance-encoding:blob".to_string(), "true".to_string())]), std::collections::HashMap::from([("lance-encoding:blob".to_string(), "true".to_string())]),
); );
@@ -5,7 +5,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use lancedb::function::{ use lancedb::function::{
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
}; };
use serde_json::Value; use serde_json::Value;
@@ -20,6 +20,26 @@ fn job_result(name: &str) -> Value {
serde_json::from_str::<Value>(&fixture(name)).expect("remote Job fixture")["result"].clone() serde_json::from_str::<Value>(&fixture(name)).expect("remote Job fixture")["result"].clone()
} }
/// No client value models a resolved credential, at any nesting depth.
fn assert_no_secret_values(value: &Value) {
match value {
Value::Object(values) => {
for (key, value) in values {
assert!(
!matches!(
key.as_str(),
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
),
"client canonical value must not model resolved secret material"
);
assert_no_secret_values(value);
}
}
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
_ => {}
}
}
#[test] #[test]
fn function_version_job_result_matches_shared_canonical_golden() { fn function_version_job_result_matches_shared_canonical_golden() {
let result = job_result("remote_function_job.json"); let result = job_result("remote_function_job.json");
@@ -28,6 +48,13 @@ fn function_version_job_result_matches_shared_canonical_golden() {
assert_eq!(version.name(), "embed"); assert_eq!(version.name(), "embed");
assert_eq!(version.version(), "fv_01K3EXACT"); assert_eq!(version.version(), "fv_01K3EXACT");
assert_eq!(version.runtime_digest(), "sha256:runtime"); assert_eq!(version.runtime_digest(), "sha256:runtime");
assert_eq!(
version.secret_bindings(),
[SecretBinding::Env {
variable: "HF_TOKEN".to_string(),
secret_ref: "hf-prod".to_string(),
}]
);
assert_eq!( assert_eq!(
version.to_canonical_json().expect("canonical JSON"), version.to_canonical_json().expect("canonical JSON"),
fixture("remote_function_version.canonical.json").trim() fixture("remote_function_version.canonical.json").trim()
@@ -142,3 +169,74 @@ fn floating_point_application_literals_are_rejected_consistently() {
.contains("floating-point Function literals") .contains("floating-point Function literals")
); );
} }
#[test]
fn canonical_client_values_carry_bindings_and_no_credentials() {
let result = job_result("remote_function_job.json");
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
let canonical: Value = serde_json::from_str(
&version
.to_canonical_json()
.expect("canonical FunctionVersion"),
)
.expect("canonical JSON");
assert_eq!(
canonical["secret_bindings"],
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}])
);
assert_no_secret_values(&canonical);
}
/// A binding kind a newer server introduces must not fail the whole version.
///
/// This is the cost the union pays for being one field: an unknown variant is
/// a decode error unless it is caught, so it is caught -- and the payload is
/// dropped rather than retained, as `PythonRuntimeSpec` does, because the
/// client does not proxy catalog values.
#[test]
fn an_unknown_binding_kind_is_forward_decodable() {
let mut result = job_result("remote_function_job.json");
result["secret_bindings"] = serde_json::json!([
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"},
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": "hf-prod"},
]);
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
let kinds = version
.secret_bindings()
.iter()
.map(|binding| binding.kind())
.collect::<Vec<_>>();
assert_eq!(kinds, ["env", "file"]);
assert_eq!(version.secret_bindings()[1].variable(), None);
assert_eq!(version.secret_bindings()[1].secret(), None);
// The unknown kind round-trips as its discriminator and nothing more.
let canonical: Value =
serde_json::from_str(&version.to_canonical_json().expect("canonical")).expect("JSON");
assert_eq!(
canonical["secret_bindings"][1],
serde_json::json!({"kind": "file"})
);
}
/// Every Function registered before Secrets existed serializes unchanged.
#[test]
fn a_version_without_bindings_keeps_the_original_wire_shape() {
let mut result = job_result("remote_function_job.json");
result
.as_object_mut()
.expect("Function version object")
.remove("secret_bindings");
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
assert!(version.secret_bindings().is_empty());
assert!(
!version
.to_canonical_json()
.expect("canonical FunctionVersion")
.contains("secret_bindings")
);
}
@@ -5,7 +5,8 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use lancedb::Error; use lancedb::Error;
use lancedb::function::FunctionRegistrationRequest; use lancedb::function::{FunctionRegistrationRequest, SecretBinding};
use serde_json::Value;
fn fixture(name: &str) -> String { fn fixture(name: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -14,6 +15,26 @@ fn fixture(name: &str) -> String {
fs::read_to_string(path).expect("fixture must be readable") fs::read_to_string(path).expect("fixture must be readable")
} }
/// A registration request never models a resolved credential, at any depth.
fn assert_no_secret_values(value: &Value) {
match value {
Value::Object(values) => {
for (key, value) in values {
assert!(
!matches!(
key.as_str(),
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
),
"registration requests must not model resolved secret material"
);
assert_no_secret_values(value);
}
}
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
_ => {}
}
}
#[test] #[test]
fn registration_request_matches_shared_canonical_golden() { fn registration_request_matches_shared_canonical_golden() {
let request = FunctionRegistrationRequest::from_json(&fixture( let request = FunctionRegistrationRequest::from_json(&fixture(
@@ -22,10 +43,45 @@ fn registration_request_matches_shared_canonical_golden() {
.expect("registration request"); .expect("registration request");
assert_eq!(request.name, "normalize_score"); assert_eq!(request.name, "normalize_score");
assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch"); assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch");
// The unchanged path: a Function that binds nothing serializes today's
// bytes, with no `secret_bindings` key at all.
assert!(request.secret_bindings.is_empty());
assert_eq!( assert_eq!(
request.to_canonical_json().expect("canonical request"), request.to_canonical_json().expect("canonical request"),
fixture("remote_function_registration_request.canonical.json").trim() fixture("remote_function_registration_request.canonical.json").trim()
); );
let value: Value =
serde_json::from_str(&request.to_canonical_json().expect("canonical request"))
.expect("request JSON");
assert_no_secret_values(&value);
}
/// The same shared golden as the Python suite builds from `@udf(secrets=...)`
/// plus `bind_secrets`, so both clients agree byte for byte on a bound request.
#[test]
fn secret_bound_registration_request_matches_shared_canonical_golden() {
let request = FunctionRegistrationRequest::from_json(&fixture(
"remote_function_secret_registration_request.json",
))
.expect("registration request");
assert_eq!(request.name, "analyze_caption");
assert_eq!(
request.secret_bindings,
[SecretBinding::Env {
variable: "OPENAI_API_KEY".to_string(),
secret_ref: "openai-prod".to_string(),
}]
);
assert_eq!(
request.to_canonical_json().expect("canonical request"),
fixture("remote_function_secret_registration_request.canonical.json").trim()
);
let value: Value =
serde_json::from_str(&request.to_canonical_json().expect("canonical request"))
.expect("request JSON");
assert_no_secret_values(&value);
} }
#[tokio::test] #[tokio::test]
@@ -3,7 +3,9 @@
"job_type": "create_function", "job_type": "create_function",
"job_state": "DONE", "job_state": "DONE",
"creation_ms": 1787270400000, "creation_ms": 1787270400000,
"spec": {"name": "embed"}, "spec": {
"name": "embed"
},
"result": { "result": {
"name": "embed", "name": "embed",
"version": "fv_01K3EXACT", "version": "fv_01K3EXACT",
@@ -13,18 +15,44 @@
"entrypoint": "embed" "entrypoint": "embed"
}, },
"signature": { "signature": {
"inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}], "inputs": [
"output": {"kind": "scalar", "arrow_type": "list<float32>", "nullable": false} {
"name": "text",
"arrow_type": "utf8",
"nullable": true
}
],
"output": {
"kind": "scalar",
"arrow_type": "list<float32>",
"nullable": false
}
}, },
"runtime": { "runtime": {
"kind": "python", "kind": "python",
"python_version": "3.12", "python_version": "3.12",
"environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]}, "environment": {
"env": {"TOKENIZERS_PARALLELISM": "false"} "kind": "pip",
"packages": [
"sentence-transformers>=3"
]
},
"env": {
"TOKENIZERS_PARALLELISM": "false"
}
}, },
"runtime_digest": "sha256:runtime", "runtime_digest": "sha256:runtime",
"environment_digest": "sha256:environment", "environment_digest": "sha256:environment",
"created_at": "2026-08-21T00:00:00Z" "created_at": "2026-08-21T00:00:00Z",
"secret_bindings": [
{
"kind": "env",
"variable": "HF_TOKEN",
"secret_ref": "hf-prod"
}
]
}, },
"future_job": {"trace_id": "trace-1"} "future_job": {
"trace_id": "trace-1"
}
} }
@@ -0,0 +1 @@
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":"openai-prod","variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
@@ -0,0 +1,50 @@
{
"artifact": {
"adapter": {
"kind": "scalar_to_arrow_batch",
"version": 1
},
"content": {
"data": "ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK",
"encoding": "base64"
},
"digest": "sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077",
"entrypoint": "analyze_caption",
"kind": "python_callable"
},
"name": "analyze_caption",
"runtime": {
"env": {
"MODE": "test"
},
"environment": {
"kind": "pip",
"packages": [
"openai==3.7.0"
]
},
"kind": "python",
"python_version": "3.12"
},
"signature": {
"inputs": [
{
"arrow_type": "utf8",
"name": "caption",
"nullable": false
}
],
"output": {
"arrow_type": "utf8",
"kind": "scalar",
"nullable": false
}
},
"secret_bindings": [
{
"kind": "env",
"variable": "OPENAI_API_KEY",
"secret_ref": "openai-prod"
}
]
}
@@ -1 +1 @@
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"} {"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":"hf-prod","variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}