mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-15 16:22:27 +00:00
Merge main into gatekeeper/fix-4392-1
Preserve upstream blob-mode validation alongside explicit remote conversion diagnostics. Refresh regression coverage for server-backed blob queries and the upstream closed-file error contract.
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.bumpversion]
|
||||
current_version = "0.41.0-beta.1"
|
||||
current_version = "0.41.0-beta.6"
|
||||
parse = """(?x)
|
||||
(?P<major>0|[1-9]\\d*)\\.
|
||||
(?P<minor>0|[1-9]\\d*)\\.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Setup protoc
|
||||
description: Install the protoc version pinned for wheel builds, and expose it for builds that install protoc themselves
|
||||
|
||||
inputs:
|
||||
install:
|
||||
description: "Install protoc on the runner. Set to false when the build runs in a container (e.g. manylinux) and installs protoc there."
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
outputs:
|
||||
version:
|
||||
description: "The pinned protoc version, e.g. 36.2"
|
||||
value: ${{ steps.pin.outputs.version }}
|
||||
linux-x86_64-sha256:
|
||||
description: "SHA-256 of the protoc linux-x86_64 zip"
|
||||
value: ${{ steps.pin.outputs.linux-x86_64-sha256 }}
|
||||
linux-aarch_64-sha256:
|
||||
description: "SHA-256 of the protoc linux-aarch_64 zip"
|
||||
value: ${{ steps.pin.outputs.linux-aarch_64-sha256 }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# protobuf does not publish checksum files for protoc. These are the digests
|
||||
# GitHub records for the release assets; when bumping, copy them from
|
||||
# `gh api repos/protocolbuffers/protobuf/releases/tags/v<version>`.
|
||||
- name: Set protoc pin
|
||||
id: pin
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "version=36.2"
|
||||
echo "linux-x86_64-sha256=121f6c7afe1d4d0e3ea6aab9432038599250134cbf4474cb1167d2c7decd4278"
|
||||
echo "linux-aarch_64-sha256=8b8f18bd2b30346efbc698dd5a73dd7c805f3ef8380f6dfc95c768f3f1852f6a"
|
||||
echo "osx-universal_binary-sha256=83d0dc80672f53486f54299bca6177159f9cad1fd54d4d3143eeea53c4423e1b"
|
||||
echo "win64-sha256=f0c128dc0d8492eceece83bb459a4c0e316764b929ffbf1aa416357fd644edd3"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Install protoc
|
||||
if: ${{ inputs.install == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
PROTOC_VERSION: ${{ steps.pin.outputs.version }}
|
||||
RUNNER_OS_NAME: ${{ runner.os }}
|
||||
RUNNER_ARCH_NAME: ${{ runner.arch }}
|
||||
LINUX_X86_64_SHA256: ${{ steps.pin.outputs.linux-x86_64-sha256 }}
|
||||
LINUX_AARCH_64_SHA256: ${{ steps.pin.outputs.linux-aarch_64-sha256 }}
|
||||
OSX_SHA256: ${{ steps.pin.outputs.osx-universal_binary-sha256 }}
|
||||
WIN64_SHA256: ${{ steps.pin.outputs.win64-sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUNNER_OS_NAME/$RUNNER_ARCH_NAME" in
|
||||
Linux/X64) platform="linux-x86_64"; sha256="$LINUX_X86_64_SHA256" ;;
|
||||
Linux/ARM64) platform="linux-aarch_64"; sha256="$LINUX_AARCH_64_SHA256" ;;
|
||||
macOS/*) platform="osx-universal_binary"; sha256="$OSX_SHA256" ;;
|
||||
Windows/X64) platform="win64"; sha256="$WIN64_SHA256" ;;
|
||||
*) echo "Unsupported runner: $RUNNER_OS_NAME/$RUNNER_ARCH_NAME" >&2; exit 1 ;;
|
||||
esac
|
||||
zip="$RUNNER_TEMP/protoc.zip"
|
||||
dest="$RUNNER_TEMP/protoc"
|
||||
curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${platform}.zip" -o "$zip"
|
||||
# Hash stdin rather than a path: Windows paths contain backslashes,
|
||||
# which make sha256sum escape its output.
|
||||
if [ "$RUNNER_OS_NAME" = "macOS" ]; then
|
||||
actual=$(shasum -a 256 < "$zip" | cut -d' ' -f1)
|
||||
else
|
||||
actual=$(sha256sum < "$zip" | cut -d' ' -f1)
|
||||
fi
|
||||
if [ "$actual" != "$sha256" ]; then
|
||||
echo "protoc checksum mismatch: expected $sha256, got $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RUNNER_OS_NAME" = "Windows" ]; then
|
||||
# Git Bash on Windows runners does not reliably ship unzip.
|
||||
7z x -y -o"$dest" "$zip" > /dev/null
|
||||
else
|
||||
unzip -q -o "$zip" -d "$dest"
|
||||
fi
|
||||
rm "$zip"
|
||||
echo "$dest/bin" >> "$GITHUB_PATH"
|
||||
"$dest/bin/protoc" --version
|
||||
@@ -29,6 +29,11 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get protoc version
|
||||
id: protoc
|
||||
uses: ./.github/actions/setup-protoc
|
||||
with:
|
||||
install: "false"
|
||||
- name: CONFIRM ARM BUILD
|
||||
shell: bash
|
||||
env:
|
||||
@@ -54,13 +59,14 @@ runs:
|
||||
maturin-version: "1.12.4"
|
||||
command: build
|
||||
working-directory: python
|
||||
docker-options: "-e PIP_EXTRA_INDEX_URL='https://pypi.fury.io/lance-format/ https://pypi.fury.io/lancedb/' -e PROTOC=/usr/local/bin/protoc ${{ inputs.rustflags != '' && format('-e RUSTFLAGS={0}', inputs.rustflags) || '' }}"
|
||||
docker-options: "-e PIP_EXTRA_INDEX_URL='https://pypi.fury.io/lance-format/ https://pypi.fury.io/lancedb/' -e PROTOC=/usr/local/bin/protoc -e PROTOC_VERSION=${{ steps.protoc.outputs.version }} -e PROTOC_SHA256=${{ steps.protoc.outputs.linux-x86_64-sha256 }} ${{ inputs.rustflags != '' && format('-e RUSTFLAGS={0}', inputs.rustflags) || '' }}"
|
||||
target: x86_64-unknown-linux-gnu
|
||||
manylinux: ${{ inputs.manylinux }}
|
||||
args: ${{ inputs.args }}
|
||||
before-script-linux: |
|
||||
set -e
|
||||
curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v24.4/protoc-24.4-linux-x86_64.zip -o /tmp/protoc.zip
|
||||
curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip -o /tmp/protoc.zip
|
||||
echo "${PROTOC_SHA256} /tmp/protoc.zip" | sha256sum -c -
|
||||
unzip /tmp/protoc.zip -d /usr/local
|
||||
rm /tmp/protoc.zip
|
||||
/usr/local/bin/protoc --version
|
||||
@@ -71,14 +77,15 @@ runs:
|
||||
maturin-version: "1.12.4"
|
||||
command: build
|
||||
working-directory: python
|
||||
docker-options: "-e PIP_EXTRA_INDEX_URL='https://pypi.fury.io/lance-format/ https://pypi.fury.io/lancedb/' -e PROTOC=/usr/local/bin/protoc ${{ inputs.rustflags != '' && format('-e RUSTFLAGS={0}', inputs.rustflags) || '' }}"
|
||||
docker-options: "-e PIP_EXTRA_INDEX_URL='https://pypi.fury.io/lance-format/ https://pypi.fury.io/lancedb/' -e PROTOC=/usr/local/bin/protoc -e PROTOC_VERSION=${{ steps.protoc.outputs.version }} -e PROTOC_SHA256=${{ steps.protoc.outputs.linux-aarch_64-sha256 }} ${{ inputs.rustflags != '' && format('-e RUSTFLAGS={0}', inputs.rustflags) || '' }}"
|
||||
target: aarch64-unknown-linux-gnu
|
||||
manylinux: ${{ inputs.manylinux }}
|
||||
args: ${{ inputs.args }}
|
||||
before-script-linux: |
|
||||
set -e
|
||||
yum install -y clang
|
||||
curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v24.4/protoc-24.4-linux-aarch_64.zip -o /tmp/protoc.zip
|
||||
curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-aarch_64.zip -o /tmp/protoc.zip
|
||||
echo "${PROTOC_SHA256} /tmp/protoc.zip" | sha256sum -c -
|
||||
unzip /tmp/protoc.zip -d /usr/local
|
||||
rm /tmp/protoc.zip
|
||||
/usr/local/bin/protoc --version
|
||||
|
||||
@@ -12,10 +12,7 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install macos dependency
|
||||
shell: bash
|
||||
run: |
|
||||
brew install protobuf
|
||||
- uses: ./.github/actions/setup-protoc
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
|
||||
@@ -12,15 +12,7 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Protoc v21.12
|
||||
working-directory: C:\
|
||||
run: |
|
||||
New-Item -Path 'C:\protoc' -ItemType Directory
|
||||
Set-Location C:\protoc
|
||||
Invoke-WebRequest https://github.com/protocolbuffers/protobuf/releases/download/v21.12/protoc-21.12-win64.zip -OutFile C:\protoc\protoc.zip
|
||||
7z x protoc.zip
|
||||
Add-Content $env:GITHUB_PATH "C:\protoc\bin"
|
||||
shell: powershell
|
||||
- uses: ./.github/actions/setup-protoc
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- .github/workflows/build_linux_wheel/action.yml
|
||||
- .github/workflows/build_mac_wheel/action.yml
|
||||
- .github/workflows/build_windows_wheel/action.yml
|
||||
- .github/actions/setup-protoc/action.yml
|
||||
- Cargo.toml # Change in dependency frequently breaks builds
|
||||
- Cargo.lock
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ on:
|
||||
- .github/workflows/build_linux_wheel/**
|
||||
- .github/workflows/build_mac_wheel/**
|
||||
- .github/workflows/build_windows_wheel/**
|
||||
- .github/actions/setup-protoc/**
|
||||
- .github/workflows/run_tests/**
|
||||
|
||||
permissions:
|
||||
|
||||
Generated
+145
-248
@@ -986,40 +986,36 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
|
||||
checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"h2 0.3.27",
|
||||
"h2 0.4.16",
|
||||
"http 0.2.12",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper 1.9.0",
|
||||
"hyper-rustls 0.24.2",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.62.5"
|
||||
version = "0.62.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a"
|
||||
checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
]
|
||||
|
||||
@@ -1070,9 +1066,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.12.1"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32"
|
||||
checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -1170,7 +1166,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit 0.7.3",
|
||||
@@ -1819,7 +1815,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3315,7 +3311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3588,8 +3584,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "fsst"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"rand 0.9.5",
|
||||
@@ -4000,11 +3996,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4110,25 +4104,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.16"
|
||||
@@ -4430,30 +4405,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -4464,7 +4415,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.16",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"httparse",
|
||||
@@ -4476,21 +4427,6 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"log",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
@@ -4498,12 +4434,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
@@ -4514,7 +4450,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
@@ -4533,12 +4469,12 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -4880,7 +4816,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5095,26 +5031,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonb"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"ethnum",
|
||||
"fast-float2",
|
||||
"itoa",
|
||||
"jiff",
|
||||
"nom 8.0.0",
|
||||
"num-traits",
|
||||
"ordered-float 5.3.0",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kanaria"
|
||||
version = "0.2.0"
|
||||
@@ -5143,8 +5059,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
|
||||
|
||||
[[package]]
|
||||
name = "lance"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -5217,8 +5133,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-arrow"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5232,7 +5148,7 @@ dependencies = [
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"half",
|
||||
"jsonb",
|
||||
"lance-jsonb",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
@@ -5240,7 +5156,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lance-arrow-scalar"
|
||||
version = "58.0.0"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5254,7 +5170,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lance-arrow-stats"
|
||||
version = "58.0.0"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5263,8 +5179,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-bitpacking"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"crunchy",
|
||||
@@ -5274,8 +5190,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-core"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5312,8 +5228,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datafusion"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5330,10 +5246,10 @@ dependencies = [
|
||||
"datafusion-physical-expr",
|
||||
"futures",
|
||||
"half",
|
||||
"jsonb",
|
||||
"lance-arrow",
|
||||
"lance-core",
|
||||
"lance-geo",
|
||||
"lance-jsonb",
|
||||
"log",
|
||||
"pin-project",
|
||||
"prost",
|
||||
@@ -5344,8 +5260,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datagen"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5362,8 +5278,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-derive"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5372,8 +5288,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-encoding"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -5406,8 +5322,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-file"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -5439,8 +5355,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-geo"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"datafusion",
|
||||
"geo-traits",
|
||||
@@ -5454,8 +5370,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -5486,7 +5402,6 @@ dependencies = [
|
||||
"hex",
|
||||
"itertools 0.14.0",
|
||||
"jieba-rs",
|
||||
"jsonb",
|
||||
"lance-arrow",
|
||||
"lance-arrow-stats",
|
||||
"lance-bitpacking",
|
||||
@@ -5497,6 +5412,7 @@ dependencies = [
|
||||
"lance-geo",
|
||||
"lance-index-core",
|
||||
"lance-io",
|
||||
"lance-jsonb",
|
||||
"lance-linalg",
|
||||
"lance-select",
|
||||
"lance-table",
|
||||
@@ -5526,8 +5442,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index-core"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5550,8 +5466,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-io"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5591,10 +5507,28 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-jsonb"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"ethnum",
|
||||
"fast-float2",
|
||||
"itoa",
|
||||
"jiff",
|
||||
"nom 8.0.0",
|
||||
"num-traits",
|
||||
"ordered-float 5.3.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-linalg"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5608,8 +5542,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"async-trait",
|
||||
@@ -5623,8 +5557,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace-impls"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -5678,8 +5612,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-select"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5693,8 +5627,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-table"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5734,8 +5668,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-testing"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5748,8 +5682,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-tokenizer"
|
||||
version = "14.0.0-beta.7"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
|
||||
version = "14.0.0-beta.15"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"frostem",
|
||||
"icu_segmenter",
|
||||
@@ -5762,7 +5696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb"
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -5835,7 +5769,7 @@ dependencies = [
|
||||
"rand 0.9.5",
|
||||
"random_word",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"reqwest 0.13.4",
|
||||
"roaring",
|
||||
"rstest",
|
||||
"semver",
|
||||
@@ -5845,6 +5779,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"snafu 0.8.9",
|
||||
"strum_macros 0.28.0",
|
||||
"tempfile",
|
||||
"test-log",
|
||||
"tokenizers",
|
||||
@@ -5859,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb-nodejs"
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5884,7 +5819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb-python"
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -6901,7 +6836,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"httparse",
|
||||
"humantime",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"itertools 0.15.0",
|
||||
"md-5",
|
||||
"nix 0.31.3",
|
||||
@@ -8381,8 +8316,8 @@ dependencies = [
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.45",
|
||||
"socket2 0.6.3",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -8391,18 +8326,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"lru-slab",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.1",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
@@ -8420,9 +8356,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8567,6 +8503,15 @@ dependencies = [
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
@@ -8931,12 +8876,12 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.16",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -8945,7 +8890,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
@@ -8953,7 +8898,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.6.10",
|
||||
@@ -8974,28 +8919,31 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.16",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.6.10",
|
||||
@@ -9220,19 +9168,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9246,7 +9182,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.15",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -9284,14 +9220,14 @@ dependencies = [
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.15",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9300,16 +9236,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -9425,16 +9351,6 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.3.0"
|
||||
@@ -9530,6 +9446,7 @@ version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -9856,16 +9773,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
@@ -10284,7 +10191,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10497,7 +10404,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -10524,23 +10431,13 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -10633,20 +10530,20 @@ dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.16",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.6.3",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
@@ -10954,7 +10851,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls 0.23.45",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11305,7 +11202,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11775,7 +11672,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"hyper 1.9.0",
|
||||
"hyper",
|
||||
"more-asserts",
|
||||
"rand 0.10.1",
|
||||
"redb",
|
||||
@@ -11911,9 +11808,9 @@ checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.15"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+16
-16
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
|
||||
rust-version = "1.94.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
lance = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-core = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datagen = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-file = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-io = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-index = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-linalg = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace-impls = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-table = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-testing = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datafusion = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-encoding = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-arrow = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance = { "version" = "=14.0.0-beta.15", default-features = false, "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-core = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datagen = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-file = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-io = { "version" = "=14.0.0-beta.15", default-features = false, "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-index = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-linalg = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace-impls = { "version" = "=14.0.0-beta.15", default-features = false, "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-table = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-testing = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datafusion = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-encoding = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-arrow = { "version" = "=14.0.0-beta.15", "tag" = "v14.0.0-beta.15", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lancedb = { path = "rust/lancedb", default-features = false }
|
||||
ahash = "0.8"
|
||||
# Note that this one does not include pyarrow
|
||||
@@ -72,10 +72,10 @@ oauth2 = { version = "5.0", default-features = false }
|
||||
regex = "1.10"
|
||||
semver = "1.0.25"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
tempfile = "3.5.0"
|
||||
tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] }
|
||||
tonic = { version = "0.14", features = ["tls-native-roots", "tls-ring"] }
|
||||
tonic = { version = "0.14", features = ["tls-native-roots", "tls-aws-lc"] }
|
||||
uuid = { version = "1.7.0", features = ["v4", "v7"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
|
||||
+7
-4
@@ -15,18 +15,21 @@ unreleased features.
|
||||
|
||||
### Preview the docs
|
||||
|
||||
Run these commands from the LanceDB repository root. The subshell changes into
|
||||
`docs` to load `mkdocs.yml`, while leaving your shell in the repository root.
|
||||
|
||||
```shell
|
||||
cd docs
|
||||
mkdocs serve
|
||||
(cd docs && PYTHONPATH=. mkdocs serve)
|
||||
```
|
||||
|
||||
If you want to just generate the HTML files:
|
||||
|
||||
```shell
|
||||
PYTHONPATH=. mkdocs build -f docs/mkdocs.yml
|
||||
(cd docs && PYTHONPATH=. mkdocs build)
|
||||
```
|
||||
|
||||
If successful, you should see a `docs/site` directory that you can verify locally.
|
||||
If successful, you should see a `docs/site` directory in the repository root that
|
||||
you can verify locally.
|
||||
|
||||
## Adding examples
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
|
||||
<dependency>
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-core</artifactId>
|
||||
<version>0.41.0-beta.1</version>
|
||||
<version>0.41.0-beta.6</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ containing the new version number of the table after altering the columns.
|
||||
abstract blobColumns(): Promise<string[]>
|
||||
```
|
||||
|
||||
Blob v2 columns, including nested dotted paths.
|
||||
Blob v2 columns addressable by row ID, including struct-nested dotted paths.
|
||||
Blobs inside lists are omitted because a row ID does not identify an element.
|
||||
|
||||
#### Returns
|
||||
|
||||
@@ -527,6 +528,8 @@ 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.
|
||||
One missing or deleted row ID rejects the entire batch, including on remote
|
||||
tables; no partial handles are returned.
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -556,6 +559,10 @@ 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.
|
||||
One missing or deleted row ID rejects the entire batch, including on remote
|
||||
tables; no partial results are returned.
|
||||
Blobs inside lists cannot be fetched by row ID.
|
||||
|
||||
Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
|
||||
The client splits requests automatically and reads an individual larger
|
||||
blob through the Range route. This method still materializes all bytes in
|
||||
|
||||
@@ -460,17 +460,16 @@ exhaustiely searched to find matches. This parameter controls how many
|
||||
partitions should be searched.
|
||||
|
||||
Increasing this value will increase the recall of your query but will
|
||||
also increase the latency of your query. The default value is 20. This
|
||||
default is good for many cases but the best value to use will depend on
|
||||
your data and the recall that you need to achieve.
|
||||
also increase the latency of your query. If this method is not called,
|
||||
Lance's adaptive probe defaults are used.
|
||||
|
||||
For best results we recommend tuning this parameter with a benchmark against
|
||||
your actual data to find the smallest possible value that will still give
|
||||
you the desired recall.
|
||||
|
||||
For more fine grained control over behavior when you have a very narrow filter
|
||||
you can use `minimumNprobes` and `maximumNprobes`. This method sets both
|
||||
the minimum and maximum to the same value.
|
||||
The value is retained as `nprobes` through client and server request
|
||||
construction. Lance sets both probe bounds to this value. Explicit minimum
|
||||
or maximum settings can override their respective bounds.
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -647,6 +646,38 @@ rerank(reranker): VectorQuery
|
||||
|
||||
***
|
||||
|
||||
### searchEffort()
|
||||
|
||||
```ts
|
||||
searchEffort(searchEffort): VectorQuery
|
||||
```
|
||||
|
||||
Set the initial IVF search effort to a finite value in [0, 1].
|
||||
|
||||
The default, null, uses Auto (internally 0.5). Zero starts at the caller
|
||||
minimum; one searches all partitions within the caller maximum. Later
|
||||
probing may continue when too few candidates are found.
|
||||
Any explicit effort, including 0.5, overrides nprobes in either call order.
|
||||
Explicit minimum and maximum bounds still apply. This is not a recall
|
||||
guarantee and does not change refinement or HNSW ef. The MemWAL LSM
|
||||
scanner does not yet support this option.
|
||||
|
||||
#### Parameters
|
||||
|
||||
* **searchEffort**: `null` \| `number`
|
||||
|
||||
#### Returns
|
||||
|
||||
[`VectorQuery`](VectorQuery.md)
|
||||
|
||||
#### Example
|
||||
|
||||
```ts
|
||||
table.vectorSearch([0.1, 0.2]).searchEffort(0.75).limit(10);
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
### select()
|
||||
|
||||
```ts
|
||||
|
||||
@@ -14,6 +14,8 @@ 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.
|
||||
Blobs inside lists can be written and queried as descriptors, but cannot
|
||||
currently be fetched because a row ID does not identify a list element.
|
||||
|
||||
## Parameters
|
||||
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
|
||||
## Properties
|
||||
|
||||
### blobRequestConcurrency?
|
||||
|
||||
```ts
|
||||
optional blobRequestConcurrency: number;
|
||||
```
|
||||
|
||||
Maximum number of concurrent blob HTTP requests across all tables and
|
||||
blob handles on this connection. Defaults to 8. Can also be set with
|
||||
`LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY`. Must be greater than zero.
|
||||
|
||||
***
|
||||
|
||||
### extraHeaders?
|
||||
|
||||
```ts
|
||||
|
||||
@@ -37,6 +37,8 @@ The following tokenizers are available:
|
||||
|
||||
"raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token.
|
||||
|
||||
"code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens.
|
||||
|
||||
"icu" - ICU dictionary-based word segmentation.
|
||||
|
||||
"icu/split" - ICU segmentation with simple-style delimiter splitting.
|
||||
|
||||
@@ -15,6 +15,7 @@ optional limit: number;
|
||||
```
|
||||
|
||||
An upper bound on how many tables to return.
|
||||
Defaults to 100. Zero returns an empty page without a continuation token.
|
||||
|
||||
A page may hold fewer than this and still not be the last one, so keep
|
||||
going while the response carries a page token rather than while pages are
|
||||
|
||||
@@ -19,7 +19,7 @@ instead.
|
||||
optional limit: number;
|
||||
```
|
||||
|
||||
An optional limit to the number of results to return.
|
||||
The maximum number of names to return. Omitted returns all names; zero returns none.
|
||||
|
||||
***
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ type BaseTokenizer:
|
||||
| "whitespace"
|
||||
| "raw"
|
||||
| "ngram"
|
||||
| "code"
|
||||
| "icu"
|
||||
| "icu/split"
|
||||
| `jieba/${string}`
|
||||
|
||||
@@ -48,6 +48,169 @@ are ordinary connections. Dropping a database requires it to be empty.
|
||||
::: lancedb.catalog.DatabaseNames
|
||||
|
||||
|
||||
## Authorization
|
||||
|
||||
Some catalogs support the authorization API. This is an API that can be used to
|
||||
bind access control lists (ACLs) to individual catalog objects.
|
||||
|
||||
Each ACL contains a subject, an object, and privilege.
|
||||
|
||||
### Subjects
|
||||
|
||||
There are three kinds of subjects: principals, groups, and roles.
|
||||
|
||||
A principal uniquely identifies each user accessing the system. Each LanceDB
|
||||
Enterprise API key corresponds to a unique principal, and each OpenID user has
|
||||
a unique principal.
|
||||
|
||||
All principals have an immutable ID which never changes, and is safe to include
|
||||
in log files. For example, the API key
|
||||
`sk_AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYPQ====` maps to the
|
||||
principal ID `pk_aaaqeayeaudaocajbifqydiob4`. APIs such as `list_acls` will
|
||||
return the principal ID, never the API key.
|
||||
|
||||
OpenID principals also typically have a human-readable name associated with
|
||||
them. For example, a principal might be named `bob@example.com` and might have
|
||||
a principal ID of `8a004400-9443-4fab-9997-b9372a7ca8fd`. Whenever
|
||||
possible, APIs will return the principal name for convenience. However,
|
||||
principal names are mutable and can be changed by the identity provider.
|
||||
|
||||
Here is an example of getting information about an API key:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
principal = catalog.authz.get_principal(Subject.principal_api_key(api_key))
|
||||
print(f"Found principal {principal.principal_id}")
|
||||
```
|
||||
|
||||
OpenID principals can be members of groups. Like principals, groups also have
|
||||
IDs and human-readable names. It is not possible to modify group membership via
|
||||
this API; it is managed by the OpenID identity provider.
|
||||
|
||||
Here is an example of getting information about a group based on its name:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
group = catalog.authz.get_group(Subject.group_name("Engineering"))
|
||||
print(f"Found group {group.group_id} with name {group.group_name}")
|
||||
```
|
||||
|
||||
Roles can contain either principals or groups. Unlike groups, they are managed
|
||||
by the catalog, not the identity provider. Roles can also contain API key
|
||||
principals.
|
||||
|
||||
Here is an example of managing the `reader` role:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
catalog.authz.add_role_binding(subject=Subject.principal_name("Alice"), role="reader")
|
||||
```
|
||||
It may take a few milliseconds for changes to role bindings to become visible
|
||||
to all clients.
|
||||
|
||||
More subject types may be added in the future.
|
||||
|
||||
### Objects
|
||||
|
||||
An object is any resource in the system that we can attach authorization rules to.
|
||||
The Python builders and Rust client share resource validation and wire encoding.
|
||||
Methods also accept literal strings such as `table:tenant/db:public$events`;
|
||||
malformed names for known resource types raise `ValueError` locally before a
|
||||
request is sent. Unknown resource types are preserved for compatibility with
|
||||
newer servers.
|
||||
|
||||
Object types include:
|
||||
|
||||
The System object, a singleton which represents the entire system. You can
|
||||
refer to it as `Object.system()`.
|
||||
|
||||
Database objects which represents specific databases. For example,
|
||||
`Object.database("analytics")` represents the "analytics" database.
|
||||
|
||||
Table objects which represent specific tables. For example,
|
||||
`Object.table(database="analytics", name="events")` represents the "events"
|
||||
table inside the "analytics" database. Tables are in the default namespace (which
|
||||
is named "public") unless a specific namespace is given.
|
||||
|
||||
Similarly, View objects represent specific views. For example,
|
||||
`Object.view(database="analytics", name="recent_events")` represents the
|
||||
"recent_events" view inside the "analytics" database. Views are in the default
|
||||
namespace (which is named "public") unless a specific namespace is given.
|
||||
|
||||
Secret objects identify named credentials, for example,
|
||||
`Object.secret(database="analytics", name="api-key")`. Function objects identify
|
||||
registered functions, for example,
|
||||
`Object.function(database="analytics", name="caption")`. Both default to the
|
||||
`public` namespace. Pass `namespace_path=["platform", "ml"]` to select a nested
|
||||
namespace. These objects identify resources for ACL operations; a secret object
|
||||
contains the credential's name, not its value.
|
||||
|
||||
More object types may be added in the future.
|
||||
|
||||
### Access control lists
|
||||
|
||||
An access control list attaches a specific set of privileges to a specific
|
||||
(subject, object) pair. So, for example, we might have an access control list
|
||||
that gives `bob@example.com` the ability to read the foo table, that looks like
|
||||
this:
|
||||
|
||||
```python
|
||||
from lancedb.authz import Object, Subject, Privilege
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
catalog.authz.add_acl(
|
||||
object=Object.table(database="analytics", name="foo"),
|
||||
subject=Subject.principal_name("bob@example.com"),
|
||||
privilege=Privilege.SELECT,
|
||||
)
|
||||
```
|
||||
|
||||
Reading a table generally requires USAGE on its database and namespace, plus
|
||||
SELECT on the table. Each object has at most one owner.
|
||||
|
||||
OWNERSHIP is a special privilege which grants all the other privileges. By
|
||||
default, the principal who created an object owns it. Only one subject can own
|
||||
an object; granting OWNERSHIP to a new subject removes it from the previous
|
||||
owner. OWNERSHIP cannot be deleted.
|
||||
|
||||
To delegate secret or function creation, grant `CREATE_SECRET` or
|
||||
`CREATE_FUNCTION` on the containing namespace. This preserves namespace ownership:
|
||||
|
||||
```python
|
||||
for privilege in [Privilege.CREATE_SECRET, Privilege.CREATE_FUNCTION]:
|
||||
catalog.authz.add_acl(
|
||||
object=Object.namespace(database="analytics", namespace_path=["public"]),
|
||||
subject=Subject.role("developer"),
|
||||
privilege=privilege,
|
||||
)
|
||||
```
|
||||
|
||||
Both privileges also accept their string names in synchronous and asynchronous
|
||||
ACL methods.
|
||||
|
||||
By default, the `list_acls` function lists all the access control entries in
|
||||
the system. By adding a function parameter, the output is filtered to just ACLs
|
||||
matching that parameter. For example, this code lists all the ACLs for the
|
||||
"reader" role:
|
||||
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
for entry in catalog.authz.list_acls(subject=Subject.role("reader")):
|
||||
print(entry)
|
||||
```
|
||||
|
||||
Or, when using the async API:
|
||||
```python
|
||||
catalog = await lancedb.connect_catalog_async(endpoint, api_key=api_key)
|
||||
async for entry in catalog.authz.list_acls(subject=Subject.role("reader")):
|
||||
print(entry)
|
||||
```
|
||||
|
||||
## Remote SQL
|
||||
|
||||
Submit SQL against a remote LanceDB database through the connection.
|
||||
@@ -342,6 +505,12 @@ pylance, LanceDB uses a matching `lance.blob.v2` extension type so blob columns
|
||||
still work. Queries return descriptors. Call
|
||||
[`fetch_blob_files`][lancedb.table.Table.fetch_blob_files] for lazy reads or
|
||||
[`fetch_blobs`][lancedb.table.Table.fetch_blobs] for eager bytes.
|
||||
One missing or deleted row ID rejects the entire blob-read batch; the APIs do
|
||||
not return partial results.
|
||||
|
||||
Blobs inside lists can be written and queried as descriptors. They are omitted
|
||||
from `blob_columns()` because row ids do not identify individual list elements,
|
||||
so the fetch APIs cannot read their bytes yet.
|
||||
|
||||
::: lancedb.blob
|
||||
|
||||
@@ -359,6 +528,8 @@ still work. Queries return descriptors. Call
|
||||
|
||||
## Exceptions
|
||||
|
||||
::: lancedb.remote.errors.HttpError
|
||||
|
||||
::: lancedb.exceptions.MissingValueError
|
||||
|
||||
::: lancedb.exceptions.MissingColumnError
|
||||
|
||||
@@ -215,18 +215,28 @@ async def test_query_to_pandas_kwargs(db_path_factory):
|
||||
.to_pandas(blob_mode="bytes")
|
||||
)
|
||||
|
||||
# Vector / FTS / hybrid queries can't materialize blob columns,
|
||||
# so omit them from the projection
|
||||
# Vector / FTS / hybrid queries return "bytes" or "descriptions".
|
||||
# Lazy BlobFile handles need a plain scan.
|
||||
df_vec = (
|
||||
tbl.search(query_vector)
|
||||
.limit(10)
|
||||
.select(["id", "vector"])
|
||||
.to_pandas(split_blocks=True, self_destruct=True)
|
||||
.select(["id", "video"])
|
||||
.to_pandas(blob_mode="bytes", split_blocks=True, self_destruct=True)
|
||||
)
|
||||
|
||||
# to_arrow and to_list take the same blob_mode
|
||||
hits = tbl.search(query_vector).limit(2).select(["id", "video"])
|
||||
arrow_bytes = hits.to_arrow(blob_mode="bytes")
|
||||
rows = hits.to_list(blob_mode="bytes")
|
||||
# --8<-- [end:query_to_pandas_kwargs]
|
||||
|
||||
assert len(df_lazy) == 1
|
||||
assert len(df_bytes) == 1
|
||||
assert df_bytes["video"].iloc[0] == b"fake_video_bytes_1"
|
||||
assert len(df_vec) == 10
|
||||
assert "video" not in df_vec.columns
|
||||
assert all(
|
||||
video == f"fake_video_bytes_{i}".encode()
|
||||
for i, video in zip(df_vec["id"], df_vec["video"])
|
||||
)
|
||||
assert arrow_bytes["video"].to_pylist() == [row["video"] for row in rows]
|
||||
assert all(isinstance(row["video"], bytes) for row in rows)
|
||||
|
||||
Generated
+64
-85
@@ -556,6 +556,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
@@ -1065,12 +1074,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.21.3",
|
||||
"darling_macro 0.21.3",
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1089,11 +1098,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.21.3"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1114,11 +1122,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.3"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.21.3",
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
@@ -1177,7 +1185,7 @@ dependencies = [
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"sqlparser 0.61.0",
|
||||
"tempfile",
|
||||
@@ -1292,7 +1300,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"object_store",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -1392,7 +1400,7 @@ dependencies = [
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"tempfile",
|
||||
"url",
|
||||
]
|
||||
@@ -1457,7 +1465,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"sha2",
|
||||
"unicode-segmentation",
|
||||
@@ -2078,7 +2086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7af6e24ed12cf382082d5a7f365df2b8e3d6b1518f615d54c85165e9b9718250"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2498,7 +2506,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2877,7 +2885,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"ordered-float",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2950,7 +2958,7 @@ dependencies = [
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"rayon",
|
||||
"roaring",
|
||||
"rustc-hash",
|
||||
@@ -2985,7 +2993,7 @@ dependencies = [
|
||||
"half",
|
||||
"jsonb",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3007,7 +3015,7 @@ dependencies = [
|
||||
"half",
|
||||
"jsonb",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3071,7 +3079,7 @@ dependencies = [
|
||||
"object_store",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"roaring",
|
||||
"serde_json",
|
||||
"snafu 0.9.1",
|
||||
@@ -3110,7 +3118,7 @@ dependencies = [
|
||||
"object_store",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"roaring",
|
||||
"serde_json",
|
||||
"snafu 0.9.1",
|
||||
@@ -3169,7 +3177,7 @@ dependencies = [
|
||||
"futures",
|
||||
"half",
|
||||
"hex",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"rand_distr 0.5.1",
|
||||
"rand_xoshiro",
|
||||
]
|
||||
@@ -3214,7 +3222,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"strum 0.26.3",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3306,7 +3314,7 @@ dependencies = [
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"rand_distr 0.5.1",
|
||||
"rangemap",
|
||||
"rayon",
|
||||
@@ -3352,7 +3360,7 @@ dependencies = [
|
||||
"path_abs",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -3374,7 +3382,7 @@ dependencies = [
|
||||
"lance-arrow 8.0.0",
|
||||
"lance-core 8.0.0",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3428,7 +3436,7 @@ dependencies = [
|
||||
"lance-table",
|
||||
"log",
|
||||
"object_store",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"roaring",
|
||||
"serde_json",
|
||||
"time",
|
||||
@@ -3508,7 +3516,7 @@ dependencies = [
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"rangemap",
|
||||
"roaring",
|
||||
"semver",
|
||||
@@ -3533,7 +3541,7 @@ dependencies = [
|
||||
"lance-arrow 8.0.0",
|
||||
"num-traits",
|
||||
"pprof",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3603,7 +3611,7 @@ dependencies = [
|
||||
"pin-project",
|
||||
"polars",
|
||||
"polars-arrow",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -4366,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4584,7 +4592,7 @@ dependencies = [
|
||||
"polars-error",
|
||||
"polars-row",
|
||||
"polars-utils",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
"rand_distr 0.4.3",
|
||||
"rayon",
|
||||
"regex",
|
||||
@@ -4783,7 +4791,7 @@ dependencies = [
|
||||
"polars-error",
|
||||
"polars-lazy",
|
||||
"polars-plan",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser 0.39.0",
|
||||
@@ -5096,9 +5104,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -5107,9 +5115,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -5177,7 +5185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5187,7 +5195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5533,9 +5541,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.35"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -5569,9 +5577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5747,15 +5755,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.16.1"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.1.0",
|
||||
"serde_core",
|
||||
@@ -5766,11 +5776,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.16.1"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -6544,7 +6554,7 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6917,21 +6927,8 @@ dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6998,15 +6995,6 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
@@ -7016,15 +7004,6 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -7310,9 +7289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.15"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
@@ -139,9 +139,9 @@ Learn how to configure, build, and search LanceDB vector indexes, including buil
|
||||
| Parameter | Description |
|
||||
| :--- | :--- |
|
||||
| `limit` | Number of results to return (the `k` in `k-ANN`). |
|
||||
| `nprobes` | Shorthand that sets both `minimum_nprobes` and `maximum_nprobes` to the same value. LanceDB defaults this to 20 which is a good default for many cases. At very high scales you will likely need to experiment to find your preferred recall / latency balance. |
|
||||
| `minimum_nprobes` | Minimum number of partitions scanned. |
|
||||
| `maximum_nprobes` | Maximum number of partitions scanned. Only scans more than `minimum_nprobes` if an initial pass does not return enough results — useful for narrow filters. Set to `0` to remove the cap. |
|
||||
| `nprobes` | Sets both `minimum_nprobes` and `maximum_nprobes` to the same value in Lance. Leave unset to use Lance's adaptive defaults. At very high scales you will likely need to experiment to find your preferred recall / latency balance. |
|
||||
| `minimum_nprobes` | Minimum number of partitions scanned. Leave unset to use Lance's default. |
|
||||
| `maximum_nprobes` | Maximum number of partitions scanned. Only scans more than `minimum_nprobes` if an initial pass does not return enough results — useful for narrow filters. Leave unset, or set to `0`, to remove the cap. |
|
||||
| `ef` | HNSW search-time exploration factor. Start around `1.5 * k` and increase up to `10 * k` for higher recall. |
|
||||
| `refine_factor` | Reads and reranks additional candidates in memory to recover recall lost to quantization. Start between `5` and `50` (a factor of `25` reads and reranks the top `25 * k` candidates) and measure the latency cost, typically a few milliseconds, against the recall gained. |
|
||||
| `distance_range(lower_bound, upper_bound)` | Return only rows whose distance falls within `[lower_bound, upper_bound)`. Either bound is optional. Useful for near-duplicate detection or "close-enough" matching. |
|
||||
@@ -165,8 +165,8 @@ older client.
|
||||
|
||||
| Index type | Guidance |
|
||||
| :--- | :--- |
|
||||
| `IVF_HNSW_FLAT`, `IVF_HNSW_SQ` | Keep the default `nprobes`, then tune `ef` first. Expect higher latency variance under filtered search. |
|
||||
| `IVF_RQ`, `IVF_PQ` | Keep the default `nprobes` and raise when recall is insufficient. If using a selective filter you may need to increase `maximum_nprobes` if you do not received enough results. |
|
||||
| `IVF_HNSW_FLAT`, `IVF_HNSW_SQ` | Leave the probe settings unset, then tune `ef` first. Expect higher latency variance under filtered search. |
|
||||
| `IVF_RQ`, `IVF_PQ` | Leave the probe settings unset and configure them only when recall is insufficient. If using a selective filter you may need to increase `maximum_nprobes` if you do not receive enough results. |
|
||||
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -456,8 +456,6 @@ When you call `to_pandas()` on a local LanceDB table that contains Blob API colu
|
||||
- `"bytes"`: eagerly materializes each blob as `bytes`. Use this when you need the raw payload in the DataFrame, for example to decode an image or audio clip in-process.
|
||||
- `"descriptions"`: returns blob descriptors (offsets, sizes, and positions) instead of the data itself. Use this when you want to plan I/O without paying the cost of loading every blob.
|
||||
|
||||
`"bytes"` and `"descriptions"` require a filesystem-backed Lance dataset and are not supported on in-memory tables.
|
||||
|
||||
Extra keyword arguments are forwarded to the underlying PyArrow / Lance pandas conversion, so you can also pass options like `split_blocks` or `self_destruct`:
|
||||
|
||||
<CodeGroup>
|
||||
@@ -466,10 +464,13 @@ Extra keyword arguments are forwarded to the underlying PyArrow / Lance pandas c
|
||||
</CodeBlock>
|
||||
</CodeGroup>
|
||||
|
||||
Query builders also accept `blob_mode` on their `to_pandas()` method:
|
||||
Query builders also accept `blob_mode` on `to_pandas()`, `to_arrow()`, and `to_list()`:
|
||||
|
||||
- Plain scans support `"lazy"`, `"bytes"`, and `"descriptions"` with filters, projections, aliases, `limit`, and `offset`.
|
||||
- Vector, FTS, hybrid, and ordered queries can't materialize blob columns through `to_pandas()`; omit blob columns from the projection for those query shapes.
|
||||
- Vector, FTS, hybrid, and take queries support `"bytes"` and `"descriptions"`. Lazy `BlobFile` handles need a plain scan.
|
||||
- In `to_arrow()` and `to_list()`, `"lazy"` returns blob descriptors that carry the row id `fetch_blobs` reads, and `"descriptions"` returns them without it.
|
||||
- A plain scan's `to_pandas()` without `timeout` reads the local dataset through Lance's scanner, so it also returns `"bytes"` for legacy blob columns and under namespace query pushdown. In-memory tables have no local dataset, so they take the `to_arrow()` route.
|
||||
- Every other `"bytes"` route raises `ValueError` for legacy blob columns and blobs inside lists, and `NotImplementedError` on remote tables and namespace query pushdown, where the blob fetch could read a different table version than the query.
|
||||
- This works on both sync and async query builders. Extra PyArrow kwargs like `split_blocks` and `self_destruct` are still forwarded.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PyMultimodalImports = "import lancedb\nimport pyarrow as pa\nimport
|
||||
|
||||
export const PyProcessResults = "# Convert back to PIL Image\nfor _, row in results.iterrows():\n image_bytes = row['image_blob']\n image = Image.open(io.BytesIO(image_bytes))\n print(f\"Retrieved image: {row['filename']}, Size: {image.size}\")\n # You can now use 'image' with other libraries or display it\n";
|
||||
|
||||
export const PyQueryToPandasKwargs = "# Plain scan query: blob_mode is supported end to end\ndf_lazy = (\n tbl.search()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"lazy\")\n)\n\n# Same call shape works on async query builders\ndf_bytes = await (\n tbl_async.query()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"bytes\")\n)\n\n# Vector / FTS / hybrid queries can't materialize blob columns,\n# so omit them from the projection\ndf_vec = (\n tbl.search(query_vector)\n .limit(10)\n .select([\"id\", \"vector\"])\n .to_pandas(split_blocks=True, self_destruct=True)\n)\n";
|
||||
export const PyQueryToPandasKwargs = "# Plain scan query: blob_mode is supported end to end\ndf_lazy = (\n tbl.search()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"lazy\")\n)\n\n# Same call shape works on async query builders\ndf_bytes = await (\n tbl_async.query()\n .where(\"id = 1\")\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"bytes\")\n)\n\n# Vector / FTS / hybrid queries return \"bytes\" or \"descriptions\".\n# Lazy BlobFile handles need a plain scan.\ndf_vec = (\n tbl.search(query_vector)\n .limit(10)\n .select([\"id\", \"video\"])\n .to_pandas(blob_mode=\"bytes\", split_blocks=True, self_destruct=True)\n)\n\n# to_arrow and to_list take the same blob_mode\nhits = tbl.search(query_vector).limit(2).select([\"id\", \"video\"])\narrow_bytes = hits.to_arrow(blob_mode=\"bytes\")\nrows = hits.to_list(blob_mode=\"bytes\")\n";
|
||||
|
||||
export const PySearchData = "# Search for similar images\nquery_vector = np.random.rand(128).astype(np.float32)\nresults = tbl.search(query_vector).limit(1).to_pandas()\n";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<parent>
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-parent</artifactId>
|
||||
<version>0.41.0-beta.1</version>
|
||||
<version>0.41.0-beta.6</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
@@ -44,7 +44,30 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.17.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Declared directly so downstream consumers resolve the patched versions:
|
||||
the parent's jackson-bom only manages our own build, and otherwise Maven's
|
||||
nearest-wins mediation picks older versions via arrow-vector and
|
||||
lance-namespace-apache-client. -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.jaxrs</groupId>
|
||||
<artifactId>jackson-jaxrs-json-provider</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
+12
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-parent</artifactId>
|
||||
<version>0.41.0-beta.1</version>
|
||||
<version>0.41.0-beta.6</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>${project.artifactId}</name>
|
||||
<description>LanceDB Java SDK Parent POM</description>
|
||||
@@ -28,7 +28,8 @@
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<arrow.version>15.0.0</arrow.version>
|
||||
<lance-core.version>14.0.0-beta.7</lance-core.version>
|
||||
<lance-core.version>14.0.0-beta.15</lance-core.version>
|
||||
<jackson.version>2.18.11</jackson.version>
|
||||
<spotless.skip>false</spotless.skip>
|
||||
<spotless.version>2.30.0</spotless.version>
|
||||
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
|
||||
@@ -62,6 +63,15 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Pin every Jackson artifact, including ones pulled in transitively
|
||||
(e.g. jackson-core), to a single patched version. -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.lance</groupId>
|
||||
<artifactId>lance-core</artifactId>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lancedb-nodejs"
|
||||
edition.workspace = true
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
@@ -266,8 +266,41 @@ describe("remote catalog", () => {
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
await expect(catalog.connectDatabase("a$b")).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"/db",
|
||||
"db/",
|
||||
"a//b",
|
||||
"a/./b",
|
||||
"a/../b",
|
||||
"a b",
|
||||
"a:b",
|
||||
"a$b",
|
||||
"a%b",
|
||||
"a?b",
|
||||
"a#b",
|
||||
"a\\b",
|
||||
"café",
|
||||
"a\nb",
|
||||
])(
|
||||
"rejects invalid database name %j before sending requests",
|
||||
async (name) => {
|
||||
await withCatalog([], async (catalog, requests) => {
|
||||
for (const method of [
|
||||
"createDatabase",
|
||||
"connectDatabase",
|
||||
"dropDatabase",
|
||||
] as const) {
|
||||
await expect(catalog[method](name)).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
);
|
||||
}
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
import { mkdirSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { Field, Float64, Schema } from "apache-arrow";
|
||||
import * as tmp from "tmp";
|
||||
import {
|
||||
@@ -180,6 +181,32 @@ describe("given a connection", () => {
|
||||
expect(seen).toEqual(created);
|
||||
});
|
||||
|
||||
it("should list all names and use a default page size of 100", async () => {
|
||||
const names = Array.from(
|
||||
{ length: 130 },
|
||||
(_, i) => `t${i.toString().padStart(3, "0")}`,
|
||||
);
|
||||
for (const name of names) {
|
||||
mkdirSync(join(tmpDir.name, `${name}.lance`));
|
||||
}
|
||||
await expect(db.tableNames()).resolves.toEqual(names);
|
||||
await expect(db.tableNames({ limit: 0 })).resolves.toEqual([]);
|
||||
const first = await db.listTables();
|
||||
expect(first.tables).toEqual(names.slice(0, 100));
|
||||
expect(first.pageToken).toBeDefined();
|
||||
const second = await db.listTables({ pageToken: first.pageToken });
|
||||
expect(second.tables).toEqual(names.slice(100));
|
||||
expect(second.pageToken).toBeUndefined();
|
||||
await expect(db.listTables({ limit: 200 })).resolves.toEqual({
|
||||
tables: names,
|
||||
pageToken: undefined,
|
||||
});
|
||||
await expect(db.listTables({ limit: 0 })).resolves.toEqual({
|
||||
tables: [],
|
||||
pageToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list tables in a namespace", async () => {
|
||||
const db = await connect(tmpDir.name, {
|
||||
// biome-ignore lint/style/useNamingConvention: opaque backend property key, must match Rust
|
||||
|
||||
@@ -527,3 +527,106 @@ describe("Query orderBy", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IVF search effort", () => {
|
||||
let directory: tmp.DirResult;
|
||||
let table: Table;
|
||||
const vector = Array.from({ length: 16 }, (_, i) => (i === 0 ? 1 : 0));
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(directory.name);
|
||||
const rows = Array.from({ length: 512 }, (_, id) => ({
|
||||
id,
|
||||
vector: Array.from(
|
||||
{ length: 16 },
|
||||
(_, j) =>
|
||||
(Math.floor(id / 32) === j ? 1 : 0) + Math.sin(id * 17 + j) * 0.001,
|
||||
),
|
||||
}));
|
||||
table = await db.createTable("effort", rows.slice(0, 256));
|
||||
await table.add(rows.slice(256));
|
||||
await table.createIndex("vector", {
|
||||
config: Index.ivfFlat({ numPartitions: 16 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
directory.removeCallback();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 6],
|
||||
[0, 1],
|
||||
[0.25, 3],
|
||||
[0.5, 6],
|
||||
[0.75, 10],
|
||||
[1, 16],
|
||||
])(
|
||||
"preserves effort %s through execution and probe precedence",
|
||||
async (effort, expected) => {
|
||||
const partitions = async (query: ReturnType<Table["vectorSearch"]>) => {
|
||||
const plan = await query.limit(8).analyzePlan();
|
||||
const matches = [...plan.matchAll(/partitions_searched=(\d+)/g)];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.reduce((total, match) => total + Number(match[1]), 0);
|
||||
};
|
||||
expect(
|
||||
await partitions(table.vectorSearch(vector).searchEffort(effort)),
|
||||
).toBe(expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).nprobes(1).searchEffort(effort),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).nprobes(1),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.nprobes(1)
|
||||
.searchEffort(null),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).maximumNprobes(2),
|
||||
),
|
||||
).toBeLessThanOrEqual(2);
|
||||
const actual = await table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const exact = await table
|
||||
.vectorSearch(vector)
|
||||
.bypassVectorIndex()
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const ids = new Set(exact.map((row) => row.id));
|
||||
expect(
|
||||
actual.filter((row) => ids.has(row.id)).length / 8,
|
||||
).toBeGreaterThanOrEqual(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
-0.1,
|
||||
1.1,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
])("rejects invalid effort %s before execution", (effort) => {
|
||||
expect(() => table.vectorSearch(vector).searchEffort(effort)).toThrow(
|
||||
"search_effort must be finite and in [0, 1]",
|
||||
);
|
||||
expect(() =>
|
||||
table.vectorSearch(vector).fullTextSearch("dog").searchEffort(effort),
|
||||
).toThrow("search_effort must be finite and in [0, 1]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,6 +480,15 @@ describe("remote connection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a zero blob request concurrency limit", async () => {
|
||||
await expect(
|
||||
connect("db://dev", {
|
||||
apiKey: "fake",
|
||||
clientConfig: { blobRequestConcurrency: 0 },
|
||||
}),
|
||||
).rejects.toThrow("blob_request_concurrency");
|
||||
});
|
||||
|
||||
it("shows the full error messages on retry errors", async () => {
|
||||
await withMockDatabase(
|
||||
(_req, res) => {
|
||||
|
||||
@@ -1231,7 +1231,9 @@ describe("When creating an index", () => {
|
||||
expect(() => tbl.query().nearestTo(queryVec).minimumNprobes(0)).toThrow(
|
||||
"Invalid input, minimum_nprobes must be greater than 0",
|
||||
);
|
||||
expect(() => tbl.query().nearestTo(queryVec).maximumNprobes(5)).toThrow(
|
||||
expect(() =>
|
||||
tbl.query().nearestTo(queryVec).minimumNprobes(10).maximumNprobes(5),
|
||||
).toThrow(
|
||||
"Invalid input, maximum_nprobes must be greater than or equal to minimum_nprobes",
|
||||
);
|
||||
|
||||
@@ -2815,6 +2817,18 @@ describe("when dealing with blob columns", () => {
|
||||
expect(Buffer.from(await files[1]!.read()).toString()).toBe("beta");
|
||||
});
|
||||
|
||||
it("rejects a whole blob read batch when one row ID was deleted", async () => {
|
||||
const { table, rowIds } = await openBlobTable();
|
||||
await table.delete("id = 2");
|
||||
|
||||
await expect(table.fetchBlobs("image", rowIds)).rejects.toThrow(
|
||||
`first missing row ids: [${rowIds[1]}]`,
|
||||
);
|
||||
await expect(table.fetchBlobFiles("image", rowIds)).rejects.toThrow(
|
||||
`first missing row ids: [${rowIds[1]}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a LargeBinary Arrow column to a blob table", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([new Field("id", new Int64()), blob("image")]);
|
||||
@@ -3027,7 +3041,7 @@ describe("when dealing with blob columns", () => {
|
||||
expect(bytes[0]!.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates and adds list blob columns", async () => {
|
||||
it("creates list blobs without advertising unfetchable elements", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
@@ -3046,7 +3060,16 @@ describe("when dealing with blob columns", () => {
|
||||
{ id: 3n, images: [gamma, null] },
|
||||
{ id: 4n, images: [] },
|
||||
]);
|
||||
expect(await table.blobColumns()).toEqual(["images.image"]);
|
||||
expect(await table.blobColumns()).toEqual([]);
|
||||
const [first] = await table.query().withRowId().toArray();
|
||||
for (const column of ["images", "images.image"]) {
|
||||
await expect(
|
||||
table.fetchBlobs(column, [first._rowid as bigint]),
|
||||
).rejects.toThrow(/blobs inside lists cannot be fetched/);
|
||||
await expect(
|
||||
table.fetchBlobFiles(column, [first._rowid as bigint]),
|
||||
).rejects.toThrow(/blobs inside lists cannot be fetched/);
|
||||
}
|
||||
const rows = await table.query().toArray();
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
||||
expect(descriptorSizes(byId.get(1)!.images)).toEqual([
|
||||
@@ -3092,6 +3115,7 @@ describe("when dealing with blob columns", () => {
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(await table.blobColumns()).toEqual([]);
|
||||
const rows = await table.query().toArray();
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
||||
expect(
|
||||
@@ -4050,6 +4074,28 @@ describe.each(arrowVersions)(
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["Klingon", "english"])(
|
||||
"rejects unsupported full text language %s with a catchable error",
|
||||
(language) => {
|
||||
expect(() => Index.fts({ language })).toThrow(
|
||||
new Error(
|
||||
`LanceDB does not support the requested language: '${language}'`,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("full text search with a supported language", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("test", [{ text: "running" }]);
|
||||
await table.createIndex("text", {
|
||||
config: Index.fts({ language: "English", stem: true }),
|
||||
});
|
||||
|
||||
const results = await table.search("run").toArray();
|
||||
expect(results.map((row) => row.text)).toEqual(["running"]);
|
||||
});
|
||||
|
||||
test("full text search without lowercase", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const data = [
|
||||
@@ -4180,6 +4226,20 @@ describe.each(arrowVersions)(
|
||||
expect(mustNotResults.length).toBe(1);
|
||||
});
|
||||
|
||||
test("full text search code tokenizer", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("test", [
|
||||
{ id: 1, text: "def getUserName(user_id): return user_id" },
|
||||
{ id: 2, text: "def parseFile(file_path): return file_path" },
|
||||
]);
|
||||
await table.createIndex("text", {
|
||||
config: Index.fts({ baseTokenizer: "code" }),
|
||||
});
|
||||
|
||||
const results = await table.search("getUserName", "fts").toArray();
|
||||
expect(results.map((row) => row.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("full text search ngram", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const data = [
|
||||
|
||||
@@ -81,6 +81,8 @@ export type BlobOptions = {
|
||||
*
|
||||
* Query results are descriptors, not payload bytes. Use {@link Table.fetchBlobs}
|
||||
* or {@link Table.fetchBlobFiles} to read bytes.
|
||||
* Blobs inside lists can be written and queried as descriptors, but cannot
|
||||
* currently be fetched because a row ID does not identify a list element.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface TableNamesOptions {
|
||||
* the last table name from the previous page.
|
||||
*/
|
||||
startAfter?: string;
|
||||
/** An optional limit to the number of results to return. */
|
||||
/** The maximum number of names to return. Omitted returns all names; zero returns none. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface ListTablesOptions {
|
||||
pageToken?: string;
|
||||
/**
|
||||
* An upper bound on how many tables to return.
|
||||
* Defaults to 100. Zero returns an empty page without a continuation token.
|
||||
*
|
||||
* A page may hold fewer than this and still not be the last one, so keep
|
||||
* going while the response carries a page token rather than while pages are
|
||||
|
||||
@@ -491,6 +491,7 @@ export type BaseTokenizer =
|
||||
| "whitespace"
|
||||
| "raw"
|
||||
| "ngram"
|
||||
| "code"
|
||||
| "icu"
|
||||
| "icu/split"
|
||||
| `jieba/${string}`
|
||||
@@ -520,6 +521,8 @@ export interface FtsOptions {
|
||||
*
|
||||
* "raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token.
|
||||
*
|
||||
* "code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens.
|
||||
*
|
||||
* "icu" - ICU dictionary-based word segmentation.
|
||||
*
|
||||
* "icu/split" - ICU segmentation with simple-style delimiter splitting.
|
||||
|
||||
+26
-6
@@ -532,17 +532,16 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
* partitions should be searched.
|
||||
*
|
||||
* Increasing this value will increase the recall of your query but will
|
||||
* also increase the latency of your query. The default value is 20. This
|
||||
* default is good for many cases but the best value to use will depend on
|
||||
* your data and the recall that you need to achieve.
|
||||
* also increase the latency of your query. If this method is not called,
|
||||
* Lance's adaptive probe defaults are used.
|
||||
*
|
||||
* For best results we recommend tuning this parameter with a benchmark against
|
||||
* your actual data to find the smallest possible value that will still give
|
||||
* you the desired recall.
|
||||
*
|
||||
* For more fine grained control over behavior when you have a very narrow filter
|
||||
* you can use `minimumNprobes` and `maximumNprobes`. This method sets both
|
||||
* the minimum and maximum to the same value.
|
||||
* The value is retained as `nprobes` through client and server request
|
||||
* construction. Lance sets both probe bounds to this value. Explicit minimum
|
||||
* or maximum settings can override their respective bounds.
|
||||
*/
|
||||
nprobes(nprobes: number): VectorQuery {
|
||||
this.doVectorCall((inner) => inner.nprobes(nprobes));
|
||||
@@ -550,6 +549,27 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the initial IVF search effort to a finite value in [0, 1].
|
||||
*
|
||||
* The default, null, uses Auto (internally 0.5). Zero starts at the caller
|
||||
* minimum; one searches all partitions within the caller maximum. Later
|
||||
* probing may continue when too few candidates are found.
|
||||
* Any explicit effort, including 0.5, overrides nprobes in either call order.
|
||||
* Explicit minimum and maximum bounds still apply. This is not a recall
|
||||
* guarantee and does not change refinement or HNSW ef. The MemWAL LSM
|
||||
* scanner does not yet support this option.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* table.vectorSearch([0.1, 0.2]).searchEffort(0.75).limit(10);
|
||||
* ```
|
||||
*/
|
||||
searchEffort(searchEffort: number | null): VectorQuery {
|
||||
this.doVectorCall((inner) => inner.searchEffort(searchEffort));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the minimum number of probes used.
|
||||
*
|
||||
|
||||
@@ -526,7 +526,8 @@ export abstract class Table {
|
||||
abstract takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery;
|
||||
|
||||
/**
|
||||
* Blob v2 columns, including nested dotted paths.
|
||||
* Blob v2 columns addressable by row ID, including struct-nested dotted paths.
|
||||
* Blobs inside lists are omitted because a row ID does not identify an element.
|
||||
*/
|
||||
abstract blobColumns(): Promise<string[]>;
|
||||
|
||||
@@ -536,6 +537,10 @@ export abstract class Table {
|
||||
* 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.
|
||||
* One missing or deleted row ID rejects the entire batch, including on remote
|
||||
* tables; no partial results are returned.
|
||||
* Blobs inside lists cannot be fetched by row ID.
|
||||
*
|
||||
* Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
|
||||
* The client splits requests automatically and reads an individual larger
|
||||
* blob through the Range route. This method still materializes all bytes in
|
||||
@@ -553,6 +558,8 @@ export abstract class Table {
|
||||
*
|
||||
* Preserves input order, duplicates, and nulls. Use this for large payloads.
|
||||
* See {@link Table.fetchBlobs} for row-ID validity across versions.
|
||||
* One missing or deleted row ID rejects the entire batch, including on remote
|
||||
* tables; no partial handles are returned.
|
||||
*/
|
||||
abstract fetchBlobFiles(
|
||||
column: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-darwin-arm64",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.darwin-arm64.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-gnu",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-musl",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-gnu",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-musl",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-arm64-msvc",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-x64-msvc",
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.win32-x64-msvc.node",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"ann"
|
||||
],
|
||||
"private": false,
|
||||
"version": "0.41.0-beta.1",
|
||||
"version": "0.41.0-beta.6",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
|
||||
+6
-1
@@ -239,7 +239,12 @@ impl Index {
|
||||
opts = opts.base_tokenizer(base_tokenizer);
|
||||
}
|
||||
if let Some(language) = language {
|
||||
opts = opts.language(&language).unwrap();
|
||||
opts = opts.language(&language).map_err(|_| {
|
||||
napi::Error::from_reason(format!(
|
||||
"LanceDB does not support the requested language: '{}'",
|
||||
language
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Some(max_token_length) = max_token_length {
|
||||
opts = opts.max_token_length(Some(max_token_length as usize));
|
||||
|
||||
@@ -298,6 +298,16 @@ impl VectorQuery {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn search_effort(&mut self, search_effort: Option<f64>) -> napi::Result<()> {
|
||||
self.inner = self
|
||||
.inner
|
||||
.clone()
|
||||
.search_effort(search_effort)
|
||||
.default_error()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn minimum_nprobes(&mut self, minimum_nprobe: u32) -> napi::Result<()> {
|
||||
self.inner = self
|
||||
|
||||
@@ -92,6 +92,10 @@ pub struct ClientConfig {
|
||||
pub user_agent: Option<String>,
|
||||
pub retry_config: Option<RetryConfig>,
|
||||
pub timeout_config: Option<TimeoutConfig>,
|
||||
/// Maximum number of concurrent blob HTTP requests across all tables and
|
||||
/// blob handles on this connection. Defaults to 8. Can also be set with
|
||||
/// `LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY`. Must be greater than zero.
|
||||
pub blob_request_concurrency: Option<u32>,
|
||||
pub extra_headers: Option<HashMap<String, String>>,
|
||||
/// The delimiter joining a namespace path and a name into one object
|
||||
/// identifier. `"$"` is the only supported value, and leaving this unset is
|
||||
@@ -428,6 +432,7 @@ impl From<ClientConfig> for lancedb::remote::ClientConfig {
|
||||
.unwrap_or(concat!("LanceDB-Node-Client/", env!("CARGO_PKG_VERSION")).to_string()),
|
||||
retry_config: config.retry_config.map(Into::into).unwrap_or_default(),
|
||||
timeout_config: config.timeout_config.map(Into::into).unwrap_or_default(),
|
||||
blob_request_concurrency: config.blob_request_concurrency.map(|value| value as usize),
|
||||
extra_headers: config.extra_headers.unwrap_or_default(),
|
||||
id_delimiter: config.id_delimiter,
|
||||
tls_config: config.tls_config.map(Into::into),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lancedb-python"
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
description = "Python bindings for LanceDB"
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies = [
|
||||
"pyarrow>=16",
|
||||
"pydantic>=2.7.4,<3",
|
||||
"tqdm>=4.27.0",
|
||||
"lance-namespace>=0.3.2"
|
||||
"lance-namespace>=0.13.0"
|
||||
]
|
||||
description = "lancedb"
|
||||
authors = [{ name = "LanceDB Devs", email = "dev@lancedb.com" }]
|
||||
@@ -63,7 +63,7 @@ tests = [
|
||||
"polars>=0.19, <=1.32.3",
|
||||
"pyarrow<25",
|
||||
"pyarrow-stubs>=16.0",
|
||||
"pylance==14.0.0b6",
|
||||
"pylance==14.0.0b12",
|
||||
"requests>=2.31.0",
|
||||
"datafusion>=54,<55",
|
||||
"opentelemetry-sdk>=1.30.0",
|
||||
@@ -73,6 +73,7 @@ dev = [
|
||||
"pre-commit>=3.5.0",
|
||||
"pyright>=1.1.350",
|
||||
'typing-extensions>=4.0.0; python_version < "3.11"',
|
||||
"maturin>=1.10",
|
||||
]
|
||||
docs = ["mkdocs", "mkdocs-jupyter", "mkdocs-material", "mkdocstrings-python"]
|
||||
clip = ["torch", "pillow>=12.1.1", "open-clip-torch"]
|
||||
|
||||
+250
-53
@@ -6,13 +6,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import operator
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from .expr import Expr
|
||||
from .schema import row_addressable_blob_v2_paths
|
||||
from .schema import blob_column_paths, row_addressable_blob_v2_paths
|
||||
from .types import BlobMode, QueryProjection, QueryProjectionSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,21 +27,24 @@ BLOB_MODE_TO_HANDLING = {
|
||||
|
||||
ROW_ID_FIELD_NAME = "_lance_row_id"
|
||||
|
||||
FetchBlobsSync = Callable[[str, pa.Table], pa.Array | pa.ChunkedArray]
|
||||
FetchBlobsAsync = Callable[[str, pa.Table], Awaitable[pa.Array | pa.ChunkedArray]]
|
||||
FetchBlobsSync = Callable[[str, list[int]], pa.Array | pa.ChunkedArray]
|
||||
FetchBlobsAsync = Callable[[str, list[int]], Awaitable[pa.Array | pa.ChunkedArray]]
|
||||
|
||||
|
||||
class BlobFile(io.RawIOBase):
|
||||
"""Seekable lazy handle from :meth:`~lancedb.table.Table.fetch_blob_files`.
|
||||
|
||||
Bytes load on ``read`` or ``read_range``, not when the handle is opened.
|
||||
Use :meth:`aread` from async code.
|
||||
Bytes load on ``read``, ``read1``, ``read_range``, or ``read_ranges``, not
|
||||
when the handle is opened. Range reads leave the cursor unchanged. Use
|
||||
:meth:`aread` from async code.
|
||||
"""
|
||||
|
||||
def __init__(self, inner) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def aread(self) -> bytes:
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
return await self._inner.read()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -58,13 +62,16 @@ class BlobFile(io.RawIOBase):
|
||||
|
||||
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
||||
if whence == io.SEEK_SET:
|
||||
self._inner.seek(offset)
|
||||
position = offset
|
||||
elif whence == io.SEEK_CUR:
|
||||
self._inner.seek(self._inner.tell() + offset)
|
||||
position = self._inner.tell() + offset
|
||||
elif whence == io.SEEK_END:
|
||||
self._inner.seek(self._inner.size() + offset)
|
||||
position = self._inner.size() + offset
|
||||
else:
|
||||
raise ValueError(f"invalid whence: {whence}")
|
||||
if position < 0:
|
||||
raise ValueError(f"negative seek value {position}")
|
||||
self._inner.seek(position)
|
||||
return self._inner.tell()
|
||||
|
||||
def tell(self) -> int:
|
||||
@@ -74,17 +81,39 @@ class BlobFile(io.RawIOBase):
|
||||
return self._inner.size()
|
||||
|
||||
def readall(self) -> bytes:
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
return self._inner.read_bytes()
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size == -1:
|
||||
def read(self, size: Optional[int] = -1) -> bytes:
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
if size is None or size < 0:
|
||||
return self._inner.read_bytes()
|
||||
return super().read(size)
|
||||
|
||||
def read1(self, size: Optional[int] = -1) -> bytes:
|
||||
"""Read up to ``size`` bytes with one underlying read."""
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
if size is None or size < 0:
|
||||
size = io.DEFAULT_BUFFER_SIZE
|
||||
return self._inner.read_up_to(size)
|
||||
|
||||
def read_range(self, offset: int, length: int) -> bytes:
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
return self._inner.read_range(offset, length)
|
||||
|
||||
def read_ranges(self, ranges: list[tuple[int, int]]) -> list[bytes]:
|
||||
"""Read ``(offset, length)`` pairs without moving the cursor."""
|
||||
if self.closed:
|
||||
raise ValueError("read of closed file")
|
||||
return self._inner.read_ranges(ranges)
|
||||
|
||||
def readinto(self, b: WriteableBuffer) -> int:
|
||||
if self.closed:
|
||||
raise ValueError("readinto of closed file")
|
||||
view = memoryview(b).cast("B")
|
||||
chunk = self._inner.read_up_to(len(view))
|
||||
view[: len(chunk)] = chunk
|
||||
@@ -107,10 +136,12 @@ def projection_includes_blob_column(
|
||||
columns = set(blob_columns)
|
||||
if not columns:
|
||||
return False
|
||||
if projection is None:
|
||||
if not projection:
|
||||
return True
|
||||
for output, source in _iter_projection_pairs(projection):
|
||||
if output in columns or source in columns:
|
||||
if output in columns or any(
|
||||
column == source or column.startswith(f"{source}.") for column in columns
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -119,17 +150,69 @@ def blob_v2_projection_sources(
|
||||
schema: pa.Schema,
|
||||
projection: QueryProjection,
|
||||
) -> dict[str, str]:
|
||||
blob_columns = row_addressable_blob_v2_paths(schema)
|
||||
if not blob_columns:
|
||||
return {}
|
||||
columns = set(blob_columns)
|
||||
if projection is None:
|
||||
return _project_blob_paths(row_addressable_blob_v2_paths(schema), projection)
|
||||
|
||||
|
||||
def reject_unfetchable_blob_columns(
|
||||
schema: pa.Schema, projection: QueryProjection, blob_sources: dict[str, str]
|
||||
) -> None:
|
||||
# Rerankers can drop field metadata, so validate against the source schema.
|
||||
unfetchable = [
|
||||
output
|
||||
for output in _project_blob_paths(blob_column_paths(schema), projection)
|
||||
if output not in blob_sources
|
||||
]
|
||||
if unfetchable:
|
||||
raise ValueError(
|
||||
f"blob_mode='bytes' cannot fetch blob columns {unfetchable}. Bytes are "
|
||||
"fetched by row id, which covers blob v2 columns with one blob per "
|
||||
"row but not legacy blob columns or blobs inside lists. Use "
|
||||
"blob_mode='descriptions' or remove these columns from the projection."
|
||||
)
|
||||
|
||||
|
||||
def reject_unmatched_blob_outputs(
|
||||
output_schema: pa.Schema, blob_sources: dict[str, str]
|
||||
) -> None:
|
||||
# Another spelling of a blob column, such as a quoted or recased name, is a
|
||||
# blob in the query's output schema but maps to no source by name.
|
||||
unmatched = [
|
||||
path for path in blob_column_paths(output_schema) if path not in blob_sources
|
||||
]
|
||||
if unmatched:
|
||||
raise ValueError(
|
||||
f"blob_mode='bytes' could not match result columns {unmatched} to a blob "
|
||||
"column of the table. Select blob columns by their plain names, or "
|
||||
"use blob_mode='descriptions'."
|
||||
)
|
||||
|
||||
|
||||
def _project_blob_paths(
|
||||
blob_columns: list[str], projection: QueryProjection
|
||||
) -> dict[str, str]:
|
||||
"""Map each projected output path to the blob column path it reads."""
|
||||
# An empty projection selects all columns.
|
||||
if not projection:
|
||||
return {column: column for column in blob_columns}
|
||||
return {
|
||||
output: source
|
||||
for output, source in _iter_projection_pairs(projection)
|
||||
if source in columns
|
||||
}
|
||||
sources: dict[str, str] = {}
|
||||
entries: dict[str, str] = {}
|
||||
for output, source in _iter_projection_pairs(projection):
|
||||
for column in blob_columns:
|
||||
if column == source:
|
||||
path = output
|
||||
elif column.startswith(f"{source}."):
|
||||
path = f"{output}{column[len(source) :]}"
|
||||
else:
|
||||
continue
|
||||
if path in sources:
|
||||
raise ValueError(
|
||||
f"projection entries {entries[path]!r} and {output!r} both "
|
||||
f"produce blob column '{path}'. Rename one of them or drop "
|
||||
"the redundant selection."
|
||||
)
|
||||
sources[path] = column
|
||||
entries[path] = output
|
||||
return sources
|
||||
|
||||
|
||||
def v2_projection_needs_row_id(
|
||||
@@ -179,11 +262,14 @@ async def replace_v2_blob_columns_with_bytes(
|
||||
blob_sources: dict[str, str],
|
||||
fetch_blobs: FetchBlobsAsync,
|
||||
) -> pa.Table:
|
||||
for output_name, source_name in blob_sources.items():
|
||||
if output_name not in tbl.column_names:
|
||||
continue
|
||||
blobs = await fetch_blobs(source_name, tbl)
|
||||
tbl = _set_blob_column(tbl, output_name, blobs)
|
||||
present = _present_blob_sources(tbl, blob_sources)
|
||||
if not present:
|
||||
return tbl
|
||||
row_ids = _hit_row_ids(tbl, present)
|
||||
for output_name, source_name in present.items():
|
||||
tbl = _set_blob_column(
|
||||
tbl, output_name, await fetch_blobs(source_name, row_ids)
|
||||
)
|
||||
return tbl
|
||||
|
||||
|
||||
@@ -192,19 +278,48 @@ def replace_v2_blob_columns_with_bytes_sync(
|
||||
blob_sources: dict[str, str],
|
||||
fetch_blobs: FetchBlobsSync,
|
||||
) -> pa.Table:
|
||||
for output_name, source_name in blob_sources.items():
|
||||
if output_name not in tbl.column_names:
|
||||
continue
|
||||
blobs = fetch_blobs(source_name, tbl)
|
||||
tbl = _set_blob_column(tbl, output_name, blobs)
|
||||
present = _present_blob_sources(tbl, blob_sources)
|
||||
if not present:
|
||||
return tbl
|
||||
row_ids = _hit_row_ids(tbl, present)
|
||||
for output_name, source_name in present.items():
|
||||
tbl = _set_blob_column(tbl, output_name, fetch_blobs(source_name, row_ids))
|
||||
return tbl
|
||||
|
||||
|
||||
def _present_blob_sources(
|
||||
tbl: pa.Table, blob_sources: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
output: source
|
||||
for output, source in blob_sources.items()
|
||||
if _has_column_path(tbl.schema, output)
|
||||
}
|
||||
|
||||
|
||||
def _hit_row_ids(tbl: pa.Table, blob_sources: dict[str, str]) -> list[int]:
|
||||
"""Read row ids before replacing columns, since a swapped alias can overwrite
|
||||
the lookup source."""
|
||||
if "_rowid" in tbl.column_names:
|
||||
return tbl["_rowid"].to_pylist()
|
||||
for output in blob_sources:
|
||||
descriptor = _leaf_struct_column(tbl, output)
|
||||
if pa.types.is_struct(descriptor.type) and (
|
||||
ROW_ID_FIELD_NAME in descriptor.type.names
|
||||
):
|
||||
return descriptor.field(ROW_ID_FIELD_NAME).to_pylist()
|
||||
raise ValueError(
|
||||
f"query result has no '_rowid' column and no '{ROW_ID_FIELD_NAME}' "
|
||||
f"field on blob columns {list(blob_sources)}. Call .with_row_id(True) "
|
||||
"to fetch blob bytes for this query."
|
||||
)
|
||||
|
||||
|
||||
def stash_auto_row_ids(tbl: pa.Table, blob_paths: Iterable[str]) -> pa.Table:
|
||||
if "_rowid" not in tbl.column_names:
|
||||
raise ValueError("query result has no '_rowid' column to hide")
|
||||
|
||||
present_paths = [p for p in blob_paths if p.split(".")[0] in tbl.column_names]
|
||||
present_paths = [p for p in blob_paths if _has_column_path(tbl.schema, p)]
|
||||
if not present_paths:
|
||||
raise ValueError("query result has no blob v2 column to carry a row id")
|
||||
|
||||
@@ -224,7 +339,7 @@ def read_row_ids_from_hits(hits: pa.Table, blob_column: str) -> list[int]:
|
||||
|
||||
try:
|
||||
leaf = _leaf_struct_column(hits, blob_column)
|
||||
if ROW_ID_FIELD_NAME in leaf.type.names:
|
||||
if pa.types.is_struct(leaf.type) and ROW_ID_FIELD_NAME in leaf.type.names:
|
||||
return leaf.field(ROW_ID_FIELD_NAME).to_pylist()
|
||||
except KeyError:
|
||||
pass
|
||||
@@ -287,9 +402,37 @@ def _iter_projection_pairs(
|
||||
yield name, source if source is not None else expr.to_sql()
|
||||
|
||||
|
||||
def _set_blob_column(tbl: pa.Table, output_name: str, blobs: pa.Array) -> pa.Table:
|
||||
index = tbl.schema.get_field_index(output_name)
|
||||
return tbl.set_column(index, pa.field(output_name, blobs.type), [blobs])
|
||||
def _has_column_path(schema: pa.Schema, path: str) -> bool:
|
||||
top_name, rest = _split_column_path(schema, path)
|
||||
index = schema.get_field_index(top_name)
|
||||
return (
|
||||
index >= 0 and _walk_struct_fields(schema.field(index).type, rest) is not None
|
||||
)
|
||||
|
||||
|
||||
def _set_blob_column(
|
||||
tbl: pa.Table, output_name: str, blobs: pa.Array | pa.ChunkedArray
|
||||
) -> pa.Table:
|
||||
top_name, rest = _split_column_path(tbl.schema, output_name)
|
||||
if not rest:
|
||||
index = tbl.schema.get_field_index(top_name)
|
||||
return tbl.set_column(index, pa.field(top_name, blobs.type), [blobs])
|
||||
|
||||
leaf_name = rest[-1]
|
||||
if isinstance(blobs, pa.ChunkedArray):
|
||||
blobs = blobs.combine_chunks()
|
||||
|
||||
def replace_blob(children: list, child_fields: list) -> None:
|
||||
for i, field in enumerate(child_fields):
|
||||
if field.name == leaf_name:
|
||||
children[i] = blobs
|
||||
child_fields[i] = pa.field(
|
||||
leaf_name, blobs.type, nullable=field.nullable
|
||||
)
|
||||
return
|
||||
raise KeyError(f"Blob field '{leaf_name}' not found in '{output_name}'")
|
||||
|
||||
return _transform_struct_column(tbl, top_name, rest[:-1], replace_blob)
|
||||
|
||||
|
||||
def _embed_row_id_in_column(tbl: pa.Table, path: str, row_ids: pa.Array) -> pa.Table:
|
||||
@@ -297,7 +440,9 @@ def _embed_row_id_in_column(tbl: pa.Table, path: str, row_ids: pa.Array) -> pa.T
|
||||
children.append(row_ids)
|
||||
child_fields.append(pa.field(ROW_ID_FIELD_NAME, pa.uint64(), nullable=False))
|
||||
|
||||
return _transform_struct_column(tbl, path, add_row_id)
|
||||
return _transform_struct_column(
|
||||
tbl, *_split_column_path(tbl.schema, path), add_row_id
|
||||
)
|
||||
|
||||
|
||||
def strip_auto_row_ids(tbl: pa.Table, blob_paths: Iterable[str]) -> pa.Table:
|
||||
@@ -314,16 +459,20 @@ def strip_auto_row_ids(tbl: pa.Table, blob_paths: Iterable[str]) -> pa.Table:
|
||||
return
|
||||
|
||||
for path in blob_paths:
|
||||
if path.split(".")[0] not in tbl.column_names:
|
||||
if not _has_column_path(tbl.schema, path):
|
||||
continue
|
||||
tbl = _transform_struct_column(tbl, path, drop_row_id)
|
||||
tbl = _transform_struct_column(
|
||||
tbl, *_split_column_path(tbl.schema, path), drop_row_id
|
||||
)
|
||||
return tbl
|
||||
|
||||
|
||||
def _transform_struct_column(
|
||||
tbl: pa.Table, path: str, leaf_transform: Callable[[list, list], None]
|
||||
tbl: pa.Table,
|
||||
top_name: str,
|
||||
rest: list[str],
|
||||
leaf_transform: Callable[[list, list], None],
|
||||
) -> pa.Table:
|
||||
top_name, *rest = path.split(".")
|
||||
top_index = tbl.schema.get_field_index(top_name)
|
||||
top_field = tbl.schema.field(top_index)
|
||||
top_array = tbl.column(top_name)
|
||||
@@ -381,26 +530,74 @@ def _rebuild_struct(
|
||||
|
||||
|
||||
def _leaf_struct_column(tbl: pa.Table, path: str) -> pa.StructArray:
|
||||
parts = path.split(".")
|
||||
column = tbl.column(parts[0])
|
||||
top_name, rest = _split_column_path(tbl.schema, path)
|
||||
column = tbl.column(top_name)
|
||||
if isinstance(column, pa.ChunkedArray):
|
||||
column = column.combine_chunks()
|
||||
for part in parts[1:]:
|
||||
for part in rest:
|
||||
if not pa.types.is_struct(column.type):
|
||||
raise KeyError(f"blob path '{path}' crosses non-struct column")
|
||||
column = column.field(part)
|
||||
return column
|
||||
|
||||
|
||||
def _split_column_path(schema: pa.Schema, path: str) -> tuple[str, list[str]]:
|
||||
"""Prefer a path ending in a struct, since a flat alias can shadow a nested
|
||||
blob path."""
|
||||
parts = path.split(".")
|
||||
fallback = None
|
||||
for cut in range(len(parts), 0, -1):
|
||||
top_name, rest = ".".join(parts[:cut]), parts[cut:]
|
||||
index = schema.get_field_index(top_name)
|
||||
if index < 0:
|
||||
continue
|
||||
leaf_type = _walk_struct_fields(schema.field(index).type, rest)
|
||||
if leaf_type is None:
|
||||
continue
|
||||
if pa.types.is_struct(leaf_type):
|
||||
return top_name, rest
|
||||
fallback = fallback or (top_name, rest)
|
||||
return fallback or (parts[0], parts[1:])
|
||||
|
||||
|
||||
def _walk_struct_fields(
|
||||
data_type: pa.DataType, names: list[str]
|
||||
) -> Optional[pa.DataType]:
|
||||
for name in names:
|
||||
if not pa.types.is_struct(data_type):
|
||||
return None
|
||||
index = data_type.get_field_index(name)
|
||||
if index < 0:
|
||||
return None
|
||||
data_type = data_type.field(index).type
|
||||
return data_type
|
||||
|
||||
|
||||
def _normalize_blob_row_ids(
|
||||
row_ids: Union[list[int], pa.Table], blob_column: str
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table], blob_column: str
|
||||
) -> list[int]:
|
||||
if isinstance(row_ids, pa.Table):
|
||||
return read_row_ids_from_hits(row_ids, blob_column)
|
||||
if isinstance(row_ids, (pa.Array, pa.ChunkedArray)):
|
||||
raise ValueError(
|
||||
"pass a query table with _rowid, not a column array "
|
||||
"(use fetch_blobs('image', hits), not fetch_blobs('image', hits['image']))"
|
||||
)
|
||||
return list(row_ids)
|
||||
values = read_row_ids_from_hits(row_ids, blob_column)
|
||||
elif isinstance(row_ids, (pa.Array, pa.ChunkedArray)):
|
||||
if not pa.types.is_integer(row_ids.type):
|
||||
raise ValueError(
|
||||
"row id arrays must have an integer type; pass a query table with "
|
||||
"_rowid when using a blob descriptor column"
|
||||
)
|
||||
values = row_ids.to_pylist()
|
||||
else:
|
||||
values = list(row_ids)
|
||||
|
||||
normalized = []
|
||||
for value in values:
|
||||
try:
|
||||
row_id = operator.index(value)
|
||||
except TypeError as exc:
|
||||
raise ValueError("row ids must be integers without nulls") from exc
|
||||
if row_id < 0:
|
||||
raise ValueError("row ids must be non-negative")
|
||||
normalized.append(row_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def _wrap_blob_files(handles: Iterable[object]) -> list[Optional[BlobFile]]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional, Tuple, Any, TypedDict, Union, Literal
|
||||
from typing import Dict, List, Optional, Sequence, Tuple, Any, TypedDict, Union, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import pyarrow as pa
|
||||
@@ -311,6 +311,7 @@ class BlobFile:
|
||||
def tell(self) -> int: ...
|
||||
def size(self) -> int: ...
|
||||
def read_range(self, offset: int, length: int) -> bytes: ...
|
||||
def read_ranges(self, ranges: list[tuple[int, int]]) -> list[bytes]: ...
|
||||
def read_up_to(self, length: int) -> bytes: ...
|
||||
|
||||
class Job:
|
||||
@@ -563,7 +564,7 @@ class Table:
|
||||
async def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: List[Tuple[int, int, int]],
|
||||
requests: Sequence[Sequence[int]],
|
||||
) -> pa.LargeBinaryArray: ...
|
||||
async def fetch_blob_files(
|
||||
self, column: str, row_ids: list[int]
|
||||
@@ -727,6 +728,7 @@ class VectorQuery:
|
||||
def use_lsm(self, enable: bool): ...
|
||||
def refine_factor(self, refine_factor: int): ...
|
||||
def nprobes(self, nprobes: int): ...
|
||||
def search_effort(self, search_effort: Optional[float]): ...
|
||||
def minimum_nprobes(self, minimum_nprobes: int): ...
|
||||
def maximum_nprobes(self, maximum_nprobes: int): ...
|
||||
def bypass_vector_index(self): ...
|
||||
@@ -752,6 +754,7 @@ class HybridQuery:
|
||||
def distance_type(self, distance_type: str): ...
|
||||
def refine_factor(self, refine_factor: int): ...
|
||||
def nprobes(self, nprobes: int): ...
|
||||
def search_effort(self, search_effort: Optional[float]): ...
|
||||
def minimum_nprobes(self, minimum_nprobes: int): ...
|
||||
def maximum_nprobes(self, maximum_nprobes: int): ...
|
||||
def bypass_vector_index(self): ...
|
||||
@@ -778,8 +781,10 @@ class PyQueryRequest:
|
||||
use_lsm: Optional[bool]
|
||||
column: Optional[str]
|
||||
query_vector: Optional[List[pa.Array]]
|
||||
nprobes: Optional[int]
|
||||
minimum_nprobes: Optional[int]
|
||||
maximum_nprobes: Optional[int]
|
||||
search_effort: Optional[float]
|
||||
lower_bound: Optional[float]
|
||||
upper_bound: Optional[float]
|
||||
ef: Optional[int]
|
||||
@@ -951,6 +956,8 @@ class PermutationReader:
|
||||
def __init__(self, base_table: Table, permutation_table: Table): ...
|
||||
|
||||
class Catalog:
|
||||
@property
|
||||
def authz(self) -> AuthorizationClient: ...
|
||||
@property
|
||||
def uri(self) -> str: ...
|
||||
async def create_database(
|
||||
@@ -997,3 +1004,60 @@ class JobListing:
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> JobListing: ...
|
||||
async def __anext__(self) -> JobInfo: ...
|
||||
|
||||
class AuthorizationClient:
|
||||
async def list_acls(self, request: str) -> str: ...
|
||||
async def add_acl(self, request: str) -> None: ...
|
||||
async def delete_acl(self, request: str) -> None: ...
|
||||
async def list_role_bindings(self, request: str) -> str: ...
|
||||
async def add_role_binding(self, request: str) -> None: ...
|
||||
async def delete_role_binding(self, request: str) -> None: ...
|
||||
async def get_principal(self, request: str) -> str: ...
|
||||
async def get_group(self, request: str) -> str: ...
|
||||
|
||||
class AuthzSubject:
|
||||
def __init__(self, kind: str, value: str): ...
|
||||
@staticmethod
|
||||
def from_canonical(value: str) -> AuthzSubject: ...
|
||||
@property
|
||||
def kind(self) -> str: ...
|
||||
@property
|
||||
def value(self) -> str: ...
|
||||
@property
|
||||
def display_value(self) -> str: ...
|
||||
def to_wire(self, context: Optional[str] = None) -> str: ...
|
||||
def __str__(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class AuthzPrivilege:
|
||||
def __init__(self, value: str): ...
|
||||
@staticmethod
|
||||
def known() -> list[str]: ...
|
||||
def __str__(self) -> str: ...
|
||||
|
||||
class AuthzObject:
|
||||
def __init__(self, value: str): ...
|
||||
@staticmethod
|
||||
def system() -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def database(name: str) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def namespace(database: str, namespace_path: Sequence[str]) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def table(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def view(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def secret(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def function(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
def __str__(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Deployment-wide authorization through ``catalog.authz``.
|
||||
|
||||
ACLs grant one privilege on an object to a principal, group, or role. Role
|
||||
bindings grant a role to a principal or group. Identity-provider group membership
|
||||
is read-only. Name resolution and permission checks are performed by the server.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import json
|
||||
from typing import Literal, Optional, TypeVar, Union, cast
|
||||
|
||||
from . import _lancedb
|
||||
from .background_loop import LOOP
|
||||
|
||||
__all__ = [
|
||||
"Authorization",
|
||||
"AsyncAuthorization",
|
||||
"Subject",
|
||||
"Object",
|
||||
"Privilege",
|
||||
"AccessControlEntry",
|
||||
"RoleBinding",
|
||||
"IdentityInfo",
|
||||
"PrincipalInfo",
|
||||
"GroupInfo",
|
||||
]
|
||||
|
||||
_PAGE_SIZE = 100
|
||||
_T = TypeVar("_T")
|
||||
_SubjectKind = Literal[
|
||||
"principal_id",
|
||||
"principal_name",
|
||||
"group_id",
|
||||
"group_name",
|
||||
"role",
|
||||
"principal_api_key",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Subject:
|
||||
"""An immutable identity selector. Prefer the named factory methods.
|
||||
|
||||
IDs are stable; names must resolve unambiguously on the server. API-key
|
||||
selectors identify the subject of an operation, not its authenticated caller.
|
||||
Secret values are omitted from representations. Unknown canonical subject
|
||||
kinds returned by newer servers use ``kind="unknown"`` and preserve the full
|
||||
wire string in ``value``; they can be passed back to ACL methods.
|
||||
"""
|
||||
|
||||
kind: _SubjectKind
|
||||
_value: str = field(repr=False)
|
||||
|
||||
_inner: _lancedb.AuthzSubject = field(init=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(
|
||||
self, "_inner", _lancedb.AuthzSubject(self.kind, self._value)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def principal_id(cls, id: str) -> "Subject":
|
||||
"""Select a principal by canonical identity-provider ID."""
|
||||
return cls("principal_id", id)
|
||||
|
||||
@classmethod
|
||||
def principal_name(cls, name: str) -> "Subject":
|
||||
"""Select a principal by an unambiguous provider name."""
|
||||
return cls("principal_name", name)
|
||||
|
||||
@classmethod
|
||||
def group_id(cls, id: str) -> "Subject":
|
||||
"""Select a group by canonical identity-provider ID."""
|
||||
return cls("group_id", id)
|
||||
|
||||
@classmethod
|
||||
def group_name(cls, name: str) -> "Subject":
|
||||
"""Select a group by an unambiguous provider name."""
|
||||
return cls("group_name", name)
|
||||
|
||||
@classmethod
|
||||
def role(cls, name: str) -> "Subject":
|
||||
"""Select a LanceDB-managed role, without an ``r:`` prefix."""
|
||||
return cls("role", name)
|
||||
|
||||
@classmethod
|
||||
def principal_api_key(cls, api_key: str) -> "Subject":
|
||||
"""Select the principal holding this secret API key."""
|
||||
return cls("principal_api_key", api_key)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
value = self._inner.display_value
|
||||
return f"Subject.{self.kind}({value!r})"
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
"""The selected value, or full wire string for unknown subject kinds.
|
||||
|
||||
API-key values are secrets.
|
||||
"""
|
||||
return self._value
|
||||
|
||||
def __reduce__(self):
|
||||
return type(self), (self.kind, self._value)
|
||||
|
||||
def _wire(self) -> str:
|
||||
return self._inner.to_wire()
|
||||
|
||||
@classmethod
|
||||
def _canonical(cls, value: str) -> "Subject":
|
||||
inner = _lancedb.AuthzSubject.from_canonical(value)
|
||||
return cls(cast(_SubjectKind, inner.kind), inner.value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Object:
|
||||
"""An immutable resource name in the authorization syntax.
|
||||
|
||||
Use the builders to avoid interpreting separators as part of a name.
|
||||
Methods also accept raw object strings, validated by the same Rust parser.
|
||||
Unknown resource types are preserved for compatibility with newer servers.
|
||||
Omitted or empty namespace paths select the database's public namespace.
|
||||
"""
|
||||
|
||||
_value: str
|
||||
_inner: _lancedb.AuthzObject = field(init=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "_inner", _lancedb.AuthzObject(self._value))
|
||||
|
||||
@classmethod
|
||||
def system(cls) -> "Object":
|
||||
"""The deployment-wide system object."""
|
||||
return cls(str(_lancedb.AuthzObject.system()))
|
||||
|
||||
@classmethod
|
||||
def database(cls, name: str) -> "Object":
|
||||
"""A database; slash-containing names remain one logical database."""
|
||||
return cls(str(_lancedb.AuthzObject.database(name)))
|
||||
|
||||
@classmethod
|
||||
def namespace(
|
||||
cls, *, database: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A namespace path, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.namespace(database, namespace_path)))
|
||||
|
||||
@classmethod
|
||||
def table(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A table, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.table(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def view(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A view, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.view(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def secret(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A secret, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.secret(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def function(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A function, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.function(database, namespace_path, name)))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._inner)
|
||||
|
||||
def __reduce__(self):
|
||||
return type(self), (self._value,)
|
||||
|
||||
|
||||
class Privilege(str, Enum):
|
||||
"""Known privileges, validated and serialized by Rust.
|
||||
|
||||
ACL methods also accept strings such as ``"SELECT"`` and future privilege
|
||||
names such as ``"PRIVILEGE20"``. Future names are preserved verbatim;
|
||||
their meaning and validity are determined by the server.
|
||||
|
||||
Grant ``CREATE_SECRET`` or ``CREATE_FUNCTION`` on a namespace to delegate
|
||||
creation of those resources without transferring namespace ownership.
|
||||
"""
|
||||
|
||||
def __new__(cls, value: str):
|
||||
canonical = str(_lancedb.AuthzPrivilege(value))
|
||||
member = str.__new__(cls, canonical)
|
||||
member._value_ = canonical
|
||||
return member
|
||||
|
||||
OWNERSHIP = "OWNERSHIP"
|
||||
USAGE = "USAGE"
|
||||
SELECT = "SELECT"
|
||||
INSERT = "INSERT"
|
||||
UPDATE = "UPDATE"
|
||||
DELETE = "DELETE"
|
||||
CREATE_DATABASE = "CREATE_DATABASE"
|
||||
CREATE_NAMESPACE = "CREATE_NAMESPACE"
|
||||
CREATE_TABLE = "CREATE_TABLE"
|
||||
CREATE_MATERIALIZED_VIEW = "CREATE_MATERIALIZED_VIEW"
|
||||
CREATE_VIEW = "CREATE_VIEW"
|
||||
CREATE_SECRET = "CREATE_SECRET"
|
||||
CREATE_FUNCTION = "CREATE_FUNCTION"
|
||||
OPERATE = "OPERATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessControlEntry:
|
||||
"""One object/subject/privilege grant; subject contains a canonical ID or role."""
|
||||
|
||||
object: str
|
||||
subject: Subject
|
||||
subject_name: Optional[str]
|
||||
privilege: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleBinding:
|
||||
"""A direct principal/group role binding, not an expanded effective role."""
|
||||
|
||||
subject: Subject
|
||||
subject_name: Optional[str]
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityInfo:
|
||||
"""A canonical identity-provider ID and its optional display name."""
|
||||
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrincipalInfo:
|
||||
"""A principal's groups and effective roles, including group-inherited roles."""
|
||||
|
||||
principal_id: str
|
||||
principal_name: Optional[str]
|
||||
groups: list[IdentityInfo]
|
||||
roles: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupInfo:
|
||||
"""A group's provider-managed members and LanceDB-managed direct roles."""
|
||||
|
||||
group_id: str
|
||||
group_name: Optional[str]
|
||||
principals: list[IdentityInfo]
|
||||
roles: list[str]
|
||||
|
||||
|
||||
def _subject(subject: Subject, context: Optional[str] = None) -> str:
|
||||
if not isinstance(subject, Subject):
|
||||
raise ValueError("subject must be a Subject selector")
|
||||
return subject._inner.to_wire(context)
|
||||
|
||||
|
||||
def _object(value: Union[str, Object]) -> str:
|
||||
if not isinstance(value, (str, Object)) or not str(value):
|
||||
raise ValueError("object must be a nonempty string or Object")
|
||||
return str(value) if isinstance(value, Object) else str(_lancedb.AuthzObject(value))
|
||||
|
||||
|
||||
def _limit(limit: Optional[int]) -> None:
|
||||
if limit is not None and (
|
||||
isinstance(limit, bool) or not isinstance(limit, int) or limit < 0
|
||||
):
|
||||
raise ValueError("limit must be a nonnegative integer or None")
|
||||
|
||||
|
||||
def _privilege(value: Union[str, Privilege]) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("privilege must be a string or Privilege")
|
||||
return str(_lancedb.AuthzPrivilege(value))
|
||||
|
||||
|
||||
def _entry(value: dict) -> AccessControlEntry:
|
||||
return AccessControlEntry(
|
||||
value["object"],
|
||||
Subject._canonical(value["subject_id"]),
|
||||
value.get("subject_name"),
|
||||
value["privilege"],
|
||||
)
|
||||
|
||||
|
||||
def _binding(value: dict) -> RoleBinding:
|
||||
return RoleBinding(
|
||||
Subject._canonical(value["subject_id"]),
|
||||
value.get("subject_name"),
|
||||
value["role"],
|
||||
)
|
||||
|
||||
|
||||
class AsyncAuthorization:
|
||||
"""Async authorization returned by ``AsyncCatalog.authz``.
|
||||
|
||||
HTTP failures raise [HttpError][lancedb.remote.errors.HttpError], preserving
|
||||
status and request ID. Invalid local arguments raise ValueError. Lists are
|
||||
lazy async iterators: failures can occur during iteration after some results
|
||||
have been yielded. Iteration does not provide a consistent snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: _lancedb.AuthorizationClient):
|
||||
self._inner = inner
|
||||
|
||||
async def _pages(self, method, request: dict, key: str, limit: Optional[int]):
|
||||
yielded = 0
|
||||
while limit is None or yielded < limit:
|
||||
request["max_results"] = (
|
||||
_PAGE_SIZE if limit is None else min(_PAGE_SIZE, limit - yielded)
|
||||
)
|
||||
response = json.loads(await method(json.dumps(request)))
|
||||
for value in response[key]:
|
||||
yield value
|
||||
yielded += 1
|
||||
if limit is not None and yielded >= limit:
|
||||
return
|
||||
token = response.get("continuation_token")
|
||||
if not token:
|
||||
return
|
||||
if token == request.get("continuation_token"):
|
||||
raise RuntimeError(
|
||||
"Authorization listing returned a repeated continuation token"
|
||||
)
|
||||
request["continuation_token"] = token
|
||||
|
||||
def list_acls(
|
||||
self,
|
||||
*,
|
||||
object: Optional[Union[str, Object]] = None,
|
||||
subject: Optional[Subject] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[AccessControlEntry]:
|
||||
"""Iterate grants, optionally filtered by exact object and/or subject.
|
||||
|
||||
Requires USAGE on the supplied object, or on system if object is omitted.
|
||||
``limit`` bounds yielded entries across all pages; None lists all and zero
|
||||
makes no requests. Missing matches produce an empty iterator.
|
||||
"""
|
||||
_limit(limit)
|
||||
request = {}
|
||||
if object is not None:
|
||||
request["object"] = _object(object)
|
||||
if subject is not None:
|
||||
request["subject"] = _subject(subject)
|
||||
|
||||
async def entries():
|
||||
async for value in self._pages(
|
||||
self._inner.list_acls, request, "entries", limit
|
||||
):
|
||||
yield _entry(value)
|
||||
|
||||
return entries()
|
||||
|
||||
async def add_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Grant one privilege; requires OWNERSHIP on the object.
|
||||
|
||||
A duplicate grant raises HTTP 409. Granting OWNERSHIP transfers ownership.
|
||||
Writes are not automatically retried after ambiguous failures.
|
||||
"""
|
||||
await self._inner.add_acl(
|
||||
json.dumps(
|
||||
{
|
||||
"object": _object(object),
|
||||
"subject": _subject(subject),
|
||||
"privilege": _privilege(privilege),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Revoke one privilege; requires OWNERSHIP on the object.
|
||||
|
||||
A missing grant raises HTTP 404. OWNERSHIP itself cannot be deleted.
|
||||
"""
|
||||
await self._inner.delete_acl(
|
||||
json.dumps(
|
||||
{
|
||||
"object": _object(object),
|
||||
"subject": _subject(subject),
|
||||
"privilege": _privilege(privilege),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def list_role_bindings(
|
||||
self,
|
||||
*,
|
||||
subject: Optional[Subject] = None,
|
||||
role: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[RoleBinding]:
|
||||
"""Iterate direct bindings, optionally filtered by principal/group and role.
|
||||
|
||||
Requires USAGE on system. Limit semantics match ``list_acls``. Principals'
|
||||
roles inherited through groups are available through ``get_principal``.
|
||||
"""
|
||||
_limit(limit)
|
||||
request = {}
|
||||
if subject is not None:
|
||||
request["subject"] = _subject(subject, "role_member")
|
||||
if role is not None:
|
||||
request["role"] = role
|
||||
|
||||
async def bindings():
|
||||
async for value in self._pages(
|
||||
self._inner.list_role_bindings, request, "bindings", limit
|
||||
):
|
||||
yield _binding(value)
|
||||
|
||||
return bindings()
|
||||
|
||||
async def add_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Grant a role idempotently; requires OPERATE on system."""
|
||||
await self._inner.add_role_binding(
|
||||
json.dumps({"subject": _subject(subject, "role_member"), "role": role})
|
||||
)
|
||||
|
||||
async def delete_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Idempotently revoke a role; requires OPERATE on system."""
|
||||
await self._inner.delete_role_binding(
|
||||
json.dumps({"subject": _subject(subject, "role_member"), "role": role})
|
||||
)
|
||||
|
||||
async def get_principal(self, subject: Subject) -> PrincipalInfo:
|
||||
"""Look up groups and effective roles; requires USAGE on system.
|
||||
|
||||
Select by ID, name, or API key. Missing/ambiguous identities raise HTTP
|
||||
404/409; unavailable or unconfigured resolution raises HTTP 503/501.
|
||||
"""
|
||||
value = json.loads(
|
||||
await self._inner.get_principal(
|
||||
json.dumps({"principal": _subject(subject, "principal")})
|
||||
)
|
||||
)
|
||||
return PrincipalInfo(
|
||||
value["principal_id"],
|
||||
value.get("principal_name"),
|
||||
[IdentityInfo(**group) for group in value["groups"]],
|
||||
value["roles"],
|
||||
)
|
||||
|
||||
async def get_group(self, subject: Subject) -> GroupInfo:
|
||||
"""Look up a group's members and roles by ID/name; requires USAGE on system.
|
||||
|
||||
Membership is read-only and comes from the identity provider. Lookup
|
||||
errors have the same HTTP semantics as ``get_principal``.
|
||||
"""
|
||||
value = json.loads(
|
||||
await self._inner.get_group(
|
||||
json.dumps({"group": _subject(subject, "group")})
|
||||
)
|
||||
)
|
||||
return GroupInfo(
|
||||
value["group_id"],
|
||||
value.get("group_name"),
|
||||
[IdentityInfo(**principal) for principal in value["principals"]],
|
||||
value["roles"],
|
||||
)
|
||||
|
||||
|
||||
async def _next(iterator: AsyncIterator[_T]) -> _T:
|
||||
return await iterator.__anext__()
|
||||
|
||||
|
||||
def _sync_iter(iterator: AsyncIterator[_T]) -> Iterator[_T]:
|
||||
try:
|
||||
while True:
|
||||
yield LOOP.run(_next(iterator))
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
LOOP.run(close())
|
||||
|
||||
|
||||
class Authorization:
|
||||
"""Synchronous authorization returned by ``Catalog.authz``.
|
||||
|
||||
Shares the async API's permissions, errors, and lazy pagination behavior.
|
||||
Group membership comes from the identity provider and cannot be edited here.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: AsyncAuthorization):
|
||||
self._inner = inner
|
||||
|
||||
def list_acls(
|
||||
self,
|
||||
*,
|
||||
object: Optional[Union[str, Object]] = None,
|
||||
subject: Optional[Subject] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[AccessControlEntry]:
|
||||
"""Iterate grants.
|
||||
|
||||
See [list_acls][lancedb.authz.AsyncAuthorization.list_acls] for permissions.
|
||||
"""
|
||||
return _sync_iter(
|
||||
self._inner.list_acls(object=object, subject=subject, limit=limit)
|
||||
)
|
||||
|
||||
def add_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Grant a privilege; duplicates raise HTTP 409.
|
||||
|
||||
OWNERSHIP transfers ownership.
|
||||
"""
|
||||
LOOP.run(
|
||||
self._inner.add_acl(object=object, subject=subject, privilege=privilege)
|
||||
)
|
||||
|
||||
def delete_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Revoke a privilege. Missing grants raise HTTP 404.
|
||||
|
||||
OWNERSHIP cannot be deleted.
|
||||
"""
|
||||
LOOP.run(
|
||||
self._inner.delete_acl(object=object, subject=subject, privilege=privilege)
|
||||
)
|
||||
|
||||
def list_role_bindings(
|
||||
self,
|
||||
*,
|
||||
subject: Optional[Subject] = None,
|
||||
role: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[RoleBinding]:
|
||||
"""Iterate direct role bindings, using the same limit semantics as list_acls."""
|
||||
return _sync_iter(
|
||||
self._inner.list_role_bindings(subject=subject, role=role, limit=limit)
|
||||
)
|
||||
|
||||
def add_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Grant a role idempotently; requires OPERATE on system."""
|
||||
LOOP.run(self._inner.add_role_binding(subject=subject, role=role))
|
||||
|
||||
def delete_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Idempotently revoke a role; requires OPERATE on system."""
|
||||
LOOP.run(self._inner.delete_role_binding(subject=subject, role=role))
|
||||
|
||||
def get_principal(self, subject: Subject) -> PrincipalInfo:
|
||||
"""Look up groups and effective roles; requires USAGE on system."""
|
||||
return LOOP.run(self._inner.get_principal(subject))
|
||||
|
||||
def get_group(self, subject: Subject) -> GroupInfo:
|
||||
"""Look up a group's members and roles; requires USAGE on system."""
|
||||
return LOOP.run(self._inner.get_group(subject))
|
||||
@@ -3,11 +3,13 @@
|
||||
|
||||
"""Remote catalogs manage databases through a server's root namespace."""
|
||||
|
||||
from functools import cached_property
|
||||
from datetime import timedelta
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Union
|
||||
|
||||
from . import _lancedb
|
||||
from .background_loop import LOOP
|
||||
from .authz import AsyncAuthorization, Authorization
|
||||
from .db import AsyncConnection, DBConnection
|
||||
from .remote import ClientConfig, OAuthConfig
|
||||
from .remote.db import RemoteDBConnection
|
||||
@@ -86,6 +88,11 @@ class AsyncCatalog:
|
||||
def __init__(self, inner: _lancedb.Catalog):
|
||||
self._inner = inner
|
||||
|
||||
@cached_property
|
||||
def authz(self) -> AsyncAuthorization:
|
||||
"""Authorization sharing this catalog's scope and credentials."""
|
||||
return AsyncAuthorization(self._inner.authz)
|
||||
|
||||
@property
|
||||
def uri(self) -> str:
|
||||
"""The catalog's root namespace endpoint."""
|
||||
@@ -157,6 +164,11 @@ class Catalog:
|
||||
self._sql_host_override = sql_host_override
|
||||
self._oauth_config = oauth_config
|
||||
|
||||
@cached_property
|
||||
def authz(self) -> Authorization:
|
||||
"""Authorization sharing this catalog's scope and credentials."""
|
||||
return Authorization(self._inner.authz)
|
||||
|
||||
@property
|
||||
def uri(self) -> str:
|
||||
"""The catalog's root namespace endpoint."""
|
||||
|
||||
+20
-14
@@ -317,7 +317,7 @@ class DBConnection(EnforceOverrides):
|
||||
page_token: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> ListTablesResponse:
|
||||
"""List all tables in this database with pagination support.
|
||||
"""List a page of tables in this database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -328,7 +328,8 @@ class DBConnection(EnforceOverrides):
|
||||
Token for pagination. Use the token from a previous response
|
||||
to get the next page of results.
|
||||
limit: int, optional
|
||||
The maximum number of results to return.
|
||||
The maximum number of results to return, default 100.
|
||||
Zero returns an empty page without a continuation token.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -343,7 +344,7 @@ class DBConnection(EnforceOverrides):
|
||||
def table_names(
|
||||
self,
|
||||
page_token: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
limit: Optional[int] = None,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Iterable[str]:
|
||||
@@ -357,8 +358,9 @@ class DBConnection(EnforceOverrides):
|
||||
page_token: str, optional
|
||||
The token to use for pagination. If not present, start from the beginning.
|
||||
Typically, this token is last table name from the previous page.
|
||||
limit: int, default 10
|
||||
The size of the page to return.
|
||||
limit: int, optional
|
||||
The maximum number of tables to return. None returns all tables;
|
||||
zero returns an empty list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -1507,7 +1509,7 @@ class LanceDBConnection(DBConnection):
|
||||
page_token: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> ListTablesResponse:
|
||||
"""List all tables in this database with pagination support.
|
||||
"""List a page of tables in this database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -1518,7 +1520,8 @@ class LanceDBConnection(DBConnection):
|
||||
Token for pagination. Use the token from a previous response
|
||||
to get the next page of results.
|
||||
limit: int, optional
|
||||
The maximum number of results to return.
|
||||
The maximum number of results to return, default 100.
|
||||
Zero returns an empty page without a continuation token.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -1537,7 +1540,7 @@ class LanceDBConnection(DBConnection):
|
||||
def table_names(
|
||||
self,
|
||||
page_token: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
limit: Optional[int] = None,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Iterable[str]:
|
||||
@@ -1552,8 +1555,9 @@ class LanceDBConnection(DBConnection):
|
||||
The namespace to list tables in.
|
||||
page_token: str, optional
|
||||
The token to use for pagination.
|
||||
limit: int, default 10
|
||||
The maximum number of tables to return.
|
||||
limit: int, optional
|
||||
The maximum number of tables to return. None returns all tables;
|
||||
zero returns an empty list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -2383,7 +2387,7 @@ class AsyncConnection(object):
|
||||
page_token: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> ListTablesResponse:
|
||||
"""List all tables in this database with pagination support.
|
||||
"""List a page of tables in this database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -2394,7 +2398,8 @@ class AsyncConnection(object):
|
||||
Token for pagination. Use the token from a previous response
|
||||
to get the next page of results.
|
||||
limit: int, optional
|
||||
The maximum number of results to return.
|
||||
The maximum number of results to return, default 100.
|
||||
Zero returns an empty page without a continuation token.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -2431,8 +2436,9 @@ class AsyncConnection(object):
|
||||
|
||||
This can be combined with limit to implement pagination by setting this to
|
||||
the last table name from the previous page.
|
||||
limit: int, default 10
|
||||
The number of results to return.
|
||||
limit: int, optional
|
||||
The maximum number of tables to return. None returns all tables;
|
||||
zero returns an empty list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
@@ -372,7 +372,7 @@ class FunctionVersion(_RemoteValue):
|
||||
image: FunctionImage
|
||||
signature: FunctionSignature
|
||||
secret_bindings: tuple[SecretBinding, ...] = ()
|
||||
created_at: str
|
||||
created_at_millis: int
|
||||
metadata: Mapping[str, str]
|
||||
disabled: bool
|
||||
|
||||
|
||||
@@ -112,9 +112,42 @@ class Fm:
|
||||
class FTS:
|
||||
"""Describe a FTS index configuration.
|
||||
|
||||
`FTS` is a full-text search index that can be used on `String` columns
|
||||
`FTS` supports string columns, lists of strings, and JSON columns created
|
||||
from PyArrow ``pa.json_()``. JSON columns use Lance's JSON-aware tokenizer
|
||||
and are stored as ``LargeBinary`` with the ``lance.json`` logical type.
|
||||
Arbitrary binary columns are not supported.
|
||||
|
||||
For example, it works with `title`, `description`, `content`, etc.
|
||||
JSON queries use ``path,str,value`` triples. Paths are relative to the JSON
|
||||
column and use dots for nested object keys. For example,
|
||||
``meta.tag,str,nature`` matches the string value at ``$.meta.tag``;
|
||||
it does not search other paths or JSON key names. The value is processed
|
||||
with the configured text tokenizer. Use ``str``, not ``string``.
|
||||
|
||||
Create and query a JSON index through the modern API::
|
||||
|
||||
import pyarrow as pa
|
||||
from lancedb.index import FTS
|
||||
|
||||
table = db.create_table(
|
||||
"documents",
|
||||
pa.table({"doc": pa.array(
|
||||
['{"meta":{"tag":"nature"}}'], type=pa.json_()
|
||||
)}),
|
||||
)
|
||||
table.create_index("doc", config=FTS())
|
||||
table.search(
|
||||
"meta.tag,str,nature", query_type="fts", fts_columns="doc"
|
||||
).to_list()
|
||||
|
||||
Create the index on the JSON column itself, not a JSON subpath such as
|
||||
``doc.meta.tag``. JSON-path scalar indexes do not provide FTS path scoping.
|
||||
Plain text such as ``nature`` is not a valid JSON FTS query. The current
|
||||
Lance tokenizer can panic on malformed triples; see
|
||||
https://github.com/lance-format/lance/issues/8812.
|
||||
|
||||
Remote tables use the same ``FTS()`` configuration and require a server
|
||||
that supports JSON FTS. Wait for ``table.wait_for_index(["doc_idx"])``
|
||||
before searching a newly created remote index with its default name.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
@@ -105,6 +105,11 @@ def _query_to_namespace_request(
|
||||
|
||||
if not isinstance(query, Query):
|
||||
raise TypeError(f"Expected Query, got {type(query)}")
|
||||
if query.search_effort is not None:
|
||||
raise ValueError(
|
||||
"namespace QueryTable does not support search_effort; "
|
||||
"execute the query locally"
|
||||
)
|
||||
|
||||
vector_request = None
|
||||
if query.vector is not None:
|
||||
@@ -190,8 +195,12 @@ def _query_to_namespace_request(
|
||||
kwargs["with_row_id"] = query.with_row_id
|
||||
if query.ef is not None:
|
||||
kwargs["ef"] = query.ef
|
||||
if query.nprobes is not None:
|
||||
kwargs["nprobes"] = query.nprobes
|
||||
if query.minimum_nprobes is not None:
|
||||
kwargs["nprobes"] = query.minimum_nprobes
|
||||
kwargs["minimum_nprobes"] = query.minimum_nprobes
|
||||
if query.maximum_nprobes is not None:
|
||||
kwargs["maximum_nprobes"] = query.maximum_nprobes
|
||||
if query.refine_factor is not None:
|
||||
kwargs["refine_factor"] = query.refine_factor
|
||||
if query.lower_bound is not None:
|
||||
@@ -205,6 +214,13 @@ def _query_to_namespace_request(
|
||||
if query.distance_type is not None:
|
||||
kwargs["distance_type"] = query.distance_type
|
||||
|
||||
if kwargs.get("maximum_nprobes") == 0:
|
||||
# The generated 0.13 model rejects LanceDB's unbounded sentinel;
|
||||
# model_copy preserves direct wire pass-through to the Lance receiver.
|
||||
maximum_nprobes = kwargs.pop("maximum_nprobes")
|
||||
return QueryTableRequest(**kwargs).model_copy(
|
||||
update={"maximum_nprobes": maximum_nprobes}
|
||||
)
|
||||
return QueryTableRequest(**kwargs)
|
||||
|
||||
|
||||
@@ -522,13 +538,15 @@ class LanceNamespaceDBConnection(DBConnection):
|
||||
def table_names(
|
||||
self,
|
||||
page_token: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
limit: Optional[int] = None,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Iterable[str]:
|
||||
"""
|
||||
List table names in the database.
|
||||
|
||||
If limit is None, return all table names; zero returns an empty list.
|
||||
|
||||
.. deprecated::
|
||||
Use :meth:`list_tables` instead, which provides proper pagination support.
|
||||
"""
|
||||
@@ -1157,13 +1175,15 @@ class AsyncLanceNamespaceDBConnection:
|
||||
async def table_names(
|
||||
self,
|
||||
page_token: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
limit: Optional[int] = None,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Iterable[str]:
|
||||
"""
|
||||
List table names in the namespace.
|
||||
|
||||
If limit is None, return all table names; zero returns an empty list.
|
||||
|
||||
.. deprecated::
|
||||
Use :meth:`list_tables` instead, which provides proper pagination support.
|
||||
"""
|
||||
|
||||
+463
-114
@@ -12,6 +12,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
@@ -40,7 +41,7 @@ from .rerankers.base import Reranker
|
||||
from .rerankers.rrf import RRFReranker
|
||||
from .rerankers.util import check_reranker_result
|
||||
from .schema import blob_column_paths, is_blob_like_field, schema_has_blob_field
|
||||
from .util import _validate_query_vector, flatten_columns
|
||||
from .util import _validate_query_vector, flatten_columns, get_uri_scheme
|
||||
from . import _wal_hybrid # WAL-PK-FUSION: delete.
|
||||
from ._blob import (
|
||||
BLOB_MODE_TO_HANDLING,
|
||||
@@ -49,6 +50,8 @@ from ._blob import (
|
||||
blob_auto_row_id_for_scan,
|
||||
blob_v2_projection_sources,
|
||||
finalize_blob_query_table,
|
||||
reject_unfetchable_blob_columns,
|
||||
reject_unmatched_blob_outputs,
|
||||
replace_v2_blob_columns_with_bytes,
|
||||
replace_v2_blob_columns_with_bytes_sync,
|
||||
strip_auto_row_ids,
|
||||
@@ -99,15 +102,45 @@ class _LanceScanner(Protocol):
|
||||
def to_reader(self): ...
|
||||
|
||||
|
||||
def _blob_mode_requires_native_pandas(blob_mode: BlobMode, schema: pa.Schema) -> bool:
|
||||
return blob_mode in BLOB_MODE_TO_HANDLING and schema_has_blob_field(schema)
|
||||
|
||||
|
||||
def _unsupported_blob_pandas_error(reason: str) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
"blob columns require Lance native scanner conversion for query "
|
||||
f"to_pandas(), but {reason}. Use a plain scan query or remove blob "
|
||||
"columns from the projection."
|
||||
f"to_pandas(), but {reason}. Use a plain scan query, pass "
|
||||
"blob_mode='bytes' or blob_mode='descriptions', or remove blob columns "
|
||||
"from the projection."
|
||||
)
|
||||
|
||||
|
||||
def _supports_local_blob_fetch(
|
||||
async_table: "AsyncTable", pushdown_operations: Iterable[str]
|
||||
) -> bool:
|
||||
# Remote tables and QueryTable pushdown answer queries on a server, so a
|
||||
# fetch through this handle could read another version.
|
||||
return async_table._inner._is_native() and "QueryTable" not in pushdown_operations
|
||||
|
||||
|
||||
def _blob_bytes_without_snapshot_error() -> NotImplementedError:
|
||||
return NotImplementedError(
|
||||
"blob_mode='bytes' cannot safely materialize blob columns without a "
|
||||
"stable table snapshot. Remote tables and namespace QueryTable pushdown "
|
||||
"run the query on a server, so the blob fetch could read another table "
|
||||
"version. Use blob_mode='descriptions'."
|
||||
)
|
||||
|
||||
|
||||
def _query_needs_lance_scanner(builder: Any) -> bool:
|
||||
"""Whether the query sets options that to_arrow ignores."""
|
||||
to_query_object = getattr(builder, "to_query_object", None)
|
||||
if to_query_object is None:
|
||||
return False
|
||||
try:
|
||||
query = to_query_object()
|
||||
except NotImplementedError:
|
||||
return False
|
||||
return (
|
||||
query.fragments is not None
|
||||
or query.fragment_ids is not None
|
||||
or bool(query.with_row_address)
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +184,8 @@ def _combine_where(
|
||||
|
||||
|
||||
def _projection_to_scanner_kwargs(columns: QueryProjection) -> Dict[str, Any]:
|
||||
if columns is None:
|
||||
# An empty projection selects all columns.
|
||||
if not columns:
|
||||
return {}
|
||||
if isinstance(columns, list):
|
||||
if all(isinstance(column, str) for column in columns):
|
||||
@@ -175,7 +209,8 @@ def _projection_to_scanner_kwargs(columns: QueryProjection) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _query_request_projection(req: "PyQueryRequest") -> QueryProjection:
|
||||
if req.select_source_columns is not None:
|
||||
# Treat empty map as a calculated select
|
||||
if req.select_source_columns:
|
||||
return req.select_source_columns
|
||||
return req.select
|
||||
|
||||
@@ -475,8 +510,8 @@ class MatchQuery(FullTextQuery):
|
||||
----------
|
||||
query : str
|
||||
The query string to match against.
|
||||
column : str
|
||||
The name of the column to match against.
|
||||
column : str, optional
|
||||
The column to match against. None leaves column selection to query planning.
|
||||
boost : float, default 1.0
|
||||
The boost factor for the query.
|
||||
The score of each matching document is multiplied by this value.
|
||||
@@ -505,7 +540,7 @@ class MatchQuery(FullTextQuery):
|
||||
"""
|
||||
|
||||
query: str
|
||||
column: str
|
||||
column: Optional[str] = None
|
||||
boost: float = pydantic.Field(1.0, kw_only=True)
|
||||
fuzziness: Optional[int] = pydantic.Field(0, kw_only=True)
|
||||
max_expansions: int = pydantic.Field(50, kw_only=True)
|
||||
@@ -693,8 +728,9 @@ class Query(pydantic.BaseModel):
|
||||
if True then apply the filter after vector / FTS search. This is ignored for
|
||||
plain SQL filtering.
|
||||
nprobes : Optional[int]
|
||||
The number of IVF partitions to search. If this is None then a default
|
||||
number of partitions will be used.
|
||||
The legacy number of IVF partitions to search. Lance sets both probe
|
||||
bounds to this value. If this is None then Lance's default probe settings
|
||||
will be used.
|
||||
|
||||
- A higher number makes search more accurate but also slower.
|
||||
|
||||
@@ -773,22 +809,28 @@ class Query(pydantic.BaseModel):
|
||||
# distance type to use for vector search
|
||||
distance_type: Optional[str] = None
|
||||
|
||||
# legacy number of IVF partitions to search
|
||||
#
|
||||
# Lance sets both probe bounds to this value. Explicit bounds can override it.
|
||||
nprobes: Optional[int] = None
|
||||
|
||||
# which columns to return in the results (dict values may be str or Expr)
|
||||
columns: QueryProjection = None
|
||||
|
||||
# minimum number of IVF partitions to search
|
||||
#
|
||||
# If None then a default value (20) will be used.
|
||||
# If None then Lance's default will be used.
|
||||
minimum_nprobes: Optional[int] = None
|
||||
|
||||
# maximum number of IVF partitions to search
|
||||
#
|
||||
# If None then a default value (20) will be used.
|
||||
#
|
||||
# If 0 then no limit will be applied and all partitions could be searched
|
||||
# if needed to satisfy the limit.
|
||||
# If None then Lance's default will be used. If 0 then no limit will be applied
|
||||
# and all partitions could be searched if needed to satisfy the limit.
|
||||
maximum_nprobes: Optional[int] = None
|
||||
|
||||
# Optional initial IVF effort; None preserves Auto and explicit nprobes.
|
||||
search_effort: Optional[float] = None
|
||||
|
||||
# lower bound for distance search
|
||||
lower_bound: Optional[float] = None
|
||||
|
||||
@@ -847,8 +889,10 @@ class Query(pydantic.BaseModel):
|
||||
query.vector_column = req.column
|
||||
query.vector = req.query_vector
|
||||
query.distance_type = req.distance_type
|
||||
query.nprobes = req.nprobes
|
||||
query.minimum_nprobes = req.minimum_nprobes
|
||||
query.maximum_nprobes = req.maximum_nprobes
|
||||
query.search_effort = req.search_effort
|
||||
query.lower_bound = req.lower_bound
|
||||
query.upper_bound = req.upper_bound
|
||||
query.ef = req.ef
|
||||
@@ -1063,44 +1107,42 @@ class LanceQueryBuilder(ABC):
|
||||
The maximum time to wait for the query to complete.
|
||||
If None, wait indefinitely.
|
||||
blob_mode: str, default "lazy"
|
||||
Controls how blob columns are returned for plain scan queries.
|
||||
Vector, FTS, hybrid, and other non-native query shapes keep the
|
||||
existing Arrow conversion path and only support blob descriptions.
|
||||
Controls how blob columns are returned. ``"lazy"`` returns
|
||||
:class:`~lancedb._blob.BlobFile` handles and needs a plain scan.
|
||||
``"descriptions"`` and ``"bytes"`` work for every query shape. A
|
||||
plain scan without ``timeout`` reads the local dataset through
|
||||
Lance's scanner, which also returns legacy blob columns as bytes.
|
||||
Other shapes use :meth:`to_arrow` with the same ``blob_mode``.
|
||||
**kwargs
|
||||
Forwarded to pyarrow.Table.to_pandas after query execution and
|
||||
optional flattening.
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
output_schema = getattr(self, "output_schema", None)
|
||||
if output_schema is not None:
|
||||
schema = output_schema()
|
||||
if _blob_mode_requires_native_pandas(blob_mode, schema):
|
||||
native_error = None
|
||||
if (flatten is None or blob_mode == "descriptions") and timeout is None:
|
||||
try:
|
||||
df = self._plain_scan_to_pandas(
|
||||
blob_mode, flatten=flatten, **kwargs
|
||||
)
|
||||
if df is not None:
|
||||
return df
|
||||
except Exception as err:
|
||||
native_error = err
|
||||
reason = (
|
||||
if output_schema is not None and schema_has_blob_field(output_schema()):
|
||||
if (flatten is None or blob_mode != "lazy") and timeout is None:
|
||||
df = self._plain_scan_to_pandas(blob_mode, flatten=flatten, **kwargs)
|
||||
if df is not None:
|
||||
return df
|
||||
if blob_mode == "lazy" or _query_needs_lance_scanner(self):
|
||||
raise _unsupported_blob_pandas_error(
|
||||
"this query shape cannot use Lance native pandas conversion"
|
||||
if native_error is None
|
||||
else str(native_error)
|
||||
)
|
||||
raise _unsupported_blob_pandas_error(reason) from native_error
|
||||
|
||||
tbl = flatten_columns(self.to_arrow(timeout=timeout), flatten)
|
||||
if _blob_mode_requires_native_pandas(blob_mode, tbl.schema):
|
||||
tbl = flatten_columns(self._to_arrow_in_mode(timeout, blob_mode), flatten)
|
||||
if blob_mode == "lazy" and schema_has_blob_field(tbl.schema):
|
||||
raise _unsupported_blob_pandas_error(
|
||||
"this query shape cannot use Lance native pandas conversion"
|
||||
)
|
||||
return tbl.to_pandas(**kwargs)
|
||||
|
||||
@abstractmethod
|
||||
def to_arrow(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
"""
|
||||
Execute the query and return the results as an
|
||||
[Apache Arrow Table](https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table).
|
||||
@@ -1114,6 +1156,11 @@ class LanceQueryBuilder(ABC):
|
||||
timeout: Optional[timedelta]
|
||||
The maximum time to wait for the query to complete.
|
||||
If None, wait indefinitely.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1144,7 +1191,12 @@ class LanceQueryBuilder(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def to_list(self, *, timeout: Optional[timedelta] = None) -> List[dict]:
|
||||
def to_list(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Execute the query and return the results as a list of dictionaries.
|
||||
|
||||
@@ -1157,8 +1209,19 @@ class LanceQueryBuilder(ABC):
|
||||
timeout: Optional[timedelta]
|
||||
The maximum time to wait for the query to complete.
|
||||
If None, wait indefinitely.
|
||||
blob_mode: str, default "lazy"
|
||||
Passed through to :meth:`to_arrow`.
|
||||
"""
|
||||
return self.to_arrow(timeout=timeout).to_pylist()
|
||||
validate_blob_mode(blob_mode)
|
||||
return self._to_arrow_in_mode(timeout, blob_mode).to_pylist()
|
||||
|
||||
def _to_arrow_in_mode(
|
||||
self, timeout: Optional[timedelta], blob_mode: BlobMode
|
||||
) -> pa.Table:
|
||||
# Older subclasses may not accept blob_mode. Omit it when it has no effect.
|
||||
if blob_mode == "lazy" or not schema_has_blob_field(self._table.schema):
|
||||
return self.to_arrow(timeout=timeout)
|
||||
return self.to_arrow(timeout=timeout, blob_mode=blob_mode)
|
||||
|
||||
def to_pydantic(
|
||||
self, model: type[T], *, timeout: Optional[timedelta] = None
|
||||
@@ -1331,19 +1394,62 @@ class LanceQueryBuilder(ABC):
|
||||
query.with_row_id = True
|
||||
return query
|
||||
|
||||
def _finalize_blob_query_table(self, tbl: pa.Table) -> pa.Table:
|
||||
blob_auto_row_id = self._blob_auto_row_id_enabled()
|
||||
blob_paths = (
|
||||
blob_v2_projection_sources(self._table.schema, self._columns).keys()
|
||||
if blob_auto_row_id
|
||||
else ()
|
||||
def _validated_blob_sources(self, blob_mode: BlobMode) -> dict[str, str]:
|
||||
"""Map projected blob v2 outputs to their source columns.
|
||||
|
||||
Bytes mode is checked here, before the query runs.
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
schema = self._table.schema
|
||||
blob_sources = blob_v2_projection_sources(schema, self._columns)
|
||||
if blob_mode != "bytes":
|
||||
return blob_sources
|
||||
reject_unfetchable_blob_columns(schema, self._columns, blob_sources)
|
||||
if blob_sources and not _supports_local_blob_fetch(
|
||||
self._table._table, getattr(self._table, "_pushdown_operations", ())
|
||||
):
|
||||
raise _blob_bytes_without_snapshot_error()
|
||||
reject_unmatched_blob_outputs(self._blob_output_schema(), blob_sources)
|
||||
return blob_sources
|
||||
|
||||
def _blob_output_schema(self) -> pa.Schema:
|
||||
return self.output_schema()
|
||||
|
||||
def _apply_arrow_blob_mode(
|
||||
self, tbl: pa.Table, blob_mode: BlobMode, blob_sources: dict[str, str]
|
||||
) -> pa.Table:
|
||||
if blob_mode == "lazy":
|
||||
return tbl
|
||||
if blob_mode == "descriptions":
|
||||
return strip_auto_row_ids(tbl, blob_sources)
|
||||
return replace_v2_blob_columns_with_bytes_sync(
|
||||
tbl, blob_sources, self._table.fetch_blobs
|
||||
)
|
||||
return finalize_blob_query_table(
|
||||
|
||||
def _finalize_blob_query_table(
|
||||
self,
|
||||
tbl: pa.Table,
|
||||
*,
|
||||
blob_mode: BlobMode,
|
||||
blob_sources: dict[str, str],
|
||||
) -> pa.Table:
|
||||
blob_auto_row_id = self._blob_auto_row_id_enabled()
|
||||
finalized = finalize_blob_query_table(
|
||||
tbl,
|
||||
user_requested_row_id=self._user_requested_row_id(),
|
||||
blob_auto_row_id=blob_auto_row_id,
|
||||
blob_paths=blob_paths,
|
||||
blob_paths=blob_sources.keys() if blob_auto_row_id else (),
|
||||
)
|
||||
finalized = self._apply_arrow_blob_mode(finalized, blob_mode, blob_sources)
|
||||
# with_row_id(False) does not reach the scan, which adds _rowid anyway.
|
||||
# Lazy keeps it because fetch_blobs(hits) reads it there.
|
||||
if (
|
||||
blob_mode != "lazy"
|
||||
and self._with_row_id is False
|
||||
and "_rowid" in finalized.column_names
|
||||
):
|
||||
finalized = finalized.drop_columns(["_rowid"])
|
||||
return finalized
|
||||
|
||||
def with_row_address(self, with_row_address: bool = True) -> Self:
|
||||
"""Set whether to return row addresses.
|
||||
@@ -1556,6 +1662,11 @@ class LanceQueryBuilder(ABC):
|
||||
if not _query_is_plain_scan(query):
|
||||
return None
|
||||
|
||||
# Remote and in-memory tables have no Lance dataset to scan.
|
||||
if not self._table._table._inner._is_native() or (
|
||||
get_uri_scheme(self._table._dataset_path) == "memory"
|
||||
):
|
||||
return None
|
||||
dataset = self._table.to_lance()
|
||||
blob_auto_row_id = self._blob_auto_row_id_enabled()
|
||||
blob_sources = (
|
||||
@@ -1563,6 +1674,10 @@ class LanceQueryBuilder(ABC):
|
||||
if blob_mode == "bytes"
|
||||
else {}
|
||||
)
|
||||
if blob_sources:
|
||||
reject_unfetchable_blob_columns(
|
||||
self._table.schema, query.columns, blob_sources
|
||||
)
|
||||
scanner = dataset.scanner(
|
||||
**_scanner_kwargs_for_query(
|
||||
query,
|
||||
@@ -1576,7 +1691,7 @@ class LanceQueryBuilder(ABC):
|
||||
blob_mode=blob_mode,
|
||||
blob_sources=blob_sources,
|
||||
fetch_blobs=self._table.fetch_blobs,
|
||||
strip_auto_row_id=blob_auto_row_id,
|
||||
strip_auto_row_id=not self._user_requested_row_id(),
|
||||
flatten=flatten,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -1627,8 +1742,10 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
super().__init__(table)
|
||||
self._query = query
|
||||
self._distance_type = None
|
||||
self._nprobes = None
|
||||
self._minimum_nprobes = None
|
||||
self._maximum_nprobes = None
|
||||
self._search_effort = None
|
||||
self._lower_bound = None
|
||||
self._upper_bound = None
|
||||
self._refine_factor = None
|
||||
@@ -1691,9 +1808,9 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
See discussion in [Querying an ANN Index](https://lancedb.com/docs/indexing/)
|
||||
for tuning advice.
|
||||
|
||||
This method sets both the minimum and maximum number of probes to the same
|
||||
value. See `minimum_nprobes` and `maximum_nprobes` for more fine-grained
|
||||
control.
|
||||
The value is retained as `nprobes` through client and server request
|
||||
construction. Lance sets both probe bounds to this value. Explicit minimum
|
||||
or maximum settings can override their respective bounds.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -1707,8 +1824,28 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
"""
|
||||
if nprobes <= 0:
|
||||
raise ValueError("Invalid input, nprobes must be greater than 0")
|
||||
self._minimum_nprobes = nprobes
|
||||
self._maximum_nprobes = nprobes
|
||||
self._nprobes = nprobes
|
||||
return self
|
||||
|
||||
def search_effort(self, search_effort: Optional[float]) -> LanceVectorQueryBuilder:
|
||||
"""Set the initial IVF search effort to a finite value in [0, 1].
|
||||
|
||||
None (the default) uses Auto, internally 0.5. Zero starts at the caller
|
||||
minimum; one searches all partitions within the caller maximum. Later
|
||||
probing may continue when too few candidates are found. Any explicit
|
||||
effort, including 0.5, overrides nprobes in either call order. Explicit
|
||||
minimum and maximum bounds still apply. This is not a recall guarantee
|
||||
and does not change refinement or HNSW ef. MemWAL LSM queries do not
|
||||
yet support this option.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect("memory://")
|
||||
>>> table = db.create_table("effort_vectors", [{"vector": [0.1, 0.2]}])
|
||||
>>> query = table.search([0.1, 0.2]).search_effort(0.75)
|
||||
"""
|
||||
self._search_effort = search_effort
|
||||
return self
|
||||
|
||||
def minimum_nprobes(self, minimum_nprobes: int) -> LanceVectorQueryBuilder:
|
||||
@@ -1815,7 +1952,12 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
"""
|
||||
return self._table._output_schema(self.to_query_object())
|
||||
|
||||
def to_arrow(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
"""
|
||||
Execute the query and return the results as an
|
||||
[Apache Arrow Table](https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table).
|
||||
@@ -1829,9 +1971,17 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
timeout: Optional[timedelta]
|
||||
The maximum time to wait for the query to complete.
|
||||
If None, wait indefinitely.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
"""
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
return self._finalize_blob_query_table(
|
||||
self.to_batches(timeout=timeout).read_all()
|
||||
self.to_batches(timeout=timeout).read_all(),
|
||||
blob_mode=blob_mode,
|
||||
blob_sources=blob_sources,
|
||||
)
|
||||
|
||||
def to_query_object(self) -> Query:
|
||||
@@ -1850,8 +2000,10 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
|
||||
limit=self._limit,
|
||||
distance_type=self._distance_type,
|
||||
columns=self._columns,
|
||||
nprobes=self._nprobes,
|
||||
minimum_nprobes=self._minimum_nprobes,
|
||||
maximum_nprobes=self._maximum_nprobes,
|
||||
search_effort=self._search_effort,
|
||||
lower_bound=self._lower_bound,
|
||||
upper_bound=self._upper_bound,
|
||||
refine_factor=self._refine_factor,
|
||||
@@ -2102,16 +2254,23 @@ class LanceFtsQueryBuilder(LanceQueryBuilder):
|
||||
"""
|
||||
return self._table._output_schema(self.to_query_object())
|
||||
|
||||
def to_arrow(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
self._table._ensure_no_legacy_fts_index()
|
||||
|
||||
query = self._query_for_scan()
|
||||
results = self._table._execute_query(query, timeout=timeout)
|
||||
results = results.read_all()
|
||||
results = self._table._execute_query(query, timeout=timeout).read_all()
|
||||
if self._reranker is not None:
|
||||
results = self._reranker.rerank_fts(self._query, results)
|
||||
check_reranker_result(results)
|
||||
return self._finalize_blob_query_table(results)
|
||||
return self._finalize_blob_query_table(
|
||||
results, blob_mode=blob_mode, blob_sources=blob_sources
|
||||
)
|
||||
|
||||
def to_batches(
|
||||
self, /, batch_size: Optional[int] = None, timeout: Optional[timedelta] = None
|
||||
@@ -2138,9 +2297,17 @@ class LanceFtsQueryBuilder(LanceQueryBuilder):
|
||||
|
||||
|
||||
class LanceEmptyQueryBuilder(LanceQueryBuilder):
|
||||
def to_arrow(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
return self._finalize_blob_query_table(
|
||||
self.to_batches(timeout=timeout).read_all()
|
||||
self.to_batches(timeout=timeout).read_all(),
|
||||
blob_mode=blob_mode,
|
||||
blob_sources=blob_sources,
|
||||
)
|
||||
|
||||
def to_query_object(self) -> Query:
|
||||
@@ -2208,8 +2375,10 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
self._fts_columns = fts_columns
|
||||
self._norm = None
|
||||
self._reranker = None
|
||||
self._nprobes = None
|
||||
self._minimum_nprobes = None
|
||||
self._maximum_nprobes = None
|
||||
self._search_effort = None
|
||||
self._refine_factor = None
|
||||
self._distance_type = None
|
||||
self._phrase_query = None
|
||||
@@ -2271,16 +2440,30 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
def to_query_object(self) -> Query:
|
||||
raise NotImplementedError("to_query_object not yet supported on a hybrid query")
|
||||
|
||||
def to_arrow(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
blob_sources = self._validated_blob_sources(blob_mode)
|
||||
# WAL-PK-FUSION: without the fallback, this body is `_run_hybrid`'s.
|
||||
return _wal_hybrid.with_pk_fallback(
|
||||
lambda: self._run_hybrid(timeout=timeout),
|
||||
lambda: self._run_hybrid(
|
||||
timeout=timeout, blob_mode=blob_mode, blob_sources=blob_sources
|
||||
),
|
||||
self._table,
|
||||
fused=lambda: self._pk_fusion is not None,
|
||||
caller_requested_row_id=self._caller_requested_row_id,
|
||||
)
|
||||
|
||||
def _run_hybrid(self, *, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def _run_hybrid(
|
||||
self,
|
||||
*,
|
||||
timeout: Optional[timedelta] = None,
|
||||
blob_mode: BlobMode,
|
||||
blob_sources: dict[str, str],
|
||||
) -> pa.Table:
|
||||
self._create_query_builders()
|
||||
fts_query, vector_query = self._fts_query, self._vector_query
|
||||
# WAL-PK-FUSION: without the fallback, both legs always ask for row ids.
|
||||
@@ -2307,18 +2490,33 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
with_row_ids=True,
|
||||
offset=self._offset,
|
||||
)
|
||||
return self._finish_hybrid_results(results)
|
||||
return self._finish_hybrid_results(
|
||||
results, blob_mode=blob_mode, blob_sources=blob_sources
|
||||
)
|
||||
|
||||
def _finish_hybrid_results(self, results: pa.Table) -> pa.Table:
|
||||
def _finish_hybrid_results(
|
||||
self,
|
||||
results: pa.Table,
|
||||
*,
|
||||
blob_mode: BlobMode,
|
||||
blob_sources: dict[str, str],
|
||||
) -> pa.Table:
|
||||
if self._pk_fusion is not None: # WAL-PK-FUSION: delete.
|
||||
return self._pk_fusion.strip(results)
|
||||
return self._apply_arrow_blob_mode(
|
||||
self._pk_fusion.strip(results), blob_mode, blob_sources
|
||||
)
|
||||
if self._user_requested_row_id():
|
||||
return results
|
||||
return self._apply_arrow_blob_mode(results, blob_mode, blob_sources)
|
||||
if self._blob_auto_row_id_enabled():
|
||||
return self._finalize_blob_query_table(results)
|
||||
return self._finalize_blob_query_table(
|
||||
results, blob_mode=blob_mode, blob_sources=blob_sources
|
||||
)
|
||||
if "_rowid" in results.column_names:
|
||||
return results.drop(["_rowid"])
|
||||
return results
|
||||
# Fetch bytes before dropping _rowid.
|
||||
return self._apply_arrow_blob_mode(results, blob_mode, blob_sources).drop(
|
||||
["_rowid"]
|
||||
)
|
||||
return self._apply_arrow_blob_mode(results, blob_mode, blob_sources)
|
||||
|
||||
@staticmethod
|
||||
def _combine_hybrid_results(
|
||||
@@ -2499,6 +2697,9 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
Higher values will yield better recall (more likely to find vectors if
|
||||
they exist) at the expense of latency.
|
||||
|
||||
The value is retained as `nprobes` until Lance sets both probe bounds to it.
|
||||
Explicit minimum or maximum settings can override their respective bounds.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
nprobes: int
|
||||
@@ -2511,8 +2712,33 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
"""
|
||||
if nprobes <= 0:
|
||||
raise ValueError("Invalid input, nprobes must be greater than 0")
|
||||
self._minimum_nprobes = nprobes
|
||||
self._maximum_nprobes = nprobes
|
||||
self._nprobes = nprobes
|
||||
return self
|
||||
|
||||
def search_effort(self, search_effort: Optional[float]) -> LanceHybridQueryBuilder:
|
||||
"""Set the initial IVF search effort to a finite value in [0, 1].
|
||||
|
||||
None (the default) uses Auto, internally 0.5. Zero starts at the caller
|
||||
minimum; one searches all partitions within the caller maximum. Later
|
||||
probing may continue when too few candidates are found. Any explicit
|
||||
effort, including 0.5, overrides nprobes in either call order. Explicit
|
||||
minimum and maximum bounds still apply. This is not a recall guarantee
|
||||
and does not change refinement or HNSW ef. MemWAL LSM queries do not
|
||||
yet support this option.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect("memory://")
|
||||
>>> table = db.create_table(
|
||||
... "effort_hybrid", [{"vector": [0.1, 0.2], "text": "dog"}]
|
||||
... )
|
||||
>>> query = (
|
||||
... table.search(query_type="hybrid").vector([0.1, 0.2])
|
||||
... .text("dog").search_effort(0.75)
|
||||
... )
|
||||
"""
|
||||
self._search_effort = search_effort
|
||||
return self
|
||||
|
||||
def minimum_nprobes(self, minimum_nprobes: int) -> LanceHybridQueryBuilder:
|
||||
@@ -2741,6 +2967,10 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
)
|
||||
return "\n".join(results)
|
||||
|
||||
def _blob_output_schema(self) -> pa.Schema:
|
||||
self._create_query_builders()
|
||||
return self._vector_query.output_schema()
|
||||
|
||||
def _create_query_builders(self):
|
||||
"""Set up and configure the vector and FTS query builders."""
|
||||
vector_query, fts_query = self._validate_query(
|
||||
@@ -2791,6 +3021,10 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
|
||||
self._fts_query.phrase_query(True)
|
||||
if self._distance_type:
|
||||
self._vector_query.metric(self._distance_type)
|
||||
if self._nprobes is not None:
|
||||
self._vector_query.nprobes(self._nprobes)
|
||||
if self._search_effort is not None:
|
||||
self._vector_query.search_effort(self._search_effort)
|
||||
if self._minimum_nprobes is not None:
|
||||
self._vector_query.minimum_nprobes(self._minimum_nprobes)
|
||||
if self._maximum_nprobes is not None:
|
||||
@@ -2997,7 +3231,48 @@ class AsyncQueryBase(object):
|
||||
"""
|
||||
return await self._inner.output_schema()
|
||||
|
||||
async def to_arrow(self, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
async def _validated_blob_sources(self, blob_mode: BlobMode) -> dict[str, str]:
|
||||
"""Map projected blob v2 outputs to their source columns.
|
||||
|
||||
Bytes mode is checked here, before the query runs.
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
# Lazy needs no mapping, and skipping it saves remote tables a request.
|
||||
if blob_mode == "lazy" or self._table is None:
|
||||
return {}
|
||||
schema = await self._table.schema()
|
||||
projection = _query_request_projection(self._inner.to_query_request())
|
||||
blob_sources = blob_v2_projection_sources(schema, projection)
|
||||
if blob_mode != "bytes":
|
||||
return blob_sources
|
||||
reject_unfetchable_blob_columns(schema, projection, blob_sources)
|
||||
if blob_sources and not _supports_local_blob_fetch(
|
||||
self._table, self._table._pushdown_operations
|
||||
):
|
||||
raise _blob_bytes_without_snapshot_error()
|
||||
reject_unmatched_blob_outputs(await self._blob_output_schema(), blob_sources)
|
||||
return blob_sources
|
||||
|
||||
async def _blob_output_schema(self) -> pa.Schema:
|
||||
return await self.output_schema()
|
||||
|
||||
async def _apply_arrow_blob_mode(
|
||||
self, tbl: pa.Table, blob_mode: BlobMode, blob_sources: dict[str, str]
|
||||
) -> pa.Table:
|
||||
if blob_mode == "lazy":
|
||||
return tbl
|
||||
if blob_mode == "descriptions":
|
||||
return strip_auto_row_ids(tbl, blob_sources)
|
||||
return await replace_v2_blob_columns_with_bytes(
|
||||
tbl, blob_sources, self._table.fetch_blobs
|
||||
)
|
||||
|
||||
async def to_arrow(
|
||||
self,
|
||||
timeout: Optional[timedelta] = None,
|
||||
*,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
"""
|
||||
Execute the query and collect the results into an Apache Arrow Table.
|
||||
|
||||
@@ -3011,13 +3286,26 @@ class AsyncQueryBase(object):
|
||||
The maximum time to wait for the query to complete.
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
Remote tables and namespace query pushdown refuse ``"bytes"``.
|
||||
"""
|
||||
blob_sources = await self._validated_blob_sources(blob_mode)
|
||||
batch_iter = await self.to_batches(timeout=timeout)
|
||||
return self._finalize_blob_query_table(
|
||||
tbl = self._finalize_blob_query_table(
|
||||
pa.Table.from_batches(await batch_iter.read_all(), schema=batch_iter.schema)
|
||||
)
|
||||
return await self._apply_arrow_blob_mode(tbl, blob_mode, blob_sources)
|
||||
|
||||
async def to_list(self, timeout: Optional[timedelta] = None) -> List[dict]:
|
||||
async def to_list(
|
||||
self,
|
||||
timeout: Optional[timedelta] = None,
|
||||
*,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Execute the query and return the results as a list of dictionaries.
|
||||
|
||||
@@ -3031,8 +3319,10 @@ class AsyncQueryBase(object):
|
||||
The maximum time to wait for the query to complete.
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
Passed through to :meth:`to_arrow`.
|
||||
"""
|
||||
return (await self.to_arrow(timeout=timeout)).to_pylist()
|
||||
return (await self.to_arrow(timeout=timeout, blob_mode=blob_mode)).to_pylist()
|
||||
|
||||
async def to_pandas(
|
||||
self,
|
||||
@@ -3074,40 +3364,42 @@ class AsyncQueryBase(object):
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
Controls how blob columns are returned. Remote queries support
|
||||
"descriptions"; "bytes" and "lazy" are not yet supported.
|
||||
Controls how blob columns are returned. ``"lazy"`` returns
|
||||
:class:`~lancedb._blob.BlobFile` handles and needs a plain scan.
|
||||
``"descriptions"`` and ``"bytes"`` work for every query shape. A
|
||||
plain scan without ``timeout`` reads the local dataset through
|
||||
Lance's scanner, which also returns legacy blob columns as bytes.
|
||||
Other shapes use :meth:`to_arrow` with the same ``blob_mode``.
|
||||
Remote queries support ``"descriptions"`` only.
|
||||
**kwargs
|
||||
Forwarded to pyarrow.Table.to_pandas after query execution and
|
||||
optional flattening.
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
if self._table is not None and not self._table._inner._is_native():
|
||||
await self._validated_blob_sources(blob_mode)
|
||||
return await self._remote_to_pandas(
|
||||
flatten=flatten, timeout=timeout, blob_mode=blob_mode, **kwargs
|
||||
)
|
||||
|
||||
if hasattr(self._inner, "output_schema"):
|
||||
schema = await self.output_schema()
|
||||
if _blob_mode_requires_native_pandas(blob_mode, schema):
|
||||
native_error = None
|
||||
if (flatten is None or blob_mode == "descriptions") and timeout is None:
|
||||
try:
|
||||
df = await self._plain_scan_to_pandas(
|
||||
blob_mode, flatten=flatten, **kwargs
|
||||
)
|
||||
if df is not None:
|
||||
return df
|
||||
except Exception as err:
|
||||
native_error = err
|
||||
reason = (
|
||||
if hasattr(self._inner, "output_schema") and schema_has_blob_field(
|
||||
await self.output_schema()
|
||||
):
|
||||
if (flatten is None or blob_mode != "lazy") and timeout is None:
|
||||
df = await self._plain_scan_to_pandas(
|
||||
blob_mode, flatten=flatten, **kwargs
|
||||
)
|
||||
if df is not None:
|
||||
return df
|
||||
if blob_mode == "lazy" or _query_needs_lance_scanner(self):
|
||||
raise _unsupported_blob_pandas_error(
|
||||
"this query shape cannot use Lance native pandas conversion"
|
||||
if native_error is None
|
||||
else str(native_error)
|
||||
)
|
||||
raise _unsupported_blob_pandas_error(reason) from native_error
|
||||
|
||||
tbl = flatten_columns(await self.to_arrow(timeout=timeout), flatten)
|
||||
if _blob_mode_requires_native_pandas(blob_mode, tbl.schema):
|
||||
tbl = flatten_columns(
|
||||
await self.to_arrow(timeout=timeout, blob_mode=blob_mode), flatten
|
||||
)
|
||||
if blob_mode == "lazy" and schema_has_blob_field(tbl.schema):
|
||||
raise _unsupported_blob_pandas_error(
|
||||
"this query shape cannot use Lance native pandas conversion"
|
||||
)
|
||||
@@ -3162,6 +3454,13 @@ class AsyncQueryBase(object):
|
||||
if blob_mode == "bytes"
|
||||
else {}
|
||||
)
|
||||
if blob_sources:
|
||||
reject_unfetchable_blob_columns(schema, query.columns, blob_sources)
|
||||
# Remote and in-memory tables have no Lance dataset to scan.
|
||||
if not self._table._inner._is_native() or (
|
||||
get_uri_scheme(await self._table.uri()) == "memory"
|
||||
):
|
||||
return None
|
||||
dataset = await self._table.to_lance()
|
||||
scanner = dataset.scanner(
|
||||
**_scanner_kwargs_for_query(
|
||||
@@ -3176,7 +3475,7 @@ class AsyncQueryBase(object):
|
||||
blob_mode=blob_mode,
|
||||
blob_sources=blob_sources,
|
||||
fetch_blobs=self._table.fetch_blobs,
|
||||
strip_auto_row_id=blob_auto_row_id,
|
||||
strip_auto_row_id=self._with_row_id is not True,
|
||||
flatten=flatten,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -3690,7 +3989,7 @@ class AsyncVectorQueryBase:
|
||||
|
||||
def nprobes(self, nprobes: int) -> Self:
|
||||
"""
|
||||
Set the number of partitions to search (probe)
|
||||
Set the legacy IVF probe parameter
|
||||
|
||||
The number of probes must be greater than 0.
|
||||
|
||||
@@ -3702,20 +4001,49 @@ class AsyncVectorQueryBase:
|
||||
|
||||
The partition whose centroids are closest to the query vector will be
|
||||
exhaustiely searched to find matches. This parameter controls how many
|
||||
partitions should be searched.
|
||||
partitions will be searched.
|
||||
|
||||
Increasing this value will increase the recall of your query but will
|
||||
also increase the latency of your query. The default value is 20. This
|
||||
default is good for many cases but the best value to use will depend on
|
||||
your data and the recall that you need to achieve.
|
||||
also increase the latency of your query. If this method is not called,
|
||||
Lance's adaptive probe defaults are used.
|
||||
|
||||
For best results we recommend tuning this parameter with a benchmark against
|
||||
your actual data to find the smallest possible value that will still give
|
||||
you the desired recall.
|
||||
|
||||
LanceDB retains this as `nprobes` through local and remote request
|
||||
construction. Lance sets both probe bounds to this value. Explicit minimum
|
||||
or maximum settings can override their respective bounds.
|
||||
"""
|
||||
self._inner.nprobes(nprobes)
|
||||
return self
|
||||
|
||||
def search_effort(self, search_effort: Optional[float]) -> Self:
|
||||
"""Set the initial IVF search effort to a finite value in [0, 1].
|
||||
|
||||
None preserves Auto (internally 0.5) and fixed nprobes. Any explicit
|
||||
effort, including 0.5, overrides nprobes in either call order; explicit
|
||||
minimum and maximum bounds remain effective. Zero starts at the caller
|
||||
minimum and one searches all partitions within the maximum. Later
|
||||
probing may continue to obtain enough candidates. This is not a recall
|
||||
guarantee and does not change refinement or HNSW ef. MemWAL LSM queries
|
||||
do not yet support this option.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import asyncio
|
||||
>>> import lancedb
|
||||
>>> async def build_query():
|
||||
... db = await lancedb.connect_async("memory://")
|
||||
... table = await db.create_table(
|
||||
... "effort_async", [{"vector": [0.1, 0.2]}]
|
||||
... )
|
||||
... return table.vector_search([0.1, 0.2]).search_effort(0.75)
|
||||
>>> query = asyncio.run(build_query())
|
||||
"""
|
||||
self._inner.search_effort(search_effort)
|
||||
return self
|
||||
|
||||
def minimum_nprobes(self, minimum_nprobes: int) -> Self:
|
||||
"""Set the minimum number of probes to use.
|
||||
|
||||
@@ -3987,6 +4315,10 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
|
||||
|
||||
return self
|
||||
|
||||
async def _blob_output_schema(self) -> pa.Schema:
|
||||
vector_query = AsyncVectorQuery(self._inner.to_vector_query(), self._table)
|
||||
return await vector_query.output_schema()
|
||||
|
||||
async def _create_child_queries(
|
||||
self,
|
||||
) -> Tuple["AsyncFTSQuery", "AsyncVectorQuery", int, int]:
|
||||
@@ -4348,7 +4680,12 @@ class BaseQueryBuilder(object):
|
||||
|
||||
return pa.RecordBatchReader.from_batches(async_reader.schema, iter_sync())
|
||||
|
||||
def to_arrow(self, timeout: Optional[timedelta] = None) -> pa.Table:
|
||||
def to_arrow(
|
||||
self,
|
||||
timeout: Optional[timedelta] = None,
|
||||
*,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> pa.Table:
|
||||
"""
|
||||
Execute the query and collect the results into an Apache Arrow Table.
|
||||
|
||||
@@ -4362,10 +4699,20 @@ class BaseQueryBuilder(object):
|
||||
The maximum time to wait for the query to complete.
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
"""
|
||||
return LOOP.run(self._inner.to_arrow(timeout))
|
||||
return LOOP.run(self._inner.to_arrow(timeout, blob_mode=blob_mode))
|
||||
|
||||
def to_list(self, timeout: Optional[timedelta] = None) -> List[dict]:
|
||||
def to_list(
|
||||
self,
|
||||
timeout: Optional[timedelta] = None,
|
||||
*,
|
||||
blob_mode: BlobMode = "lazy",
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Execute the query and return the results as a list of dictionaries.
|
||||
|
||||
@@ -4379,8 +4726,10 @@ class BaseQueryBuilder(object):
|
||||
The maximum time to wait for the query to complete.
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
Passed through to :meth:`to_arrow`.
|
||||
"""
|
||||
return LOOP.run(self._inner.to_list(timeout))
|
||||
return LOOP.run(self._inner.to_list(timeout, blob_mode=blob_mode))
|
||||
|
||||
def to_pandas(
|
||||
self,
|
||||
@@ -4422,7 +4771,7 @@ class BaseQueryBuilder(object):
|
||||
If not specified, no timeout is applied. If the query does not
|
||||
complete within the specified time, an error will be raised.
|
||||
blob_mode: str, default "lazy"
|
||||
Controls how blob columns are returned for plain scan queries.
|
||||
Passed through to :meth:`AsyncQueryBase.to_pandas`.
|
||||
**kwargs
|
||||
Forwarded to pyarrow.Table.to_pandas after query execution and
|
||||
optional flattening.
|
||||
|
||||
@@ -170,6 +170,10 @@ class ClientConfig:
|
||||
Configuration for retrying failed requests.
|
||||
timeout_config: Optional[TimeoutConfig]
|
||||
Configuration for request timeouts.
|
||||
blob_request_concurrency: Optional[int]
|
||||
Maximum concurrent blob HTTP requests across all handles on this
|
||||
connection. Defaults to 8. Set a positive integer here or via
|
||||
`LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY`.
|
||||
extra_headers: Optional[dict]
|
||||
Additional headers to include in requests.
|
||||
id_delimiter: Optional[str]
|
||||
@@ -193,6 +197,7 @@ class ClientConfig:
|
||||
user_agent: str = f"LanceDB-Python-Client/{__version__}"
|
||||
retry_config: RetryConfig = field(default_factory=RetryConfig)
|
||||
timeout_config: Optional[TimeoutConfig] = field(default_factory=TimeoutConfig)
|
||||
blob_request_concurrency: Optional[int] = None
|
||||
extra_headers: Optional[dict] = None
|
||||
id_delimiter: Optional[str] = None
|
||||
tls_config: Optional[TlsConfig] = None
|
||||
|
||||
@@ -112,6 +112,7 @@ def _client_config_to_dict(config: ClientConfig) -> dict[str, Any]:
|
||||
"user_agent": config.user_agent,
|
||||
"retry_config": _retry_config_to_dict(config.retry_config),
|
||||
"timeout_config": _timeout_config_to_dict(config.timeout_config),
|
||||
"blob_request_concurrency": config.blob_request_concurrency,
|
||||
"extra_headers": config.extra_headers,
|
||||
"id_delimiter": config.id_delimiter,
|
||||
"tls_config": _tls_config_to_dict(config.tls_config),
|
||||
@@ -379,7 +380,7 @@ class RemoteDBConnection(DBConnection):
|
||||
page_token: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> ListTablesResponse:
|
||||
"""List all tables in this database with pagination support.
|
||||
"""List a page of tables in this database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -390,7 +391,8 @@ class RemoteDBConnection(DBConnection):
|
||||
Token for pagination. Use the token from a previous response
|
||||
to get the next page of results.
|
||||
limit: int, optional
|
||||
The maximum number of results to return.
|
||||
The maximum number of results to return, default 100.
|
||||
Zero returns an empty page without a continuation token.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -409,7 +411,7 @@ class RemoteDBConnection(DBConnection):
|
||||
def table_names(
|
||||
self,
|
||||
page_token: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
limit: Optional[int] = None,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> Iterable[str]:
|
||||
@@ -425,8 +427,9 @@ class RemoteDBConnection(DBConnection):
|
||||
Empty list represents root namespace.
|
||||
page_token: str
|
||||
The last token to start the new page.
|
||||
limit: int, default 10
|
||||
The maximum number of tables to return for each page.
|
||||
limit: int, optional
|
||||
The maximum number of tables to return. None returns all tables;
|
||||
zero returns an empty list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
@@ -58,6 +58,7 @@ from lancedb.table import IndexConfigType, KNOWN_METRICS
|
||||
import pyarrow as pa
|
||||
|
||||
from lancedb.common import DATA, VEC, VECTOR_COLUMN_NAME
|
||||
from lancedb._blob import validate_blob_mode
|
||||
from lancedb.merge import LanceMergeInsertBuilder
|
||||
from lancedb.embeddings import EmbeddingFunctionRegistry
|
||||
from lancedb.table import _normalize_progress
|
||||
@@ -244,8 +245,9 @@ class RemoteTable(Table):
|
||||
"""List all versions of the table"""
|
||||
return LOOP.run(self._table.list_versions())
|
||||
|
||||
def to_arrow(self) -> pa.Table:
|
||||
def to_arrow(self, *, blob_mode: BlobMode = "lazy") -> pa.Table:
|
||||
"""to_arrow() is not supported for remote tables."""
|
||||
validate_blob_mode(blob_mode)
|
||||
raise NotImplementedError(
|
||||
"to_arrow() is not supported for remote tables. "
|
||||
"Use table.search().to_arrow() instead."
|
||||
@@ -253,6 +255,7 @@ class RemoteTable(Table):
|
||||
|
||||
def to_pandas(self, blob_mode: BlobMode = "lazy", **kwargs):
|
||||
"""to_pandas() is not supported for remote tables."""
|
||||
validate_blob_mode(blob_mode)
|
||||
raise NotImplementedError(
|
||||
"to_pandas() is not supported for remote tables. "
|
||||
"Use table.search().to_pandas() instead."
|
||||
@@ -1179,7 +1182,9 @@ class RemoteTable(Table):
|
||||
return LOOP.run(self._table.blob_columns())
|
||||
|
||||
def fetch_blobs(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return LOOP.run(self._table.fetch_blobs(column, row_ids))
|
||||
|
||||
@@ -1187,7 +1192,9 @@ class RemoteTable(Table):
|
||||
return LOOP.run(self._table.fetch_blob_ranges(column, list(requests)))
|
||||
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
return LOOP.run(self._table.fetch_blob_files(column, row_ids))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"""Schema helpers for Lance blob columns."""
|
||||
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.ipc
|
||||
@@ -181,13 +181,60 @@ def _resolve_blob_type():
|
||||
return _resolved_blob_type
|
||||
|
||||
|
||||
def blob(name: str, nullable: bool = True) -> pa.Field:
|
||||
def blob(
|
||||
name: str,
|
||||
nullable: bool = True,
|
||||
*,
|
||||
inline_size_threshold: Optional[int] = None,
|
||||
dedicated_size_threshold: Optional[int] = None,
|
||||
pack_file_size_threshold: Optional[int] = None,
|
||||
) -> pa.Field:
|
||||
"""Create a Lance blob v2 column field.
|
||||
|
||||
When pylance is installed this is ``lance.blob.BlobType``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Field name.
|
||||
nullable:
|
||||
Whether the column accepts null values.
|
||||
inline_size_threshold:
|
||||
Maximum payload size in bytes to keep inline in the data file
|
||||
before using packed blob storage. Zero is allowed.
|
||||
dedicated_size_threshold:
|
||||
Maximum payload size in bytes to store in packed blob storage
|
||||
before using dedicated blob storage. Must be positive.
|
||||
pack_file_size_threshold:
|
||||
Maximum size in bytes of a single packed blob sidecar file before
|
||||
a new one is started. Must be positive.
|
||||
|
||||
The thresholds are forwarded to ``lance.blob_field``, which validates
|
||||
them and stores them as field metadata, so pylance is required whenever
|
||||
any threshold is set. When all thresholds are omitted the behavior is
|
||||
unchanged.
|
||||
"""
|
||||
blob_type = _resolve_blob_type()
|
||||
return pa.field(name, blob_type(), nullable=nullable)
|
||||
if (
|
||||
inline_size_threshold is None
|
||||
and dedicated_size_threshold is None
|
||||
and pack_file_size_threshold is None
|
||||
):
|
||||
blob_type = _resolve_blob_type()
|
||||
return pa.field(name, blob_type(), nullable=nullable)
|
||||
try:
|
||||
lance = importlib.import_module("lance")
|
||||
except ModuleNotFoundError as err:
|
||||
raise ValueError(
|
||||
"Setting blob tier thresholds requires pylance to be installed"
|
||||
) from err
|
||||
_resolve_blob_type()
|
||||
return lance.blob_field(
|
||||
name,
|
||||
nullable=nullable,
|
||||
inline_size_threshold=inline_size_threshold,
|
||||
dedicated_size_threshold=dedicated_size_threshold,
|
||||
pack_file_size_threshold=pack_file_size_threshold,
|
||||
)
|
||||
|
||||
|
||||
def vector(dimension: int, value_type: pa.DataType = pa.float32()) -> pa.DataType:
|
||||
|
||||
+191
-39
@@ -576,6 +576,55 @@ def _cast_to_target_schema(
|
||||
return pa.RecordBatchReader.from_batches(reordered_schema, gen())
|
||||
|
||||
|
||||
def _preserve_blob_v2_fields(
|
||||
inferred_schema: pa.Schema, existing_schema: pa.Schema
|
||||
) -> pa.Schema:
|
||||
"""Keep existing blob fields when an overwrite infers a replacement schema."""
|
||||
existing_fields = {field.name: field for field in existing_schema}
|
||||
fields = [
|
||||
_preserve_blob_v2_field(field, existing_fields[field.name])
|
||||
if field.name in existing_fields
|
||||
else field
|
||||
for field in inferred_schema
|
||||
]
|
||||
return pa.schema(fields, metadata=inferred_schema.metadata)
|
||||
|
||||
|
||||
def _preserve_blob_v2_field(inferred: pa.Field, existing: pa.Field) -> pa.Field:
|
||||
if is_blob_v2_field(existing):
|
||||
return existing
|
||||
|
||||
inferred_type, existing_type = inferred.type, existing.type
|
||||
if pa.types.is_struct(inferred_type) and pa.types.is_struct(existing_type):
|
||||
existing_children = {field.name: field for field in existing_type}
|
||||
children = [
|
||||
_preserve_blob_v2_field(field, existing_children[field.name])
|
||||
if field.name in existing_children
|
||||
else field
|
||||
for field in inferred_type
|
||||
]
|
||||
return inferred.with_type(pa.struct(children))
|
||||
|
||||
if _is_list_like(inferred_type) and _is_list_like(existing_type):
|
||||
value_field = _preserve_blob_v2_field(
|
||||
inferred_type.value_field, existing_type.value_field
|
||||
)
|
||||
if value_field == inferred_type.value_field:
|
||||
return inferred
|
||||
value_field = value_field.with_name(existing_type.value_field.name)
|
||||
if pa.types.is_fixed_size_list(inferred_type):
|
||||
value_type = pa.list_(value_field, inferred_type.list_size)
|
||||
elif pa.types.is_large_list(inferred_type):
|
||||
value_type = pa.large_list(value_field)
|
||||
else:
|
||||
value_type = pa.list_(value_field)
|
||||
return inferred.with_type(value_type)
|
||||
|
||||
if blob_v2_column_paths(pa.schema([existing])):
|
||||
return existing
|
||||
return inferred
|
||||
|
||||
|
||||
def _coerce_blob_write_columns(
|
||||
batch: pa.RecordBatch, target_schema: pa.Schema
|
||||
) -> pa.RecordBatch:
|
||||
@@ -691,12 +740,7 @@ def _coerce_blob_list_values(
|
||||
|
||||
|
||||
def _coerce_value_to_blob(values: pa.Array, target_field: pa.Field) -> pa.Array:
|
||||
if pa.types.is_null(values.type):
|
||||
data = pa.nulls(len(values), type=pa.large_binary())
|
||||
elif pa.types.is_large_binary(values.type):
|
||||
data = values
|
||||
else:
|
||||
data = values.cast(pa.large_binary())
|
||||
value_field = "uri" if _is_string_like(values.type) else "data"
|
||||
length = len(values)
|
||||
storage_type = target_field.type
|
||||
if isinstance(storage_type, pa.ExtensionType):
|
||||
@@ -704,8 +748,11 @@ def _coerce_value_to_blob(values: pa.Array, target_field: pa.Field) -> pa.Array:
|
||||
storage_fields = list(storage_type)
|
||||
children = []
|
||||
for storage_field in storage_fields:
|
||||
if storage_field.name == "data":
|
||||
children.append(data)
|
||||
if storage_field.name == value_field and not pa.types.is_null(values.type):
|
||||
if values.type == storage_field.type:
|
||||
children.append(values)
|
||||
else:
|
||||
children.append(values.cast(storage_field.type))
|
||||
else:
|
||||
children.append(pa.nulls(length, type=storage_field.type))
|
||||
storage = pa.StructArray.from_arrays(
|
||||
@@ -725,7 +772,19 @@ def _physical_array_and_type(array: pa.Array) -> tuple[pa.Array, pa.DataType]:
|
||||
|
||||
|
||||
def _can_coerce_to_blob(data_type: pa.DataType) -> bool:
|
||||
return _is_binary_like(data_type) or pa.types.is_null(data_type)
|
||||
return (
|
||||
_is_binary_like(data_type)
|
||||
or _is_string_like(data_type)
|
||||
or pa.types.is_null(data_type)
|
||||
)
|
||||
|
||||
|
||||
def _is_string_like(data_type: pa.DataType) -> bool:
|
||||
return (
|
||||
pa.types.is_string(data_type)
|
||||
or pa.types.is_large_string(data_type)
|
||||
or pa.types.is_string_view(data_type)
|
||||
)
|
||||
|
||||
|
||||
def _is_binary_like(data_type: pa.DataType) -> bool:
|
||||
@@ -887,6 +946,24 @@ def _arrow_json_storage_type(input_type: pa.DataType) -> Optional[pa.DataType]:
|
||||
|
||||
|
||||
def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field:
|
||||
if is_blob_v2_field(target_field):
|
||||
source_type = field.type
|
||||
if pa.types.is_struct(source_type):
|
||||
if not any(child.name in ("data", "uri") for child in source_type):
|
||||
raise ValueError(
|
||||
f"blob struct input for column '{field.name}' must contain "
|
||||
"a 'data' or 'uri' child"
|
||||
)
|
||||
elif not _can_coerce_to_blob(source_type) and not isinstance(
|
||||
source_type, pa.ExtensionType
|
||||
):
|
||||
raise ValueError(
|
||||
f"cannot coerce column '{field.name}' with type {source_type} "
|
||||
"into a blob v2 struct. expected binary bytes "
|
||||
"(Binary, LargeBinary, BinaryView), strings "
|
||||
"(Utf8, LargeUtf8, Utf8View), or a Struct with a "
|
||||
"'data' or 'uri' child"
|
||||
)
|
||||
# LanceDB exposes stored JSON columns as lance.json (JSONB-backed LargeBinary), but
|
||||
# casting the input to that storage type here merely relabels the raw JSON bytes as
|
||||
# JSONB. Lance must see arrow.json so it can perform the JSONB encoding.
|
||||
@@ -1322,9 +1399,17 @@ class Table(ABC):
|
||||
return self.to_arrow().to_pandas(**kwargs)
|
||||
|
||||
@abstractmethod
|
||||
def to_arrow(self) -> pa.Table:
|
||||
def to_arrow(self, *, blob_mode: BlobMode = "lazy") -> pa.Table:
|
||||
"""Return the table as a pyarrow Table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pa.Table
|
||||
@@ -1750,7 +1835,8 @@ class Table(ABC):
|
||||
- pyarrow.Table or pyarrow.RecordBatch
|
||||
mode: str
|
||||
The mode to use when writing the data. Valid values are
|
||||
"append" and "overwrite".
|
||||
"append" and "overwrite". Overwrite infers a replacement schema
|
||||
but preserves existing blob v2 fields present in the new data.
|
||||
on_bad_vectors: str, default "error"
|
||||
What to do if any of the vectors are not the same size or contains NaNs.
|
||||
One of "error", "drop", "fill".
|
||||
@@ -2066,19 +2152,30 @@ class Table(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def blob_columns(self) -> list[str]:
|
||||
"""Names of the blob v2 columns declared on this table."""
|
||||
"""Names of blob v2 columns that can be fetched by row id.
|
||||
|
||||
Struct-nested blobs use dotted paths. Blobs inside lists are omitted
|
||||
because a row id does not identify an individual list element.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_blobs(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> pa.LargeBinaryArray:
|
||||
"""Materialize full blob bytes for ``column`` at the given rows.
|
||||
|
||||
The result has the same length and order as ``row_ids``. Null blobs
|
||||
produce null slots; valid empty blobs produce ``b""``.
|
||||
|
||||
``_rowid`` values stay valid after compaction when the table has stable
|
||||
row ids.
|
||||
``row_ids`` may be a list, an integer Arrow array or chunked array,
|
||||
or a query table with row ids. ``_rowid`` values stay valid after
|
||||
compaction when the table has stable row ids.
|
||||
One missing or deleted row ID rejects the entire batch; no partial
|
||||
results are returned.
|
||||
|
||||
Blobs inside lists cannot be fetched by row id yet.
|
||||
|
||||
Remote servers limit each request to 1024 row IDs and 64 MiB of blob
|
||||
bytes. The client splits requests automatically and reads an individual
|
||||
@@ -2090,17 +2187,20 @@ class Table(ABC):
|
||||
def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
requests: Sequence[Sequence[int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
"""Materialize row-specific byte ranges from a blob v2 column.
|
||||
|
||||
Each request is a ``(row_id, offset, length)`` tuple. Requests may be
|
||||
repeated or reordered, including multiple ranges for the same blob.
|
||||
Each request is a three-item ``(row_id, offset, length)`` sequence.
|
||||
Requests may be repeated or reordered, including multiple ranges for
|
||||
the same blob.
|
||||
The result has the same length and order as ``requests``; null blobs
|
||||
produce null slots and empty ranges on non-null blobs produce ``b""``.
|
||||
|
||||
``_rowid`` values stay valid after compaction when the table has stable
|
||||
row ids.
|
||||
One missing or deleted row ID rejects the entire batch; no partial
|
||||
results are returned.
|
||||
|
||||
Row IDs can be obtained from a query with ``with_row_id(True)``. This
|
||||
API is currently supported only by local tables.
|
||||
@@ -2108,18 +2208,22 @@ class Table(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
"""Open lazy, seekable :class:`~lancedb._blob.BlobFile` handles.
|
||||
|
||||
Prefer this over :meth:`fetch_blobs` for large payloads. ``row_ids`` is
|
||||
a ``list[int]`` or a query ``pyarrow.Table`` carrying row identity via
|
||||
``_rowid`` or a ``_lance_row_id`` field on the blob descriptor. Null
|
||||
rows are ``None``. Remote tables require LanceDB Cloud server 0.5.0 or
|
||||
newer.
|
||||
a ``list[int]``, an integer Arrow array or chunked array, or a query
|
||||
``pyarrow.Table`` carrying row identity via ``_rowid`` or a
|
||||
``_lance_row_id`` field on the blob descriptor. Null blob values return
|
||||
``None``. Remote tables require LanceDB Cloud server 0.5.0 or newer.
|
||||
|
||||
``_rowid`` values stay valid after compaction when the table has stable
|
||||
row ids.
|
||||
One missing or deleted row ID rejects the entire batch; no partial
|
||||
handles are returned.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -2877,6 +2981,8 @@ class LanceTable(Table):
|
||||
managed_versioning=managed_versioning,
|
||||
)
|
||||
)
|
||||
# Take queries read the pushdown set from the inner table.
|
||||
self._table._pushdown_operations = self._pushdown_operations
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -3025,19 +3131,23 @@ class LanceTable(Table):
|
||||
return LOOP.run(self._table.blob_columns())
|
||||
|
||||
def fetch_blobs(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return LOOP.run(self._table.fetch_blobs(column, row_ids))
|
||||
|
||||
def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
requests: Sequence[Sequence[int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return LOOP.run(self._table.fetch_blob_ranges(column, list(requests)))
|
||||
|
||||
def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
return LOOP.run(self._table.fetch_blob_files(column, row_ids))
|
||||
|
||||
@@ -3251,12 +3361,23 @@ class LanceTable(Table):
|
||||
|
||||
return self.to_lance().to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
|
||||
def to_arrow(self) -> pa.Table:
|
||||
def to_arrow(self, *, blob_mode: BlobMode = "lazy") -> pa.Table:
|
||||
"""Return the table as a pyarrow Table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pa.Table"""
|
||||
validate_blob_mode(blob_mode)
|
||||
if blob_mode != "lazy" and schema_has_blob_field(self.schema):
|
||||
return self.search().to_arrow(blob_mode=blob_mode)
|
||||
if (
|
||||
_should_push_down_query_table(
|
||||
self._namespace_client, self._pushdown_operations
|
||||
@@ -3992,7 +4113,8 @@ class LanceTable(Table):
|
||||
The data to insert into the table.
|
||||
mode: str
|
||||
The mode to use when writing the data. Valid values are
|
||||
"append" and "overwrite".
|
||||
"append" and "overwrite". Overwrite infers a replacement schema
|
||||
but preserves existing blob v2 fields present in the new data.
|
||||
on_bad_vectors: str, default "error"
|
||||
What to do if any of the vectors are not the same size or contains NaNs.
|
||||
One of "error", "drop", "fill", "null".
|
||||
@@ -4452,6 +4574,7 @@ class LanceTable(Table):
|
||||
and not self._route_pushdown_to_rust
|
||||
and self.current_branch() is None
|
||||
and query.take_offsets is None
|
||||
and query.search_effort is None
|
||||
):
|
||||
from lancedb.namespace import _execute_server_side_query
|
||||
|
||||
@@ -5698,22 +5821,37 @@ class AsyncTable:
|
||||
return await self.query().to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
return (await self.to_lance()).to_pandas(blob_mode=blob_mode, **kwargs)
|
||||
|
||||
async def to_arrow(self) -> pa.Table:
|
||||
async def to_arrow(self, *, blob_mode: BlobMode = "lazy") -> pa.Table:
|
||||
"""Return the table as a pyarrow Table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
blob_mode: str, default "lazy"
|
||||
``"lazy"`` returns blob v2 descriptors that carry the row id
|
||||
``fetch_blobs`` reads. ``"descriptions"`` returns them without it.
|
||||
``"bytes"`` returns payload bytes and raises for legacy blob columns
|
||||
and blobs inside lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pa.Table
|
||||
"""
|
||||
validate_blob_mode(blob_mode)
|
||||
schema = await self.schema()
|
||||
fetches_bytes = blob_mode == "bytes" and schema_has_blob_field(schema)
|
||||
if (
|
||||
_should_push_down_query_table(
|
||||
not fetches_bytes
|
||||
and _should_push_down_query_table(
|
||||
self._namespace_client, self._pushdown_operations
|
||||
)
|
||||
and not self._route_pushdown_to_rust
|
||||
):
|
||||
return (await self._execute_query(Query())).read_all()
|
||||
|
||||
return await self.query().to_arrow()
|
||||
tbl = (await self._execute_query(Query())).read_all()
|
||||
if blob_mode == "descriptions":
|
||||
tbl = strip_auto_row_ids(tbl, row_addressable_blob_v2_paths(schema))
|
||||
return tbl
|
||||
# The query builder refuses bytes under pushdown before running the query.
|
||||
return await self.query().to_arrow(blob_mode=blob_mode)
|
||||
|
||||
async def create_index(
|
||||
self,
|
||||
@@ -6009,7 +6147,8 @@ class AsyncTable:
|
||||
- pyarrow.Table or pyarrow.RecordBatch
|
||||
mode: str
|
||||
The mode to use when writing the data. Valid values are
|
||||
"append" and "overwrite".
|
||||
"append" and "overwrite". Overwrite infers a replacement schema
|
||||
but preserves existing blob v2 fields present in the new data.
|
||||
on_bad_vectors: str, default "error"
|
||||
What to do if any of the vectors are not the same size or contains NaNs.
|
||||
One of "error", "drop", "fill", "null".
|
||||
@@ -6047,6 +6186,11 @@ class AsyncTable:
|
||||
data, _ = sanitize_create_table(
|
||||
data, None, on_bad_vectors=on_bad_vectors, fill_value=fill_value
|
||||
)
|
||||
if blob_v2_column_paths(schema):
|
||||
# Plain bytes cannot be inferred as blob v2. Keep matching blob
|
||||
# fields while allowing other columns to use the inferred schema.
|
||||
target_schema = _preserve_blob_v2_fields(data.schema, schema)
|
||||
data = _cast_to_target_schema(data, target_schema)
|
||||
elif on_bad_vectors != "error" or (
|
||||
schema.metadata is not None and b"embedding_functions" in schema.metadata
|
||||
):
|
||||
@@ -6451,9 +6595,12 @@ class AsyncTable:
|
||||
)
|
||||
if query.distance_type is not None:
|
||||
async_query = async_query.distance_type(query.distance_type)
|
||||
if query.nprobes is not None:
|
||||
async_query = async_query.nprobes(query.nprobes)
|
||||
if query.search_effort is not None:
|
||||
async_query = async_query.search_effort(query.search_effort)
|
||||
if query.minimum_nprobes is not None and query.maximum_nprobes is not None:
|
||||
# Set both to the minimum first to avoid min > max error.
|
||||
async_query = async_query.nprobes(
|
||||
async_query = async_query.minimum_nprobes(
|
||||
query.minimum_nprobes
|
||||
).maximum_nprobes(query.maximum_nprobes)
|
||||
elif query.minimum_nprobes is not None:
|
||||
@@ -6492,6 +6639,7 @@ class AsyncTable:
|
||||
)
|
||||
and not self._route_pushdown_to_rust
|
||||
and query.take_offsets is None
|
||||
and query.search_effort is None
|
||||
):
|
||||
from lancedb.namespace import _execute_server_side_query
|
||||
|
||||
@@ -7086,7 +7234,9 @@ class AsyncTable:
|
||||
return await self._inner.blob_columns()
|
||||
|
||||
async def fetch_blobs(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return await self._inner.fetch_blobs(
|
||||
column, _normalize_blob_row_ids(row_ids, column)
|
||||
@@ -7095,12 +7245,14 @@ class AsyncTable:
|
||||
async def fetch_blob_ranges(
|
||||
self,
|
||||
column: str,
|
||||
requests: Sequence[Tuple[int, int, int]],
|
||||
requests: Sequence[Sequence[int]],
|
||||
) -> pa.LargeBinaryArray:
|
||||
return await self._inner.fetch_blob_ranges(column, list(requests))
|
||||
|
||||
async def fetch_blob_files(
|
||||
self, column: str, row_ids: Union[list[int], pa.Table]
|
||||
self,
|
||||
column: str,
|
||||
row_ids: Union[list[int], pa.Array, pa.ChunkedArray, pa.Table],
|
||||
) -> "list[Optional[BlobFile]]":
|
||||
handles = await self._inner.fetch_blob_files(
|
||||
column, _normalize_blob_row_ids(row_ids, column)
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import pathlib
|
||||
import warnings
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from functools import singledispatch
|
||||
from typing import Tuple, Union, Optional, Any, List
|
||||
from urllib.parse import urlparse
|
||||
@@ -373,6 +374,14 @@ def _(value: float):
|
||||
return str(value)
|
||||
|
||||
|
||||
@value_to_sql.register(Decimal)
|
||||
def _(value: Decimal):
|
||||
if not value.is_finite():
|
||||
raise ValueError("Non-finite Decimal values cannot be converted to SQL")
|
||||
# Quote exact decimal text so the target-column cast avoids float rounding.
|
||||
return value_to_sql(format(value, "f"))
|
||||
|
||||
|
||||
@value_to_sql.register(bool)
|
||||
def _(value: bool):
|
||||
return str(value).upper()
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import asyncio
|
||||
from dataclasses import FrozenInstanceError
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import lancedb
|
||||
from lancedb.authz import (
|
||||
AccessControlEntry,
|
||||
AsyncAuthorization,
|
||||
Authorization,
|
||||
GroupInfo,
|
||||
IdentityInfo,
|
||||
Object,
|
||||
PrincipalInfo,
|
||||
Privilege,
|
||||
RoleBinding,
|
||||
Subject,
|
||||
)
|
||||
from lancedb.remote import HeaderProvider
|
||||
from lancedb.remote.errors import HttpError
|
||||
from test_catalog import catalog_server as _catalog_server
|
||||
|
||||
catalog_server = _catalog_server
|
||||
|
||||
|
||||
class DynamicHeaders(HeaderProvider):
|
||||
def get_headers(self):
|
||||
return {
|
||||
"Authorization": "Bearer rotating-token",
|
||||
"X-LanceDB-Database": "wrong-dynamic-db",
|
||||
"X-LanceDB-Database-Prefix": "wrong-prefix",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_authz_operations_and_scope(catalog_server, asynchronous):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"subject_id": "g:team-id",
|
||||
"object": "table:tenant/db:a$b$events",
|
||||
"privilege": "PRIVILEGE20",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [
|
||||
{
|
||||
"subject_id": "p:alice-id",
|
||||
"subject_name": "Alice",
|
||||
"role": "reader",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"principal_id": "alice-id",
|
||||
"groups": [{"id": "team-id"}],
|
||||
"roles": ["reader"],
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"group_id": "team-id",
|
||||
"group_name": "Engineering",
|
||||
"principals": [{"id": "alice-id", "name": "Alice"}],
|
||||
"roles": ["reader"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
config = {
|
||||
"header_provider": DynamicHeaders(),
|
||||
"extra_headers": {"x-lancedb-database": "wrong-static-db"},
|
||||
}
|
||||
obj = Object.table(database="tenant/db", namespace_path=["a", "b"], name="events")
|
||||
|
||||
async def exercise():
|
||||
if asynchronous:
|
||||
catalog = await lancedb.connect_catalog_async(
|
||||
endpoint, client_config=config
|
||||
)
|
||||
else:
|
||||
catalog = lancedb.connect_catalog(endpoint, client_config=config)
|
||||
authz = catalog.authz
|
||||
assert authz is catalog.authz
|
||||
assert requests == [] # Constructing handles does not probe a database.
|
||||
|
||||
async def call(method, *args, **kwargs):
|
||||
value = method(*args, **kwargs)
|
||||
return await value if asynchronous else value
|
||||
|
||||
await call(
|
||||
authz.add_acl,
|
||||
object=obj,
|
||||
subject=Subject.group_name("Engineering"),
|
||||
privilege=Privilege.SELECT,
|
||||
)
|
||||
await call(
|
||||
authz.delete_acl,
|
||||
object=obj,
|
||||
subject=Subject.group_id("team-id"),
|
||||
privilege="SELECT",
|
||||
)
|
||||
await call(
|
||||
authz.add_role_binding,
|
||||
subject=Subject.principal_name("Alice"),
|
||||
role="reader",
|
||||
)
|
||||
await call(
|
||||
authz.delete_role_binding,
|
||||
subject=Subject.principal_api_key("subject-secret"),
|
||||
role="reader",
|
||||
)
|
||||
iterator = authz.list_acls(object=obj, subject=Subject.role("reader"))
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert entries == [
|
||||
AccessControlEntry(
|
||||
str(obj), Subject.group_id("team-id"), None, "PRIVILEGE20"
|
||||
)
|
||||
]
|
||||
iterator = authz.list_role_bindings(
|
||||
subject=Subject.group_id("team-id"), role="reader"
|
||||
)
|
||||
bindings = (
|
||||
[binding async for binding in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert bindings == [
|
||||
RoleBinding(Subject.principal_id("alice-id"), "Alice", "reader")
|
||||
]
|
||||
assert await call(
|
||||
authz.get_principal, Subject.principal_id("alice-id")
|
||||
) == PrincipalInfo("alice-id", None, [IdentityInfo("team-id")], ["reader"])
|
||||
assert await call(
|
||||
authz.get_group, Subject.group_name("Engineering")
|
||||
) == GroupInfo(
|
||||
"team-id", "Engineering", [IdentityInfo("alice-id", "Alice")], ["reader"]
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert [path for path, _, _ in requests] == [
|
||||
"/admin/authz/acl/add",
|
||||
"/admin/authz/acl/delete",
|
||||
"/admin/authz/role_binding/add",
|
||||
"/admin/authz/role_binding/delete",
|
||||
"/admin/authz/acl/list",
|
||||
"/admin/authz/role_binding/list",
|
||||
"/admin/authz/principal",
|
||||
"/admin/authz/group",
|
||||
]
|
||||
assert [body for _, _, body in requests] == [
|
||||
{"object": str(obj), "subject": "G:Engineering", "privilege": "SELECT"},
|
||||
{"object": str(obj), "subject": "g:team-id", "privilege": "SELECT"},
|
||||
{"subject": "P:Alice", "role": "reader"},
|
||||
{"subject": "a:subject-secret", "role": "reader"},
|
||||
{"object": str(obj), "subject": "r:reader", "max_results": 100},
|
||||
{"subject": "g:team-id", "role": "reader", "max_results": 100},
|
||||
{"principal": "p:alice-id"},
|
||||
{"group": "G:Engineering"},
|
||||
]
|
||||
for _, headers, _ in requests:
|
||||
headers = {key.lower(): value for key, value in headers.items()}
|
||||
assert "x-lancedb-database" not in headers
|
||||
assert "x-lancedb-database-prefix" not in headers
|
||||
assert headers["authorization"] == "Bearer rotating-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("limit", [None, 0, 1, 3])
|
||||
def test_lazy_expanded_acl_pagination(catalog_server, asynchronous, limit):
|
||||
endpoint, requests, responses = catalog_server
|
||||
|
||||
def entry(privilege):
|
||||
return {
|
||||
"object": "database:db",
|
||||
"subject_id": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [entry("SELECT"), entry("INSERT")],
|
||||
"continuation_token": "opaque/first",
|
||||
},
|
||||
),
|
||||
(200, {"entries": [], "continuation_token": "opaque/second"}),
|
||||
(200, {"entries": [entry("UPDATE"), entry("DELETE")]}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint, api_key="caller-secret")
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint, api_key="caller-secret")
|
||||
)
|
||||
iterator = catalog.authz.list_acls(subject=Subject.role("reader"), limit=limit)
|
||||
assert requests == []
|
||||
return [entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
|
||||
values = asyncio.run(exercise())
|
||||
expected = ["SELECT", "INSERT", "UPDATE", "DELETE"]
|
||||
assert [entry.privilege for entry in values] == (
|
||||
expected if limit is None else expected[:limit]
|
||||
)
|
||||
assert len(requests) == (0 if limit == 0 else 1 if limit == 1 else 3)
|
||||
for _, headers, body in requests:
|
||||
assert headers["x-api-key"] == "caller-secret"
|
||||
assert body["subject"] == "r:reader"
|
||||
if len(requests) == 3:
|
||||
assert requests[1][2]["continuation_token"] == "opaque/first"
|
||||
assert requests[2][2]["continuation_token"] == "opaque/second"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 501, 503])
|
||||
def test_http_errors_preserve_status_without_replaying_writes(catalog_server, status):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append((status, {"error": "authorization failure"}))
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
with pytest.raises(HttpError) as error:
|
||||
authz.add_acl(
|
||||
object="system", subject=Subject.role("operator"), privilege="OPERATE"
|
||||
)
|
||||
assert error.value.status_code == status
|
||||
assert error.value.request_id
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_role_pagination_and_late_failure(catalog_server, asynchronous):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [{"subject_id": "g:team", "role": "reader"}],
|
||||
"continuation_token": "next",
|
||||
},
|
||||
),
|
||||
(403, {"error": "denied"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
iterator = catalog.authz.list_role_bindings(role="reader")
|
||||
first = await anext(iterator) if asynchronous else next(iterator)
|
||||
assert first.subject == Subject.group_id("team")
|
||||
assert len(requests) == 1
|
||||
with pytest.raises(HttpError) as error:
|
||||
if asynchronous:
|
||||
await anext(iterator)
|
||||
else:
|
||||
next(iterator)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert requests[1][2] == {
|
||||
"role": "reader",
|
||||
"max_results": 100,
|
||||
"continuation_token": "next",
|
||||
}
|
||||
|
||||
|
||||
def test_early_close_does_not_fetch_next_page(catalog_server):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append(
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [{"subject_id": "g:team", "role": "reader"}],
|
||||
"continuation_token": "next",
|
||||
},
|
||||
)
|
||||
)
|
||||
iterator = lancedb.connect_catalog(endpoint).authz.list_role_bindings()
|
||||
next(iterator)
|
||||
iterator.close()
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_selectors_objects_and_redaction():
|
||||
secret = Subject.principal_api_key("do-not-print-this")
|
||||
assert "do-not-print-this" not in repr(secret)
|
||||
assert "do-not-print-this" not in str(secret)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
secret.kind = "role"
|
||||
assert str(Object.system()) == "system"
|
||||
assert str(Object.database("tenant/db")) == "database:tenant/db"
|
||||
assert (
|
||||
str(Object.namespace(database="db", namespace_path=["a", "b"]))
|
||||
== "namespace:db:a$b"
|
||||
)
|
||||
assert str(Object.table(database="db", name="events")) == "table:db:public$events"
|
||||
assert str(Object.view(database="db", name="recent")) == "view:db:public$recent"
|
||||
assert Subject.principal_id("p:id")._wire() == "p:p:id"
|
||||
assert Subject.group_id("team-id").value == "team-id"
|
||||
for namespace in (["a$b"], [""], "public", [".."]):
|
||||
with pytest.raises(ValueError):
|
||||
Object.table(database="db", namespace_path=namespace, name="events")
|
||||
for database in ("", "a//b", "a/../b", "a:b", "a%2Fb", "a$b"):
|
||||
with pytest.raises(ValueError):
|
||||
Object.database(database)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_before_io_and_empty_lists():
|
||||
inner = AsyncMock()
|
||||
inner.list_acls.return_value = json.dumps({"entries": []})
|
||||
inner.list_role_bindings.return_value = json.dumps({"bindings": []})
|
||||
authz = AsyncAuthorization(inner)
|
||||
for limit in (-1, 1.5, True):
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_acls(limit=limit)
|
||||
with pytest.raises(ValueError):
|
||||
Authorization(authz).list_role_bindings(limit=limit)
|
||||
with pytest.raises(ValueError):
|
||||
await authz.get_group(Subject.principal_id("alice"))
|
||||
with pytest.raises(ValueError):
|
||||
await authz.get_principal(Subject.group_id("team"))
|
||||
for method in (authz.add_role_binding, authz.delete_role_binding):
|
||||
with pytest.raises(ValueError):
|
||||
await method(subject=Subject.role("reader"), role="writer")
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_role_bindings(subject=Subject.role("reader"))
|
||||
assert [entry async for entry in authz.list_acls(limit=0)] == []
|
||||
assert inner.mock_calls == []
|
||||
assert [entry async for entry in authz.list_acls()] == []
|
||||
assert [entry async for entry in authz.list_role_bindings()] == []
|
||||
|
||||
|
||||
def test_api_key_subject_is_not_logged(catalog_server):
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append((200, {"principal_id": "public-id", "groups": [], "roles": []}))
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import lancedb, sys; from lancedb.authz import Subject; "
|
||||
"lancedb.connect_catalog(sys.argv[1]).authz.get_principal("
|
||||
"Subject.principal_api_key('test-subject-secret-never-log'))",
|
||||
endpoint,
|
||||
],
|
||||
env={**os.environ, "LANCEDB_LOG": "debug"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert requests[0][2] == {"principal": "a:test-subject-secret-never-log"}
|
||||
assert "test-subject-secret-never-log" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builder, kwargs, wire",
|
||||
[
|
||||
("system", {}, "system"),
|
||||
("secret", {"database": "db", "name": "api-key"}, "secret:db:public$api-key"),
|
||||
(
|
||||
"function",
|
||||
{"database": "db", "name": "caption"},
|
||||
"function:db:public$caption",
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "api-key"},
|
||||
"secret:tenant/db:a$b$api-key",
|
||||
),
|
||||
(
|
||||
"function",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "caption"},
|
||||
"function:tenant/db:a$b$caption",
|
||||
),
|
||||
("database", {"name": "tenant/db"}, "database:tenant/db"),
|
||||
(
|
||||
"namespace",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"]},
|
||||
"namespace:tenant/db:a$b",
|
||||
),
|
||||
(
|
||||
"table",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "events"},
|
||||
"table:tenant/db:a$b$events",
|
||||
),
|
||||
(
|
||||
"view",
|
||||
{"database": "db", "namespace_path": ["public"], "name": "recent"},
|
||||
"view:db:public$recent",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rust_backed_object_builders(builder, kwargs, wire):
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
value = getattr(Object, builder)(**kwargs)
|
||||
assert str(value) == wire
|
||||
assert str(value._inner) == wire
|
||||
assert str(_lancedb.AuthzObject(wire)) == wire
|
||||
assert value == Object(wire)
|
||||
assert hash(value) == hash(Object(wire))
|
||||
assert pickle.loads(pickle.dumps(value)) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind, prefix",
|
||||
[
|
||||
("principal_id", "p:"),
|
||||
("principal_name", "P:"),
|
||||
("group_id", "g:"),
|
||||
("group_name", "G:"),
|
||||
("role", "r:"),
|
||||
("principal_api_key", "a:"),
|
||||
],
|
||||
)
|
||||
def test_rust_backed_subject_selectors(kind, prefix):
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
value = "p:literal@example.com / 名前"
|
||||
subject = getattr(Subject, kind)(value)
|
||||
assert subject.kind == kind
|
||||
assert subject.value == value
|
||||
assert subject._wire() == prefix + value
|
||||
assert _lancedb.AuthzSubject(kind, value).to_wire() == prefix + value
|
||||
assert pickle.loads(pickle.dumps(subject)) == subject
|
||||
assert hash(subject) == hash(getattr(Subject, kind)(value))
|
||||
with pytest.raises(ValueError):
|
||||
getattr(Subject, kind)("")
|
||||
if kind == "principal_api_key":
|
||||
assert value not in repr(subject._inner)
|
||||
assert value not in str(subject._inner)
|
||||
if kind in ("principal_id", "group_id", "role"):
|
||||
assert Subject._canonical(prefix + value) == subject
|
||||
else:
|
||||
with pytest.raises(ValueError) as error:
|
||||
Subject._canonical(prefix + value)
|
||||
assert value not in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wire",
|
||||
[
|
||||
"",
|
||||
"database:",
|
||||
"database:a//b",
|
||||
"database:a/../b",
|
||||
"database:a%2Fb",
|
||||
"namespace:db:a$$b",
|
||||
"table:db:events",
|
||||
"table:db:public$",
|
||||
"table:db:public$..",
|
||||
"table:db:a/b$events",
|
||||
"view:db:public$café",
|
||||
"secret:db:public$",
|
||||
"function:db:public$..",
|
||||
"secret:db:a$$key",
|
||||
"function:db:caption",
|
||||
"secret:db:public$bad/name",
|
||||
"function:db:public$bad:name",
|
||||
"system:db",
|
||||
],
|
||||
)
|
||||
def test_invalid_raw_objects_fail_before_io(catalog_server, wire):
|
||||
endpoint, requests, _ = catalog_server
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
with pytest.raises(ValueError):
|
||||
Object(wire)
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_acls(object=wire)
|
||||
with pytest.raises(ValueError):
|
||||
authz.add_acl(object=wire, subject=Subject.role("reader"), privilege="SELECT")
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wire",
|
||||
[
|
||||
"future:db:resource",
|
||||
"table_extension:db:resource",
|
||||
"secret:tenant/db:a$b$key",
|
||||
"function:tenant/db:a$b$caption",
|
||||
],
|
||||
)
|
||||
def test_objects_round_trip_through_acl_requests(catalog_server, wire):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"object": wire,
|
||||
"subject_id": "r:reader",
|
||||
"privilege": "SELECT",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
obj = Object(wire)
|
||||
assert str(obj) == wire
|
||||
authz.add_acl(object=obj, subject=Subject.role("reader"), privilege="SELECT")
|
||||
entries = list(authz.list_acls(object=wire))
|
||||
assert entries[0].object == wire
|
||||
authz.delete_acl(
|
||||
object=entries[0].object, subject=Subject.role("reader"), privilege="SELECT"
|
||||
)
|
||||
assert len(requests) == 3
|
||||
for _, _, body in requests:
|
||||
assert body["object"] == wire
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_requests_validate_selector_context_before_io(catalog_server):
|
||||
endpoint, requests, _ = catalog_server
|
||||
catalog = await lancedb.connect_catalog_async(endpoint)
|
||||
native = catalog.authz._inner
|
||||
for method, request in [
|
||||
(native.list_role_bindings, {"subject": "r:reader"}),
|
||||
(native.add_role_binding, {"subject": "r:reader", "role": "writer"}),
|
||||
(native.delete_role_binding, {"subject": "r:reader", "role": "writer"}),
|
||||
(native.get_principal, {"principal": "g:group"}),
|
||||
(native.get_group, {"group": "a:never-log-this-key"}),
|
||||
]:
|
||||
with pytest.raises(ValueError) as error:
|
||||
await method(json.dumps(request))
|
||||
assert "never-log-this-key" not in str(error.value)
|
||||
assert requests == []
|
||||
|
||||
|
||||
def test_privilege_values_use_rust_serialization():
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
names = [value.value for value in Privilege]
|
||||
assert names == _lancedb.AuthzPrivilege.known()
|
||||
for privilege in Privilege:
|
||||
assert json.loads(json.dumps(privilege)) == privilege.value
|
||||
assert str(_lancedb.AuthzPrivilege(privilege)) == privilege.value
|
||||
assert pickle.loads(pickle.dumps(privilege)) is privilege
|
||||
for index in range(64):
|
||||
alias = f"PRIVILEGE{index}"
|
||||
expected = names[index] if index < len(names) else alias
|
||||
assert str(_lancedb.AuthzPrivilege(alias)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"creation_privilege", [None, "CREATE_SECRET", "CREATE_FUNCTION"]
|
||||
)
|
||||
def test_privilege_names_and_future_values_round_trip(
|
||||
catalog_server, asynchronous, creation_privilege
|
||||
):
|
||||
endpoint, requests, responses = catalog_server
|
||||
inputs = [
|
||||
Privilege.SELECT,
|
||||
"SELECT",
|
||||
"PRIVILEGE2",
|
||||
"PRIVILEGE20",
|
||||
"PRIVILEGE63",
|
||||
"PRIVILEGE64",
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
]
|
||||
expected = ["SELECT", "SELECT", "SELECT", *inputs[3:]]
|
||||
object_id = "table:db:public$t"
|
||||
if creation_privilege is not None:
|
||||
inputs = [Privilege[creation_privilege], creation_privilege]
|
||||
expected = [creation_privilege, creation_privilege]
|
||||
object_id = "namespace:db:public"
|
||||
for privilege in inputs:
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"object": object_id,
|
||||
"subject_id": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
authz = catalog.authz
|
||||
for privilege, canonical in zip(inputs, expected):
|
||||
added = authz.add_acl(
|
||||
object=object_id,
|
||||
subject=Subject.role("reader"),
|
||||
privilege=privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await added
|
||||
iterator = authz.list_acls()
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert entries[0].privilege == canonical
|
||||
deleted = authz.delete_acl(
|
||||
object=entries[0].object,
|
||||
subject=entries[0].subject,
|
||||
privilege=entries[0].privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await deleted
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert len(requests) == 3 * len(inputs)
|
||||
for index, canonical in enumerate(expected):
|
||||
assert requests[index * 3][2]["object"] == object_id
|
||||
assert requests[index * 3][2]["privilege"] == canonical
|
||||
assert requests[index * 3 + 2][2]["object"] == object_id
|
||||
assert requests[index * 3 + 2][2]["privilege"] == canonical
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_invalid_privileges_fail_before_io(catalog_server, asynchronous):
|
||||
endpoint, requests, _ = catalog_server
|
||||
invalid = [
|
||||
"",
|
||||
"select",
|
||||
"SELECT ",
|
||||
"UNKNOWN",
|
||||
"PRIVILEGE",
|
||||
"PRIVILEGE-1",
|
||||
"PRIVILEGE+1",
|
||||
"PRIVILEGE1x",
|
||||
None,
|
||||
2,
|
||||
True,
|
||||
["SELECT"],
|
||||
]
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
async_authz = catalog.authz if asynchronous else catalog.authz._inner
|
||||
native = async_authz._inner
|
||||
for privilege in invalid:
|
||||
for method in [catalog.authz.add_acl, catalog.authz.delete_acl]:
|
||||
with pytest.raises(ValueError, match="privilege"):
|
||||
result = method(
|
||||
object="system",
|
||||
subject=Subject.role("reader"),
|
||||
privilege=privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await result
|
||||
for method in [
|
||||
native.add_acl,
|
||||
native.delete_acl,
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
await method(
|
||||
json.dumps(
|
||||
{
|
||||
"object": "system",
|
||||
"subject": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_acl_page_preserves_unknown_subjects(catalog_server, asynchronous):
|
||||
import pickle
|
||||
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"subject_id": "p:known",
|
||||
"object": "system",
|
||||
"privilege": "USAGE",
|
||||
},
|
||||
{
|
||||
"subject_id": "x:future-subject",
|
||||
"object": "system",
|
||||
"privilege": "USAGE",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
iterator = catalog.authz.list_acls()
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].subject == Subject.principal_id("known")
|
||||
subject = entries[1].subject
|
||||
assert subject.kind == "unknown"
|
||||
assert subject.value == "x:future-subject"
|
||||
assert subject._wire() == "x:future-subject"
|
||||
assert pickle.loads(pickle.dumps(subject)) == subject
|
||||
result = catalog.authz.delete_acl(
|
||||
object=entries[1].object, subject=subject, privilege=entries[1].privilege
|
||||
)
|
||||
if asynchronous:
|
||||
await result
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert len(requests) == 2
|
||||
assert requests[1][2]["subject"] == "x:future-subject"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder", ["secret", "function"])
|
||||
def test_secret_and_function_builders_validate_components(builder):
|
||||
from lancedb import _lancedb
|
||||
|
||||
for database, namespace, name in [
|
||||
("a//b", ["public"], "valid"),
|
||||
("db", ["a$b"], "valid"),
|
||||
("db", "public", "valid"),
|
||||
("db", ["public"], "bad$name"),
|
||||
("db", ["public"], ".."),
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
getattr(Object, builder)(
|
||||
database=database, namespace_path=namespace, name=name
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
getattr(_lancedb.AuthzObject, builder)(database, namespace, name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builder", ["namespace", "table", "view", "secret", "function"]
|
||||
)
|
||||
def test_empty_and_omitted_namespace_paths_select_public(builder):
|
||||
from lancedb import _lancedb
|
||||
|
||||
kwargs = {"database": "db"}
|
||||
native_args = ()
|
||||
wire = "namespace:db:public"
|
||||
if builder != "namespace":
|
||||
kwargs["name"] = "item"
|
||||
native_args = ("item",)
|
||||
wire = f"{builder}:db:public$item"
|
||||
|
||||
build = getattr(Object, builder)
|
||||
expected = build(**kwargs, namespace_path=["public"])
|
||||
assert str(expected) == wire
|
||||
assert build(**kwargs) == expected
|
||||
for namespace in [[], ()]:
|
||||
assert build(**kwargs, namespace_path=namespace) == expected
|
||||
assert (
|
||||
str(getattr(_lancedb.AuthzObject, builder)("db", namespace, *native_args))
|
||||
== wire
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,3 +263,45 @@ async def test_database_names_invalid_limit(catalog_server, asynchronous, page_l
|
||||
next(names)
|
||||
assert names.num_page_results() == 0
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_catalog_rejects_invalid_database_names_before_io(
|
||||
catalog_server, asynchronous
|
||||
):
|
||||
endpoint, requests, _ = catalog_server
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"/db",
|
||||
"db/",
|
||||
"a//b",
|
||||
"a/./b",
|
||||
"a/../b",
|
||||
"a b",
|
||||
"a:b",
|
||||
"a$b",
|
||||
"a%b",
|
||||
"a?b",
|
||||
"a#b",
|
||||
"a\\b",
|
||||
"café",
|
||||
"a\nb",
|
||||
]:
|
||||
for method in [
|
||||
catalog.create_database,
|
||||
catalog.connect_database,
|
||||
catalog.drop_database,
|
||||
]:
|
||||
with pytest.raises(ValueError, match="Invalid database name"):
|
||||
result = method(name)
|
||||
if asynchronous:
|
||||
await result
|
||||
assert requests == []
|
||||
|
||||
@@ -245,6 +245,40 @@ def test_db_contains_and_len_include_all_table_name_pages(tmp_db: lancedb.DBConn
|
||||
assert "does_not_exist" not in tmp_db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_listing_defaults_and_zero_limit(tmp_path):
|
||||
names = [f"t{i:03}" for i in range(130)]
|
||||
for name in names:
|
||||
(tmp_path / f"{name}.lance").mkdir()
|
||||
|
||||
db = lancedb.connect(tmp_path)
|
||||
assert db.table_names() == names
|
||||
assert db.table_names(limit=None) == names
|
||||
assert db.table_names(limit=0) == []
|
||||
first = db.list_tables()
|
||||
assert first.tables == names[:100]
|
||||
assert first.page_token
|
||||
second = db.list_tables(page_token=first.page_token)
|
||||
assert second.tables == names[100:]
|
||||
assert second.page_token is None
|
||||
assert db.list_tables(limit=200).tables == names
|
||||
empty = db.list_tables(limit=0)
|
||||
assert empty.tables == []
|
||||
assert empty.page_token is None
|
||||
|
||||
adb = await lancedb.connect_async(tmp_path)
|
||||
assert await adb.table_names() == names
|
||||
assert await adb.table_names(limit=0) == []
|
||||
first = await adb.list_tables()
|
||||
assert first.tables == names[:100]
|
||||
second = await adb.list_tables(page_token=first.page_token)
|
||||
assert second.tables == names[100:]
|
||||
assert second.page_token is None
|
||||
empty = await adb.list_tables(limit=0)
|
||||
assert empty.tables == []
|
||||
assert empty.page_token is None
|
||||
|
||||
|
||||
def test_db_contains_stops_after_matching_table_page(
|
||||
tmp_db: lancedb.DBConnection, monkeypatch
|
||||
):
|
||||
|
||||
@@ -2,13 +2,78 @@
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import lancedb
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
from lancedb import LanceDBConnection
|
||||
from lancedb.index import FTS
|
||||
|
||||
# TODO: setup integ test mark and script
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("LANCEDB_URI"), reason="Set LANCEDB_URI to a JSON FTS server"
|
||||
)
|
||||
def test_json_fts_against_server():
|
||||
"""Requires a writable database and capacity to run the distributed indexer.
|
||||
|
||||
Configure LANCEDB_URI, LANCEDB_API_KEY, and optionally LANCEDB_REGION and
|
||||
LANCEDB_HOST_OVERRIDE before running this test.
|
||||
"""
|
||||
uri = os.environ["LANCEDB_URI"]
|
||||
assert uri.startswith("db://"), "LANCEDB_URI must identify a remote database"
|
||||
conn = lancedb.connect(
|
||||
uri,
|
||||
api_key=os.environ.get("LANCEDB_API_KEY"),
|
||||
region=os.environ.get("LANCEDB_REGION", "us-east-1"),
|
||||
host_override=os.environ.get("LANCEDB_HOST_OVERRIDE"),
|
||||
)
|
||||
name = f"json_fts_{uuid4().hex}"
|
||||
table = conn.create_table(
|
||||
name,
|
||||
pa.table(
|
||||
{
|
||||
"id": [0, 1, 2],
|
||||
"doc": pa.array(
|
||||
[
|
||||
'{"meta":{"tag":"nature"},"author":"alice"}',
|
||||
'{"meta":{"tag":"city"},"author":"nature"}',
|
||||
'{"meta":{"tag":"nature"},"author":"bob"}',
|
||||
],
|
||||
type=pa.json_(),
|
||||
),
|
||||
"raw": pa.array([b"nature"] * 3, type=pa.large_binary()),
|
||||
}
|
||||
),
|
||||
)
|
||||
try:
|
||||
table.create_index("doc", config=FTS(), name="doc_fts")
|
||||
table.wait_for_index(["doc_fts"], timeout=timedelta(minutes=5))
|
||||
stats = table.index_stats("doc_fts")
|
||||
assert stats.num_indexed_rows == 3
|
||||
assert stats.num_unindexed_rows == 0
|
||||
assert stats.index_type == "FTS"
|
||||
for query, expected in [
|
||||
("meta.tag,str,nature", [0, 2]),
|
||||
("meta.tag,str,alice", []),
|
||||
("meta.tag,str,tag", []),
|
||||
]:
|
||||
search = table.search(query, query_type="fts", fts_columns="doc")
|
||||
assert sorted(row["id"] for row in search.to_list()) == expected
|
||||
plan = search.explain_plan(True)
|
||||
assert "MatchQuery" in plan, plan
|
||||
assert "FlatMatchQuery" not in plan, plan
|
||||
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
|
||||
table.create_index("raw", config=FTS())
|
||||
finally:
|
||||
conn.drop_table(name)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Need to set up a local server")
|
||||
def test_against_local_server():
|
||||
conn = LanceDBConnection("lancedb+http://localhost:10024")
|
||||
|
||||
@@ -1555,7 +1555,7 @@ def _mock_remote_function_catalog():
|
||||
)["image"],
|
||||
"signature": body["signature"],
|
||||
"secret_bindings": body.get("secret_bindings", []),
|
||||
"created_at": "2026-08-21T00:00:00Z",
|
||||
"created_at_millis": 1787270400000, # 2026-08-21T00:00:00Z
|
||||
}
|
||||
response = {"job_id": "job-register"}
|
||||
status = 202
|
||||
|
||||
@@ -45,6 +45,75 @@ from utils import exception_output
|
||||
TEST_LANGUAGE_MODEL_HOME = Path(__file__).parent / "models"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type")
|
||||
@pytest.mark.parametrize("block_size", [128, 256])
|
||||
def test_json_fts(tmp_path, block_size):
|
||||
db = ldb.connect(tmp_path)
|
||||
data = pa.table(
|
||||
{
|
||||
"id": [0, 1, 2, 3],
|
||||
"doc": pa.array(
|
||||
[
|
||||
'{"title":"brown fox","meta":{"tag":"nature"},"author":"alice"}',
|
||||
'{"title":"blue car","meta":{"tag":"city"}}',
|
||||
'{"title":"brown bear","meta":{"tag":"nature"}}',
|
||||
'{"title":"red fox","author":"alice"}',
|
||||
],
|
||||
type=pa.json_(),
|
||||
),
|
||||
}
|
||||
)
|
||||
table = db.create_table("json_fts", data)
|
||||
table.create_index("doc", config=FTS(block_size=block_size), name="doc_fts")
|
||||
table = db.open_table("json_fts")
|
||||
index = table.list_indices()[0]
|
||||
assert index.name == "doc_fts"
|
||||
assert index.index_type == "FTS"
|
||||
assert index.columns == ["doc"]
|
||||
stats = table.index_stats("doc_fts")
|
||||
assert stats.num_indexed_rows == 4
|
||||
assert stats.num_unindexed_rows == 0
|
||||
|
||||
for query, expected in [
|
||||
("title,str,brown", [0, 2]),
|
||||
("meta.tag,str,nature", [0, 2]),
|
||||
("author,str,alice", [0, 3]),
|
||||
("title,str,alice", []),
|
||||
("title,str,title", []),
|
||||
]:
|
||||
search = table.search(query, query_type="fts", fts_columns="doc")
|
||||
assert sorted(row["id"] for row in search.to_list()) == expected
|
||||
plan = search.explain_plan(True)
|
||||
assert "MatchQuery" in plan, plan
|
||||
assert "FlatMatchQuery" not in plan, plan
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type")
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_fts_async(tmp_path):
|
||||
db = await ldb.connect_async(tmp_path)
|
||||
table = await db.create_table(
|
||||
"json_fts",
|
||||
pa.table({"doc": pa.array(['{"meta":{"tag":"nature"}}'], type=pa.json_())}),
|
||||
)
|
||||
await table.create_index("doc", config=FTS())
|
||||
search = table.query().nearest_to_text(MatchQuery("meta.tag,str,nature", "doc"))
|
||||
assert len(await search.to_list()) == 1
|
||||
plan = await search.explain_plan(True)
|
||||
assert "MatchQuery" in plan, plan
|
||||
assert "FlatMatchQuery" not in plan, plan
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [pa.binary(), pa.large_binary()])
|
||||
def test_fts_rejects_raw_binary(tmp_path, dtype):
|
||||
table = ldb.connect(tmp_path).create_table(
|
||||
"binary_fts", pa.table({"doc": pa.array([b'{"title":"brown"}'], type=dtype)})
|
||||
)
|
||||
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
|
||||
table.create_index("doc", config=FTS())
|
||||
assert table.list_indices() == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def table(tmp_path) -> ldb.table.LanceTable:
|
||||
# Use local random state to avoid affecting other tests
|
||||
|
||||
@@ -355,6 +355,20 @@ def test_hybrid_query_minimum_nprobes_zero_raises(sync_table: Table):
|
||||
)
|
||||
|
||||
|
||||
def test_hybrid_query_nprobes_remains_independent(sync_table: Table):
|
||||
query = sync_table.search(query_type="hybrid").nprobes(20)
|
||||
|
||||
assert query._nprobes == 20
|
||||
assert query._minimum_nprobes is None
|
||||
assert query._maximum_nprobes is None
|
||||
|
||||
query = query.minimum_nprobes(5).nprobes(20)
|
||||
|
||||
assert query._nprobes == 20
|
||||
assert query._minimum_nprobes == 5
|
||||
assert query._maximum_nprobes is None
|
||||
|
||||
|
||||
def test_hybrid_query_distance_range(sync_table: Table):
|
||||
reranker = RRFReranker(return_score="all")
|
||||
result = (
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
import tempfile
|
||||
import shutil
|
||||
import importlib
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
import pytest
|
||||
import pyarrow as pa
|
||||
import lancedb
|
||||
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
|
||||
from lancedb.listing import AsyncListing, Listing
|
||||
from lancedb.namespace import _MAX_QUERY_K
|
||||
from lancedb.namespace import _MAX_QUERY_K, _query_to_namespace_request
|
||||
from lancedb.query import Query
|
||||
from lancedb.table import AsyncTable, LanceTable
|
||||
|
||||
|
||||
@@ -73,6 +78,132 @@ def _namespace_lance_table(namespace_client: _NamespaceClient) -> LanceTable:
|
||||
return table
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("namespace_path", [[], ["test_ns"]])
|
||||
async def test_namespace_table_names_defaults_and_limits(tmp_path, namespace_path):
|
||||
db = lancedb.connect_namespace("dir", {"root": str(tmp_path)})
|
||||
if namespace_path:
|
||||
db.create_namespace(namespace_path)
|
||||
names = [f"t{idx:03}" for idx in range(12)]
|
||||
schema = pa.schema([pa.field("id", pa.int64())])
|
||||
for name in names:
|
||||
db.create_table(name, schema=schema, namespace_path=namespace_path)
|
||||
|
||||
assert list(db.table_names(namespace_path=namespace_path)) == names
|
||||
assert list(db.table_names(limit=None, namespace_path=namespace_path)) == names
|
||||
assert list(db.table_names(limit=0, namespace_path=namespace_path)) == []
|
||||
assert list(db.table_names(limit=2, namespace_path=namespace_path)) == names[:2]
|
||||
assert (
|
||||
list(
|
||||
db.table_names(page_token=names[1], limit=2, namespace_path=namespace_path)
|
||||
)
|
||||
== names[2:4]
|
||||
)
|
||||
|
||||
adb = lancedb.connect_namespace_async("dir", {"root": str(tmp_path)})
|
||||
assert list(await adb.table_names(namespace_path=namespace_path)) == names
|
||||
assert (
|
||||
list(await adb.table_names(limit=None, namespace_path=namespace_path)) == names
|
||||
)
|
||||
assert list(await adb.table_names(limit=0, namespace_path=namespace_path)) == []
|
||||
assert (
|
||||
list(await adb.table_names(limit=2, namespace_path=namespace_path)) == names[:2]
|
||||
)
|
||||
assert (
|
||||
list(
|
||||
await adb.table_names(
|
||||
page_token=names[1], limit=2, namespace_path=namespace_path
|
||||
)
|
||||
)
|
||||
== names[2:4]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paginated_rest_namespace():
|
||||
names = [f"t{idx:03}" for idx in reversed(range(12))]
|
||||
requests = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
url = urlparse(self.path)
|
||||
query = parse_qs(url.query)
|
||||
requests.append((unquote(url.path), query))
|
||||
token = query.get("page_token", [None])[0]
|
||||
if token not in {None, "opaque-next"}:
|
||||
self.send_error(400, "Unknown page token")
|
||||
return
|
||||
start = 0 if token is None else 10
|
||||
body = {"tables": names[start : start + 10]}
|
||||
if start == 0:
|
||||
body["page_token"] = "opaque-next"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(body).encode())
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
with http.server.HTTPServer(("127.0.0.1", 0), Handler) as server:
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.start()
|
||||
try:
|
||||
yield {"uri": f"http://127.0.0.1:{server.server_port}"}, requests
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("namespace_path", [[], ["n"]])
|
||||
async def test_rest_namespace_table_names_collects_pages(
|
||||
paginated_rest_namespace, namespace_path
|
||||
):
|
||||
properties, requests = paginated_rest_namespace
|
||||
names = [f"t{idx:03}" for idx in range(12)]
|
||||
db = lancedb.connect_namespace("rest", properties)
|
||||
first = db.list_tables(namespace_path=namespace_path)
|
||||
second = db.list_tables(namespace_path=namespace_path, page_token=first.page_token)
|
||||
assert len(first.tables) == 10
|
||||
assert first.page_token == "opaque-next"
|
||||
assert sorted(first.tables + second.tables) == names
|
||||
assert second.page_token is None
|
||||
|
||||
assert list(db.table_names(namespace_path=namespace_path)) == names
|
||||
assert list(db.table_names(limit=None, namespace_path=namespace_path)) == names
|
||||
assert list(db.table_names(limit=11, namespace_path=namespace_path)) == names[:11]
|
||||
assert (
|
||||
list(db.table_names(page_token="t005", limit=2, namespace_path=namespace_path))
|
||||
== names[6:8]
|
||||
)
|
||||
|
||||
adb = lancedb.connect_namespace_async("rest", properties)
|
||||
assert list(await adb.table_names(namespace_path=namespace_path)) == names
|
||||
assert (
|
||||
list(await adb.table_names(limit=None, namespace_path=namespace_path)) == names
|
||||
)
|
||||
assert (
|
||||
list(await adb.table_names(limit=11, namespace_path=namespace_path))
|
||||
== names[:11]
|
||||
)
|
||||
assert (
|
||||
list(
|
||||
await adb.table_names(
|
||||
page_token="t005", limit=2, namespace_path=namespace_path
|
||||
)
|
||||
)
|
||||
== names[6:8]
|
||||
)
|
||||
|
||||
request_count = len(requests)
|
||||
assert list(db.table_names(limit=0, namespace_path=namespace_path)) == []
|
||||
assert list(await adb.table_names(limit=0, namespace_path=namespace_path)) == []
|
||||
assert len(requests) == request_count
|
||||
identifier = "$" if not namespace_path else "n"
|
||||
assert all(path == f"/v1/namespace/{identifier}/table/list" for path, _ in requests)
|
||||
|
||||
|
||||
class TestNamespaceConnection:
|
||||
"""Test namespace-based LanceDB connection using DirectoryNamespace."""
|
||||
|
||||
@@ -981,6 +1112,29 @@ class TestPushdownOperations:
|
||||
]
|
||||
assert all(r.k <= 2**31 - 1 for r in namespace_client.requests)
|
||||
|
||||
def test_probe_fields_pass_through_independently(self):
|
||||
request = _query_to_namespace_request(
|
||||
["geneva", "hist"],
|
||||
Query(
|
||||
vector=[1.0, 2.0],
|
||||
nprobes=20,
|
||||
minimum_nprobes=3,
|
||||
maximum_nprobes=10,
|
||||
),
|
||||
)
|
||||
|
||||
assert request.nprobes == 20
|
||||
assert request.minimum_nprobes == 3
|
||||
assert request.maximum_nprobes == 10
|
||||
|
||||
request = _query_to_namespace_request(
|
||||
["geneva", "hist"],
|
||||
Query(vector=[1.0, 2.0], nprobes=5, maximum_nprobes=0),
|
||||
)
|
||||
assert request.nprobes == 5
|
||||
assert request.maximum_nprobes == 0
|
||||
assert request.to_dict()["maximum_nprobes"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAsyncPushdownOperations:
|
||||
@@ -1055,3 +1209,15 @@ def test_local_table_to_arrow_and_to_pandas_are_unchanged(tmp_path):
|
||||
|
||||
assert table.to_arrow().column("id").to_pylist() == [1, 2]
|
||||
assert table.to_pandas()["id"].tolist() == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", [0.0, 0.5, 1.0])
|
||||
def test_namespace_request_rejects_search_effort(effort):
|
||||
from lancedb.query import Query
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="namespace QueryTable does not support search_effort"
|
||||
):
|
||||
_query_to_namespace_request(
|
||||
["test"], Query(vector=[1.0, 0.0], search_effort=effort)
|
||||
)
|
||||
|
||||
@@ -509,18 +509,30 @@ 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_legacy_blob_descriptions_and_bytes(tmp_db):
|
||||
pytest.importorskip("lance")
|
||||
table = tmp_db.create_table(
|
||||
"test_vector_query_blob_descriptions",
|
||||
_blob_query_data(),
|
||||
storage_options=LEGACY_BLOB_STORAGE_OPTIONS,
|
||||
)
|
||||
query = table.search([1.0, 0.0]).select(["blob", "vector"]).limit(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="plain scan query"):
|
||||
table.search([1.0, 0.0]).select(["blob", "vector"]).limit(1).to_pandas(
|
||||
blob_mode="descriptions"
|
||||
)
|
||||
df = query.to_pandas(blob_mode="descriptions")
|
||||
assert df["blob"].tolist() == [{"position": 0, "size": 3}]
|
||||
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
query.to_pandas(blob_mode="bytes")
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
query.to_arrow(blob_mode="bytes")
|
||||
with pytest.raises(ValueError, match="legacy blob columns"):
|
||||
table.to_arrow(blob_mode="bytes")
|
||||
assert table.to_pandas(blob_mode="bytes")["blob"].tolist() == [
|
||||
b"one",
|
||||
b"two",
|
||||
b"three",
|
||||
b"four",
|
||||
]
|
||||
|
||||
|
||||
def test_order_by_plain_query(mem_db):
|
||||
@@ -995,12 +1007,22 @@ def test_invalid_nprobes_sync(table):
|
||||
ValueError,
|
||||
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
|
||||
):
|
||||
LanceVectorQueryBuilder(table, [0, 0], "vector").maximum_nprobes(5).to_list()
|
||||
(
|
||||
LanceVectorQueryBuilder(table, [0, 0], "vector")
|
||||
.minimum_nprobes(10)
|
||||
.maximum_nprobes(5)
|
||||
.to_list()
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="minimum_nprobes must be less than or equal to maximum_nprobes",
|
||||
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
|
||||
):
|
||||
LanceVectorQueryBuilder(table, [0, 0], "vector").minimum_nprobes(100).to_list()
|
||||
(
|
||||
LanceVectorQueryBuilder(table, [0, 0], "vector")
|
||||
.maximum_nprobes(5)
|
||||
.minimum_nprobes(100)
|
||||
.to_list()
|
||||
)
|
||||
|
||||
|
||||
def test_nprobes_works_sync(table):
|
||||
@@ -1039,12 +1061,22 @@ async def test_invalid_nprobes_async(table_async: AsyncTable):
|
||||
ValueError,
|
||||
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
|
||||
):
|
||||
await table_async.vector_search([0, 0]).maximum_nprobes(5).to_list()
|
||||
await (
|
||||
table_async.vector_search([0, 0])
|
||||
.minimum_nprobes(10)
|
||||
.maximum_nprobes(5)
|
||||
.to_list()
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="minimum_nprobes must be less than or equal to maximum_nprobes",
|
||||
):
|
||||
await table_async.vector_search([0, 0]).minimum_nprobes(100).to_list()
|
||||
await (
|
||||
table_async.vector_search([0, 0])
|
||||
.maximum_nprobes(5)
|
||||
.minimum_nprobes(100)
|
||||
.to_list()
|
||||
)
|
||||
|
||||
|
||||
def test_query_builder_with_prefilter(table):
|
||||
@@ -1635,8 +1667,9 @@ def test_query_serialization_sync(table: lancedb.table.Table):
|
||||
q,
|
||||
vector_column="vector",
|
||||
vector=[5.0, 6.0],
|
||||
minimum_nprobes=10,
|
||||
maximum_nprobes=10,
|
||||
nprobes=10,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
refine_factor=5,
|
||||
)
|
||||
|
||||
@@ -1654,8 +1687,19 @@ def test_query_serialization_sync(table: lancedb.table.Table):
|
||||
q,
|
||||
vector_column="vector",
|
||||
vector=[5.0, 6.0],
|
||||
minimum_nprobes=50,
|
||||
maximum_nprobes=50,
|
||||
nprobes=50,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
)
|
||||
|
||||
q = table.search([5.0, 6.0]).minimum_nprobes(5).nprobes(50).to_query_object()
|
||||
check_set_props(
|
||||
q,
|
||||
vector_column="vector",
|
||||
vector=[5.0, 6.0],
|
||||
nprobes=50,
|
||||
minimum_nprobes=5,
|
||||
maximum_nprobes=None,
|
||||
)
|
||||
|
||||
q = table.search([5.0, 6.0]).maximum_nprobes(10).to_query_object()
|
||||
@@ -1667,6 +1711,15 @@ def test_query_serialization_sync(table: lancedb.table.Table):
|
||||
minimum_nprobes=None,
|
||||
)
|
||||
|
||||
q = table.search([5.0, 6.0]).nprobes(5).maximum_nprobes(0).to_query_object()
|
||||
check_set_props(
|
||||
q,
|
||||
vector_column="vector",
|
||||
vector=[5.0, 6.0],
|
||||
nprobes=5,
|
||||
maximum_nprobes=0,
|
||||
)
|
||||
|
||||
q = table.search([5.0, 6.0]).distance_range(0.0, 1.0).to_query_object()
|
||||
check_set_props(
|
||||
q, vector_column="vector", vector=[5.0, 6.0], lower_bound=0.0, upper_bound=1.0
|
||||
@@ -1716,8 +1769,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
limit=10,
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
)
|
||||
@@ -1727,8 +1780,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
q,
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
@@ -1739,8 +1792,9 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
q,
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=50,
|
||||
maximum_nprobes=50,
|
||||
nprobes=50,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
@@ -1759,8 +1813,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
filter="id = 1",
|
||||
postfilter=True,
|
||||
vector=sample_vector,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
)
|
||||
@@ -1774,8 +1828,9 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
check_set_props(
|
||||
q,
|
||||
vector=sample_vector,
|
||||
minimum_nprobes=10,
|
||||
maximum_nprobes=10,
|
||||
nprobes=10,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
refine_factor=5,
|
||||
postfilter=False,
|
||||
with_row_id=False,
|
||||
@@ -1788,7 +1843,24 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
q,
|
||||
vector=sample_vector,
|
||||
minimum_nprobes=5,
|
||||
maximum_nprobes=20,
|
||||
maximum_nprobes=None,
|
||||
postfilter=False,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
q = (
|
||||
(await table_async.search([5.0, 6.0]))
|
||||
.nprobes(5)
|
||||
.maximum_nprobes(0)
|
||||
.to_query_object()
|
||||
)
|
||||
check_set_props(
|
||||
q,
|
||||
vector=sample_vector,
|
||||
nprobes=5,
|
||||
maximum_nprobes=0,
|
||||
postfilter=False,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
@@ -1806,8 +1878,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
lower_bound=0.0,
|
||||
upper_bound=1.0,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
@@ -1819,8 +1891,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
distance_type="cosine",
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
@@ -1832,8 +1904,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
ef=7,
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
bypass_vector_index=False,
|
||||
limit=10,
|
||||
@@ -1845,8 +1917,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
|
||||
bypass_vector_index=True,
|
||||
vector=sample_vector,
|
||||
postfilter=False,
|
||||
minimum_nprobes=20,
|
||||
maximum_nprobes=20,
|
||||
minimum_nprobes=None,
|
||||
maximum_nprobes=None,
|
||||
with_row_id=False,
|
||||
limit=10,
|
||||
)
|
||||
@@ -2205,3 +2277,127 @@ def test_blob_v2_to_batches_row_id(tmp_db):
|
||||
assert "_rowid" in hits.column_names
|
||||
blobs = table.fetch_blobs("blob", hits)
|
||||
assert [blobs[i].as_py() for i in range(len(blobs))] == [b"one", b"two"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metric", ["l2", "cosine", "dot"])
|
||||
@pytest.mark.parametrize(
|
||||
"effort,probes", [(None, 4), (0, 1), (0.25, 2), (0.5, 4), (0.75, 8), (1, 16)]
|
||||
)
|
||||
def test_search_effort_probe_budget(tmp_path, monkeypatch, metric, effort, probes):
|
||||
import re
|
||||
from lancedb.index import IvfFlat
|
||||
|
||||
monkeypatch.setenv("LANCE_AUTO_PROBE_MARGIN", "0")
|
||||
monkeypatch.setenv("LANCE_AUTO_MIN_INITIAL_NPROBES", "4")
|
||||
monkeypatch.setenv("LANCE_AUTO_MAX_INITIAL_NPROBES", "4")
|
||||
rng = np.random.default_rng(42)
|
||||
vectors = np.repeat(np.eye(16, dtype=np.float32), 32, axis=0)
|
||||
vectors += rng.normal(0, 0.001, vectors.shape).astype(np.float32)
|
||||
data = pa.table(
|
||||
{
|
||||
"id": np.arange(512),
|
||||
"text": ["dog"] * 512,
|
||||
"vector": pa.FixedSizeListArray.from_arrays(pa.array(vectors.ravel()), 16),
|
||||
}
|
||||
)
|
||||
table = lancedb.connect(tmp_path).create_table("effort", data.slice(0, 256))
|
||||
table.add(data.slice(256))
|
||||
table.create_index(
|
||||
"vector", config=IvfFlat(distance_type=metric, num_partitions=16)
|
||||
)
|
||||
vector = np.eye(16, dtype=np.float32)[0]
|
||||
|
||||
def search(value=effort):
|
||||
return table.search(vector).distance_type(metric).search_effort(value).limit(8)
|
||||
|
||||
def partitions(query):
|
||||
vector_plan = query.analyze_plan().split("FTS Search Plan:", 1)[0]
|
||||
values = re.findall(r"partitions_searched=(\d+)", vector_plan)
|
||||
assert values
|
||||
return sum(map(int, values))
|
||||
|
||||
assert partitions(search()) == probes
|
||||
exact = set(
|
||||
table.search(vector)
|
||||
.distance_type(metric)
|
||||
.bypass_vector_index()
|
||||
.limit(8)
|
||||
.to_arrow()["id"]
|
||||
.to_pylist()
|
||||
)
|
||||
actual = set(search().to_arrow()["id"].to_pylist())
|
||||
assert len(actual & exact) / 8 >= 0.5
|
||||
expected = 1 if effort is None else probes
|
||||
assert partitions(search().nprobes(1)) == expected
|
||||
assert (
|
||||
partitions(
|
||||
table.search(vector)
|
||||
.distance_type(metric)
|
||||
.nprobes(1)
|
||||
.search_effort(effort)
|
||||
.limit(8)
|
||||
)
|
||||
== expected
|
||||
)
|
||||
assert partitions(search().nprobes(1).search_effort(None)) == 1
|
||||
assert partitions(search().maximum_nprobes(2)) <= 2
|
||||
assert partitions(search().minimum_nprobes(3).maximum_nprobes(3)) == 3
|
||||
assert partitions(search(0).nprobes(16).maximum_nprobes(0)) == 1
|
||||
if effort is None:
|
||||
assert search().to_arrow() == search(0.5).to_arrow()
|
||||
filtered = search(0).maximum_nprobes(2).where("id % 8 = 0", prefilter=True)
|
||||
assert partitions(filtered) <= 2
|
||||
assert all(row["id"] % 8 == 0 for row in filtered.to_list())
|
||||
|
||||
if metric == "l2" and effort == 0.5:
|
||||
table.create_index("text", config=FTS())
|
||||
hybrid = (
|
||||
table.search(query_type="hybrid")
|
||||
.vector(vector)
|
||||
.text("dog")
|
||||
.nprobes(1)
|
||||
.search_effort(0.5)
|
||||
.limit(8)
|
||||
)
|
||||
assert partitions(hybrid) == 4
|
||||
assert partitions(hybrid.search_effort(None)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"effort", [-0.1, 1.1, float("nan"), float("inf"), -float("inf")]
|
||||
)
|
||||
def test_search_effort_invalid_sync(table, effort):
|
||||
with pytest.raises(ValueError, match="search_effort must be finite and in"):
|
||||
table.search([0, 0]).search_effort(effort).to_list()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("hybrid", [False, True])
|
||||
@pytest.mark.parametrize("effort", [None, 0, 0.25, 0.5, 0.75, 1])
|
||||
async def test_search_effort_async_roundtrip(table_async, hybrid, effort):
|
||||
query = table_async.vector_search([0, 0])
|
||||
if hybrid:
|
||||
query = query.nearest_to_text("dog")
|
||||
query = query.nprobes(5).search_effort(effort).minimum_nprobes(2).maximum_nprobes(3)
|
||||
request = query.to_query_object()
|
||||
assert request.search_effort == effort
|
||||
assert request.nprobes == 5
|
||||
assert request.minimum_nprobes == 2
|
||||
assert request.maximum_nprobes == 3
|
||||
assert query.search_effort(None).to_query_object().search_effort is None
|
||||
query.search_effort(effort)
|
||||
if not hybrid:
|
||||
assert len(await query.limit(1).to_list()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("hybrid", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"effort", [-0.1, 1.1, float("nan"), float("inf"), -float("inf")]
|
||||
)
|
||||
async def test_search_effort_invalid_async(table_async, hybrid, effort):
|
||||
query = table_async.vector_search([0, 0])
|
||||
if hybrid:
|
||||
query = query.nearest_to_text("dog")
|
||||
with pytest.raises(ValueError, match="search_effort must be finite and in"):
|
||||
query.search_effort(effort)
|
||||
|
||||
@@ -4,6 +4,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import multiprocessing as mp
|
||||
import pickle
|
||||
@@ -13,6 +14,7 @@ import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from packaging.version import Version
|
||||
|
||||
import lancedb
|
||||
@@ -105,6 +107,67 @@ async def mock_lancedb_connection_async(handler, **client_config):
|
||||
handle.join()
|
||||
|
||||
|
||||
def paginated_table_listing_handler(request):
|
||||
url = urlparse(request.path)
|
||||
assert url.path in {
|
||||
"/v1/table/",
|
||||
"/v1/namespace/$/table/list",
|
||||
"/v1/namespace/n/table/list",
|
||||
}
|
||||
query = parse_qs(url.query)
|
||||
limit = int(query.get("limit", [10])[0])
|
||||
assert limit > 0
|
||||
token = query.get("page_token", ["page-0"])[0]
|
||||
start = int(token.removeprefix("page-"))
|
||||
end = min(start + limit, 130)
|
||||
body = {"tables": [f"t{i:03}" for i in range(start, end)]}
|
||||
if end < 130:
|
||||
body["page_token"] = f"page-{end}"
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(json.dumps(body).encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [[], ["n"]])
|
||||
def test_remote_table_listing_defaults_and_zero_limit(namespace):
|
||||
names = [f"t{i:03}" for i in range(130)]
|
||||
with mock_lancedb_connection(paginated_table_listing_handler) as db:
|
||||
assert db.table_names(namespace_path=namespace) == names
|
||||
assert db.table_names(limit=None, namespace_path=namespace) == names
|
||||
assert db.table_names(limit=0, namespace_path=namespace) == []
|
||||
first = db.list_tables(namespace_path=namespace)
|
||||
assert first.tables == names[:100]
|
||||
assert first.page_token == "page-100"
|
||||
second = db.list_tables(namespace_path=namespace, page_token=first.page_token)
|
||||
assert second.tables == names[100:]
|
||||
assert second.page_token is None
|
||||
assert db.list_tables(namespace_path=namespace, limit=200).tables == names
|
||||
empty = db.list_tables(namespace_path=namespace, limit=0)
|
||||
assert empty.tables == []
|
||||
assert empty.page_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("namespace", [[], ["n"]])
|
||||
async def test_async_remote_table_listing_defaults_and_zero_limit(namespace):
|
||||
names = [f"t{i:03}" for i in range(130)]
|
||||
async with mock_lancedb_connection_async(paginated_table_listing_handler) as db:
|
||||
assert await db.table_names(namespace_path=namespace) == names
|
||||
assert await db.table_names(limit=0, namespace_path=namespace) == []
|
||||
first = await db.list_tables(namespace_path=namespace)
|
||||
assert first.tables == names[:100]
|
||||
assert first.page_token == "page-100"
|
||||
second = await db.list_tables(
|
||||
namespace_path=namespace, page_token=first.page_token
|
||||
)
|
||||
assert second.tables == names[100:]
|
||||
assert second.page_token is None
|
||||
empty = await db.list_tables(namespace_path=namespace, limit=0)
|
||||
assert empty.tables == []
|
||||
assert empty.page_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_db():
|
||||
def handler(request):
|
||||
@@ -805,6 +868,19 @@ def test_table_unimplemented_functions():
|
||||
assert f"search().{method}()" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["to_arrow", "to_pandas"])
|
||||
@pytest.mark.parametrize("blob_mode", ["lazy", "descriptions", "bytes", "invalid"])
|
||||
def test_remote_conversion_blob_mode_errors(method, blob_mode):
|
||||
with blob_remote_table() as table:
|
||||
if blob_mode == "invalid":
|
||||
with pytest.raises(ValueError, match="blob_mode must be one of"):
|
||||
getattr(table, method)(blob_mode=blob_mode)
|
||||
else:
|
||||
with pytest.raises(NotImplementedError) as error:
|
||||
getattr(table, method)(blob_mode=blob_mode)
|
||||
assert f"search().{method}()" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("version", [None, 1])
|
||||
@pytest.mark.parametrize("expose_location", [False, True])
|
||||
@@ -1441,6 +1517,72 @@ def test_remote_job_wait_raises_on_failure():
|
||||
job.wait()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("server_version", ["0.2.0", "0.6.0"])
|
||||
def test_remote_json_fts_config(server_version):
|
||||
from lancedb.index import FTS
|
||||
|
||||
received_requests = []
|
||||
|
||||
def handler(request):
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.send_header("phalanx-version", server_version)
|
||||
request.end_headers()
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
fields = [
|
||||
{
|
||||
"name": "doc",
|
||||
"type": {"type": "large_binary"},
|
||||
"nullable": True,
|
||||
"metadata": {"ARROW:extension:name": "lance.json"},
|
||||
},
|
||||
{
|
||||
"name": "raw",
|
||||
"type": {"type": "large_binary"},
|
||||
"nullable": True,
|
||||
},
|
||||
]
|
||||
request.wfile.write(
|
||||
json.dumps({"version": 1, "schema": {"fields": fields}}).encode()
|
||||
)
|
||||
elif request.path == "/v1/table/test/create_index/":
|
||||
received_requests.append(read_json_body(request))
|
||||
request.wfile.write(b"{}")
|
||||
else:
|
||||
raise AssertionError(f"Unexpected request: {request.path}")
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
table = db.open_table("test")
|
||||
table.create_index(
|
||||
"doc",
|
||||
config=FTS(
|
||||
with_position=True,
|
||||
base_tokenizer="whitespace",
|
||||
stem=False,
|
||||
remove_stop_words=False,
|
||||
lower_case=False,
|
||||
block_size=256,
|
||||
),
|
||||
name="doc_fts",
|
||||
replace=False,
|
||||
)
|
||||
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
|
||||
table.create_index("raw", config=FTS())
|
||||
|
||||
assert len(received_requests) == 1
|
||||
request = received_requests[0]
|
||||
assert request["column"] == "doc"
|
||||
assert request["index_type"] == "FTS"
|
||||
assert request["name"] == "doc_fts"
|
||||
assert request["replace"] is False
|
||||
assert request["with_position"] is True
|
||||
assert request["base_tokenizer"] == "whitespace"
|
||||
assert request["stem"] is False
|
||||
assert request["remove_stop_words"] is False
|
||||
assert request["lower_case"] is False
|
||||
assert request["block_size"] == 256
|
||||
|
||||
|
||||
def test_remote_create_index_new_api(caplog):
|
||||
received_requests = []
|
||||
|
||||
@@ -2132,9 +2274,6 @@ def test_query_sync_minimal():
|
||||
"ef": None,
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
"vector_column": "vector",
|
||||
"nprobes": 20,
|
||||
"minimum_nprobes": 20,
|
||||
"maximum_nprobes": 20,
|
||||
"version": None,
|
||||
}
|
||||
|
||||
@@ -2306,8 +2445,6 @@ def test_query_sync_maximal():
|
||||
"refine_factor": 10,
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
"nprobes": 5,
|
||||
"minimum_nprobes": 5,
|
||||
"maximum_nprobes": 5,
|
||||
"lower_bound": None,
|
||||
"upper_bound": None,
|
||||
"ef": None,
|
||||
@@ -2403,7 +2540,7 @@ async def test_query_async_nprobes_zero(hybrid):
|
||||
assert requests == ["/v1/table/test/describe/"]
|
||||
|
||||
|
||||
def test_query_sync_nprobes():
|
||||
def test_query_sync_probe_bounds():
|
||||
def handler(body):
|
||||
assert body == {
|
||||
"k": 10,
|
||||
@@ -2415,7 +2552,6 @@ def test_query_sync_nprobes():
|
||||
"upper_bound": None,
|
||||
"ef": None,
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
"nprobes": 5,
|
||||
"minimum_nprobes": 5,
|
||||
"maximum_nprobes": 15,
|
||||
"version": None,
|
||||
@@ -2432,7 +2568,28 @@ def test_query_sync_nprobes():
|
||||
)
|
||||
|
||||
|
||||
def test_query_sync_no_max_nprobes():
|
||||
def test_query_sync_nprobes_passthrough():
|
||||
def handler(body):
|
||||
assert body["nprobes"] == 20
|
||||
assert "minimum_nprobes" not in body
|
||||
assert "maximum_nprobes" not in body
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
table.search([1, 2, 3]).nprobes(20).to_list()
|
||||
|
||||
|
||||
def test_query_sync_nprobes_with_unbounded_maximum():
|
||||
def handler(body):
|
||||
assert body["nprobes"] == 20
|
||||
assert body["maximum_nprobes"] == 0
|
||||
return pa.table({"id": [1]})
|
||||
|
||||
with query_test_table(handler) as table:
|
||||
table.search([1, 2, 3]).nprobes(20).maximum_nprobes(0).to_list()
|
||||
|
||||
|
||||
def test_query_sync_minimum_nprobes_only():
|
||||
def handler(body):
|
||||
assert body == {
|
||||
"k": 10,
|
||||
@@ -2444,9 +2601,7 @@ def test_query_sync_no_max_nprobes():
|
||||
"upper_bound": None,
|
||||
"ef": None,
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
"nprobes": 5,
|
||||
"minimum_nprobes": 5,
|
||||
"maximum_nprobes": 0,
|
||||
"version": None,
|
||||
}
|
||||
|
||||
@@ -2456,7 +2611,6 @@ def test_query_sync_no_max_nprobes():
|
||||
(
|
||||
table.search([1, 2, 3], vector_column_name="vector2", fast_search=True)
|
||||
.minimum_nprobes(5)
|
||||
.maximum_nprobes(0)
|
||||
.to_list()
|
||||
)
|
||||
|
||||
@@ -2611,9 +2765,6 @@ def test_query_sync_hybrid():
|
||||
"refine_factor": None,
|
||||
"vector": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"vector_column": "vector",
|
||||
"nprobes": 20,
|
||||
"minimum_nprobes": 20,
|
||||
"maximum_nprobes": 20,
|
||||
"lower_bound": None,
|
||||
"upper_bound": None,
|
||||
"ef": None,
|
||||
@@ -2687,6 +2838,11 @@ def test_create_client():
|
||||
assert isinstance(db.client_config, ClientConfig)
|
||||
assert db.client_config.retry_config.retries == 42
|
||||
|
||||
db = lancedb.connect(
|
||||
**mandatory_args, client_config={"blob_request_concurrency": 3}
|
||||
)
|
||||
assert db.client_config.blob_request_concurrency == 3
|
||||
|
||||
with pytest.warns(DeprecationWarning):
|
||||
db = lancedb.connect(**mandatory_args, connection_timeout=42)
|
||||
assert db.client_config.timeout_config.connect_timeout == timedelta(seconds=42)
|
||||
@@ -3359,7 +3515,7 @@ def test_remote_blob_columns_and_fetch():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blob_mode", ["descriptions", "bytes", "lazy"])
|
||||
def test_sync_remote_blob_pandas_rejects_storage_access(
|
||||
def test_sync_remote_blob_pandas_avoids_storage_access(
|
||||
tmp_path, monkeypatch, blob_mode
|
||||
):
|
||||
import lance
|
||||
@@ -3372,10 +3528,19 @@ def test_sync_remote_blob_pandas_rejects_storage_access(
|
||||
|
||||
monkeypatch.setattr(lance, "dataset", fail_dataset)
|
||||
with blob_remote_table(location=location) as table:
|
||||
with pytest.raises(RuntimeError, match="to_lance.*remote tables") as error:
|
||||
table.search().select(["id", "image"]).to_pandas(blob_mode=blob_mode)
|
||||
|
||||
assert isinstance(error.value.__cause__, NotImplementedError)
|
||||
monkeypatch.setattr(table, "to_lance", fail_dataset)
|
||||
query = table.search().select(["id", "image"])
|
||||
if blob_mode == "descriptions":
|
||||
df = query.to_pandas(blob_mode=blob_mode)
|
||||
assert df["id"].tolist() == [1, 2, 3]
|
||||
assert "_rowid" not in df.columns
|
||||
assert df["image"][0]["size"] == 5
|
||||
elif blob_mode == "bytes":
|
||||
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
||||
query.to_pandas(blob_mode=blob_mode)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="cannot use Lance native"):
|
||||
query.to_pandas(blob_mode=blob_mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -3446,6 +3611,45 @@ async def test_async_remote_blob_pandas_rejects_unpinned_fetch(monkeypatch, blob
|
||||
await table.to_pandas(**pandas_kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["to_arrow", "to_list"])
|
||||
def test_remote_search_bytes_refused_before_query(monkeypatch, method):
|
||||
with blob_remote_table() as table:
|
||||
query = table.search().select(["id", "image"])
|
||||
descriptions = getattr(query, method)(blob_mode="descriptions")
|
||||
images = (
|
||||
descriptions["image"].to_pylist()
|
||||
if method == "to_arrow"
|
||||
else [row["image"] for row in descriptions]
|
||||
)
|
||||
assert "_lance_row_id" not in images[0]
|
||||
|
||||
def fail_query(*args, **kwargs):
|
||||
raise AssertionError("bytes must be refused before the query runs")
|
||||
|
||||
monkeypatch.setattr(table, "_execute_query", fail_query)
|
||||
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
||||
getattr(query, method)(blob_mode="bytes")
|
||||
with pytest.raises(ValueError, match="blob_mode must be one of"):
|
||||
table.to_pandas(blob_mode="handles")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_to_arrow_bytes_refused_before_query(monkeypatch):
|
||||
with blob_remote_table() as remote_table:
|
||||
table = remote_table._table
|
||||
|
||||
async def fail_fetch(*args, **kwargs):
|
||||
raise AssertionError("blob fetch must not use an unpinned remote table")
|
||||
|
||||
monkeypatch.setattr(table, "fetch_blobs", fail_fetch)
|
||||
query = table.query().select(["id", "image"])
|
||||
monkeypatch.setattr(query, "to_batches", fail_fetch)
|
||||
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
||||
await query.to_arrow(blob_mode="bytes")
|
||||
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
||||
await table.to_arrow(blob_mode="bytes")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("blob_mode", ["bytes", "lazy"])
|
||||
async def test_async_remote_nonblob_projection_to_pandas(blob_mode):
|
||||
@@ -3477,10 +3681,32 @@ def test_remote_blob_files_are_lazy_seekable_handles():
|
||||
assert empty.read_range(0, 0) == b""
|
||||
alpha.close()
|
||||
assert alpha.closed
|
||||
with pytest.raises(RuntimeError, match="already closed"):
|
||||
with pytest.raises(ValueError, match="read of closed file"):
|
||||
alpha.read_range(0, 1)
|
||||
|
||||
|
||||
def test_remote_blob_file_matches_file_error_and_range_contract():
|
||||
with blob_remote_table() as table:
|
||||
handle = table.fetch_blob_files("image", [10])[0]
|
||||
handle.seek(2)
|
||||
|
||||
with pytest.raises(ValueError, match="negative seek value -1"):
|
||||
handle.seek(-3, io.SEEK_CUR)
|
||||
assert handle.tell() == 2
|
||||
assert handle.read(None) == b"pha"
|
||||
|
||||
handle.seek(2)
|
||||
assert handle.read1(2) == b"ph"
|
||||
assert handle.read_ranges([(0, 2), (3, 2)]) == [b"al", b"ha"]
|
||||
assert handle.tell() == 4
|
||||
with pytest.raises(ValueError):
|
||||
handle.read_range(4, 2)
|
||||
|
||||
handle.close()
|
||||
with pytest.raises(ValueError, match="read of closed file"):
|
||||
handle.read(1)
|
||||
|
||||
|
||||
def test_remote_blob_fetch_accepts_query_table():
|
||||
hits = pa.table({"_rowid": pa.array([10, 20, 30], type=pa.uint64())})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import warnings
|
||||
import weakref
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, localcontext
|
||||
from time import sleep
|
||||
from typing import List
|
||||
from unittest.mock import patch
|
||||
@@ -2612,6 +2612,51 @@ def test_update_with_arrow_scalar(mem_db: DBConnection):
|
||||
assert table.to_arrow()["vector"].to_pylist() == [[1.0, 2.0, 3.0, 4.0]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_arrow_scalar", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"decimal_type, value",
|
||||
[
|
||||
(pa.decimal128(10, 2), "9.99"),
|
||||
(pa.decimal128(10, 2), "-9.99"),
|
||||
(pa.decimal128(10, 2), "0.00"),
|
||||
(pa.decimal128(10, 2), "1.23E+5"),
|
||||
(pa.decimal128(38, 18), "1E-18"),
|
||||
(pa.decimal128(38, 18), "12345678901234567.123456789012345678"),
|
||||
(pa.decimal128(38, 18), "12345678901234567890.123456789012345678"),
|
||||
(pa.decimal128(38, 18), "-12345678901234567890.123456789012345678"),
|
||||
(
|
||||
pa.decimal256(76, 38),
|
||||
"12345678901234567890123456789012345678."
|
||||
"12345678901234567890123456789012345678",
|
||||
),
|
||||
(
|
||||
pa.decimal256(76, 38),
|
||||
"-12345678901234567890123456789012345678."
|
||||
"12345678901234567890123456789012345678",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_update_decimal(mem_db: DBConnection, decimal_type, value, use_arrow_scalar):
|
||||
expected = Decimal(value)
|
||||
table = mem_db.create_table(
|
||||
"my_table",
|
||||
pa.table(
|
||||
{
|
||||
"id": [1, 2],
|
||||
"price": pa.array([Decimal("1"), expected], type=decimal_type),
|
||||
}
|
||||
),
|
||||
)
|
||||
update = table.to_arrow()["price"][1] if use_arrow_scalar else expected
|
||||
with localcontext() as context:
|
||||
context.prec = 6
|
||||
result = table.update(where="id = 1", values={"price": update})
|
||||
assert result.rows_updated == 1
|
||||
actual = table.to_arrow()
|
||||
assert actual.schema.field("price").type == decimal_type
|
||||
assert actual["price"].to_pylist() == [expected, expected]
|
||||
|
||||
|
||||
def test_update_types(mem_db: DBConnection):
|
||||
table = mem_db.create_table(
|
||||
"my_table",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from decimal import Decimal, localcontext
|
||||
from typing import Optional
|
||||
|
||||
import lance
|
||||
@@ -157,6 +158,41 @@ def test_value_to_sql_string(tmp_path):
|
||||
assert table.to_pandas().query("search == @value")["replace"].item() == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("9.99", "'9.99'"),
|
||||
("-9.99", "'-9.99'"),
|
||||
("0", "'0'"),
|
||||
("-0.00", "'-0.00'"),
|
||||
("1.23E+5", "'123000'"),
|
||||
("1E-18", "'0.000000000000000001'"),
|
||||
(
|
||||
"12345678901234567890.123456789012345678",
|
||||
"'12345678901234567890.123456789012345678'",
|
||||
),
|
||||
(
|
||||
"12345678901234567890123456789012345678."
|
||||
"12345678901234567890123456789012345678",
|
||||
"'12345678901234567890123456789012345678."
|
||||
"12345678901234567890123456789012345678'",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_value_to_sql_decimal(value, expected):
|
||||
with localcontext() as context:
|
||||
context.prec = 6
|
||||
assert value_to_sql(Decimal(value)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["NaN", "sNaN", "NaN123456", "Infinity", "-Infinity"])
|
||||
def test_value_to_sql_decimal_non_finite(value):
|
||||
with pytest.raises(
|
||||
ValueError, match="^Non-finite Decimal values cannot be converted to SQL$"
|
||||
):
|
||||
value_to_sql(Decimal(value))
|
||||
|
||||
|
||||
def test_value_to_sql_dict():
|
||||
# Simple flat struct
|
||||
assert value_to_sql({"a": 1, "b": "hello"}) == "named_struct('a', 1, 'b', 'hello')"
|
||||
@@ -951,6 +987,39 @@ def test_cast_to_target_schema_coerces_binary_to_blob_v2():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string_type", [pa.string(), pa.large_string(), pa.string_view()]
|
||||
)
|
||||
def test_cast_to_target_schema_coerces_string_to_blob_v2_uri(string_type):
|
||||
data = pa.table({"image": pa.array(["file:///payload", None], type=string_type)})
|
||||
target = pa.schema([lancedb.blob("image")])
|
||||
|
||||
output = _cast_to_target_schema(data.to_reader(), target).read_all()
|
||||
|
||||
image = output["image"].chunk(0)
|
||||
assert type(image.type) is lancedb.BlobType
|
||||
assert image.storage.to_pylist() == [
|
||||
{"data": None, "uri": "file:///payload", "position": None, "size": None},
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_type, value",
|
||||
[
|
||||
(pa.binary(4), b"abcd"),
|
||||
(pa.list_(pa.uint8()), [1, 2]),
|
||||
(pa.int64(), 42),
|
||||
],
|
||||
)
|
||||
def test_cast_to_target_schema_rejects_unsupported_blob_input(source_type, value):
|
||||
data = pa.table({"image": pa.array([value], type=source_type)})
|
||||
target = pa.schema([lancedb.blob("image")])
|
||||
|
||||
with pytest.raises(ValueError, match="cannot coerce column 'image' with type"):
|
||||
_cast_to_target_schema(data.to_reader(), target)
|
||||
|
||||
|
||||
def test_cast_to_target_schema_coerces_binary_to_metadata_blob_struct():
|
||||
storage = lancedb.blob("image").type.storage_type
|
||||
target = pa.schema(
|
||||
|
||||
@@ -119,7 +119,9 @@ def test_wal_hybrid_fuses_on_the_key_and_drops_it_again():
|
||||
limit=10,
|
||||
with_row_ids=True,
|
||||
)
|
||||
results = builder._finish_hybrid_results(combined)
|
||||
results = builder._finish_hybrid_results(
|
||||
combined, blob_mode="lazy", blob_sources={}
|
||||
)
|
||||
|
||||
assert "_rowid" not in results.column_names, "the surrogate is internal"
|
||||
assert "id" not in results.column_names, "the caller selected only `text`"
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::error::PythonErrorExt;
|
||||
use crate::runtime::future_into_py;
|
||||
use lancedb::authz::*;
|
||||
use pyo3::exceptions::{PyRuntimeError, PyValueError};
|
||||
use pyo3::types::{PyAnyMethods, PyBytes, PyString};
|
||||
use pyo3::{Bound, PyAny, PyRef, PyResult, pyclass, pymethods};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[pyclass]
|
||||
pub struct AuthorizationClient {
|
||||
pub(crate) inner: Arc<dyn Authorization>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthorizationClient {
|
||||
fn list_acls<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: ListAccessControlEntriesRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.list_acls(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn add_acl<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: AccessControlEntryRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.add_acl(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn delete_acl<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: AccessControlEntryRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.delete_acl(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn list_role_bindings<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: ListRoleBindingsRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.list_role_bindings(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn add_role_binding<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: RoleBindingRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.add_role_binding(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn delete_role_binding<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: RoleBindingRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.delete_role_binding(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn get_principal<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: GetPrincipalRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.get_principal(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn get_group<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: GetGroupRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.get_group(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared privilege parser and canonical wire names.
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzPrivilege {
|
||||
inner: Privilege,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzPrivilege {
|
||||
#[new]
|
||||
fn new(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: value.parse().map_err(PyValueError::new_err)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn known() -> Vec<String> {
|
||||
Privilege::KNOWN.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzSubject {
|
||||
inner: Subject,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzSubject {
|
||||
#[new]
|
||||
fn new(kind: &str, value: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||
let value: String = value
|
||||
.extract()
|
||||
.map_err(|_| PyValueError::new_err("Subject value must be a nonempty string"))?;
|
||||
Ok(Self {
|
||||
inner: Subject::new(kind.parse().infer_error()?, value).infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn from_canonical(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: Subject::from_canonical(value).infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn kind(&self) -> &str {
|
||||
self.inner.kind().as_str()
|
||||
}
|
||||
#[getter]
|
||||
fn value(&self) -> &str {
|
||||
self.inner.value()
|
||||
}
|
||||
#[getter]
|
||||
fn display_value(&self) -> &str {
|
||||
self.inner.display_value()
|
||||
}
|
||||
|
||||
#[pyo3(signature = (context=None))]
|
||||
fn to_wire(&self, context: Option<&str>) -> PyResult<String> {
|
||||
match context {
|
||||
None => {}
|
||||
Some("principal") => self.inner.validate_principal().infer_error()?,
|
||||
Some("group") => self.inner.validate_group().infer_error()?,
|
||||
Some("role_member") => self.inner.validate_role_member().infer_error()?,
|
||||
Some(_) => return Err(PyValueError::new_err("Invalid subject context")),
|
||||
}
|
||||
Ok(self.inner.to_wire())
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzObject {
|
||||
inner: Object,
|
||||
}
|
||||
|
||||
impl AuthzObject {
|
||||
fn validated(inner: Object) -> PyResult<Self> {
|
||||
inner.validate().map_err(PyValueError::new_err)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_path(value: &Bound<'_, PyAny>) -> PyResult<NamespacePath> {
|
||||
if value.is_instance_of::<PyString>() || value.is_instance_of::<PyBytes>() {
|
||||
return Err(PyValueError::new_err(
|
||||
"namespace must be a sequence of components",
|
||||
));
|
||||
}
|
||||
let components = value
|
||||
.extract()
|
||||
.map_err(|_| PyValueError::new_err("namespace must be a sequence of string components"))?;
|
||||
NamespacePath::new(components).map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzObject {
|
||||
#[new]
|
||||
fn new(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: value.parse().infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn system() -> Self {
|
||||
Self {
|
||||
inner: Object::System,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn database(name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Database(DatabaseObject::new(name.into())))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn namespace(database: &str, namespace: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||
Self::validated(Object::Namespace(NamespaceObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn table(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Table(TableObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
table: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn view(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::View(ViewObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
view: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn secret(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Secret(SecretObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
secret: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn function(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Function(FunctionObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
function: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,13 @@ pub struct Catalog {
|
||||
|
||||
#[pymethods]
|
||||
impl Catalog {
|
||||
#[getter]
|
||||
fn authz(&self) -> PyResult<crate::authz::AuthorizationClient> {
|
||||
Ok(crate::authz::AuthorizationClient {
|
||||
inner: self.inner.authz().infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn uri(&self) -> &str {
|
||||
self.inner.uri()
|
||||
|
||||
@@ -1220,6 +1220,7 @@ pub struct PyClientConfig {
|
||||
user_agent: String,
|
||||
retry_config: Option<PyClientRetryConfig>,
|
||||
timeout_config: Option<PyClientTimeoutConfig>,
|
||||
blob_request_concurrency: Option<usize>,
|
||||
extra_headers: Option<HashMap<String, String>>,
|
||||
id_delimiter: Option<String>,
|
||||
tls_config: Option<PyClientTlsConfig>,
|
||||
@@ -1305,6 +1306,7 @@ impl From<PyClientConfig> for lancedb::remote::ClientConfig {
|
||||
user_agent: value.user_agent,
|
||||
retry_config: value.retry_config.map(Into::into).unwrap_or_default(),
|
||||
timeout_config: value.timeout_config.map(Into::into).unwrap_or_default(),
|
||||
blob_request_concurrency: value.blob_request_concurrency,
|
||||
extra_headers: value.extra_headers.unwrap_or_default(),
|
||||
id_delimiter: value.id_delimiter,
|
||||
tls_config: value.tls_config.map(Into::into),
|
||||
|
||||
@@ -22,6 +22,7 @@ use table::{
|
||||
};
|
||||
|
||||
pub mod arrow;
|
||||
pub mod authz;
|
||||
pub mod catalog;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
@@ -70,6 +71,10 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<listing::FunctionListing>()?;
|
||||
m.add_class::<listing::JobListing>()?;
|
||||
|
||||
m.add_class::<authz::AuthorizationClient>()?;
|
||||
m.add_class::<authz::AuthzSubject>()?;
|
||||
m.add_class::<authz::AuthzObject>()?;
|
||||
m.add_class::<authz::AuthzPrivilege>()?;
|
||||
m.add_function(wrap_pyfunction!(catalog::connect_catalog, m)?)?;
|
||||
m.add_class::<Session>()?;
|
||||
m.add_class::<Table>()?;
|
||||
|
||||
+36
-10
@@ -71,7 +71,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyLanceDB<FtsQuery> {
|
||||
match class_name(&ob)?.as_str() {
|
||||
"MatchQuery" => {
|
||||
let query = ob.getattr("query")?.extract()?;
|
||||
let column = ob.getattr("column")?.extract()?;
|
||||
let column = ob.getattr("column")?.extract::<Option<String>>()?;
|
||||
let boost = ob.getattr("boost")?.extract()?;
|
||||
let fuzziness = ob.getattr("fuzziness")?.extract()?;
|
||||
let max_expansions = ob.getattr("max_expansions")?.extract()?;
|
||||
@@ -88,7 +88,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyLanceDB<FtsQuery> {
|
||||
|
||||
let mut query =
|
||||
MatchQuery::new(query)
|
||||
.with_column(Some(column))
|
||||
.with_column(column)
|
||||
.with_boost(boost)
|
||||
.with_fuzziness(fuzziness)
|
||||
.with_max_expansions(max_expansions)
|
||||
@@ -199,7 +199,7 @@ impl<'py> IntoPyObject<'py> for PyLanceDB<FtsQuery> {
|
||||
}
|
||||
namespace
|
||||
.getattr(intern!(py, "MatchQuery"))?
|
||||
.call((query.terms, query.column.unwrap()), Some(&kwargs))
|
||||
.call((query.terms, query.column), Some(&kwargs))
|
||||
}
|
||||
FtsQuery::Phrase(query) => {
|
||||
let kwargs = PyDict::new(py);
|
||||
@@ -336,10 +336,10 @@ pub struct PyQueryRequest {
|
||||
pub use_lsm: Option<bool>,
|
||||
pub column: Option<String>,
|
||||
pub query_vector: Option<PyQueryVectors>,
|
||||
pub nprobes: Option<usize>,
|
||||
pub minimum_nprobes: Option<usize>,
|
||||
// None means user did not set it and default should be used (currently 20)
|
||||
// Some(0) means user set it to None and there is no limit
|
||||
pub maximum_nprobes: Option<usize>,
|
||||
pub search_effort: Option<f64>,
|
||||
pub lower_bound: Option<f32>,
|
||||
pub upper_bound: Option<f32>,
|
||||
pub ef: Option<usize>,
|
||||
@@ -369,8 +369,10 @@ impl From<AnyQuery> for PyQueryRequest {
|
||||
use_lsm: query_request.use_lsm,
|
||||
column: None,
|
||||
query_vector: None,
|
||||
nprobes: None,
|
||||
minimum_nprobes: None,
|
||||
maximum_nprobes: None,
|
||||
search_effort: None,
|
||||
lower_bound: None,
|
||||
upper_bound: None,
|
||||
ef: None,
|
||||
@@ -396,11 +398,10 @@ impl From<AnyQuery> for PyQueryRequest {
|
||||
use_lsm: vector_query.base.use_lsm,
|
||||
column: vector_query.column,
|
||||
query_vector: Some(PyQueryVectors(vector_query.query_vector)),
|
||||
minimum_nprobes: Some(vector_query.minimum_nprobes),
|
||||
maximum_nprobes: match vector_query.maximum_nprobes {
|
||||
None => Some(0),
|
||||
Some(value) => Some(value),
|
||||
},
|
||||
nprobes: vector_query.nprobes,
|
||||
minimum_nprobes: vector_query.minimum_nprobes,
|
||||
maximum_nprobes: vector_query.maximum_nprobes,
|
||||
search_effort: vector_query.search_effort,
|
||||
lower_bound: vector_query.lower_bound,
|
||||
upper_bound: vector_query.upper_bound,
|
||||
ef: vector_query.ef,
|
||||
@@ -1000,6 +1001,15 @@ impl VectorQuery {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search_effort(&mut self, search_effort: Option<f64>) -> PyResult<()> {
|
||||
self.inner = self
|
||||
.inner
|
||||
.clone()
|
||||
.search_effort(search_effort)
|
||||
.infer_error()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn minimum_nprobes(&mut self, minimum_nprobes: u32) -> PyResult<()> {
|
||||
self.inner = self
|
||||
.inner
|
||||
@@ -1193,6 +1203,18 @@ impl HybridQuery {
|
||||
self.inner_vec.nprobes(nprobe)
|
||||
}
|
||||
|
||||
pub fn search_effort(&mut self, search_effort: Option<f64>) -> PyResult<()> {
|
||||
self.inner_vec.search_effort(search_effort)
|
||||
}
|
||||
|
||||
pub fn minimum_nprobes(&mut self, minimum_nprobes: u32) -> PyResult<()> {
|
||||
self.inner_vec.minimum_nprobes(minimum_nprobes)
|
||||
}
|
||||
|
||||
pub fn maximum_nprobes(&mut self, maximum_nprobes: u32) -> PyResult<()> {
|
||||
self.inner_vec.maximum_nprobes(maximum_nprobes)
|
||||
}
|
||||
|
||||
pub fn ef(&mut self, ef: u32) {
|
||||
self.inner_vec.ef(ef);
|
||||
}
|
||||
@@ -1237,6 +1259,10 @@ impl HybridQuery {
|
||||
req.query_vector = vec_req.query_vector;
|
||||
req.column = vec_req.column;
|
||||
req.distance_type = vec_req.distance_type;
|
||||
req.nprobes = vec_req.nprobes;
|
||||
req.minimum_nprobes = vec_req.minimum_nprobes;
|
||||
req.maximum_nprobes = vec_req.maximum_nprobes;
|
||||
req.search_effort = vec_req.search_effort;
|
||||
req.ef = vec_req.ef;
|
||||
req.refine_factor = vec_req.refine_factor;
|
||||
req.lower_bound = vec_req.lower_bound;
|
||||
|
||||
+58
-15
@@ -697,6 +697,14 @@ impl PyBlobFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn blob_file_error(operation: &str, error: lance_core::Error) -> pyo3::PyErr {
|
||||
let message = format!("blob {operation} failed: {error}");
|
||||
match error {
|
||||
lance_core::Error::InvalidInput { .. } => PyValueError::new_err(message),
|
||||
_ => PyRuntimeError::new_err(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBlobFile {
|
||||
fn read_bytes(self_: PyRef<'_, Self>) -> PyResult<Py<PyBytes>> {
|
||||
@@ -705,7 +713,7 @@ impl PyBlobFile {
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
.detach(move || block_on(async move { inner.read().await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob read failed: {e}")))?;
|
||||
.map_err(|e| blob_file_error("read", e))?;
|
||||
Ok(PyBytes::new(py, bytes.as_ref()).unbind())
|
||||
}
|
||||
|
||||
@@ -713,10 +721,7 @@ impl PyBlobFile {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let bytes = inner
|
||||
.read()
|
||||
.await
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob read failed: {e}")))?;
|
||||
let bytes = inner.read().await.map_err(|e| blob_file_error("read", e))?;
|
||||
Python::attach(|py| Ok(PyBytes::new(py, bytes.as_ref()).unbind()))
|
||||
})
|
||||
}
|
||||
@@ -739,7 +744,7 @@ impl PyBlobFile {
|
||||
self_
|
||||
.py()
|
||||
.detach(move || block_on(async move { inner.close().await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob close failed: {e}")))
|
||||
.map_err(|e| blob_file_error("close", e))
|
||||
}
|
||||
|
||||
fn is_closed(self_: PyRef<'_, Self>) -> bool {
|
||||
@@ -752,7 +757,7 @@ impl PyBlobFile {
|
||||
self_
|
||||
.py()
|
||||
.detach(move || block_on(async move { inner.seek(position).await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob seek failed: {e}")))
|
||||
.map_err(|e| blob_file_error("seek", e))
|
||||
}
|
||||
|
||||
fn tell(self_: PyRef<'_, Self>) -> PyResult<u64> {
|
||||
@@ -761,7 +766,7 @@ impl PyBlobFile {
|
||||
self_
|
||||
.py()
|
||||
.detach(move || block_on(async move { inner.tell().await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob tell failed: {e}")))
|
||||
.map_err(|e| blob_file_error("tell", e))
|
||||
}
|
||||
|
||||
fn size(self_: PyRef<'_, Self>) -> u64 {
|
||||
@@ -778,17 +783,39 @@ impl PyBlobFile {
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
.detach(move || block_on(async move { inner.read_range(offset..end).await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob read_range failed: {e}")))?;
|
||||
.map_err(|e| blob_file_error("read_range", e))?;
|
||||
Ok(PyBytes::new(py, bytes.as_ref()).unbind())
|
||||
}
|
||||
|
||||
/// Read blob-local byte ranges without moving the cursor.
|
||||
fn read_ranges(self_: PyRef<'_, Self>, ranges: Vec<(u64, u64)>) -> PyResult<Vec<Py<PyBytes>>> {
|
||||
let ranges = ranges
|
||||
.into_iter()
|
||||
.map(|(offset, length)| {
|
||||
offset
|
||||
.checked_add(length)
|
||||
.map(|end| offset..end)
|
||||
.ok_or_else(|| PyValueError::new_err("offset + length overflowed"))
|
||||
})
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
let inner = self_.inner.clone();
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
.detach(move || block_on(async move { inner.read_ranges(&ranges).await }))
|
||||
.map_err(|e| blob_file_error("read_ranges", e))?;
|
||||
Ok(bytes
|
||||
.iter()
|
||||
.map(|bytes| PyBytes::new(py, bytes.as_ref()).unbind())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_up_to(self_: PyRef<'_, Self>, length: usize) -> PyResult<Py<PyBytes>> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
.detach(move || block_on(async move { inner.read_up_to(length).await }))
|
||||
.map_err(|e| PyRuntimeError::new_err(format!("blob read_up_to failed: {e}")))?;
|
||||
.map_err(|e| blob_file_error("read_up_to", e))?;
|
||||
Ok(PyBytes::new(py, bytes.as_ref()).unbind())
|
||||
}
|
||||
}
|
||||
@@ -1462,14 +1489,30 @@ impl Table {
|
||||
pub fn fetch_blob_ranges(
|
||||
self_: PyRef<'_, Self>,
|
||||
column: String,
|
||||
requests: Vec<(u64, u64, u64)>,
|
||||
requests: Vec<Vec<i128>>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
let requests = requests
|
||||
.into_iter()
|
||||
.map(|values| {
|
||||
let [row_id, offset, length] = <[i128; 3]>::try_from(values).map_err(|_| {
|
||||
PyValueError::new_err("each blob range request must have three values")
|
||||
})?;
|
||||
let to_u64 = |value, name| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"blob range {name} must be a non-negative 64-bit integer"
|
||||
))
|
||||
})
|
||||
};
|
||||
Ok(BlobRangeRequest::new(
|
||||
to_u64(row_id, "row id")?,
|
||||
to_u64(offset, "offset")?,
|
||||
to_u64(length, "length")?,
|
||||
))
|
||||
})
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
future_into_py(self_.py(), async move {
|
||||
let requests = requests
|
||||
.into_iter()
|
||||
.map(|(row_id, offset, length)| BlobRangeRequest::new(row_id, offset, length))
|
||||
.collect::<Vec<_>>();
|
||||
let blobs: LargeBinaryArray = inner
|
||||
.fetch_blob_ranges(column, requests)
|
||||
.await
|
||||
|
||||
Generated
+41
-15
@@ -1849,19 +1849,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "0.11.1"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lance-namespace-urllib3-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/e7/d5d46594678ee479c0eda830c47b2f5c46133bd100e84e9aa6e01306eca7/lance_namespace-0.13.0.tar.gz", hash = "sha256:24554a0997bdb39595c6e4cb3ac6722069f6cd3bd1a74e7acbba7bfaf774a40d", size = 11636, upload-time = "2026-09-14T02:47:03.871523Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/47/cfa33cca1ba7c749fd2918cfc5c8ded788f378cc6424d23e4fead5a14125/lance_namespace-0.13.0-py3-none-any.whl", hash = "sha256:438c7b17aef421c21c138196e715f2510d62f07e865372047f87c1e75e618c7a", size = 13507, upload-time = "2026-09-14T02:47:01.765655Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace-urllib3-client"
|
||||
version = "0.11.1"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1869,9 +1869,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/25/9aaa4a5e8999693fb0f227c2c0c4b97bd8f0539066408cdff0b89d41b2d5/lance_namespace_urllib3_client-0.13.0.tar.gz", hash = "sha256:1e8a79c6e4e6277033597fd76aa0e2f33d909ca1436c935936e9e569221f43ef", size = 238348, upload-time = "2026-09-14T02:47:04.521878Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/4c/7b8f0712a7fe1b8655b711342bc4989696635a79e7027ee56ba3cae23e99/lance_namespace_urllib3_client-0.13.0-py3-none-any.whl", hash = "sha256:fb361eb4f6c7f2d1f9e92809609657b73e38241393e9c4516e12d6b6ee643a0a", size = 406848, upload-time = "2026-09-14T02:47:02.715954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1900,6 +1900,7 @@ clip = [
|
||||
{ name = "torch" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "maturin" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyright" },
|
||||
{ name = "ruff" },
|
||||
@@ -1980,7 +1981,8 @@ requires-dist = [
|
||||
{ name = "huggingface-hub", marker = "extra == 'embeddings'", specifier = ">=0.19.0" },
|
||||
{ name = "ibm-watsonx-ai", marker = "python_full_version >= '3.10' and extra == 'embeddings'", specifier = ">=1.1.2" },
|
||||
{ name = "instructorembedding", marker = "extra == 'embeddings'", specifier = ">=1.0.1" },
|
||||
{ name = "lance-namespace", specifier = ">=0.3.2" },
|
||||
{ name = "lance-namespace", specifier = ">=0.13.0" },
|
||||
{ name = "maturin", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "mkdocs", marker = "extra == 'docs'" },
|
||||
{ name = "mkdocs-jupyter", marker = "extra == 'docs'" },
|
||||
{ name = "mkdocs-material", marker = "extra == 'docs'" },
|
||||
@@ -2005,7 +2007,7 @@ requires-dist = [
|
||||
{ name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7.4,<3" },
|
||||
{ name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" },
|
||||
{ name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b6" },
|
||||
{ name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b12" },
|
||||
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" },
|
||||
{ name = "pytest", marker = "extra == 'tests'", specifier = ">=7.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.21" },
|
||||
@@ -2156,6 +2158,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maturin"
|
||||
version = "1.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", hash = "sha256:94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a", size = 385504, upload-time = "2026-08-24T12:11:22.665Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", hash = "sha256:6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82", size = 10206220, upload-time = "2026-08-24T12:10:53.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7", size = 19416513, upload-time = "2026-08-24T12:10:56.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd", size = 10014962, upload-time = "2026-08-24T12:10:58.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55", size = 10196223, upload-time = "2026-08-24T12:11:00.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e", size = 10541186, upload-time = "2026-08-24T12:11:02.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136", size = 10083468, upload-time = "2026-08-24T12:11:05.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1", size = 10047786, upload-time = "2026-08-24T12:11:07.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d", size = 13315332, upload-time = "2026-08-24T12:11:09.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15", size = 10807183, upload-time = "2026-08-24T12:11:11.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176", size = 10413274, upload-time = "2026-08-24T12:11:14.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", hash = "sha256:da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca", size = 8928744, upload-time = "2026-08-24T12:11:16.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", hash = "sha256:552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1", size = 10335085, upload-time = "2026-08-24T12:11:18.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
@@ -3912,7 +3938,7 @@ crypto = [
|
||||
|
||||
[[package]]
|
||||
name = "pylance"
|
||||
version = "14.0.0b6"
|
||||
version = "14.0.0b12"
|
||||
source = { registry = "https://pypi.fury.io/lance-format/" }
|
||||
dependencies = [
|
||||
{ name = "lance-namespace" },
|
||||
@@ -3922,12 +3948,12 @@ dependencies = [
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_DuQEn/pylance-14.0.0b6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3eade86fa30d9ff3248ee7ffb0b27ce29495394d12327da4d22b5c54bc2b59fe" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_K5sqj/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10e291974ee738a1d5d7e9dad17a634394a91cb60080b5ceac209077fbaec43c" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_EnbkT/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61bac10f5081c1210edd017f81c8c784d800b89446bd0ed05d4a24e7001965bc" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_19ffYg/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c627ad4a9870611becf84409d8e5ecddc6be64e1dd53bee4bd5fb10ab561e5c" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_2eLLY5/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bc603a7dd690233f1fe000600171e1c092f153bc9060cdf198825c4bbe1418aa" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_t8ggc/pylance-14.0.0b6-cp310-abi3-win_amd64.whl", hash = "sha256:1d32df32a3d157611450def07eae8ace3a7401286eac68cad9a24e0d5deed2db" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_1Ixasc/pylance-14.0.0b12-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:84350a3c5c1eea3398f4c4fad7cfe85b831d831a5e43b5ca2a783dbdb0e11969" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_sCEkG/pylance-14.0.0b12-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76b386c182a6a8e7b86fbef28f84d1dd39e4626ac8681a0cf36f40a45dd7cb87" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_1vdT1C/pylance-14.0.0b12-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6aa548c7937fcdfa8f1943a2d59041a42f8b500199880dbb509c7195fa253381" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_2fXu1V/pylance-14.0.0b12-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:79f82d6a6c50f37aab338470e3733436e6a27fbb5972d29fcf453ece56d3b3c6" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_24UavN/pylance-14.0.0b12-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:de0305999257002785f60169e6926896584d66f1da050bb5afd1d48c6e3d9bb9" },
|
||||
{ url = "https://pypi.fury.io/lance-format/-/ver_XNynX/pylance-14.0.0b12-cp310-abi3-win_amd64.whl", hash = "sha256:4d0cdb689e898ce71e8e434d00a3b97639cea8fb97da21f1c921543be363268f" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+13
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lancedb"
|
||||
version = "0.41.0-beta.1"
|
||||
version = "0.41.0-beta.6"
|
||||
edition.workspace = true
|
||||
description = "LanceDB: A serverless, low-latency vector database for AI applications"
|
||||
license.workspace = true
|
||||
@@ -66,19 +66,23 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-openai = { version = "0.20.0", optional = true }
|
||||
serde_with = { version = "3.8.1" }
|
||||
strum_macros = "0.28"
|
||||
tempfile = { workspace = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-sdk-bedrockruntime = { version = "1.27.0", optional = true }
|
||||
# Default features include the legacy `rustls` feature, which pulls in rustls 0.21.
|
||||
aws-sdk-bedrockruntime = { version = "1.77.0", optional = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
# For remote feature
|
||||
reqwest = { version = "0.12.0", default-features = false, features = [
|
||||
reqwest = { version = "0.13.0", default-features = false, features = [
|
||||
"charset",
|
||||
"gzip",
|
||||
"http2",
|
||||
"json",
|
||||
"macos-system-configuration",
|
||||
"query",
|
||||
# Avoid linking OpenSSL into Python wheels, which breaks on FIPS hosts.
|
||||
"rustls-tls-native-roots",
|
||||
# This uses the aws-lc-rs crypto provider.
|
||||
"rustls",
|
||||
"stream",
|
||||
"system-proxy",
|
||||
], optional = true }
|
||||
tonic = { workspace = true, optional = true }
|
||||
http = { version = "1", optional = true } # Matching what is in reqwest
|
||||
@@ -114,9 +118,10 @@ random_word = { version = "0.4.3", features = ["en"] }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "test-util"] }
|
||||
uuid = { workspace = true }
|
||||
walkdir = "2"
|
||||
aws-sdk-dynamodb = { version = "1.55.0" }
|
||||
aws-sdk-s3 = { version = "1.55.0" }
|
||||
aws-sdk-kms = { version = "1.48.0" }
|
||||
# Default features include the legacy `rustls` feature, which pulls in rustls 0.21.
|
||||
aws-sdk-dynamodb = { version = "1.68.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
aws-sdk-s3 = { version = "1.79.0", default-features = false, features = ["default-https-client", "rt-tokio", "sigv4a", "http-1x"] }
|
||||
aws-sdk-kms = { version = "1.63.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
aws-config = { version = "1.5.10" }
|
||||
aws-smithy-runtime = { version = "1.9.1" }
|
||||
aws-smithy-types.workspace = true
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Authorization requests, responses, and the deployment-wide API.
|
||||
|
||||
use super::{Object, Privilege, Subject};
|
||||
use crate::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Filters and pagination for a page of ACL grants. Omitted filters match all.
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListAccessControlEntriesRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub object: Option<Object>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<Subject>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Maximum stored subject/object pairs, not expanded privilege entries.
|
||||
/// The server caps this at 100; None uses its default. Must be positive.
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
|
||||
/// One privilege granted to a canonical subject on an object.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct AccessControlEntry {
|
||||
#[serde(deserialize_with = "Subject::deserialize_canonical")]
|
||||
pub subject_id: Subject,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_name: Option<String>,
|
||||
pub object: Object,
|
||||
pub privilege: Privilege,
|
||||
}
|
||||
|
||||
/// One page of grants. A stored subject/object pair may expand to several entries.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListAccessControlEntriesResponse {
|
||||
pub entries: Vec<AccessControlEntry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
}
|
||||
|
||||
/// One grant to add or delete. The subject may be selected by ID, name, or API key.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct AccessControlEntryRequest {
|
||||
pub subject: Subject,
|
||||
pub object: Object,
|
||||
/// A privilege such as [`Privilege::Select`] or [`Privilege::Usage`].
|
||||
/// [`Privilege::Unknown`] preserves future privilege names.
|
||||
pub privilege: Privilege,
|
||||
}
|
||||
|
||||
/// Filters and pagination for direct principal/group role bindings.
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListRoleBindingsRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<Subject>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Maximum bindings on this page; the server caps this at 100.
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
|
||||
/// A direct principal or group membership in a LanceDB-managed role.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RoleBinding {
|
||||
#[serde(deserialize_with = "Subject::deserialize_canonical")]
|
||||
pub subject_id: Subject,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_name: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// One page of direct bindings, without expanding group membership.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListRoleBindingsResponse {
|
||||
pub bindings: Vec<RoleBinding>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
}
|
||||
|
||||
/// A role to grant to or revoke from a principal or group. Roles cannot nest.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct RoleBindingRequest {
|
||||
pub subject: Subject,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Select a principal by canonical ID, provider name, or API key.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GetPrincipalRequest {
|
||||
pub principal: Subject,
|
||||
}
|
||||
|
||||
/// Select a group by canonical ID or provider name.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GetGroupRequest {
|
||||
pub group: Subject,
|
||||
}
|
||||
|
||||
/// A canonical identity-provider ID and optional display name.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct IdentityInfo {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A principal, its provider-managed groups, and roles held directly or via groups.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct PrincipalInfo {
|
||||
pub principal_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_name: Option<String>,
|
||||
pub groups: Vec<IdentityInfo>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// A group, its provider-managed members, and its directly granted roles.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct GroupInfo {
|
||||
pub group_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_name: Option<String>,
|
||||
pub principals: Vec<IdentityInfo>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// Authorization operations on one deployment, independent of database selection.
|
||||
///
|
||||
/// ACL writes preserve server conflict/not-found errors and must not be replayed
|
||||
/// after ambiguous transport failures. Role-binding writes are idempotent.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Authorization: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// List a page of ACLs. Requires USAGE on the object, or system if unfiltered.
|
||||
async fn list_acls(
|
||||
&self,
|
||||
request: ListAccessControlEntriesRequest,
|
||||
) -> Result<ListAccessControlEntriesResponse>;
|
||||
/// Grant a privilege; requires OWNERSHIP. Duplicate grants return HTTP 409.
|
||||
/// Granting OWNERSHIP transfers it from the previous owner.
|
||||
async fn add_acl(&self, request: AccessControlEntryRequest) -> Result<()>;
|
||||
/// Revoke a privilege; requires OWNERSHIP. Missing grants return HTTP 404.
|
||||
/// OWNERSHIP cannot be deleted.
|
||||
async fn delete_acl(&self, request: AccessControlEntryRequest) -> Result<()>;
|
||||
/// List a page of direct bindings; requires USAGE on system.
|
||||
async fn list_role_bindings(
|
||||
&self,
|
||||
request: ListRoleBindingsRequest,
|
||||
) -> Result<ListRoleBindingsResponse>;
|
||||
/// Idempotently grant a role; requires OPERATE on system.
|
||||
async fn add_role_binding(&self, request: RoleBindingRequest) -> Result<()>;
|
||||
/// Idempotently revoke a role; requires OPERATE on system.
|
||||
async fn delete_role_binding(&self, request: RoleBindingRequest) -> Result<()>;
|
||||
/// Inspect a principal and effective roles; requires USAGE on system.
|
||||
async fn get_principal(&self, request: GetPrincipalRequest) -> Result<PrincipalInfo>;
|
||||
/// Inspect a group and direct roles; requires USAGE on system.
|
||||
async fn get_group(&self, request: GetGroupRequest) -> Result<GroupInfo>;
|
||||
}
|
||||
|
||||
impl AccessControlEntryRequest {
|
||||
/// Build one grant from validated resource and identity selectors.
|
||||
pub fn new(object: Object, subject: Subject, privilege: Privilege) -> Self {
|
||||
Self {
|
||||
object,
|
||||
subject,
|
||||
privilege,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ListRoleBindingsRequest {
|
||||
/// Reject role subjects, since roles cannot contain other roles.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(subject) = &self.subject {
|
||||
subject.validate_role_member()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RoleBindingRequest {
|
||||
/// Build a role grant/revocation for a principal or group.
|
||||
pub fn new(subject: Subject, role: impl Into<String>) -> Result<Self> {
|
||||
subject.validate_role_member()?;
|
||||
Ok(Self {
|
||||
subject,
|
||||
role: role.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the member selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.subject.validate_role_member()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetPrincipalRequest {
|
||||
/// Select a principal by ID, name, or API key.
|
||||
pub fn new(principal: Subject) -> Result<Self> {
|
||||
principal.validate_principal()?;
|
||||
Ok(Self { principal })
|
||||
}
|
||||
|
||||
/// Validate the lookup selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.principal.validate_principal()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetGroupRequest {
|
||||
/// Select a group by ID or name.
|
||||
pub fn new(group: Subject) -> Result<Self> {
|
||||
group.validate_group()?;
|
||||
Ok(Self { group })
|
||||
}
|
||||
|
||||
/// Validate the lookup selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.group.validate_group()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Deployment-wide authorization, accessed through a catalog.
|
||||
//!
|
||||
//! Subjects use `p:ID`, `P:NAME`, `g:ID`, `G:NAME`, `r:ROLE`, or `a:API_KEY`.
|
||||
//! Objects use literal names such as `table:tenant/db:public$events`.
|
||||
//! Names are resolved by the server. Request types intentionally do not implement
|
||||
//! Debug because their subject selectors may contain secret API keys.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(feature = "remote")]
|
||||
//! # async fn example() -> lancedb::Result<()> {
|
||||
//! let catalog = lancedb::connect_catalog("https://example.com")
|
||||
//! .api_key("secret")
|
||||
//! .execute().await?;
|
||||
//! let authz = catalog.authz()?;
|
||||
//! use lancedb::authz::{AccessControlEntryRequest, Object, TableObject, NamespacePath, Subject, Privilege};
|
||||
//! let grant = AccessControlEntryRequest::new(
|
||||
//! Object::Table(TableObject {
|
||||
//! database: "analytics".into(),
|
||||
//! namespace: NamespacePath::from_component("public"),
|
||||
//! table: "foo".into(),
|
||||
//! }),
|
||||
//! Subject::role("reader")?,
|
||||
//! Privilege::Select,
|
||||
//! );
|
||||
//! authz.add_acl(grant).await?;
|
||||
//! let page = authz.list_acls(Default::default()).await?;
|
||||
//! for entry in page.entries {
|
||||
//! println!("{} {} {}", entry.object, entry.subject_id, entry.privilege);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
pub mod object;
|
||||
pub mod privilege;
|
||||
pub mod subject;
|
||||
|
||||
pub use api::*;
|
||||
pub use object::{
|
||||
DatabaseObject, FunctionObject, NamespaceObject, NamespacePath, Object, SecretObject,
|
||||
TableObject, ViewObject,
|
||||
};
|
||||
pub use privilege::*;
|
||||
pub use subject::{Subject, SubjectKind};
|
||||
@@ -0,0 +1,992 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::utils::{
|
||||
validate_database_name, validate_namespace_component, validate_secret_name,
|
||||
validate_table_name, validate_view_name,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _};
|
||||
|
||||
/// The default namespace for authorization resources.
|
||||
pub const DEFAULT_NAMESPACE: &str = "public";
|
||||
|
||||
pub const SYSTEM_TYPE_STRING: &str = "system";
|
||||
pub const DATABASE_TYPE_STRING: &str = "database";
|
||||
pub const NAMESPACE_TYPE_STRING: &str = "namespace";
|
||||
pub const TABLE_TYPE_STRING: &str = "table";
|
||||
pub const VIEW_TYPE_STRING: &str = "view";
|
||||
pub const SECRET_TYPE_STRING: &str = "secret";
|
||||
pub const FUNCTION_TYPE_STRING: &str = "function";
|
||||
pub const UNKNOWN_TYPE_STRING: &str = "unknown";
|
||||
|
||||
/// A non-empty path through the namespace hierarchy.
|
||||
///
|
||||
/// The `$` separator is only the canonical wire encoding. Authorization code
|
||||
/// should use the components and prefix helpers instead of interpreting the
|
||||
/// encoded form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NamespacePath(Vec<String>);
|
||||
|
||||
impl NamespacePath {
|
||||
/// Construct a path, validating each component without splitting separators.
|
||||
/// An empty path selects the default `public` namespace.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::NamespacePath;
|
||||
///
|
||||
/// let path = NamespacePath::new(vec![]).unwrap();
|
||||
/// assert_eq!(path.components(), &["public"]);
|
||||
/// assert_eq!(path, NamespacePath::default());
|
||||
/// ```
|
||||
pub fn new(components: Vec<String>) -> Result<Self, String> {
|
||||
if components.is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
for component in &components {
|
||||
validate_namespace_component(component).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(Self(components))
|
||||
}
|
||||
|
||||
/// Construct a single-component path.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the component is invalid. Use [`Self::new`] for untrusted input.
|
||||
pub fn from_component(component: impl Into<String>) -> Self {
|
||||
Self::new(vec![component.into()]).expect("namespace components must be valid")
|
||||
}
|
||||
|
||||
pub fn components(&self) -> &[String] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn parent(&self) -> Option<Self> {
|
||||
(self.0.len() > 1).then(|| Self(self.0[..self.0.len() - 1].to_vec()))
|
||||
}
|
||||
|
||||
pub fn prefixes(&self) -> impl Iterator<Item = Self> + '_ {
|
||||
(1..=self.0.len()).map(|length| Self(self.0[..length].to_vec()))
|
||||
}
|
||||
|
||||
pub fn is_descendant_of(&self, ancestor: &Self) -> bool {
|
||||
self.0.len() > ancestor.0.len() && self.0.starts_with(&ancestor.0)
|
||||
}
|
||||
|
||||
pub fn parse(path: &str) -> Result<Self, String> {
|
||||
Self::new(path.split('$').map(str::to_string).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NamespacePath {
|
||||
/// Select the default `public` namespace.
|
||||
fn default() -> Self {
|
||||
Self(vec![DEFAULT_NAMESPACE.to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NamespacePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0.join("$"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An authorization resource, serialized using its canonical wire name.
|
||||
///
|
||||
/// Unknown resource types are preserved so clients can read rules created by
|
||||
/// newer servers. Known resource types validate names during parsing and serialization.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Object {
|
||||
System,
|
||||
Database(DatabaseObject),
|
||||
Namespace(NamespaceObject),
|
||||
Table(TableObject),
|
||||
View(ViewObject),
|
||||
Secret(SecretObject),
|
||||
Function(FunctionObject),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Object {
|
||||
/// Validate resource names, including objects recovered from storage keys.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::System => Ok(()),
|
||||
Self::Database(object) => object.validate(),
|
||||
Self::Namespace(object) => object.validate(),
|
||||
Self::Table(object) => object.validate(),
|
||||
Self::View(object) => object.validate(),
|
||||
Self::Secret(object) => object.validate(),
|
||||
Self::Function(object) => object.validate(),
|
||||
Self::Unknown(value) if value.is_empty() => Err("object must not be empty".into()),
|
||||
Self::Unknown(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The database an object belongs to, or None.
|
||||
pub fn database(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::System => None,
|
||||
Self::Database(object) => Some(&object.database),
|
||||
Self::Namespace(object) => Some(&object.database),
|
||||
Self::Table(object) => Some(&object.database),
|
||||
Self::View(object) => Some(&object.database),
|
||||
Self::Secret(object) => Some(&object.database),
|
||||
Self::Function(object) => Some(&object.database),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => SYSTEM_TYPE_STRING,
|
||||
Self::Database(_) => DATABASE_TYPE_STRING,
|
||||
Self::Namespace(_) => NAMESPACE_TYPE_STRING,
|
||||
Self::Table(_) => TABLE_TYPE_STRING,
|
||||
Self::View(_) => VIEW_TYPE_STRING,
|
||||
Self::Secret(_) => SECRET_TYPE_STRING,
|
||||
Self::Function(_) => FUNCTION_TYPE_STRING,
|
||||
Self::Unknown(_) => UNKNOWN_TYPE_STRING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Object {
|
||||
type Err = crate::Error;
|
||||
|
||||
fn from_str(value: &str) -> crate::Result<Self> {
|
||||
Self::deserialize(serde::de::value::StrDeserializer::<serde::de::value::Error>::new(value))
|
||||
.map_err(|error| crate::Error::InvalidInput {
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Object {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::System => f.write_str(SYSTEM_TYPE_STRING),
|
||||
Self::Database(object) => object.fmt(f),
|
||||
Self::Namespace(object) => object.fmt(f),
|
||||
Self::Table(object) => object.fmt(f),
|
||||
Self::View(object) => object.fmt(f),
|
||||
Self::Secret(object) => object.fmt(f),
|
||||
Self::Function(object) => object.fmt(f),
|
||||
Self::Unknown(s) => f.write_str(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Object {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Object {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let kind = s.split(':').next().unwrap_or_default();
|
||||
if s == SYSTEM_TYPE_STRING {
|
||||
Ok(Self::System)
|
||||
} else if s.is_empty() || kind == SYSTEM_TYPE_STRING {
|
||||
Err(D::Error::custom(format!("invalid Object {s:?}")))
|
||||
} else if kind == DATABASE_TYPE_STRING {
|
||||
let database = DatabaseObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Database(database))
|
||||
} else if kind == NAMESPACE_TYPE_STRING {
|
||||
let namespace = NamespaceObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Namespace(namespace))
|
||||
} else if kind == TABLE_TYPE_STRING {
|
||||
let table =
|
||||
TableObject::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(s))?;
|
||||
Ok(Self::Table(table))
|
||||
} else if kind == VIEW_TYPE_STRING {
|
||||
let view =
|
||||
ViewObject::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(s))?;
|
||||
Ok(Self::View(view))
|
||||
} else if kind == SECRET_TYPE_STRING {
|
||||
let secret = SecretObject::deserialize(
|
||||
serde::de::value::StringDeserializer::<D::Error>::new(s),
|
||||
)?;
|
||||
Ok(Self::Secret(secret))
|
||||
} else if kind == FUNCTION_TYPE_STRING {
|
||||
let function = FunctionObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Function(function))
|
||||
} else {
|
||||
Ok(Self::Unknown(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A database resource, encoded as `database:NAME`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DatabaseObject {
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
impl DatabaseObject {
|
||||
pub fn new(database: String) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DatabaseObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "database:{}", self.database)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DatabaseObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DatabaseObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let database = s
|
||||
.strip_prefix("database:")
|
||||
.ok_or_else(|| D::Error::custom(format!("invalid DatabaseObject {s:?}")))?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A namespace resource, encoded as `namespace:DATABASE:PATH`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NamespaceObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
}
|
||||
|
||||
impl NamespaceObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NamespaceObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "namespace:{}:{}", self.database, self.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NamespaceObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NamespaceObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid NamespaceObject {s:?}"));
|
||||
let rest = s.strip_prefix("namespace:").ok_or_else(invalid)?;
|
||||
let (database, namespace) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A table resource, encoded as `table:DATABASE:NAMESPACE$TABLE`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TableObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub table: String,
|
||||
}
|
||||
|
||||
impl TableObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_table_name(&self.table).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TableObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"table:{}:{}${}",
|
||||
self.database, self.namespace, self.table
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for TableObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TableObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid TableObject {s:?}"));
|
||||
let rest = s.strip_prefix("table:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, table) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
table: table.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view resource, encoded as `view:DATABASE:NAMESPACE$VIEW`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ViewObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub view: String,
|
||||
}
|
||||
|
||||
impl ViewObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_view_name(&self.view).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ViewObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "view:{}:{}${}", self.database, self.namespace, self.view)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ViewObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid ViewObject {s:?}"));
|
||||
let rest = s.strip_prefix("view:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, view) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
view: view.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
/// A secret resource, encoded as `secret:DATABASE:NAMESPACE$SECRET`.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{SecretObject, NamespacePath, Object};
|
||||
///
|
||||
/// let object = Object::Secret(SecretObject {
|
||||
/// database: "analytics".into(),
|
||||
/// namespace: NamespacePath::from_component("public"),
|
||||
/// secret: "example".into(),
|
||||
/// });
|
||||
/// assert_eq!(object.to_string(), "secret:analytics:public$example");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SecretObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl SecretObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_secret_name(&self.secret).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"secret:{}:{}${}",
|
||||
self.database, self.namespace, self.secret
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SecretObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SecretObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid SecretObject {s:?}"));
|
||||
let rest = s.strip_prefix("secret:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, secret) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
secret: secret.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A function resource, encoded as `function:DATABASE:NAMESPACE$FUNCTION`.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{FunctionObject, NamespacePath, Object};
|
||||
///
|
||||
/// let object = Object::Function(FunctionObject {
|
||||
/// database: "analytics".into(),
|
||||
/// namespace: NamespacePath::from_component("public"),
|
||||
/// function: "example".into(),
|
||||
/// });
|
||||
/// assert_eq!(object.to_string(), "function:analytics:public$example");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FunctionObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub function: String,
|
||||
}
|
||||
|
||||
impl FunctionObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_table_name(&self.function).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FunctionObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"function:{}:{}${}",
|
||||
self.database, self.namespace, self.function
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FunctionObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FunctionObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid FunctionObject {s:?}"));
|
||||
let rest = s.strip_prefix("function:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, function) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
function: function.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_object_round_trip() {
|
||||
let encoded = serde_json::to_string(&Object::System).unwrap();
|
||||
assert_eq!(encoded, r#""system""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Object>(&encoded).unwrap(),
|
||||
Object::System
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_database_round_trip() {
|
||||
let object = Object::Database(DatabaseObject {
|
||||
database: "db".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""database:db""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_namespace_round_trip() {
|
||||
let object = Object::Namespace(NamespaceObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""namespace:db:ns""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_table_round_trip() {
|
||||
let object = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "foo".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""table:db:ns$foo""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_view_round_trip() {
|
||||
let object = Object::View(ViewObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
view: "foo".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""view:db:ns$foo""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_objects_round_trip_and_validate() {
|
||||
let resource = SecretObject {
|
||||
database: "tenant/db".into(),
|
||||
namespace: NamespacePath::new(vec!["a".into(), "b".into()]).unwrap(),
|
||||
secret: "example".into(),
|
||||
};
|
||||
let wire = "secret:tenant/db:a$b$example";
|
||||
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SecretObject>(serde_json::json!(wire)).unwrap(),
|
||||
resource
|
||||
);
|
||||
let object = Object::Secret(resource.clone());
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.type_name(), "secret");
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
for name in ["", ".", "..", "bad$name", "bad/name", "bad:name"] {
|
||||
let invalid = SecretObject {
|
||||
secret: name.into(),
|
||||
..resource.clone()
|
||||
};
|
||||
assert!(serde_json::to_value(&invalid).is_err());
|
||||
assert!(serde_json::to_value(Object::Secret(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_objects_round_trip_and_validate() {
|
||||
let resource = FunctionObject {
|
||||
database: "tenant/db".into(),
|
||||
namespace: NamespacePath::new(vec!["a".into(), "b".into()]).unwrap(),
|
||||
function: "example".into(),
|
||||
};
|
||||
let wire = "function:tenant/db:a$b$example";
|
||||
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FunctionObject>(serde_json::json!(wire)).unwrap(),
|
||||
resource
|
||||
);
|
||||
let object = Object::Function(resource.clone());
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.type_name(), "function");
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
for name in ["", ".", "..", "bad$name", "bad/name", "bad:name"] {
|
||||
let invalid = FunctionObject {
|
||||
function: name.into(),
|
||||
..resource.clone()
|
||||
};
|
||||
assert!(serde_json::to_value(&invalid).is_err());
|
||||
assert!(serde_json::to_value(Object::Function(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_objects_round_trip() {
|
||||
for wire in [
|
||||
"future:db:resource",
|
||||
"table_extension:db:resource",
|
||||
"garbage",
|
||||
] {
|
||||
let object: Object = wire.parse().unwrap();
|
||||
assert_eq!(object, Object::Unknown(wire.into()));
|
||||
assert_eq!(object.database(), None);
|
||||
assert_eq!(object.type_name(), UNKNOWN_TYPE_STRING);
|
||||
assert_eq!(object.to_string(), wire);
|
||||
assert_eq!(serde_json::to_value(&object).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_object_round_trip() {
|
||||
let object = DatabaseObject {
|
||||
database: "db".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""database:db""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DatabaseObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_object_round_trip() {
|
||||
let object = NamespaceObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""namespace:db:ns""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<NamespaceObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_object_rejects_missing_namespace() {
|
||||
assert!(serde_json::from_str::<NamespaceObject>(r#""namespace:db""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_round_trip() {
|
||||
let object = TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "foo".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""table:db:ns$foo""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TableObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_rejects_missing_prefix() {
|
||||
assert!(serde_json::from_str::<TableObject>(r#""foo""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_rejects_missing_table() {
|
||||
assert!(serde_json::from_str::<TableObject>(r#""table:db:ns""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_order_by_variant_then_path_components() {
|
||||
let database = Object::Database(DatabaseObject::new("z".to_string()));
|
||||
let namespace = Object::Namespace(NamespaceObject {
|
||||
database: "a".to_string(),
|
||||
namespace: NamespacePath::from_component("a"),
|
||||
});
|
||||
assert!(Object::System < database);
|
||||
assert!(database < namespace);
|
||||
|
||||
let first_table = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("a"),
|
||||
table: "z".to_string(),
|
||||
});
|
||||
let second_table = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("b"),
|
||||
table: "a".to_string(),
|
||||
});
|
||||
assert!(first_table < second_table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_namespace_paths_select_public() {
|
||||
let namespace = NamespacePath::new(vec![]).unwrap();
|
||||
assert_eq!(namespace, NamespacePath::default());
|
||||
assert_eq!(namespace.components(), &["public"]);
|
||||
assert!(namespace.parent().is_none());
|
||||
assert_eq!(
|
||||
namespace.prefixes().collect::<Vec<_>>(),
|
||||
vec![namespace.clone()]
|
||||
);
|
||||
|
||||
let database = "db".to_string();
|
||||
let objects = [
|
||||
Object::Namespace(NamespaceObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
}),
|
||||
Object::Table(TableObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
table: "item".into(),
|
||||
}),
|
||||
Object::View(ViewObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
view: "item".into(),
|
||||
}),
|
||||
Object::Secret(SecretObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
secret: "item".into(),
|
||||
}),
|
||||
Object::Function(FunctionObject {
|
||||
database,
|
||||
namespace,
|
||||
function: "item".into(),
|
||||
}),
|
||||
];
|
||||
for (object, wire) in objects.into_iter().zip([
|
||||
"namespace:db:public",
|
||||
"table:db:public$item",
|
||||
"view:db:public$item",
|
||||
"secret:db:public$item",
|
||||
"function:db:public$item",
|
||||
]) {
|
||||
assert_eq!(serde_json::to_value(&object).unwrap(), wire);
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_path_returns_cumulative_prefixes() {
|
||||
let path = NamespacePath::new(vec!["ns1".into(), "ns2".into(), "ns3".into()]).unwrap();
|
||||
assert_eq!(
|
||||
path.prefixes()
|
||||
.map(|prefix| prefix.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["ns1", "ns1$ns2", "ns1$ns2$ns3"]
|
||||
);
|
||||
assert_eq!(path.parent().unwrap().to_string(), "ns1$ns2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_objects_round_trip_without_escaping() {
|
||||
for wire in [
|
||||
"database:tenant/db",
|
||||
"namespace:tenant/db:a$b",
|
||||
"table:tenant/db:a$b$c",
|
||||
"view:tenant/db:a$b$c",
|
||||
"secret:tenant/db:a$b$c",
|
||||
"function:tenant/db:a$b$c",
|
||||
] {
|
||||
let object: Object = serde_json::from_value(serde_json::json!(wire)).unwrap();
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.to_string(), wire);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_reject_invalid_names_and_legacy_forms() {
|
||||
for database in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"db:bad",
|
||||
"db%2Fchild",
|
||||
"db$child",
|
||||
"/db",
|
||||
"db/",
|
||||
"db//child",
|
||||
"db/../child",
|
||||
"db name",
|
||||
] {
|
||||
for wire in [
|
||||
format!("database:{database}"),
|
||||
format!("namespace:{database}:ns"),
|
||||
format!("table:{database}:ns$t"),
|
||||
format!("view:{database}:ns$v"),
|
||||
format!("secret:{database}:ns$s"),
|
||||
format!("function:{database}:ns$f"),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for name in ["", "bad:name", "bad/name", "bad%24name", "bad name"] {
|
||||
for wire in [
|
||||
format!("namespace:db:{name}"),
|
||||
format!("table:db:{name}$t"),
|
||||
format!("view:db:{name}$v"),
|
||||
format!("table:db:ns${name}"),
|
||||
format!("view:db:ns${name}"),
|
||||
format!("secret:db:{name}$s"),
|
||||
format!("function:db:{name}$f"),
|
||||
format!("secret:db:ns${name}"),
|
||||
format!("function:db:ns${name}"),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for wire in [
|
||||
"",
|
||||
"system:db",
|
||||
"database",
|
||||
"namespace",
|
||||
"table",
|
||||
"view",
|
||||
"namespace:db/ns",
|
||||
"table:db/ns/t",
|
||||
"view:db/ns/v",
|
||||
"table:db:ns",
|
||||
"view:db:ns",
|
||||
"namespace:db:a$$b",
|
||||
"table:db:ns$.",
|
||||
"view:db:ns$..",
|
||||
"table:db:a$$t",
|
||||
"view:db:$v",
|
||||
"secret",
|
||||
"function",
|
||||
"secret:db/ns/s",
|
||||
"function:db/ns/f",
|
||||
"secret:db:ns",
|
||||
"function:db:ns",
|
||||
"secret:db:a$$s",
|
||||
"function:db:$f",
|
||||
"secret:db:ns$.",
|
||||
"function:db:ns$..",
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
assert!(matches!(
|
||||
wire.parse::<Object>(),
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_components_use_namespace_validation() {
|
||||
for component in ["a$b", "c/d", "e%f", "a:b", "", ".", ".."] {
|
||||
assert!(NamespacePath::new(vec![component.into()]).is_err());
|
||||
}
|
||||
assert!(NamespacePath::new(vec!["a.b".into(), "c..d".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_rejects_invalid_directly_constructed_objects() {
|
||||
let database = DatabaseObject::new("db:bad".into());
|
||||
let namespace = NamespaceObject {
|
||||
database: "db%2Fbad".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
};
|
||||
let table = TableObject {
|
||||
database: "db".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "bad$name".into(),
|
||||
};
|
||||
let view = ViewObject {
|
||||
database: "db".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
view: "..".into(),
|
||||
};
|
||||
assert!(serde_json::to_string(&database).is_err());
|
||||
assert!(serde_json::to_string(&namespace).is_err());
|
||||
assert!(serde_json::to_string(&table).is_err());
|
||||
assert!(serde_json::to_string(&view).is_err());
|
||||
for object in [
|
||||
Object::Database(database),
|
||||
Object::Namespace(namespace),
|
||||
Object::Table(table),
|
||||
Object::View(view),
|
||||
] {
|
||||
assert!(serde_json::to_string(&object).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
/// A LanceDB privilege, serialized by name (for example, `SELECT`).
|
||||
/// Numeric aliases use `PRIVILEGE` followed by decimal digits. Numbers 0 through
|
||||
/// 13 map to the known privileges in declaration order. Unassigned numbers are
|
||||
/// preserved verbatim; their meaning and validity are determined by the server.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!("PRIVILEGE0".parse::<Privilege>().unwrap(), Privilege::Ownership);
|
||||
/// assert_eq!("PRIVILEGE2".parse::<Privilege>().unwrap(), Privilege::Select);
|
||||
///
|
||||
/// let privilege: Privilege = "PRIVILEGE256".parse().unwrap();
|
||||
/// assert_eq!(privilege.to_string(), "PRIVILEGE256");
|
||||
///
|
||||
/// // Delegate creation on a namespace without transferring its ownership.
|
||||
/// for privilege in [Privilege::CreateSecret, Privilege::CreateFunction] {
|
||||
/// assert!(privilege.valid_for_namespace());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, strum_macros::Display)]
|
||||
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[non_exhaustive]
|
||||
pub enum Privilege {
|
||||
/// A special privilege that conveys ownership.
|
||||
Ownership,
|
||||
|
||||
/// On a database: allows accessing the namespaces inside.
|
||||
/// On a namespace: allows accessing the tables inside.
|
||||
///
|
||||
/// When looking for Usage on a Database, if there are no ACLs
|
||||
/// found for that database, we assume it doesn't exist yet and
|
||||
/// check for CreateDatabase on System.
|
||||
///
|
||||
/// When looking for Usage on a public (aka default) Namespace,
|
||||
/// if there are no ACLs found for that Namespace, we assume it
|
||||
/// doesn't exist yet and check for CreateNamespace on the
|
||||
/// containing database instead.
|
||||
Usage,
|
||||
|
||||
/// On a table: allows reading rows.
|
||||
Select,
|
||||
|
||||
/// On a table: allows appending rows.
|
||||
Insert,
|
||||
|
||||
/// On a table: allows modifying existing rows.
|
||||
Update,
|
||||
|
||||
/// On a table: allows deleting rows.
|
||||
Delete,
|
||||
|
||||
/// On the system object: can create a database.
|
||||
CreateDatabase,
|
||||
|
||||
/// On a database or namespace: can create immediate child namespaces.
|
||||
CreateNamespace,
|
||||
|
||||
/// On a namespace: can create a table in that namespace.
|
||||
CreateTable,
|
||||
|
||||
/// On a namespace: can create a materialized view in that namespace.
|
||||
CreateMaterializedView,
|
||||
|
||||
/// On a namespace: can create a view in that namespace.
|
||||
CreateView,
|
||||
|
||||
/// On a namespace: can create a secret without taking ownership of the namespace.
|
||||
CreateSecret,
|
||||
|
||||
/// On a namespace: can create a function without taking ownership of the namespace.
|
||||
CreateFunction,
|
||||
|
||||
/// On the system object:
|
||||
/// - can read server configuration.
|
||||
Operate,
|
||||
|
||||
/// A future privilege name, preserved verbatim.
|
||||
#[strum(to_string = "{0}")]
|
||||
Unknown(UnknownPrivilege),
|
||||
}
|
||||
|
||||
/// An unknown privilege.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnknownPrivilege(String);
|
||||
|
||||
impl fmt::Display for UnknownPrivilege {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Privilege {
|
||||
pub const KNOWN: [Self; 14] = [
|
||||
Self::Ownership,
|
||||
Self::Usage,
|
||||
Self::Select,
|
||||
Self::Insert,
|
||||
Self::Update,
|
||||
Self::Delete,
|
||||
Self::CreateDatabase,
|
||||
Self::CreateNamespace,
|
||||
Self::CreateTable,
|
||||
Self::CreateMaterializedView,
|
||||
Self::CreateView,
|
||||
Self::CreateSecret,
|
||||
Self::CreateFunction,
|
||||
Self::Operate,
|
||||
];
|
||||
|
||||
pub fn valid_for_system(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Operate | Self::CreateDatabase | Self::Usage | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_database(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership | Self::Usage | Self::CreateNamespace | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_namespace(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership
|
||||
| Self::Usage
|
||||
| Self::CreateNamespace
|
||||
| Self::CreateTable
|
||||
| Self::CreateMaterializedView
|
||||
| Self::CreateView
|
||||
| Self::CreateSecret
|
||||
| Self::CreateFunction
|
||||
| Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_table(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership
|
||||
| Self::Select
|
||||
| Self::Insert
|
||||
| Self::Update
|
||||
| Self::Delete
|
||||
| Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_view(&self) -> bool {
|
||||
matches!(self, Self::Ownership | Self::Select | Self::Unknown(_))
|
||||
}
|
||||
|
||||
pub fn valid_for_secret(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership | Self::Usage | Self::Update | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_function(&self) -> bool {
|
||||
matches!(self, Self::Ownership | Self::Usage | Self::Unknown(_))
|
||||
}
|
||||
|
||||
/// Whether this privilege can mutate database contents or schema.
|
||||
/// Unknown privileges are conservatively treated as writes.
|
||||
pub fn is_write(&self) -> bool {
|
||||
!matches!(self, Self::Usage | Self::Select | Self::Operate)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Privilege {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(name: &str) -> Result<Self, Self::Err> {
|
||||
if let Some(privilege) = Self::KNOWN.into_iter().find(|p| p.to_string() == name) {
|
||||
return Ok(privilege);
|
||||
}
|
||||
if let Some(digits) = name.strip_prefix("PRIVILEGE")
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
return Ok(match digits.parse::<u64>() {
|
||||
Ok(0) => Self::Ownership,
|
||||
Ok(1) => Self::Usage,
|
||||
Ok(2) => Self::Select,
|
||||
Ok(3) => Self::Insert,
|
||||
Ok(4) => Self::Update,
|
||||
Ok(5) => Self::Delete,
|
||||
Ok(6) => Self::CreateDatabase,
|
||||
Ok(7) => Self::CreateNamespace,
|
||||
Ok(8) => Self::CreateTable,
|
||||
Ok(9) => Self::CreateMaterializedView,
|
||||
Ok(10) => Self::CreateView,
|
||||
Ok(11) => Self::CreateSecret,
|
||||
Ok(12) => Self::CreateFunction,
|
||||
Ok(13) => Self::Operate,
|
||||
_ => Self::Unknown(UnknownPrivilege(name.to_owned())),
|
||||
});
|
||||
}
|
||||
Err(format!("invalid privilege {name:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a privilege to its numeric identifier.
|
||||
///
|
||||
/// Unknown privileges return an error if their number exceeds [`u8::MAX`].
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!(u8::try_from(&Privilege::Select).unwrap(), 2);
|
||||
/// let unknown: Privilege = "PRIVILEGE255".parse().unwrap();
|
||||
/// assert_eq!(u8::try_from(&unknown).unwrap(), 255);
|
||||
/// let too_large: Privilege = "PRIVILEGE256".parse().unwrap();
|
||||
/// assert!(u8::try_from(&too_large).is_err());
|
||||
/// ```
|
||||
impl TryFrom<&Privilege> for u8 {
|
||||
type Error = std::num::ParseIntError;
|
||||
|
||||
fn try_from(privilege: &Privilege) -> Result<Self, Self::Error> {
|
||||
Ok(match privilege {
|
||||
Privilege::Ownership => 0,
|
||||
Privilege::Usage => 1,
|
||||
Privilege::Select => 2,
|
||||
Privilege::Insert => 3,
|
||||
Privilege::Update => 4,
|
||||
Privilege::Delete => 5,
|
||||
Privilege::CreateDatabase => 6,
|
||||
Privilege::CreateNamespace => 7,
|
||||
Privilege::CreateTable => 8,
|
||||
Privilege::CreateMaterializedView => 9,
|
||||
Privilege::CreateView => 10,
|
||||
Privilege::CreateSecret => 11,
|
||||
Privilege::CreateFunction => 12,
|
||||
Privilege::Operate => 13,
|
||||
Privilege::Unknown(unknown) => unknown.0["PRIVILEGE".len()..].parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an owned privilege to its numeric identifier.
|
||||
///
|
||||
/// Like the borrowed conversion, this returns an error for numbers above 255.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!(u8::try_from(Privilege::Ownership).unwrap(), 0);
|
||||
/// ```
|
||||
impl TryFrom<Privilege> for u8 {
|
||||
type Error = std::num::ParseIntError;
|
||||
|
||||
fn try_from(privilege: Privilege) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&privilege)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Privilege {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Privilege {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Privilege;
|
||||
|
||||
#[test]
|
||||
fn display_matches_serde() {
|
||||
for privilege in Privilege::KNOWN {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&privilege).unwrap(),
|
||||
format!("\"{privilege}\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_and_function_creation_privileges() {
|
||||
for (name, privilege) in [
|
||||
("CREATE_SECRET", Privilege::CreateSecret),
|
||||
("CREATE_FUNCTION", Privilege::CreateFunction),
|
||||
] {
|
||||
assert_eq!(name.parse::<Privilege>().unwrap(), privilege);
|
||||
assert_eq!(privilege.to_string(), name);
|
||||
assert_eq!(serde_json::to_value(&privilege).unwrap(), name);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(!privilege.valid_for_system());
|
||||
assert!(!privilege.valid_for_database());
|
||||
assert!(!privilege.valid_for_table());
|
||||
assert!(!privilege.valid_for_view());
|
||||
assert!(privilege.is_write());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_aliases_resolve_to_known_privileges() {
|
||||
for (number, privilege) in [
|
||||
(0, Privilege::Ownership),
|
||||
(1, Privilege::Usage),
|
||||
(2, Privilege::Select),
|
||||
(3, Privilege::Insert),
|
||||
(4, Privilege::Update),
|
||||
(5, Privilege::Delete),
|
||||
(6, Privilege::CreateDatabase),
|
||||
(7, Privilege::CreateNamespace),
|
||||
(8, Privilege::CreateTable),
|
||||
(9, Privilege::CreateMaterializedView),
|
||||
(10, Privilege::CreateView),
|
||||
(11, Privilege::CreateSecret),
|
||||
(12, Privilege::CreateFunction),
|
||||
(13, Privilege::Operate),
|
||||
] {
|
||||
assert_eq!(u8::try_from(&privilege).unwrap(), number);
|
||||
assert_eq!(u8::try_from(privilege.clone()).unwrap(), number);
|
||||
for name in [
|
||||
format!("PRIVILEGE{number}"),
|
||||
format!("PRIVILEGE{number:03}"),
|
||||
] {
|
||||
let parsed = name.parse::<Privilege>().unwrap();
|
||||
assert_eq!(parsed, privilege, "{name}");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&parsed).unwrap(),
|
||||
privilege.to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
privilege.to_string().parse::<Privilege>().unwrap(),
|
||||
privilege
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_privileges_convert_to_u8() {
|
||||
for number in 14..=u8::MAX {
|
||||
for name in [
|
||||
format!("PRIVILEGE{number}"),
|
||||
format!("PRIVILEGE{number:03}"),
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(matches!(privilege, Privilege::Unknown(_)));
|
||||
assert_eq!(u8::try_from(&privilege).unwrap(), number);
|
||||
assert_eq!(u8::try_from(privilege).unwrap(), number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileges_outside_u8_range_fail_conversion() {
|
||||
for name in [
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE0256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert_eq!(
|
||||
u8::try_from(&privilege).unwrap_err().kind(),
|
||||
&std::num::IntErrorKind::PosOverflow
|
||||
);
|
||||
assert_eq!(
|
||||
u8::try_from(privilege).unwrap_err().kind(),
|
||||
&std::num::IntErrorKind::PosOverflow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_privilege_names_round_trip() {
|
||||
for name in [
|
||||
"PRIVILEGE14",
|
||||
"PRIVILEGE20",
|
||||
"PRIVILEGE64",
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
"PRIVILEGE020",
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(matches!(privilege, Privilege::Unknown(_)));
|
||||
assert_eq!(privilege.to_string(), name);
|
||||
assert_eq!(serde_json::to_value(&privilege).unwrap(), name);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_privileges() {
|
||||
for name in [
|
||||
"",
|
||||
"select",
|
||||
" SELECT",
|
||||
"SELECT ",
|
||||
"PRIVILEGE",
|
||||
"PRIVILEGE-1",
|
||||
"PRIVILEGE+1",
|
||||
"PRIVILEGE 1",
|
||||
"PRIVILEGE1x",
|
||||
"privilege12",
|
||||
"UNKNOWN",
|
||||
] {
|
||||
assert!(name.parse::<Privilege>().is_err(), "{name}");
|
||||
assert!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_privileges_are_valid_for_all_objects_and_treated_as_writes() {
|
||||
for name in ["PRIVILEGE14", "PRIVILEGE20", "PRIVILEGE64", "PRIVILEGE256"] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(privilege.valid_for_system());
|
||||
assert!(privilege.valid_for_database());
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(privilege.valid_for_table());
|
||||
assert!(privilege.valid_for_view());
|
||||
assert!(privilege.valid_for_secret());
|
||||
assert!(privilege.valid_for_function());
|
||||
assert!(privilege.is_write());
|
||||
}
|
||||
assert!(!Privilege::Ownership.valid_for_system());
|
||||
assert!(!Privilege::Select.valid_for_database());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_privilege_is_write() {
|
||||
for privilege in Privilege::KNOWN {
|
||||
assert_eq!(
|
||||
privilege.is_write(),
|
||||
matches!(
|
||||
privilege,
|
||||
Privilege::Ownership
|
||||
| Privilege::Insert
|
||||
| Privilege::Update
|
||||
| Privilege::Delete
|
||||
| Privilege::CreateDatabase
|
||||
| Privilege::CreateNamespace
|
||||
| Privilege::CreateTable
|
||||
| Privilege::CreateMaterializedView
|
||||
| Privilege::CreateView
|
||||
| Privilege::CreateSecret
|
||||
| Privilege::CreateFunction
|
||||
),
|
||||
"unexpected write classification for {privilege}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_secret_and_create_function_are_namespace_scoped() {
|
||||
for privilege in [Privilege::CreateSecret, Privilege::CreateFunction] {
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(!privilege.valid_for_system());
|
||||
assert!(!privilege.valid_for_database());
|
||||
assert!(!privilege.valid_for_table());
|
||||
assert!(!privilege.valid_for_view());
|
||||
assert!(!privilege.valid_for_secret());
|
||||
assert!(!privilege.valid_for_function());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_and_function_privileges() {
|
||||
assert_eq!(
|
||||
Privilege::KNOWN
|
||||
.into_iter()
|
||||
.filter(Privilege::valid_for_secret)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Privilege::Ownership, Privilege::Usage, Privilege::Update]
|
||||
);
|
||||
assert_eq!(
|
||||
Privilege::KNOWN
|
||||
.into_iter()
|
||||
.filter(Privilege::valid_for_function)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Privilege::Ownership, Privilege::Usage]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Identity selectors. Provider names resolve on the server; IDs remain literal.
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The kind of identifier supplied to an authorization operation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum SubjectKind {
|
||||
PrincipalId,
|
||||
PrincipalName,
|
||||
GroupId,
|
||||
GroupName,
|
||||
Role,
|
||||
PrincipalApiKey,
|
||||
/// An unrecognized canonical subject. Its full wire string is stored as the value.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SubjectKind {
|
||||
/// Stable name used by language bindings.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrincipalId => "principal_id",
|
||||
Self::PrincipalName => "principal_name",
|
||||
Self::GroupId => "group_id",
|
||||
Self::GroupName => "group_name",
|
||||
Self::Role => "role",
|
||||
Self::PrincipalApiKey => "principal_api_key",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrincipalId => "p:",
|
||||
Self::PrincipalName => "P:",
|
||||
Self::GroupId => "g:",
|
||||
Self::GroupName => "G:",
|
||||
Self::Role => "r:",
|
||||
Self::PrincipalApiKey => "a:",
|
||||
Self::Unknown => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KINDS: [SubjectKind; 6] = [
|
||||
SubjectKind::PrincipalId,
|
||||
SubjectKind::PrincipalName,
|
||||
SubjectKind::GroupId,
|
||||
SubjectKind::GroupName,
|
||||
SubjectKind::Role,
|
||||
SubjectKind::PrincipalApiKey,
|
||||
];
|
||||
|
||||
impl FromStr for SubjectKind {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
if value == "unknown" {
|
||||
return Ok(Self::Unknown);
|
||||
}
|
||||
KINDS
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == value)
|
||||
.ok_or_else(|| invalid("Invalid subject kind"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable validated principal, group, or role selector.
|
||||
///
|
||||
/// Unrecognized canonical prefixes are preserved as [`SubjectKind::Unknown`],
|
||||
/// with the complete wire string in [`Self::value`], so future subject kinds can
|
||||
/// be listed and sent back to the server.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{Subject, SubjectKind};
|
||||
/// let subject = Subject::from_canonical("x:future-subject")?;
|
||||
/// assert_eq!(subject.kind(), SubjectKind::Unknown);
|
||||
/// assert_eq!(subject.to_wire(), "x:future-subject");
|
||||
/// # Ok::<(), lancedb::Error>(())
|
||||
/// ```
|
||||
///
|
||||
/// Debug and Display redact API keys. [`Self::to_wire`] and serialization
|
||||
/// intentionally expose the key for transport and must not be logged.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Subject;
|
||||
/// let subject = Subject::group_name("Engineering")?;
|
||||
/// assert_eq!(subject.to_wire(), "G:Engineering");
|
||||
/// assert_eq!(subject, "G:Engineering".parse::<Subject>()?);
|
||||
/// # Ok::<(), lancedb::Error>(())
|
||||
/// ```
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Subject {
|
||||
kind: SubjectKind,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> Error {
|
||||
// Never include the selector: it may contain an API key.
|
||||
Error::InvalidInput {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// Construct a selector without resolving it against an identity provider.
|
||||
pub fn new(kind: SubjectKind, value: impl Into<String>) -> Result<Self> {
|
||||
let value = value.into();
|
||||
if value.is_empty() {
|
||||
return Err(invalid("Subject value must be a nonempty string"));
|
||||
}
|
||||
if kind == SubjectKind::Unknown
|
||||
&& (!value
|
||||
.split_once(':')
|
||||
.is_some_and(|(prefix, value)| !prefix.is_empty() && !value.is_empty())
|
||||
|| KINDS.iter().any(|kind| value.starts_with(kind.prefix())))
|
||||
{
|
||||
return Err(invalid("Invalid unknown subject selector"));
|
||||
}
|
||||
Ok(Self { kind, value })
|
||||
}
|
||||
|
||||
/// Select a principal by canonical ID, without stripping any prefix in the ID.
|
||||
pub fn principal_id(id: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalId, id)
|
||||
}
|
||||
/// Select a principal by an unambiguous provider name.
|
||||
pub fn principal_name(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalName, name)
|
||||
}
|
||||
/// Select a group by canonical ID.
|
||||
pub fn group_id(id: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::GroupId, id)
|
||||
}
|
||||
/// Select a group by an unambiguous provider name.
|
||||
pub fn group_name(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::GroupName, name)
|
||||
}
|
||||
/// Select a LanceDB-managed role.
|
||||
pub fn role(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::Role, name)
|
||||
}
|
||||
/// Select the principal holding this secret API key, not the request's caller.
|
||||
pub fn principal_api_key(key: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalApiKey, key)
|
||||
}
|
||||
|
||||
/// The selector kind.
|
||||
pub fn kind(&self) -> SubjectKind {
|
||||
self.kind
|
||||
}
|
||||
/// The selected ID, name, role, or API key. API-key values are secrets.
|
||||
/// For unknown kinds, this is the full canonical wire string.
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
/// The value safe for display; API keys are replaced with `<redacted>`.
|
||||
pub fn display_value(&self) -> &str {
|
||||
if self.kind == SubjectKind::PrincipalApiKey {
|
||||
"<redacted>"
|
||||
} else {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
/// Encode the literal wire selector. The result can contain a secret API key.
|
||||
pub fn to_wire(&self) -> String {
|
||||
format!("{}{}", self.kind.prefix(), self.value)
|
||||
}
|
||||
|
||||
/// Parse a returned canonical identity, rejecting names and secret selectors.
|
||||
pub fn from_canonical(value: &str) -> Result<Self> {
|
||||
let subject: Self = value.parse()?;
|
||||
if !matches!(
|
||||
subject.kind,
|
||||
SubjectKind::PrincipalId
|
||||
| SubjectKind::GroupId
|
||||
| SubjectKind::Role
|
||||
| SubjectKind::Unknown
|
||||
) {
|
||||
return Err(invalid("Server returned a non-canonical subject"));
|
||||
}
|
||||
Ok(subject)
|
||||
}
|
||||
|
||||
/// Deserialize a canonical response subject, rejecting secrets and name selectors.
|
||||
pub fn deserialize_canonical<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
Self::from_canonical(&String::deserialize(deserializer)?).map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
/// Require a principal selector for a principal lookup.
|
||||
pub fn validate_principal(&self) -> Result<()> {
|
||||
if matches!(
|
||||
self.kind,
|
||||
SubjectKind::PrincipalId | SubjectKind::PrincipalName | SubjectKind::PrincipalApiKey
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid("Subject kind is not valid for a principal lookup"))
|
||||
}
|
||||
}
|
||||
/// Require a group selector for a group lookup.
|
||||
pub fn validate_group(&self) -> Result<()> {
|
||||
if matches!(self.kind, SubjectKind::GroupId | SubjectKind::GroupName) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid("Subject kind is not valid for a group lookup"))
|
||||
}
|
||||
}
|
||||
/// Require a principal/group selector. Roles cannot contain other roles.
|
||||
pub fn validate_role_member(&self) -> Result<()> {
|
||||
if self.kind == SubjectKind::Role {
|
||||
Err(invalid("Roles cannot be members of other roles"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Subject {
|
||||
type Err = Error;
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
for kind in KINDS {
|
||||
if let Some(value) = value.strip_prefix(kind.prefix()) {
|
||||
return Self::new(kind, value);
|
||||
}
|
||||
}
|
||||
Self::new(SubjectKind::Unknown, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Subject {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Subject")
|
||||
.field("kind", &self.kind)
|
||||
.field("value", &self.display_value())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
impl fmt::Display for Subject {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}{}", self.kind.prefix(), self.display_value())
|
||||
}
|
||||
}
|
||||
impl Serialize for Subject {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_wire())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Subject {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selectors_roundtrip_without_normalizing_values() {
|
||||
for kind in KINDS {
|
||||
for value in [
|
||||
"alice@example.com",
|
||||
"p:literal-id",
|
||||
"Engineering / R&D",
|
||||
"名前",
|
||||
] {
|
||||
let subject = Subject::new(kind, value).unwrap();
|
||||
assert_eq!(subject.to_wire().parse::<Subject>().unwrap(), subject);
|
||||
let json = serde_json::to_string(&subject).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Subject>(&json).unwrap(), subject);
|
||||
assert_eq!(subject.value(), value);
|
||||
assert!(Subject::new(kind, "").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts_and_canonical_ids() {
|
||||
for kind in KINDS {
|
||||
let subject = Subject::new(kind, "value").unwrap();
|
||||
assert_eq!(
|
||||
subject.validate_principal().is_ok(),
|
||||
matches!(
|
||||
kind,
|
||||
SubjectKind::PrincipalId
|
||||
| SubjectKind::PrincipalName
|
||||
| SubjectKind::PrincipalApiKey
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
subject.validate_group().is_ok(),
|
||||
matches!(kind, SubjectKind::GroupId | SubjectKind::GroupName)
|
||||
);
|
||||
assert_eq!(
|
||||
subject.validate_role_member().is_ok(),
|
||||
kind != SubjectKind::Role
|
||||
);
|
||||
assert_eq!(
|
||||
Subject::from_canonical(&subject.to_wire()).is_ok(),
|
||||
matches!(
|
||||
kind,
|
||||
SubjectKind::PrincipalId | SubjectKind::GroupId | SubjectKind::Role
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subjects_preserve_canonical_wire_strings() {
|
||||
for wire in ["x:future-subject", "future:literal:identifier"] {
|
||||
let subject = Subject::from_canonical(wire).unwrap();
|
||||
assert_eq!(subject.kind(), SubjectKind::Unknown);
|
||||
assert_eq!(subject.value(), wire);
|
||||
assert_eq!(subject.to_wire(), wire);
|
||||
assert_eq!(serde_json::to_value(&subject).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Subject>(serde_json::json!(wire)).unwrap(),
|
||||
subject
|
||||
);
|
||||
assert!(subject.validate_principal().is_err());
|
||||
assert!(subject.validate_group().is_err());
|
||||
}
|
||||
for wire in [
|
||||
"",
|
||||
"no-prefix",
|
||||
":value",
|
||||
"x:",
|
||||
"p:value",
|
||||
"P:value",
|
||||
"g:value",
|
||||
"G:value",
|
||||
"r:value",
|
||||
"a:secret",
|
||||
] {
|
||||
assert!(Subject::new(SubjectKind::Unknown, wire).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acl_page_preserves_known_and_unknown_subjects() {
|
||||
let payload = serde_json::json!({"entries": [
|
||||
{"subject_id": "p:known", "object": "system", "privilege": "USAGE"},
|
||||
{"subject_id": "x:future-subject", "object": "system", "privilege": "USAGE"}
|
||||
]});
|
||||
let page: crate::authz::ListAccessControlEntriesResponse =
|
||||
serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(page.entries.len(), 2);
|
||||
assert_eq!(page.entries[0].subject_id.kind(), SubjectKind::PrincipalId);
|
||||
assert_eq!(page.entries[1].subject_id.kind(), SubjectKind::Unknown);
|
||||
assert_eq!(serde_json::to_value(page).unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_only_exposed_by_explicit_serialization() {
|
||||
let secret = "never-log-this-key";
|
||||
let subject = Subject::principal_api_key(secret).unwrap();
|
||||
assert!(!format!("{subject:?} {subject}").contains(secret));
|
||||
assert!(serde_json::to_string(&subject).unwrap().contains(secret));
|
||||
let error = Subject::from_canonical(&subject.to_wire()).unwrap_err();
|
||||
assert!(!error.to_string().contains(secret));
|
||||
assert!(!format!("{error:?}").contains(secret));
|
||||
let error = secret.parse::<Subject>().unwrap_err();
|
||||
assert!(!error.to_string().contains(secret));
|
||||
}
|
||||
}
|
||||
+289
-54
@@ -247,8 +247,10 @@ impl BlobRangeRequest {
|
||||
/// `Struct<data, uri>` with the `lance.blob.v2` marker. Same layout Lance
|
||||
/// expects on write.
|
||||
///
|
||||
/// A blob column may be top-level or nested inside a struct or list. Nested
|
||||
/// blobs are addressed by a dotted path (e.g. `info.blob`) in the read APIs.
|
||||
/// A blob column may be top-level or nested inside a struct or list. Blobs
|
||||
/// nested inside structs use dotted paths (e.g. `info.blob`) in the read APIs.
|
||||
/// Blobs inside lists can be written and queried as descriptors, but cannot
|
||||
/// currently be fetched by row id.
|
||||
///
|
||||
/// ```
|
||||
/// use arrow_schema::{DataType, Field, Schema};
|
||||
@@ -262,6 +264,37 @@ pub fn blob(name: impl AsRef<str>, nullable: bool) -> Field {
|
||||
lance::blob::blob_field(name.as_ref(), nullable)
|
||||
}
|
||||
|
||||
/// Options for creating a blob v2 column field with tier thresholds.
|
||||
///
|
||||
/// Re-exported from lance so the thresholds are built exactly the way
|
||||
/// [`lance::blob::blob_field_with_options`] builds them. Note that lance does
|
||||
/// not currently expose a pack file size threshold option, so only the inline
|
||||
/// and dedicated size thresholds are supported here.
|
||||
pub use lance::blob::BlobFieldOptions as BlobOptions;
|
||||
|
||||
/// Creates an Arrow field for a Lance blob v2 column with tier thresholds.
|
||||
///
|
||||
/// Same as [`blob`], but forwards `options` to
|
||||
/// [`lance::blob::blob_field_with_options`], storing the inline and dedicated
|
||||
/// size thresholds as field metadata. With [`BlobOptions::default()`] this is
|
||||
/// identical to [`blob`].
|
||||
///
|
||||
/// ```
|
||||
/// use std::num::NonZeroUsize;
|
||||
///
|
||||
/// use lancedb::blob::BlobOptions;
|
||||
///
|
||||
/// let options = BlobOptions {
|
||||
/// inline_size_threshold: Some(8 * 1024),
|
||||
/// dedicated_size_threshold: NonZeroUsize::new(1 << 20),
|
||||
/// };
|
||||
/// let field = lancedb::blob::blob_with_options("payload", true, options);
|
||||
/// assert!(lancedb::blob::is_blob(&field));
|
||||
/// ```
|
||||
pub fn blob_with_options(name: impl AsRef<str>, nullable: bool, options: BlobOptions) -> Field {
|
||||
lance::blob::blob_field_with_options(name.as_ref(), nullable, options)
|
||||
}
|
||||
|
||||
/// Returns true if `field` is a blob v2 column.
|
||||
///
|
||||
/// ```
|
||||
@@ -286,7 +319,7 @@ fn field_tree_has_blob_v2(field: &Field) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the dotted paths of blob v2 columns under `field`, into `paths`.
|
||||
/// Collects the dotted paths of row-addressable blob v2 columns under `field`.
|
||||
fn collect_blob_paths(field: &Field, prefix: &str, paths: &mut Vec<String>) {
|
||||
let path = if prefix.is_empty() {
|
||||
field.name().clone()
|
||||
@@ -297,26 +330,58 @@ fn collect_blob_paths(field: &Field, prefix: &str, paths: &mut Vec<String>) {
|
||||
paths.push(path);
|
||||
return;
|
||||
}
|
||||
match field.data_type() {
|
||||
DataType::Struct(children) => {
|
||||
for child in children {
|
||||
collect_blob_paths(child, &path, paths);
|
||||
}
|
||||
if let DataType::Struct(children) = field.data_type() {
|
||||
for child in children {
|
||||
collect_blob_paths(child, &path, paths);
|
||||
}
|
||||
DataType::List(child) | DataType::LargeList(child) | DataType::FixedSizeList(child, _) => {
|
||||
collect_blob_paths(child, &path, paths)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_list_blob_path(field: &Field, prefix: &str, column: &str, inside_list: bool) -> bool {
|
||||
let path = if prefix.is_empty() {
|
||||
field.name().clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", field.name())
|
||||
};
|
||||
if field.is_blob_v2() {
|
||||
return inside_list && column == path;
|
||||
}
|
||||
match field.data_type() {
|
||||
DataType::Struct(children) => children
|
||||
.iter()
|
||||
.any(|child| is_list_blob_path(child, &path, column, inside_list)),
|
||||
DataType::List(child) | DataType::LargeList(child) | DataType::FixedSizeList(child, _) => {
|
||||
(column == path && field_tree_has_blob_v2(child))
|
||||
|| is_list_blob_path(child, &path, column, true)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Give list blob paths a useful error before they reach the row-based reader.
|
||||
pub(crate) fn reject_list_blob_column(schema: &Schema, column: &str) -> Result<()> {
|
||||
if schema
|
||||
.fields()
|
||||
.iter()
|
||||
.any(|field| is_list_blob_path(field, "", column, false))
|
||||
{
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"column '{column}' contains a blob inside a list; blobs inside lists cannot be fetched by row id yet"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if `schema` declares any blob v2 column, including nested ones.
|
||||
pub(crate) fn has_blob_columns(schema: &Schema) -> bool {
|
||||
schema.fields().iter().any(|f| field_tree_has_blob_v2(f))
|
||||
}
|
||||
|
||||
/// Blob v2 column paths in `schema`, declaration order preserved. Nested blobs
|
||||
/// are dotted paths (e.g. `info.blob`).
|
||||
/// Row-addressable blob v2 column paths in `schema`, in declaration order.
|
||||
/// Struct-nested blobs use dotted paths (e.g. `info.blob`); list-nested blobs
|
||||
/// are omitted because a row id cannot identify an individual list element.
|
||||
pub(crate) fn blob_column_names(schema: &Schema) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for field in schema.fields() {
|
||||
@@ -351,6 +416,11 @@ pub(crate) fn ensure_blob_v2_column(
|
||||
schema: &lance_core::datatypes::Schema,
|
||||
column: &str,
|
||||
) -> Result<()> {
|
||||
// A top-level blob cannot have a list ancestor. Keep the common read path
|
||||
// from converting the entire schema just to validate that condition.
|
||||
if column.contains('.') || schema.field(column).is_none_or(|field| !field.is_blob_v2()) {
|
||||
reject_list_blob_column(&Schema::from(schema), column)?;
|
||||
}
|
||||
match schema.field(column) {
|
||||
Some(field) if field.is_blob_v2() => Ok(()),
|
||||
Some(field) if field.is_blob() => Err(Error::InvalidInput {
|
||||
@@ -368,45 +438,115 @@ pub(crate) fn ensure_blob_v2_column(
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_all_row_ids_resolved(column: &str, requested: usize, resolved: usize) -> Result<()> {
|
||||
if requested == resolved {
|
||||
fn missing_blob_row_ids_error(column: &str, requested: usize, missing: &[u64]) -> Error {
|
||||
let detail = if missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; first missing row ids: {missing:?}")
|
||||
};
|
||||
Error::InvalidInput {
|
||||
message: format!(
|
||||
"blob read for column '{column}' requested {requested} row ids but some \
|
||||
do not exist in the table{detail}; pass row ids collected from this table"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnose only after a failed read. Stable row IDs use Lance's deletion-aware
|
||||
/// index; physical row IDs are fragment/offset pairs. Limit the error to three
|
||||
/// distinct IDs so a large failed batch stays readable.
|
||||
async fn find_missing_blob_row_ids(dataset: &Dataset, row_ids: &[u64]) -> Result<Vec<u64>> {
|
||||
let mut missing = Vec::new();
|
||||
if let Some(index) = lance::dataset::rowids::get_row_id_index(dataset).await? {
|
||||
for (row_id, address) in row_ids.iter().zip(index.get_many(row_ids)?) {
|
||||
if address.is_none() && !missing.contains(row_id) {
|
||||
missing.push(*row_id);
|
||||
if missing.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut fragments = std::collections::HashMap::new();
|
||||
for &row_id in row_ids {
|
||||
let fragment_id = row_id >> 32;
|
||||
let offset = row_id as u32;
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = fragments.entry(fragment_id) {
|
||||
let fragment = dataset.get_fragment(fragment_id as usize);
|
||||
let state = if let Some(fragment) = fragment {
|
||||
Some((
|
||||
fragment.physical_rows().await?,
|
||||
fragment.get_deletion_vector().await?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
entry.insert(state);
|
||||
}
|
||||
let is_missing = match fragments.get(&fragment_id) {
|
||||
Some(Some((physical_rows, deletions))) => {
|
||||
offset as usize >= *physical_rows
|
||||
|| deletions
|
||||
.as_ref()
|
||||
.is_some_and(|deletions| deletions.contains(offset))
|
||||
}
|
||||
Some(None) => true,
|
||||
None => unreachable!(),
|
||||
};
|
||||
if is_missing && !missing.contains(&row_id) {
|
||||
missing.push(row_id);
|
||||
if missing.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
async fn ensure_all_row_ids_resolved(
|
||||
dataset: &Dataset,
|
||||
column: &str,
|
||||
row_ids: &[u64],
|
||||
resolved: usize,
|
||||
) -> Result<()> {
|
||||
if row_ids.len() == resolved {
|
||||
return Ok(());
|
||||
}
|
||||
if resolved < requested {
|
||||
Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"blob read for column '{column}' requested {requested} row ids but only {resolved} \
|
||||
exist in the table; pass row ids collected from this table"
|
||||
),
|
||||
})
|
||||
if resolved < row_ids.len() {
|
||||
let missing = find_missing_blob_row_ids(dataset, row_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Err(missing_blob_row_ids_error(column, row_ids.len(), &missing))
|
||||
} else {
|
||||
Err(Error::Runtime {
|
||||
message: format!(
|
||||
"blob read for column '{column}' returned {resolved} results for {requested} row ids"
|
||||
"blob read for column '{column}' returned {resolved} results for {} row ids",
|
||||
row_ids.len()
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance take reports a missing physical row address as NotSupported or InvalidInput.
|
||||
fn map_blob_take_error(column: &str, requested: usize, err: lance::Error) -> Error {
|
||||
let missing_row_addr = match &err {
|
||||
lance::Error::NotSupported { source, .. } => {
|
||||
source.to_string().contains("must not target deleted rows")
|
||||
}
|
||||
lance::Error::InvalidInput { source, .. } => source
|
||||
.to_string()
|
||||
.contains("belongs to non-existent fragment"),
|
||||
_ => false,
|
||||
};
|
||||
/// Lance reports missing physical and stable row ids through several error paths
|
||||
/// without a shared typed error.
|
||||
async fn map_blob_take_error(
|
||||
dataset: &Dataset,
|
||||
column: &str,
|
||||
row_ids: &[u64],
|
||||
err: lance::Error,
|
||||
) -> Error {
|
||||
let message = err.to_string();
|
||||
let missing_row_id = message.contains("must not target deleted rows")
|
||||
|| message.contains("belongs to non-existent fragment")
|
||||
|| (message.contains("Invalid read params ") && message.contains("addressable rows"))
|
||||
|| message.contains("Could not resolve all requested row IDs");
|
||||
|
||||
if missing_row_addr {
|
||||
Error::InvalidInput {
|
||||
message: format!(
|
||||
"blob read for column '{column}' requested {requested} row ids but some \
|
||||
do not exist in the table; pass row ids collected from this table"
|
||||
),
|
||||
}
|
||||
if missing_row_id {
|
||||
let missing = find_missing_blob_row_ids(dataset, row_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
missing_blob_row_ids_error(column, row_ids.len(), &missing)
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
@@ -423,18 +563,25 @@ pub(crate) async fn take_blob_ranges_aligned(
|
||||
return Ok(LargeBinaryBuilder::new().finish());
|
||||
}
|
||||
|
||||
let row_ids = requests
|
||||
.iter()
|
||||
.map(|request| request.row_id)
|
||||
.collect::<Vec<_>>();
|
||||
let lance_requests = requests
|
||||
.iter()
|
||||
.map(|request| LanceBlobRangeRequest::new(request.row_id, request.offset, request.length))
|
||||
.collect::<Vec<_>>();
|
||||
let payloads = dataset
|
||||
let payloads = match dataset
|
||||
.read_blob_ranges(column)?
|
||||
.with_row_ids(lance_requests)
|
||||
.preserve_order(true)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| map_blob_take_error(column, requests.len(), err))?;
|
||||
ensure_all_row_ids_resolved(column, requests.len(), payloads.len())?;
|
||||
{
|
||||
Ok(payloads) => payloads,
|
||||
Err(err) => return Err(map_blob_take_error(dataset, column, &row_ids, err).await),
|
||||
};
|
||||
ensure_all_row_ids_resolved(dataset, column, &row_ids, payloads.len()).await?;
|
||||
|
||||
let mut builder = LargeBinaryBuilder::new();
|
||||
for payload in payloads {
|
||||
@@ -457,14 +604,17 @@ pub(crate) async fn take_blobs_aligned(
|
||||
return Ok(LargeBinaryBuilder::new().finish());
|
||||
}
|
||||
|
||||
let payloads = dataset
|
||||
let payloads = match dataset
|
||||
.read_blobs(column)?
|
||||
.with_row_ids(row_ids.to_vec())
|
||||
.preserve_order(true)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| map_blob_take_error(column, row_ids.len(), err))?;
|
||||
ensure_all_row_ids_resolved(column, row_ids.len(), payloads.len())?;
|
||||
{
|
||||
Ok(payloads) => payloads,
|
||||
Err(err) => return Err(map_blob_take_error(dataset, column, row_ids, err).await),
|
||||
};
|
||||
ensure_all_row_ids_resolved(dataset, column, row_ids, payloads.len()).await?;
|
||||
|
||||
let mut builder = LargeBinaryBuilder::new();
|
||||
for payload in payloads {
|
||||
@@ -487,11 +637,11 @@ pub(crate) async fn take_blob_files_aligned(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let handles = dataset
|
||||
.take_blobs(row_ids, column)
|
||||
.await
|
||||
.map_err(|err| map_blob_take_error(column, row_ids.len(), err))?;
|
||||
ensure_all_row_ids_resolved(column, row_ids.len(), handles.len())?;
|
||||
let handles = match dataset.take_blobs(row_ids, column).await {
|
||||
Ok(handles) => handles,
|
||||
Err(err) => return Err(map_blob_take_error(dataset, column, row_ids, err).await),
|
||||
};
|
||||
ensure_all_row_ids_resolved(dataset, column, row_ids, handles.len()).await?;
|
||||
Ok(handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.map(Into::into))
|
||||
@@ -501,8 +651,13 @@ pub(crate) async fn take_blob_files_aligned(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use arrow_schema::DataType;
|
||||
use lance_arrow::ARROW_EXT_NAME_KEY;
|
||||
use lance_arrow::{
|
||||
ARROW_EXT_NAME_KEY, BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY,
|
||||
BLOB_INLINE_SIZE_THRESHOLD_META_KEY,
|
||||
};
|
||||
|
||||
fn blob_schema() -> Schema {
|
||||
Schema::new(vec![
|
||||
@@ -521,6 +676,38 @@ mod tests {
|
||||
assert!(matches!(field.data_type(), DataType::Struct(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_with_options_sets_threshold_metadata() {
|
||||
let options = BlobOptions {
|
||||
inline_size_threshold: Some(8 * 1024),
|
||||
dedicated_size_threshold: NonZeroUsize::new(1 << 20),
|
||||
};
|
||||
let field = blob_with_options("payload", true, options);
|
||||
assert!(is_blob(&field));
|
||||
assert_eq!(
|
||||
field
|
||||
.metadata()
|
||||
.get(BLOB_INLINE_SIZE_THRESHOLD_META_KEY)
|
||||
.map(String::as_str),
|
||||
Some("8192")
|
||||
);
|
||||
assert_eq!(
|
||||
field
|
||||
.metadata()
|
||||
.get(BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY)
|
||||
.map(String::as_str),
|
||||
Some("1048576")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_with_default_options_matches_plain_blob() {
|
||||
let field = blob_with_options("payload", true, BlobOptions::default());
|
||||
let plain = blob("payload", true);
|
||||
assert_eq!(field.metadata(), plain.metadata());
|
||||
assert_eq!(field.data_type(), plain.data_type());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_blob_columns_detects_blob_fields() {
|
||||
assert!(has_blob_columns(&blob_schema()));
|
||||
@@ -619,6 +806,54 @@ mod tests {
|
||||
assert_eq!(blob_column_names(&schema), vec!["info.blob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_blob_paths_are_not_row_addressable() {
|
||||
let schema = Schema::new(vec![
|
||||
blob("image", true),
|
||||
Field::new(
|
||||
"info",
|
||||
DataType::Struct(vec![blob("thumbnail", true)].into()),
|
||||
true,
|
||||
),
|
||||
Field::new("images", DataType::List(Arc::new(blob("item", true))), true),
|
||||
Field::new(
|
||||
"groups",
|
||||
DataType::LargeList(Arc::new(Field::new(
|
||||
"group",
|
||||
DataType::Struct(vec![blob("photo", true)].into()),
|
||||
true,
|
||||
))),
|
||||
true,
|
||||
),
|
||||
Field::new(
|
||||
"frames",
|
||||
DataType::FixedSizeList(Arc::new(blob("frame", true)), 2),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
assert!(has_blob_columns(&schema));
|
||||
assert_eq!(blob_column_names(&schema), vec!["image", "info.thumbnail"]);
|
||||
|
||||
for column in [
|
||||
"images",
|
||||
"images.item",
|
||||
"groups",
|
||||
"groups.group.photo",
|
||||
"frames",
|
||||
"frames.frame",
|
||||
] {
|
||||
let err = reject_list_blob_column(&schema, column).unwrap_err();
|
||||
assert!(matches!(err, Error::InvalidInput { .. }));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
}
|
||||
for column in ["image", "info.thumbnail", "info"] {
|
||||
reject_list_blob_column(&schema, column).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_version_noop_without_blob_columns() {
|
||||
let schema = Schema::new(vec![Field::new("id", DataType::Int64, false)]);
|
||||
|
||||
@@ -193,6 +193,13 @@ impl Stream for DatabaseNames {
|
||||
/// A backend that manages databases. Implementations own database lifecycle semantics.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Catalog: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Obtain deployment-wide authorization, if supported by this backend.
|
||||
fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
Err(crate::Error::NotSupported {
|
||||
message: "Authorization administration is not supported by this catalog".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Catalog endpoint or location.
|
||||
fn uri(&self) -> &str;
|
||||
/// Create a database, or open it when `exist_ok` permits.
|
||||
@@ -239,6 +246,11 @@ impl CatalogConnection {
|
||||
pub fn uri(&self) -> &str {
|
||||
self.catalog.uri()
|
||||
}
|
||||
/// Obtain a handle sharing this catalog's deployment scope and credentials.
|
||||
pub fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
self.catalog.authz()
|
||||
}
|
||||
|
||||
/// Access the underlying backend.
|
||||
pub fn catalog(&self) -> &Arc<dyn Catalog> {
|
||||
&self.catalog
|
||||
|
||||
@@ -38,10 +38,7 @@ use crate::remote::{
|
||||
},
|
||||
};
|
||||
use crate::secrets::SecretInfo;
|
||||
use crate::utils::{
|
||||
validate_namespace, validate_secret_component, validate_secret_reference,
|
||||
validate_view_reference,
|
||||
};
|
||||
use crate::utils::{validate_namespace, validate_secret_reference, validate_view_reference};
|
||||
use crate::view::ViewDescription;
|
||||
use lance::io::ObjectStoreParams;
|
||||
pub use lance_file::version::LanceFileVersion;
|
||||
@@ -106,7 +103,9 @@ impl TableNamesBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// The maximum number of table names to return
|
||||
/// The maximum number of table names to return.
|
||||
///
|
||||
/// Without a limit, all names are returned. Zero returns an empty list.
|
||||
pub fn limit(mut self, limit: u32) -> Self {
|
||||
self.request.limit = Some(limit);
|
||||
self
|
||||
@@ -553,7 +552,8 @@ impl Connection {
|
||||
/// under creation, may contain only uncommitted storage, or may be concurrently
|
||||
/// dropped before it is opened.
|
||||
///
|
||||
/// The parameters `page_token` and `limit` can be used to paginate the results
|
||||
/// Without a limit, all names are returned. The parameters `start_after` and
|
||||
/// `limit` can be used to paginate the results.
|
||||
pub fn table_names(&self) -> TableNamesBuilder {
|
||||
TableNamesBuilder::new(self.internal.clone())
|
||||
}
|
||||
@@ -808,9 +808,7 @@ impl Connection {
|
||||
let database = database.clone();
|
||||
let namespace_path = namespace_path.clone();
|
||||
async move {
|
||||
for segment in &namespace_path {
|
||||
validate_secret_component("Secret namespace path segment", segment)?;
|
||||
}
|
||||
validate_namespace(&namespace_path)?;
|
||||
database.list_secrets(&namespace_path, options).await
|
||||
}
|
||||
})
|
||||
@@ -1185,7 +1183,21 @@ impl Connection {
|
||||
}
|
||||
|
||||
/// List tables with pagination support
|
||||
pub async fn list_tables(&self, request: ListTablesRequest) -> Result<ListTablesResponse> {
|
||||
///
|
||||
/// The default limit is 100 tables per page for both local and remote connections,
|
||||
/// including namespaces. Zero returns an empty page without a continuation token.
|
||||
/// Follow the response's opaque `page_token` until it is absent to retrieve every table;
|
||||
/// a page can contain fewer than the limit even when more tables remain.
|
||||
pub async fn list_tables(&self, mut request: ListTablesRequest) -> Result<ListTablesResponse> {
|
||||
// Apply the SDK's page size here so every backend receives the same request.
|
||||
let limit = request.limit.get_or_insert(100);
|
||||
if *limit <= 0 {
|
||||
return Ok(ListTablesResponse {
|
||||
context: None,
|
||||
tables: Vec::new(),
|
||||
page_token: None,
|
||||
});
|
||||
}
|
||||
self.internal.list_tables(request).await
|
||||
}
|
||||
|
||||
@@ -2271,6 +2283,61 @@ mod tests {
|
||||
assert_eq!(seen, names);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_table_listing_defaults_and_zero_limit() {
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
// Listing discovers physical entries without opening the datasets.
|
||||
let names: Vec<_> = (0..130).map(|i| format!("t{i:03}")).collect();
|
||||
for name in &names {
|
||||
std::fs::create_dir(tempdir.path().join(format!("{name}.lance"))).unwrap();
|
||||
}
|
||||
let db = connect(tempdir.path().to_str().unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.table_names().execute().await.unwrap(), names);
|
||||
assert!(
|
||||
db.table_names()
|
||||
.limit(0)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let first = db.list_tables(ListTablesRequest::default()).await.unwrap();
|
||||
assert_eq!(first.tables, names[..100]);
|
||||
assert!(first.page_token.is_some());
|
||||
let second = db
|
||||
.list_tables(ListTablesRequest {
|
||||
page_token: first.page_token,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.tables, names[100..]);
|
||||
assert!(second.page_token.is_none());
|
||||
|
||||
let all = db
|
||||
.list_tables(ListTablesRequest {
|
||||
limit: Some(200),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all.tables, names);
|
||||
assert!(all.page_token.is_none());
|
||||
let empty = db
|
||||
.list_tables(ListTablesRequest {
|
||||
limit: Some(0),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(empty.tables.is_empty());
|
||||
assert!(empty.page_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_open_table() {
|
||||
let tc = new_test_connection().await.unwrap();
|
||||
|
||||
@@ -288,16 +288,44 @@ impl Database for LanceNamespaceDatabase {
|
||||
}
|
||||
|
||||
async fn table_names(&self, request: TableNamesRequest) -> Result<Vec<String>> {
|
||||
let ns_request = ListTablesRequest {
|
||||
id: Some(request.namespace_path),
|
||||
page_token: request.start_after,
|
||||
limit: request.limit.map(|l| l as i32),
|
||||
..Default::default()
|
||||
};
|
||||
if request.limit == Some(0) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let response = self.namespace.list_tables(ns_request).await?;
|
||||
// Namespace tokens are opaque, and pages need not arrive in name order.
|
||||
// Collect the listing before applying the name cursor and overall limit.
|
||||
let mut names = Vec::new();
|
||||
let mut page_token = None;
|
||||
let mut seen_tokens = HashSet::new();
|
||||
loop {
|
||||
let response = self
|
||||
.namespace
|
||||
.list_tables(ListTablesRequest {
|
||||
id: Some(request.namespace_path.clone()),
|
||||
page_token,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
names.extend(response.tables);
|
||||
let Some(token) = response.page_token.filter(|token| !token.is_empty()) else {
|
||||
break;
|
||||
};
|
||||
if !seen_tokens.insert(token.clone()) {
|
||||
return Err(Error::Runtime {
|
||||
message: "Namespace table listing response repeated a page_token".into(),
|
||||
});
|
||||
}
|
||||
page_token = Some(token);
|
||||
}
|
||||
|
||||
Ok(response.tables)
|
||||
names.sort();
|
||||
if let Some(ref start_after) = request.start_after {
|
||||
names.retain(|name| name > start_after);
|
||||
}
|
||||
if let Some(limit) = request.limit {
|
||||
names.truncate(limit as usize);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
async fn list_tables(&self, request: ListTablesRequest) -> Result<ListTablesResponse> {
|
||||
@@ -589,6 +617,135 @@ mod tests {
|
||||
use futures::TryStreamExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScriptedNamespace {
|
||||
pages: Vec<ListTablesResponse>,
|
||||
requests: Mutex<Vec<ListTablesRequest>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LanceNamespace for ScriptedNamespace {
|
||||
fn namespace_id(&self) -> String {
|
||||
"scripted".into()
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
&self,
|
||||
request: ListTablesRequest,
|
||||
) -> lance_core::Result<ListTablesResponse> {
|
||||
let mut requests = self.requests.lock().unwrap();
|
||||
let page = self.pages[requests.len()].clone();
|
||||
requests.push(request);
|
||||
Ok(page)
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_with_pages(
|
||||
pages: Vec<ListTablesResponse>,
|
||||
) -> (LanceNamespaceDatabase, Arc<ScriptedNamespace>) {
|
||||
let namespace = Arc::new(ScriptedNamespace {
|
||||
pages,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
});
|
||||
let db = LanceNamespaceDatabase::from_namespace_client(
|
||||
namespace.clone(),
|
||||
"scripted".into(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
None,
|
||||
HashSet::new(),
|
||||
);
|
||||
(db, namespace)
|
||||
}
|
||||
|
||||
fn listing_page(names: &[&str], token: Option<&str>) -> ListTablesResponse {
|
||||
ListTablesResponse {
|
||||
tables: names.iter().map(|name| (*name).into()).collect(),
|
||||
page_token: token.map(String::from),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_table_names_collects_namespace_pages() {
|
||||
for namespace_path in [vec![], vec!["child".into()]] {
|
||||
for (start_after, limit, expected) in [
|
||||
(None, None, vec!["a", "b", "c", "d"]),
|
||||
(Some("a".into()), Some(2), vec!["b", "c"]),
|
||||
] {
|
||||
let (db, namespace) = namespace_with_pages(vec![
|
||||
listing_page(&["d", "b"], Some("opaque-1")),
|
||||
listing_page(&[], Some("opaque-2")),
|
||||
listing_page(&["c", "a"], None),
|
||||
]);
|
||||
let names = db
|
||||
.table_names(TableNamesRequest {
|
||||
namespace_path: namespace_path.clone(),
|
||||
start_after,
|
||||
limit,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(names, expected);
|
||||
|
||||
let requests = namespace.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
for request in requests.iter() {
|
||||
assert_eq!(request.id.as_ref(), Some(&namespace_path));
|
||||
assert_eq!(request.limit, None);
|
||||
}
|
||||
assert_eq!(requests[0].page_token, None);
|
||||
assert_eq!(requests[1].page_token.as_deref(), Some("opaque-1"));
|
||||
assert_eq!(requests[2].page_token.as_deref(), Some("opaque-2"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_table_names_zero_limit_skips_namespace_request() {
|
||||
let (db, namespace) = namespace_with_pages(vec![]);
|
||||
assert!(
|
||||
db.table_names(TableNamesRequest {
|
||||
limit: Some(0),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(namespace.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_table_names_stops_on_empty_namespace_token() {
|
||||
let (db, namespace) = namespace_with_pages(vec![listing_page(&["a"], Some(""))]);
|
||||
assert_eq!(
|
||||
db.table_names(TableNamesRequest::default()).await.unwrap(),
|
||||
vec!["a"]
|
||||
);
|
||||
assert_eq!(namespace.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_table_names_rejects_namespace_token_cycle() {
|
||||
let (db, namespace) = namespace_with_pages(vec![
|
||||
listing_page(&["a"], Some("one")),
|
||||
listing_page(&["b"], Some("two")),
|
||||
listing_page(&["c"], Some("one")),
|
||||
]);
|
||||
let error = db
|
||||
.table_names(TableNamesRequest::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("repeated a page_token"));
|
||||
assert_eq!(namespace.requests.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
/// Helper function to create test data
|
||||
fn create_test_data() -> RecordBatch {
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
|
||||
@@ -127,7 +127,7 @@ pub enum Error {
|
||||
"Hit retry limit for request_id={request_id} (\
|
||||
request_failures={request_failures}/{max_request_failures}, \
|
||||
connect_failures={connect_failures}/{max_connect_failures}, \
|
||||
read_failures={read_failures}/{max_read_failures})"
|
||||
read_failures={read_failures}/{max_read_failures}): {source}"
|
||||
))]
|
||||
Retry {
|
||||
request_id: String,
|
||||
|
||||
@@ -453,7 +453,8 @@ pub struct FunctionVersion {
|
||||
signature: FunctionSignature,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
secret_bindings: Vec<SecretBinding>,
|
||||
created_at: String,
|
||||
/// When this version was created, in milliseconds since the epoch.
|
||||
created_at_millis: i64,
|
||||
metadata: BTreeMap<String, String>,
|
||||
disabled: bool,
|
||||
}
|
||||
@@ -507,8 +508,9 @@ impl FunctionVersion {
|
||||
&self.secret_bindings
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &str {
|
||||
&self.created_at
|
||||
/// When this version was created, in milliseconds since the epoch.
|
||||
pub fn created_at_millis(&self) -> i64 {
|
||||
self.created_at_millis
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,17 @@ pub enum Index {
|
||||
|
||||
/// Full text search index using BM25.
|
||||
///
|
||||
/// Supports strings, lists of strings, and `lance.json` logical fields
|
||||
/// stored as `LargeBinary`. Raw binary fields are not supported. Lance
|
||||
/// selects its JSON-aware tokenizer from the field metadata.
|
||||
///
|
||||
/// JSON queries use `path,str,value` triples, where the dotted path is
|
||||
/// relative to the indexed column. For example, `meta.tag,str,nature`
|
||||
/// searches the string value at `$.meta.tag`, not other paths or key names.
|
||||
/// Create the index on the JSON column, not on a JSON subpath. Plain text
|
||||
/// queries are invalid for this tokenizer; malformed triples can panic in
|
||||
/// the current Lance dependency (see [lance#8812](https://github.com/lance-format/lance/issues/8812)).
|
||||
///
|
||||
/// The posting block size defaults to 128. Supported values are 128 and 256;
|
||||
/// a value of 256 uses the experimental FTS V3 format and may introduce
|
||||
/// breaking changes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user