mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-15 08:12:26 +00:00
Merge branch 'main' into brendan/docs-availability
This commit is contained in:
@@ -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
+48
-46
@@ -3584,8 +3584,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "fsst"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5059,8 +5059,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
|
||||
|
||||
[[package]]
|
||||
name = "lance"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5133,8 +5133,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-arrow"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5156,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.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5170,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.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.15#765641dc0d7100f032fdebc00d73eef49711c4d4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5179,8 +5179,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-bitpacking"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5190,8 +5190,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-core"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5228,8 +5228,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datafusion"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5260,8 +5260,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datagen"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5278,8 +5278,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-derive"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5288,8 +5288,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-encoding"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5322,8 +5322,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-file"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5355,8 +5355,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-geo"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5370,8 +5370,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5442,8 +5442,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index-core"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5466,8 +5466,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-io"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5509,8 +5509,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-jsonb"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5527,8 +5527,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-linalg"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5542,8 +5542,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5557,8 +5557,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace-impls"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5612,8 +5612,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-select"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5627,8 +5627,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-table"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5668,8 +5668,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-testing"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5682,8 +5682,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-tokenizer"
|
||||
version = "14.0.0-beta.13"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.13#145fd4b933fb4a9a45831f0ae9185c88e908fe6a"
|
||||
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",
|
||||
@@ -5779,6 +5779,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"snafu 0.8.9",
|
||||
"strum_macros 0.28.0",
|
||||
"tempfile",
|
||||
"test-log",
|
||||
"tokenizers",
|
||||
@@ -9445,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",
|
||||
|
||||
+15
-15
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
|
||||
rust-version = "1.94.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
lance = { "version" = "=14.0.0-beta.13", default-features = false, "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-core = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datagen = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-file = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-io = { "version" = "=14.0.0-beta.13", default-features = false, "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-index = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-linalg = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace-impls = { "version" = "=14.0.0-beta.13", default-features = false, "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-table = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-testing = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datafusion = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-encoding = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-arrow = { "version" = "=14.0.0-beta.13", "tag" = "v14.0.0-beta.13", "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,7 +72,7 @@ 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-aws-lc"] }
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -480,8 +480,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>
|
||||
@@ -490,10 +488,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";
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<arrow.version>15.0.0</arrow.version>
|
||||
<lance-core.version>14.0.0-beta.13</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>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -2817,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")]);
|
||||
@@ -3029,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),
|
||||
@@ -3048,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([
|
||||
@@ -3094,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
+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]
|
||||
@@ -955,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(
|
||||
@@ -1001,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."""
|
||||
|
||||
+345
-92
@@ -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
|
||||
|
||||
@@ -1072,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).
|
||||
@@ -1123,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
|
||||
|
||||
@@ -1153,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.
|
||||
|
||||
@@ -1166,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
|
||||
@@ -1340,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.
|
||||
@@ -1565,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 = (
|
||||
@@ -1572,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,
|
||||
@@ -1585,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,
|
||||
)
|
||||
@@ -1846,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).
|
||||
@@ -1860,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:
|
||||
@@ -2135,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
|
||||
@@ -2171,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:
|
||||
@@ -2306,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.
|
||||
@@ -2342,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(
|
||||
@@ -2804,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(
|
||||
@@ -3064,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.
|
||||
|
||||
@@ -3078,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.
|
||||
|
||||
@@ -3098,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,
|
||||
@@ -3141,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"
|
||||
)
|
||||
@@ -3229,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(
|
||||
@@ -3243,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,
|
||||
)
|
||||
@@ -4083,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]:
|
||||
@@ -4444,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.
|
||||
|
||||
@@ -4458,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.
|
||||
|
||||
@@ -4475,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,
|
||||
@@ -4518,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),
|
||||
|
||||
@@ -57,6 +57,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
|
||||
@@ -239,12 +240,14 @@ 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.")
|
||||
|
||||
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.")
|
||||
|
||||
def checkout(self, version: Union[int, str]):
|
||||
@@ -1154,7 +1157,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))
|
||||
|
||||
@@ -1162,7 +1167,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:
|
||||
|
||||
+184
-37
@@ -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
|
||||
@@ -1738,7 +1823,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".
|
||||
@@ -2054,19 +2140,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
|
||||
@@ -2078,17 +2175,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.
|
||||
@@ -2096,18 +2196,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
|
||||
@@ -2865,6 +2969,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:
|
||||
@@ -3013,19 +3119,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))
|
||||
|
||||
@@ -3239,12 +3349,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
|
||||
@@ -3980,7 +4101,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".
|
||||
@@ -5687,22 +5809,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,
|
||||
@@ -5998,7 +6135,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".
|
||||
@@ -6036,6 +6174,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
|
||||
):
|
||||
@@ -7079,7 +7222,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)
|
||||
@@ -7088,12 +7233,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)
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -2728,6 +2729,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)
|
||||
@@ -3463,6 +3469,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):
|
||||
@@ -3498,6 +3543,28 @@ def test_remote_blob_files_are_lazy_seekable_handles():
|
||||
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())})
|
||||
|
||||
|
||||
@@ -987,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>()?;
|
||||
|
||||
+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
|
||||
|
||||
@@ -66,6 +66,7 @@ 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 }
|
||||
# Default features include the legacy `rustls` feature, which pulls in rustls 0.21.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub mod arrow;
|
||||
pub mod authz;
|
||||
pub mod blob;
|
||||
pub mod catalog;
|
||||
pub mod connection;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! building client/server applications with LanceDB or as a client for some
|
||||
//! other custom LanceDB service.
|
||||
|
||||
mod authz;
|
||||
pub mod catalog;
|
||||
pub(crate) mod client;
|
||||
pub(crate) mod db;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use super::client::{HttpSend, RequestResultExt, RestfulLanceDbClient, Sender};
|
||||
use crate::Result;
|
||||
use crate::authz::*;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
pub struct RemoteAuthorization<S: HttpSend = Sender> {
|
||||
pub(crate) client: RestfulLanceDbClient<S>,
|
||||
}
|
||||
|
||||
impl<S: HttpSend> std::fmt::Debug for RemoteAuthorization<S> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RemoteAuthorization")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: HttpSend> RemoteAuthorization<S> {
|
||||
async fn post<T: Serialize + Sync>(
|
||||
&self,
|
||||
route: &str,
|
||||
request: &T,
|
||||
) -> Result<(String, reqwest::Response)> {
|
||||
// Structured objects can be constructed with invalid names. Serialize
|
||||
// before building the request so validation errors remain local errors.
|
||||
let body = serde_json::to_value(request).map_err(|error| crate::Error::InvalidInput {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let request = self
|
||||
.client
|
||||
.post(&format!("/admin/authz/{route}"))
|
||||
.json(&body);
|
||||
// Every subject selector can contain a secret. Never log these bodies,
|
||||
// and never replay an ACL mutation after an ambiguous response.
|
||||
let (id, response) = self.client.send_suppressing_body(request).await?;
|
||||
let response = self.client.check_response(&id, response).await?;
|
||||
Ok((id, response))
|
||||
}
|
||||
|
||||
async fn read<T: Serialize + Sync, R: DeserializeOwned>(
|
||||
&self,
|
||||
route: &str,
|
||||
request: &T,
|
||||
) -> Result<R> {
|
||||
let (id, response) = self.post(route, request).await?;
|
||||
response.json().await.err_to_http(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<S: HttpSend> Authorization for RemoteAuthorization<S> {
|
||||
async fn list_acls(
|
||||
&self,
|
||||
request: ListAccessControlEntriesRequest,
|
||||
) -> Result<ListAccessControlEntriesResponse> {
|
||||
self.read("acl/list", &request).await
|
||||
}
|
||||
async fn add_acl(&self, request: AccessControlEntryRequest) -> Result<()> {
|
||||
self.post("acl/add", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_acl(&self, request: AccessControlEntryRequest) -> Result<()> {
|
||||
self.post("acl/delete", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn list_role_bindings(
|
||||
&self,
|
||||
request: ListRoleBindingsRequest,
|
||||
) -> Result<ListRoleBindingsResponse> {
|
||||
request.validate()?;
|
||||
self.read("role_binding/list", &request).await
|
||||
}
|
||||
async fn add_role_binding(&self, request: RoleBindingRequest) -> Result<()> {
|
||||
request.validate()?;
|
||||
self.post("role_binding/add", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_role_binding(&self, request: RoleBindingRequest) -> Result<()> {
|
||||
request.validate()?;
|
||||
self.post("role_binding/delete", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn get_principal(&self, request: GetPrincipalRequest) -> Result<PrincipalInfo> {
|
||||
request.validate()?;
|
||||
self.read("principal", &request).await
|
||||
}
|
||||
async fn get_group(&self, request: GetGroupRequest) -> Result<GroupInfo> {
|
||||
request.validate()?;
|
||||
self.read("group", &request).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::client::test_utils::client_with_handler;
|
||||
use serde_json::json;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_preserves_wire_identity_and_continuation() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|request| {
|
||||
assert_eq!(request.url().path(), "/admin/authz/acl/list");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"object": "table:tenant/db:a$b$events", "subject": "G:Engineering", "max_results": 100, "continuation_token": "opaque/token"})
|
||||
);
|
||||
http::Response::builder().status(200).body(json!({
|
||||
"entries": [{"object": "table:tenant/db:a$b$events", "subject_id": "g:team-id", "privilege": "PRIVILEGE20"}],
|
||||
"continuation_token": "next/token"
|
||||
}).to_string()).unwrap()
|
||||
}),
|
||||
};
|
||||
let result = authz
|
||||
.list_acls(ListAccessControlEntriesRequest {
|
||||
object: Some("table:tenant/db:a$b$events".parse().unwrap()),
|
||||
subject: Some(Subject::group_name("Engineering").unwrap()),
|
||||
max_results: Some(100),
|
||||
continuation_token: Some("opaque/token".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.continuation_token.as_deref(), Some("next/token"));
|
||||
assert_eq!(
|
||||
result.entries[0].subject_id,
|
||||
Subject::group_id("team-id").unwrap()
|
||||
);
|
||||
assert_eq!(result.entries[0].subject_name, None);
|
||||
assert_eq!(
|
||||
result.entries[0].privilege,
|
||||
"PRIVILEGE20".parse::<Privilege>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acl_failure_is_not_retried_or_reclassified() {
|
||||
for status in [404, 409, 503] {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let count = calls.clone();
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(move |request| {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(body["privilege"], "USAGE");
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.body("failure")
|
||||
.unwrap()
|
||||
}),
|
||||
};
|
||||
let result = authz
|
||||
.add_acl(AccessControlEntryRequest {
|
||||
object: Object::Database(DatabaseObject::new("db".into())),
|
||||
subject: Subject::principal_api_key("secret").unwrap(),
|
||||
privilege: Privilege::Usage,
|
||||
})
|
||||
.await;
|
||||
let Err(crate::Error::Http {
|
||||
status_code,
|
||||
request_id,
|
||||
..
|
||||
}) = result
|
||||
else {
|
||||
panic!("expected Http error")
|
||||
};
|
||||
assert_eq!(status_code.unwrap().as_u16(), status);
|
||||
assert!(!request_id.is_empty());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_objects_before_sending() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|_| -> http::Response<String> {
|
||||
panic!("invalid objects must not send a request")
|
||||
}),
|
||||
};
|
||||
let request = AccessControlEntryRequest::new(
|
||||
Object::Database(DatabaseObject::new("bad:database".into())),
|
||||
Subject::principal_api_key("secret").unwrap(),
|
||||
Privilege::Usage,
|
||||
);
|
||||
for result in [
|
||||
authz.add_acl(request.clone()).await,
|
||||
authz.delete_acl(request.clone()).await,
|
||||
authz
|
||||
.list_acls(ListAccessControlEntriesRequest {
|
||||
object: Some(request.object),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map(|_| ()),
|
||||
] {
|
||||
assert!(matches!(result, Err(crate::Error::InvalidInput { .. })));
|
||||
assert!(!result.unwrap_err().to_string().contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_incompatible_subjects_before_sending() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|_| -> http::Response<String> {
|
||||
panic!("invalid selectors must not send a request")
|
||||
}),
|
||||
};
|
||||
let request = RoleBindingRequest {
|
||||
subject: Subject::role("reader").unwrap(),
|
||||
role: "writer".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
authz.add_role_binding(request.clone()).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz.delete_role_binding(request).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
let request = ListRoleBindingsRequest {
|
||||
subject: Some(Subject::role("reader").unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
authz.list_role_bindings(request).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz
|
||||
.get_principal(GetPrincipalRequest {
|
||||
principal: Subject::group_id("group").unwrap()
|
||||
})
|
||||
.await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz
|
||||
.get_group(GetGroupRequest {
|
||||
group: Subject::principal_api_key("secret").unwrap()
|
||||
})
|
||||
.await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,10 @@ impl RemoteCatalog {
|
||||
|
||||
#[async_trait]
|
||||
impl Catalog for RemoteCatalog {
|
||||
fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
Ok(self.root.authz())
|
||||
}
|
||||
|
||||
fn uri(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use reqwest::{
|
||||
header::{HeaderMap, HeaderValue},
|
||||
};
|
||||
use std::{collections::HashMap, future::Future, str::FromStr, sync::Arc, time::Duration};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::remote::db::RemoteOptions;
|
||||
@@ -15,6 +16,7 @@ use crate::remote::retry::{ResolvedRetryConfig, RetryCounter};
|
||||
use crate::utils::validate_database_name;
|
||||
|
||||
const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const DEFAULT_BLOB_REQUEST_CONCURRENCY: usize = 8;
|
||||
|
||||
pub fn redact_sensitive_headers(headers: &mut HeaderMap) {
|
||||
const SENSITIVE_HEADERS: [&str; 5] = [
|
||||
@@ -86,6 +88,11 @@ const DEFAULT_MAX_REQUEST_DURATION_DIVISOR: u32 = 2;
|
||||
pub struct ClientConfig {
|
||||
pub timeout_config: TimeoutConfig,
|
||||
pub retry_config: RetryConfig,
|
||||
/// Maximum concurrent blob HTTP requests for this connection, shared by all
|
||||
/// tables and blob handles. Defaults to 8. The environment variable
|
||||
/// `LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY` can also set this limit.
|
||||
/// The value must be greater than zero.
|
||||
pub blob_request_concurrency: Option<usize>,
|
||||
/// User agent to use for requests. The default provides the library
|
||||
/// name and version.
|
||||
pub user_agent: String,
|
||||
@@ -140,6 +147,7 @@ impl std::fmt::Debug for ClientConfig {
|
||||
f.debug_struct("ClientConfig")
|
||||
.field("timeout_config", &self.timeout_config)
|
||||
.field("retry_config", &self.retry_config)
|
||||
.field("blob_request_concurrency", &self.blob_request_concurrency)
|
||||
.field("user_agent", &self.user_agent)
|
||||
.field("extra_headers", &self.extra_headers)
|
||||
.field("id_delimiter", &self.id_delimiter)
|
||||
@@ -160,6 +168,7 @@ impl Default for ClientConfig {
|
||||
Self {
|
||||
timeout_config: TimeoutConfig::default(),
|
||||
retry_config: RetryConfig::default(),
|
||||
blob_request_concurrency: None,
|
||||
user_agent: concat!("LanceDB-Rust-Client/", env!("CARGO_PKG_VERSION")).into(),
|
||||
extra_headers: HashMap::new(),
|
||||
id_delimiter: None,
|
||||
@@ -308,6 +317,7 @@ pub struct RestfulLanceDbClient<S: HttpSend = Sender> {
|
||||
client: reqwest::Client,
|
||||
host: String,
|
||||
pub(crate) retry_config: ResolvedRetryConfig,
|
||||
pub(crate) blob_request_semaphore: Arc<Semaphore>,
|
||||
pub(crate) sender: S,
|
||||
pub(crate) header_provider: Option<Arc<dyn HeaderProvider>>,
|
||||
/// Connection-level read consistency interval. Drives the
|
||||
@@ -469,6 +479,11 @@ impl ClientConfig {
|
||||
/// mistake is reported where it was made rather than as a confusing
|
||||
/// response later. Public so a caller can ask without connecting.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.blob_request_concurrency == Some(0) {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "blob_request_concurrency must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
if let Some(delimiter) = &self.id_delimiter {
|
||||
validate_id_delimiter(delimiter)?;
|
||||
}
|
||||
@@ -504,6 +519,8 @@ impl RestfulLanceDbClient<Sender> {
|
||||
// Before anything is built from it, so the error names the caller's
|
||||
// configuration rather than a request.
|
||||
client_config.validate()?;
|
||||
let blob_request_concurrency =
|
||||
Self::resolve_blob_request_concurrency(client_config.blob_request_concurrency)?;
|
||||
|
||||
// Get the timeouts
|
||||
let timeout =
|
||||
@@ -603,6 +620,7 @@ impl RestfulLanceDbClient<Sender> {
|
||||
client,
|
||||
host,
|
||||
retry_config,
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(blob_request_concurrency)),
|
||||
sender: Sender,
|
||||
header_provider: client_config.header_provider,
|
||||
read_consistency_interval,
|
||||
@@ -611,6 +629,26 @@ impl RestfulLanceDbClient<Sender> {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_blob_request_concurrency(passed: Option<usize>) -> Result<usize> {
|
||||
let value = if let Some(value) = passed {
|
||||
value
|
||||
} else if let Ok(env) = std::env::var("LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY") {
|
||||
env.parse::<usize>().map_err(|_| Error::InvalidInput {
|
||||
message: format!(
|
||||
"LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY must be a positive integer, got '{env}'"
|
||||
),
|
||||
})?
|
||||
} else {
|
||||
DEFAULT_BLOB_REQUEST_CONCURRENCY
|
||||
};
|
||||
if value == 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "blob request concurrency must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Resolve the max bytes per insert request from config, environment, or the
|
||||
/// default. A value of `0` (from either source) disables request splitting.
|
||||
fn resolve_max_bytes_per_request(passed: Option<u64>) -> Result<Option<u64>> {
|
||||
@@ -1106,6 +1144,7 @@ pub mod test_utils {
|
||||
client: reqwest::Client::new(),
|
||||
host: "http://localhost".to_string(),
|
||||
retry_config: RetryConfig::default().try_into().unwrap(),
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(DEFAULT_BLOB_REQUEST_CONCURRENCY)),
|
||||
sender: MockSender {
|
||||
f: Arc::new(wrapper),
|
||||
},
|
||||
@@ -1132,6 +1171,11 @@ pub mod test_utils {
|
||||
client: reqwest::Client::new(),
|
||||
host: "http://localhost".to_string(),
|
||||
retry_config: config.retry_config.try_into().unwrap(),
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(
|
||||
config
|
||||
.blob_request_concurrency
|
||||
.unwrap_or(DEFAULT_BLOB_REQUEST_CONCURRENCY),
|
||||
)),
|
||||
sender: MockSender {
|
||||
f: Arc::new(wrapper),
|
||||
},
|
||||
@@ -1183,6 +1227,21 @@ mod tests {
|
||||
.expect_err("a configured delimiter other than the supported one must be refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_request_concurrency_must_be_positive() {
|
||||
let error = super::ClientConfig {
|
||||
blob_request_concurrency: Some(0),
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("blob_request_concurrency"));
|
||||
assert_eq!(
|
||||
RestfulLanceDbClient::<Sender>::resolve_blob_request_concurrency(Some(3)).unwrap(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
@@ -1515,6 +1574,7 @@ mod tests {
|
||||
client: reqwest::Client::new(),
|
||||
host: "https://example.com".to_string(),
|
||||
retry_config: RetryConfig::default().try_into().unwrap(),
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(DEFAULT_BLOB_REQUEST_CONCURRENCY)),
|
||||
sender: Sender,
|
||||
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
|
||||
read_consistency_interval: None,
|
||||
@@ -1553,6 +1613,7 @@ mod tests {
|
||||
client: reqwest::Client::new(),
|
||||
host: "https://example.com".to_string(),
|
||||
retry_config: RetryConfig::default().try_into().unwrap(),
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(DEFAULT_BLOB_REQUEST_CONCURRENCY)),
|
||||
sender: Sender,
|
||||
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
|
||||
read_consistency_interval: None,
|
||||
@@ -1617,6 +1678,7 @@ mod tests {
|
||||
client: reqwest::Client::new(),
|
||||
host: "https://example.com".to_string(),
|
||||
retry_config: RetryConfig::default().try_into().unwrap(),
|
||||
blob_request_semaphore: Arc::new(Semaphore::new(DEFAULT_BLOB_REQUEST_CONCURRENCY)),
|
||||
sender: Sender,
|
||||
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
|
||||
read_consistency_interval: None,
|
||||
|
||||
@@ -297,6 +297,12 @@ pub struct RemoteHostOverrides {
|
||||
}
|
||||
|
||||
impl RemoteDatabase {
|
||||
pub(crate) fn authz(&self) -> Arc<dyn crate::authz::Authorization> {
|
||||
Arc::new(super::authz::RemoteAuthorization {
|
||||
client: self.client.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn try_new(
|
||||
uri: &str,
|
||||
api_key: &str,
|
||||
|
||||
@@ -198,6 +198,29 @@ mod tests {
|
||||
assert_eq!(counter.connect_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_limit_message_includes_last_status_error() {
|
||||
let config = test_config();
|
||||
let mut counter = RetryCounter::new(&config, "test".to_string());
|
||||
for _ in 0..2 {
|
||||
counter
|
||||
.increment_request_failures(crate::Error::Http {
|
||||
source: "502 Bad Gateway".into(),
|
||||
request_id: "test".to_string(),
|
||||
status_code: Some(reqwest::StatusCode::BAD_GATEWAY),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let error = counter
|
||||
.increment_request_failures(crate::Error::Http {
|
||||
source: "last response: 502 Bad Gateway".into(),
|
||||
request_id: "test".to_string(),
|
||||
status_code: Some(reqwest::StatusCode::BAD_GATEWAY),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("last response: 502 Bad Gateway"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_increment_from_error_respects_global_limits() {
|
||||
// If request_failures is already at max, a connect error should still
|
||||
|
||||
@@ -1057,6 +1057,8 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
);
|
||||
}
|
||||
Select::Dynamic(pairs) => {
|
||||
// Keep the caller's alias order in the JSON object sent to the server.
|
||||
// serde_json's preserve_order feature is required here.
|
||||
let alias_map =
|
||||
serde_json::Map::from_iter(pairs.iter().map(|(name, expr)| {
|
||||
(name.clone(), serde_json::Value::String(expr.clone()))
|
||||
@@ -1402,24 +1404,29 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
});
|
||||
let streams = futures::future::try_join_all(futures);
|
||||
|
||||
if let Some(timeout) = options.timeout {
|
||||
let streams = if let Some(timeout) = options.timeout {
|
||||
let timeout_future = tokio::time::sleep(timeout);
|
||||
tokio::pin!(timeout_future);
|
||||
tokio::pin!(streams);
|
||||
tokio::select! {
|
||||
_ = &mut timeout_future => {
|
||||
Err(Error::Other {
|
||||
return Err(Error::Other {
|
||||
message: format!("Query timeout after {} ms", timeout.as_millis()),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
result = &mut streams => {
|
||||
Ok(result?)
|
||||
result?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(streams.await?)
|
||||
}
|
||||
streams.await?
|
||||
};
|
||||
|
||||
Ok(streams
|
||||
.into_iter()
|
||||
.map(|stream| restore_projection_order(stream, &query.base().select))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn prepare_query_bodies(
|
||||
@@ -1461,6 +1468,52 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_projection_order(
|
||||
stream: Pin<Box<dyn RecordBatchStream + Send>>,
|
||||
select: &Select,
|
||||
) -> Pin<Box<dyn RecordBatchStream + Send>> {
|
||||
let pairs = match select {
|
||||
Select::Dynamic(pairs) => pairs.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
||||
Select::Expr(pairs) => pairs.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
||||
_ => return stream,
|
||||
};
|
||||
|
||||
let schema = stream.schema();
|
||||
let mut remaining = (0..schema.fields().len()).collect::<Vec<_>>();
|
||||
let mut indices = Vec::with_capacity(remaining.len());
|
||||
for name in pairs {
|
||||
if let Some(position) = remaining
|
||||
.iter()
|
||||
.position(|&index| schema.field(index).name() == name)
|
||||
{
|
||||
indices.push(remaining.remove(position));
|
||||
}
|
||||
}
|
||||
indices.extend(remaining);
|
||||
if indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, &field)| index == field)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Older servers may sort alias maps even when the request preserves their
|
||||
// order. Reorder the schema and every batch while keeping server-added fields.
|
||||
let fields = indices
|
||||
.iter()
|
||||
.map(|&index| schema.field(index).clone())
|
||||
.collect::<Vec<_>>();
|
||||
let schema = Arc::new(arrow_schema::Schema::new_with_metadata(
|
||||
fields,
|
||||
schema.metadata().clone(),
|
||||
));
|
||||
let batches = stream.map(move |batch| {
|
||||
batch.and_then(|batch| batch.project(&indices).map_err(DataFusionError::from))
|
||||
});
|
||||
Box::pin(RecordBatchStreamAdapter::new(schema, batches))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TableDescription {
|
||||
version: u64,
|
||||
@@ -5251,6 +5304,14 @@ mod tests {
|
||||
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
|
||||
}
|
||||
|
||||
fn schema_describe_response(schema: &Schema) -> http::Response<String> {
|
||||
let json_schema = JsonSchema::try_from(schema).unwrap();
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(serde_json::json!({ "version": 1, "schema": json_schema }).to_string())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn blob_describe_response() -> http::Response<String> {
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("id", DataType::Int64, false),
|
||||
@@ -5258,11 +5319,7 @@ mod tests {
|
||||
Field::new("caption", DataType::Utf8, true),
|
||||
crate::blob("thumbnail", true),
|
||||
]);
|
||||
let json_schema = JsonSchema::try_from(&schema).unwrap();
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(serde_json::json!({ "version": 1, "schema": json_schema }).to_string())
|
||||
.unwrap()
|
||||
schema_describe_response(&schema)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
@@ -5281,6 +5338,95 @@ mod tests {
|
||||
assert_eq!(columns, vec!["image".to_string(), "thumbnail".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_blob_paths_are_not_advertised_or_fetchable() {
|
||||
let schema = Schema::new(vec![
|
||||
crate::blob("thumbnail", true),
|
||||
Field::new(
|
||||
"imgs",
|
||||
DataType::List(Arc::new(crate::blob("item", true))),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
let table = Table::new_with_handler_version(
|
||||
"my_table",
|
||||
semver::Version::new(0, 5, 0),
|
||||
move |request| match request.url().path() {
|
||||
"/v1/table/my_table/describe/" => schema_describe_response(&schema),
|
||||
"/v1/table/my_table/fetch_blobs/" | "/v1/table/my_table/query/" => {
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.body("Invalid input, column 'imgs' is not a blob column".to_string())
|
||||
.unwrap()
|
||||
}
|
||||
path if path.starts_with("/v1/table/my_table/blob/") => http::Response::builder()
|
||||
.status(400)
|
||||
.body("Invalid input, column 'imgs' is not a blob column".to_string())
|
||||
.unwrap(),
|
||||
path => panic!("unexpected request: {path}"),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(table.blob_columns().await.unwrap(), vec!["thumbnail"]);
|
||||
for column in ["imgs", "imgs.item"] {
|
||||
let err = table.fetch_blobs(column, &[10]).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
let err = table.fetch_blob_files(column, &[10]).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_blob_files_explains_uncached_list_descriptors() {
|
||||
let list_field = Field::new(
|
||||
"imgs",
|
||||
DataType::List(Arc::new(crate::blob("item", true))),
|
||||
true,
|
||||
);
|
||||
let schema = Schema::new(vec![list_field.clone()]);
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
list_field.clone(),
|
||||
Field::new(lance::dataset::ROW_ID, DataType::UInt64, false),
|
||||
])),
|
||||
vec![
|
||||
arrow_array::new_null_array(list_field.data_type(), 1),
|
||||
Arc::new(arrow_array::UInt64Array::from(vec![10])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let table = Table::new_with_handler_version(
|
||||
"my_table",
|
||||
semver::Version::new(0, 5, 0),
|
||||
move |request| match request.url().path() {
|
||||
"/v1/table/my_table/describe/" => {
|
||||
schema_describe_response(&schema).map(|body| body.into_bytes())
|
||||
}
|
||||
"/v1/table/my_table/query/" => http::Response::builder()
|
||||
.status(200)
|
||||
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
|
||||
.header(VERSION_HEADER, "1")
|
||||
.body(write_ipc_stream_uncompressed(&batch))
|
||||
.unwrap(),
|
||||
path => panic!("unexpected request: {path}"),
|
||||
},
|
||||
);
|
||||
|
||||
let error = table.fetch_blob_files("imgs", &[10]).await.unwrap_err();
|
||||
assert!(matches!(error, Error::InvalidInput { .. }));
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_blobs_decodes_null_aligned_bytes() {
|
||||
let mut builder = LargeBinaryBuilder::new();
|
||||
@@ -5514,7 +5660,7 @@ mod tests {
|
||||
.header(VERSION_HEADER, "42")
|
||||
.body(b"0".to_vec())
|
||||
.unwrap(),
|
||||
"bytes=0-" => http::Response::builder()
|
||||
"bytes=0-9" => http::Response::builder()
|
||||
.status(206)
|
||||
.header(reqwest::header::CONTENT_RANGE, "bytes 0-9/10")
|
||||
.body(b"0123456789".to_vec())
|
||||
@@ -5536,21 +5682,35 @@ mod tests {
|
||||
assert_eq!(blobs.value(4), b"bbb");
|
||||
let requests = requests.lock().unwrap();
|
||||
assert!(requests.contains(&"GET bytes=0-0".to_string()));
|
||||
assert!(requests.contains(&"GET bytes=0-".to_string()));
|
||||
assert!(requests.contains(&"GET bytes=0-9".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_blobs_does_not_split_unrelated_bad_requests() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let seen = requests.clone();
|
||||
let table =
|
||||
Table::new_with_handler_version("my_table", semver::Version::new(0, 5, 0), move |_| {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.body(b"unknown blob column".to_vec())
|
||||
.unwrap()
|
||||
});
|
||||
let table = Table::new_with_handler_version(
|
||||
"my_table",
|
||||
semver::Version::new(0, 5, 0),
|
||||
move |request| match request.url().path() {
|
||||
"/v1/table/my_table/describe/" => {
|
||||
schema_describe_response(&Schema::new(vec![Field::new(
|
||||
"id",
|
||||
DataType::Int64,
|
||||
false,
|
||||
)]))
|
||||
.map(|body| body.into_bytes())
|
||||
}
|
||||
"/v1/table/my_table/fetch_blobs/" => {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.body(b"unknown blob column".to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
path => panic!("unexpected request: {path}"),
|
||||
},
|
||||
);
|
||||
|
||||
assert_fetch_blobs_http_error(
|
||||
table.fetch_blobs("missing", &[10, 20]).await.unwrap_err(),
|
||||
@@ -5713,10 +5873,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_fetch_blobs_sends_a_nested_column_as_a_dotted_path() {
|
||||
let ipc = one_row_blob_ipc_stream("info.blob");
|
||||
let schema = Schema::new(vec![Field::new(
|
||||
"info",
|
||||
DataType::Struct(vec![crate::blob("blob", true)].into()),
|
||||
true,
|
||||
)]);
|
||||
let table = Table::new_with_handler_version(
|
||||
"my_table",
|
||||
semver::Version::new(0, 5, 0),
|
||||
move |request| {
|
||||
if request.url().path() == "/v1/table/my_table/describe/" {
|
||||
return schema_describe_response(&schema).map(|body| body.into_bytes());
|
||||
}
|
||||
let body = request_body_json(&request);
|
||||
assert_eq!(body["column"], "info.blob");
|
||||
http::Response::builder()
|
||||
@@ -6429,6 +6597,72 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::sql(Select::dynamic(&[("p", "x"), ("id", "id")]))]
|
||||
#[case::typed(Select::expr_projection(&[
|
||||
("p", crate::expr::col("x")),
|
||||
("id", crate::expr::col("id")),
|
||||
]))]
|
||||
#[tokio::test]
|
||||
async fn test_query_dynamic_projection_order(#[case] select: Select) {
|
||||
let table = Table::new_with_handler("my_table", |request| {
|
||||
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
let aliases = body["columns"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(aliases, ["p", "id"]);
|
||||
|
||||
// Simulate a server that returns aliases in alphabetical order.
|
||||
let data = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
Field::new("p", DataType::Int32, false),
|
||||
])),
|
||||
vec![
|
||||
Arc::new(Int32Array::from(vec![7])),
|
||||
Arc::new(Int32Array::from(vec![42])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
|
||||
.body(write_ipc_file(&data))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let batches = table
|
||||
.query()
|
||||
.select(select)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batches.len(), 1);
|
||||
let schema = batches[0].schema();
|
||||
assert_eq!(
|
||||
schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.name().as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["p", "id"]
|
||||
);
|
||||
let projected = batches[0]
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<Int32Array>()
|
||||
.unwrap();
|
||||
assert_eq!(projected.value(0), 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_vector_nested_field_path() {
|
||||
let expected_data = RecordBatch::try_new(
|
||||
|
||||
@@ -11,13 +11,13 @@ use std::time::Duration;
|
||||
|
||||
use arrow_array::{Array, LargeBinaryArray, StructArray, UInt8Array, UInt64Array};
|
||||
use arrow_schema::DataType;
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use bytes::Bytes;
|
||||
use datafusion_expr::{col, lit};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use lance::dataset::ROW_ID;
|
||||
use lance_core::datatypes::BlobKind;
|
||||
use reqwest::{Response, StatusCode, header};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, OwnedSemaphorePermit};
|
||||
|
||||
use crate::Error;
|
||||
use crate::blob::BlobFile;
|
||||
@@ -57,7 +57,26 @@ trait BlobRangeRequester: Send + Sync + std::fmt::Debug {
|
||||
range_header: &str,
|
||||
mode: RangeRequestMode,
|
||||
version: Option<u64>,
|
||||
) -> Result<(String, Response)>;
|
||||
) -> Result<BlobRangeResponse>;
|
||||
}
|
||||
|
||||
/// Keep the connection slot until the response body has been consumed or dropped.
|
||||
#[derive(Debug)]
|
||||
struct BlobRangeResponse {
|
||||
request_id: String,
|
||||
response: Response,
|
||||
_permit: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<(String, Response)> for BlobRangeResponse {
|
||||
fn from((request_id, response): (String, Response)) -> Self {
|
||||
Self {
|
||||
request_id,
|
||||
response,
|
||||
_permit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -79,7 +98,14 @@ impl<S: HttpSend> BlobRangeRequester for TableBlobRangeRequester<S> {
|
||||
range_header: &str,
|
||||
mode: RangeRequestMode,
|
||||
version: Option<u64>,
|
||||
) -> Result<(String, Response)> {
|
||||
) -> Result<BlobRangeResponse> {
|
||||
let permit = self
|
||||
.client
|
||||
.blob_request_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("blob request semaphore is never closed");
|
||||
let freshness_request =
|
||||
freshness_headers_snapshot(&self.freshness, self.read_consistency_interval);
|
||||
let mut request = freshness_request
|
||||
@@ -96,32 +122,32 @@ impl<S: HttpSend> BlobRangeRequester for TableBlobRangeRequester<S> {
|
||||
if response.status() == StatusCode::RANGE_NOT_SATISFIABLE
|
||||
&& matches!(mode, RangeRequestMode::SizeProbe)
|
||||
{
|
||||
return Ok((request_id, response));
|
||||
return Ok(BlobRangeResponse {
|
||||
request_id,
|
||||
response,
|
||||
_permit: Some(permit),
|
||||
});
|
||||
}
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
freshness_request.observe_headers(&self.freshness, response.headers());
|
||||
self.parent_freshness_request
|
||||
.observe_headers(&self.parent_freshness, response.headers());
|
||||
Ok((request_id, response))
|
||||
Ok(BlobRangeResponse {
|
||||
request_id,
|
||||
response,
|
||||
_permit: Some(permit),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SequentialResponse {
|
||||
response: Response,
|
||||
request_id: String,
|
||||
buffered: Bytes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RemoteBlobState {
|
||||
cursor: u64,
|
||||
sequential_response: Option<SequentialResponse>,
|
||||
}
|
||||
|
||||
/// Seekable Cloud blob handle over HTTP Range.
|
||||
///
|
||||
/// Nonempty handles read the table version returned by their size probe.
|
||||
/// Handles read the table version used to fetch their descriptor or probe their size.
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteBlobFile {
|
||||
requester: Arc<dyn BlobRangeRequester>,
|
||||
@@ -145,11 +171,6 @@ impl RemoteBlobFile {
|
||||
/// Close the handle without waiting for an in-flight read.
|
||||
pub(crate) async fn close(&self) -> lance_core::Result<()> {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
// Drop a retained response when the state lock is immediately available.
|
||||
// A reader holding the lock drops it instead once it observes the flag.
|
||||
if let Ok(mut state) = self.state.try_lock() {
|
||||
state.sequential_response = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -185,7 +206,11 @@ impl RemoteBlobFile {
|
||||
return Ok(Bytes::new());
|
||||
}
|
||||
let range_header = format!("bytes={}-{}", range.start, range.end - 1);
|
||||
let (request_id, response) = self
|
||||
let BlobRangeResponse {
|
||||
request_id,
|
||||
response,
|
||||
_permit,
|
||||
} = self
|
||||
.requester
|
||||
.request_range(&range_header, RangeRequestMode::DataRead, self.version)
|
||||
.await
|
||||
@@ -252,62 +277,20 @@ impl RemoteBlobFile {
|
||||
return Ok(Bytes::new());
|
||||
}
|
||||
|
||||
// Remove the retained response from shared state before awaiting. Failed or
|
||||
// cancelled reads leave the committed cursor unchanged and force the next
|
||||
// read to open a fresh response.
|
||||
let mut sequential_response = state.sequential_response.take();
|
||||
let mut cursor = state.cursor;
|
||||
let mut output = BytesMut::with_capacity(target_len);
|
||||
while output.len() < target_len {
|
||||
if sequential_response.is_none() {
|
||||
let range_header = format!("bytes={cursor}-");
|
||||
let (request_id, response) = self
|
||||
.requester
|
||||
.request_range(&range_header, RangeRequestMode::DataRead, self.version)
|
||||
.await
|
||||
.map_err(remote_blob_error)?;
|
||||
self.ensure_open()?;
|
||||
validate_partial_response(&response, cursor..self.size, self.size)?;
|
||||
sequential_response = Some(SequentialResponse {
|
||||
response,
|
||||
request_id,
|
||||
buffered: Bytes::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let needed = target_len - output.len();
|
||||
let active = sequential_response.as_mut().unwrap();
|
||||
if !active.buffered.is_empty() {
|
||||
let take = needed.min(active.buffered.len());
|
||||
output.extend_from_slice(&active.buffered.split_to(take));
|
||||
cursor += take as u64;
|
||||
continue;
|
||||
}
|
||||
let chunk = active
|
||||
.response
|
||||
.chunk()
|
||||
.await
|
||||
.err_to_http(active.request_id.clone())
|
||||
.map_err(remote_blob_error)?;
|
||||
self.ensure_open()?;
|
||||
let chunk = chunk.ok_or_else(|| {
|
||||
remote_blob_error("response ended before the requested blob range")
|
||||
})?;
|
||||
active.buffered = chunk;
|
||||
}
|
||||
// A partial read must release its HTTP response before returning. Keeping
|
||||
// it open across calls would hold a connection slot indefinitely and
|
||||
// prevent other handles from making progress.
|
||||
let end = state.cursor + target_len as u64;
|
||||
let bytes = self.read_range(state.cursor..end).await?;
|
||||
self.ensure_open()?;
|
||||
state.cursor = cursor;
|
||||
if state.cursor < self.size {
|
||||
state.sequential_response = sequential_response;
|
||||
}
|
||||
Ok(output.freeze())
|
||||
state.cursor = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn seek(&self, new_cursor: u64) -> lance_core::Result<()> {
|
||||
self.ensure_open()?;
|
||||
let mut state = self.state.lock().await;
|
||||
self.ensure_open()?;
|
||||
state.sequential_response = None;
|
||||
state.cursor = new_cursor;
|
||||
Ok(())
|
||||
}
|
||||
@@ -375,6 +358,34 @@ fn validate_partial_response(
|
||||
}
|
||||
|
||||
impl<S: HttpSend> RemoteTable<S> {
|
||||
async fn reject_list_blob_path_if_known(&self, column: &str) -> Result<()> {
|
||||
// A dotted path needs client-side validation because the server may
|
||||
// report an internal type mismatch when the path crosses a list. Use
|
||||
// an already cached schema for top-level list fields as well.
|
||||
if column.contains('.') || self.schema_cache.try_get().is_some() {
|
||||
let schema = self.schema().await?;
|
||||
crate::blob::reject_list_blob_column(schema.as_ref(), column)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn explain_list_blob_bad_request(&self, column: &str, error: Error) -> Error {
|
||||
let is_bad_request = match &error {
|
||||
Error::Http {
|
||||
status_code: Some(code),
|
||||
..
|
||||
} => *code == StatusCode::BAD_REQUEST || *code == StatusCode::UNPROCESSABLE_ENTITY,
|
||||
_ => false,
|
||||
};
|
||||
if is_bad_request
|
||||
&& let Ok(schema) = self.schema().await
|
||||
&& let Err(list_error) = crate::blob::reject_list_blob_column(schema.as_ref(), column)
|
||||
{
|
||||
return list_error;
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Blob v2 columns are marked in field metadata, which `describe` returns. Reading
|
||||
/// them from the cached schema needs no route of its own and no version gate.
|
||||
pub(super) async fn blob_columns_impl(&self) -> Result<Vec<String>> {
|
||||
@@ -387,6 +398,7 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
column: &str,
|
||||
row_ids: &[u64],
|
||||
) -> Result<LargeBinaryArray> {
|
||||
self.reject_list_blob_path_if_known(column).await?;
|
||||
// Empty requests do not require blob-route support.
|
||||
if row_ids.is_empty() {
|
||||
return Ok(LargeBinaryArray::from(Vec::<Option<&[u8]>>::new()));
|
||||
@@ -466,7 +478,10 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
let (request_id, response) = self
|
||||
.send_with_freshness(request, true, read_snapshot.freshness)
|
||||
.await?;
|
||||
let mut stream = self.read_arrow_response(&request_id, response).await?;
|
||||
let mut stream = match self.read_arrow_response(&request_id, response).await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => return Err(self.explain_list_blob_bad_request(column, error).await),
|
||||
};
|
||||
|
||||
let mut blob_chunks: Vec<Arc<dyn Array>> = Vec::new();
|
||||
while let Some(batch) = stream.try_next().await? {
|
||||
@@ -535,6 +550,7 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
column: &str,
|
||||
row_ids: &[u64],
|
||||
) -> Result<Vec<Option<BlobFile>>> {
|
||||
self.reject_list_blob_path_if_known(column).await?;
|
||||
// Empty requests do not require blob-route support.
|
||||
if row_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -542,9 +558,13 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
self.ensure_blob_files_supported()?;
|
||||
|
||||
let mut read_snapshot = self.snapshot_read_state().await;
|
||||
let sizes = self
|
||||
let sizes = match self
|
||||
.take_blob_descriptor_sizes(column, row_ids, &mut read_snapshot)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(sizes) => sizes,
|
||||
Err(error) => return Err(self.explain_list_blob_bad_request(column, error).await),
|
||||
};
|
||||
let version = read_snapshot.version;
|
||||
|
||||
let mut files = Vec::with_capacity(row_ids.len());
|
||||
@@ -568,7 +588,10 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let probed = probe_blob_files(probe_requesters).await?;
|
||||
let probed = match probe_blob_files(probe_requesters).await {
|
||||
Ok(files) => files,
|
||||
Err(error) => return Err(self.explain_list_blob_bad_request(column, error).await),
|
||||
};
|
||||
for (index, file) in probe_indices.into_iter().zip(probed) {
|
||||
files[index] = file;
|
||||
}
|
||||
@@ -589,7 +612,10 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
.iter()
|
||||
.map(|row_id| self.blob_range_requester(column, *row_id, &read_snapshot))
|
||||
.collect();
|
||||
probe_blob_files(requesters).await
|
||||
match probe_blob_files(requesters).await {
|
||||
Ok(files) => Ok(files),
|
||||
Err(error) => Err(self.explain_list_blob_bad_request(column, error).await),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the blob descriptor of every unique row in `row_ids`.
|
||||
@@ -696,6 +722,15 @@ impl<S: HttpSend> RemoteTable<S> {
|
||||
let values = batch.column_by_name(column).ok_or_else(|| {
|
||||
invalid_response(format!("blob descriptor take is missing '{column}'"))
|
||||
})?;
|
||||
// An uncached top-level list can reach the descriptor query. Explain
|
||||
// that unsupported path before the generic descriptor type check.
|
||||
if matches!(
|
||||
values.data_type(),
|
||||
DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _)
|
||||
) && let Ok(schema) = self.schema().await
|
||||
{
|
||||
crate::blob::reject_list_blob_column(schema.as_ref(), column)?;
|
||||
}
|
||||
// Only blob v2 columns read back as descriptors.
|
||||
let descriptors = values
|
||||
.as_any()
|
||||
@@ -817,7 +852,11 @@ fn probe_blob_version(response: &Response, request_id: &str) -> Result<u64> {
|
||||
///
|
||||
/// `204` represents null. `416` with `bytes */0` represents an empty blob.
|
||||
async fn probe_blob_file(requester: Arc<dyn BlobRangeRequester>) -> Result<Option<BlobFile>> {
|
||||
let (request_id, response) = requester
|
||||
let BlobRangeResponse {
|
||||
request_id,
|
||||
response,
|
||||
_permit,
|
||||
} = requester
|
||||
.request_range("bytes=0-0", RangeRequestMode::SizeProbe, None)
|
||||
.await?;
|
||||
match response.status() {
|
||||
@@ -1372,6 +1411,114 @@ mod tests {
|
||||
assert!(requests.lock().unwrap().contains(&"bytes=5-11".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn blob_requests_are_bounded_across_handles() {
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
let max_in_flight = Arc::new(AtomicUsize::new(0));
|
||||
let table = RemoteTable::new_mock_with_version_and_config(
|
||||
"my_table".to_string(),
|
||||
{
|
||||
let in_flight = in_flight.clone();
|
||||
let max_in_flight = max_in_flight.clone();
|
||||
move |request| {
|
||||
let range = request.headers().get(header::RANGE).unwrap();
|
||||
if range != "bytes=0-0" {
|
||||
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max_in_flight.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
in_flight.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
range_response(&request, PAYLOAD)
|
||||
}
|
||||
},
|
||||
Some(Version::new(0, 5, 0)),
|
||||
crate::remote::ClientConfig {
|
||||
blob_request_concurrency: Some(3),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let files = table
|
||||
.probe_files("image", &(0..16).collect::<Vec<_>>())
|
||||
.await
|
||||
.unwrap();
|
||||
let start = Arc::new(tokio::sync::Barrier::new(files.len() + 1));
|
||||
let reads = files.into_iter().map(|file| {
|
||||
let start = start.clone();
|
||||
tokio::spawn(async move {
|
||||
start.wait().await;
|
||||
file.unwrap().read_range(1..2).await
|
||||
})
|
||||
});
|
||||
let reads = reads.collect::<Vec<_>>();
|
||||
start.wait().await;
|
||||
for result in futures::future::join_all(reads).await {
|
||||
assert_eq!(result.unwrap().unwrap(), &PAYLOAD[1..2]);
|
||||
}
|
||||
let max = max_in_flight.load(Ordering::SeqCst);
|
||||
assert!(max > 1, "reads never overlapped");
|
||||
assert!(
|
||||
max <= 3,
|
||||
"{max} blob requests exceeded the configured limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_reads_release_slots_for_other_handles() {
|
||||
let table = RemoteTable::new_mock_with_version_and_config(
|
||||
"my_table".to_string(),
|
||||
|request| range_response(&request, PAYLOAD),
|
||||
Some(Version::new(0, 5, 0)),
|
||||
crate::remote::ClientConfig {
|
||||
blob_request_concurrency: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let files = table.probe_files("image", &[10, 20]).await.unwrap();
|
||||
let reads = files
|
||||
.iter()
|
||||
.map(|file| file.as_ref().unwrap().read_up_to(1));
|
||||
let results =
|
||||
tokio::time::timeout(Duration::from_secs(1), futures::future::join_all(reads))
|
||||
.await
|
||||
.expect("an idle partial response held the only connection slot");
|
||||
for result in results {
|
||||
assert_eq!(result.unwrap(), &PAYLOAD[..1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_retry_error_includes_last_status() {
|
||||
let table = RemoteTable::new_mock_with_version_and_config(
|
||||
"my_table".to_string(),
|
||||
|request| {
|
||||
if request.headers().get(header::RANGE).unwrap() == "bytes=0-0" {
|
||||
range_response(&request, PAYLOAD)
|
||||
} else {
|
||||
http::Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
},
|
||||
Some(Version::new(0, 5, 0)),
|
||||
crate::remote::ClientConfig {
|
||||
retry_config: crate::remote::RetryConfig {
|
||||
retries: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let file = table.probe_files("image", &[10]).await.unwrap();
|
||||
let error = file[0]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.read_range(1..2)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("503"), "got: {error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_blob_file_reads_the_probed_version_after_table_changes() {
|
||||
let changed = Arc::new(AtomicBool::new(false));
|
||||
@@ -1428,8 +1575,11 @@ mod tests {
|
||||
[
|
||||
("bytes=0-0".to_string(), None),
|
||||
("bytes=5-8".to_string(), Some("5".to_string())),
|
||||
("bytes=0-".to_string(), Some("5".to_string())),
|
||||
("bytes=10-".to_string(), Some("5".to_string())),
|
||||
("bytes=0-3".to_string(), Some("5".to_string())),
|
||||
(
|
||||
format!("bytes=10-{}", PAYLOAD.len() - 1),
|
||||
Some("5".to_string())
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1475,7 +1625,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_blob_file_reuses_sequential_response_until_seek() {
|
||||
async fn remote_blob_file_releases_response_after_each_partial_read() {
|
||||
let requests = Arc::new(StdMutex::new(Vec::new()));
|
||||
let table = mock_remote_blob_table(requests.clone());
|
||||
let file = table
|
||||
@@ -1493,7 +1643,7 @@ mod tests {
|
||||
assert_eq!(file.tell().await.unwrap(), 24);
|
||||
assert_eq!(
|
||||
requests.lock().unwrap().as_slice(),
|
||||
["bytes=0-0", "bytes=0-", "bytes=20-"]
|
||||
["bytes=0-0", "bytes=0-3", "bytes=4-6", "bytes=20-23"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1689,11 +1839,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_blob_file_failed_read_preserves_cursor_and_retries_fresh() {
|
||||
let sequential_requests = Arc::new(AtomicUsize::new(0));
|
||||
let read_requests = Arc::new(AtomicUsize::new(0));
|
||||
let table = RemoteTable::new_mock(
|
||||
"my_table".to_string(),
|
||||
{
|
||||
let sequential_requests = sequential_requests.clone();
|
||||
let read_requests = read_requests.clone();
|
||||
move |request| {
|
||||
let range = request
|
||||
.headers()
|
||||
@@ -1705,15 +1855,14 @@ mod tests {
|
||||
if range == "bytes=0-0" {
|
||||
return range_response(&request, PAYLOAD);
|
||||
}
|
||||
let attempt = sequential_requests.fetch_add(1, Ordering::SeqCst);
|
||||
let attempt = read_requests.fetch_add(1, Ordering::SeqCst);
|
||||
if attempt == 0 {
|
||||
// End the response five bytes early to simulate a truncated
|
||||
// sequential read.
|
||||
// Return too few bytes for the requested range.
|
||||
return http::Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes 0-{}/{}", PAYLOAD.len() - 1, PAYLOAD.len()),
|
||||
format!("bytes 0-9/{}", PAYLOAD.len()),
|
||||
)
|
||||
.body(PAYLOAD[..5].to_vec())
|
||||
.unwrap();
|
||||
@@ -1733,9 +1882,7 @@ mod tests {
|
||||
|
||||
let error = file.read_up_to(10).await.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("response ended before the requested blob range"),
|
||||
error.to_string().contains("returned 5 bytes, expected 10"),
|
||||
"got: {error}"
|
||||
);
|
||||
assert_eq!(file.tell().await.unwrap(), 0);
|
||||
@@ -1743,7 +1890,7 @@ mod tests {
|
||||
// Retry from the last committed cursor with a fresh request.
|
||||
let retried = file.read_up_to(4).await.unwrap();
|
||||
assert_eq!(retried, b"0123".as_slice());
|
||||
assert_eq!(sequential_requests.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(read_requests.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1776,7 +1923,7 @@ mod tests {
|
||||
range_header: &str,
|
||||
_mode: RangeRequestMode,
|
||||
_version: Option<u64>,
|
||||
) -> Result<(String, Response)> {
|
||||
) -> Result<BlobRangeResponse> {
|
||||
assert_eq!(range_header, "bytes=0-0");
|
||||
let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.max_in_flight.fetch_max(now, Ordering::SeqCst);
|
||||
@@ -1793,7 +1940,7 @@ mod tests {
|
||||
.header(VERSION_HEADER, "5")
|
||||
.body(vec![0u8])
|
||||
.unwrap();
|
||||
Ok((format!("probe-{}", self.index), Response::from(response)))
|
||||
Ok((format!("probe-{}", self.index), Response::from(response)).into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1957,7 +2104,7 @@ mod tests {
|
||||
range_header: &str,
|
||||
_mode: RangeRequestMode,
|
||||
_version: Option<u64>,
|
||||
) -> Result<(String, Response)> {
|
||||
) -> Result<BlobRangeResponse> {
|
||||
let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.max_in_flight.fetch_max(now, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
@@ -1977,7 +2124,7 @@ mod tests {
|
||||
)
|
||||
.body(PAYLOAD[start..=end].to_vec())
|
||||
.unwrap();
|
||||
Ok(("range".to_string(), Response::from(response)))
|
||||
Ok(("range".to_string(), Response::from(response)).into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2078,8 +2225,8 @@ mod tests {
|
||||
range_header: &str,
|
||||
_mode: RangeRequestMode,
|
||||
_version: Option<u64>,
|
||||
) -> Result<(String, Response)> {
|
||||
if range_header.ends_with('-') {
|
||||
) -> Result<BlobRangeResponse> {
|
||||
if range_header == "bytes=0-3" {
|
||||
self.started.notify_one();
|
||||
self.release.wait().await;
|
||||
}
|
||||
@@ -2091,7 +2238,7 @@ mod tests {
|
||||
)
|
||||
.body(PAYLOAD.to_vec())
|
||||
.unwrap();
|
||||
Ok(("hung".to_string(), Response::from(response)))
|
||||
Ok(("hung".to_string(), Response::from(response)).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-10
@@ -19,6 +19,9 @@ pub use lance::dataset::ReadParams;
|
||||
pub use lance::dataset::Version;
|
||||
use lance::dataset::WriteMode;
|
||||
use lance::dataset::builder::DatasetBuilder;
|
||||
pub use lance::dataset::mem_wal::MemIndexRegistry;
|
||||
/// The traits a memtable index plugin implements; see [`MemIndexRegistry`].
|
||||
pub use lance::dataset::mem_wal::index as mem_index;
|
||||
use lance::dataset::{InsertBuilder, WriteParams};
|
||||
use lance::index::DatasetIndexExt;
|
||||
use lance::index::scalar::load_segment_params;
|
||||
@@ -424,7 +427,8 @@ impl LsmWriteSpec {
|
||||
/// changes, so an index created later is maintained too; one of a kind the
|
||||
/// MemWAL cannot mirror is skipped rather than failing the table. A list is
|
||||
/// verbatim: each name must already exist and be maintainable, and an empty
|
||||
/// list maintains nothing.
|
||||
/// list maintains nothing. The maintainable kinds are Lance's built-ins plus
|
||||
/// any added with [`NativeTable::set_mem_index_registry`].
|
||||
///
|
||||
/// ```
|
||||
/// # use lancedb::table::LsmWriteSpec;
|
||||
@@ -1237,10 +1241,11 @@ impl Table {
|
||||
self.inner.count_rows(filter).await
|
||||
}
|
||||
|
||||
/// Names of the blob v2 columns in this table, in declaration order.
|
||||
/// Names of the row-addressable blob v2 columns, in declaration order.
|
||||
///
|
||||
/// Nested blobs use dotted paths (e.g. `info.blob`). Returns
|
||||
/// [`Error::NotSupported`] on table types without blob support.
|
||||
/// Blobs nested in structs use dotted paths (e.g. `info.blob`). Blobs
|
||||
/// inside lists are omitted because row ids cannot identify their elements.
|
||||
/// Returns [`Error::NotSupported`] on table types without blob support.
|
||||
pub async fn blob_columns(&self) -> Result<Vec<String>> {
|
||||
self.inner.blob_columns().await
|
||||
}
|
||||
@@ -1255,7 +1260,8 @@ impl Table {
|
||||
/// [`Self::fetch_blob_files`] for large selections.
|
||||
///
|
||||
/// `_rowid` values stay valid after compaction when the table has stable
|
||||
/// row ids.
|
||||
/// row ids. One missing or deleted row id rejects the whole request; no
|
||||
/// partial results are returned.
|
||||
///
|
||||
/// ```
|
||||
/// use arrow_array::UInt64Array;
|
||||
@@ -1279,9 +1285,10 @@ impl Table {
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// Returns [`Error::InvalidInput`] when the column does not exist or is
|
||||
/// not a blob v2 column, and [`Error::NotSupported`] on table types
|
||||
/// without blob support.
|
||||
/// Blobs inside lists cannot be fetched by row id. Returns
|
||||
/// [`Error::InvalidInput`] for those paths, when the column does not exist,
|
||||
/// or when it is not a blob v2 column. Returns [`Error::NotSupported`] on
|
||||
/// table types without blob support.
|
||||
pub async fn fetch_blobs(
|
||||
&self,
|
||||
column: impl AsRef<str>,
|
||||
@@ -1299,7 +1306,8 @@ impl Table {
|
||||
/// non-null blobs produce empty byte strings.
|
||||
///
|
||||
/// `_rowid` values stay valid after compaction when the table has stable
|
||||
/// row ids.
|
||||
/// row ids. One missing or deleted row id rejects the whole request; no
|
||||
/// partial results are returned.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::blob::BlobRangeRequest;
|
||||
@@ -1338,9 +1346,13 @@ impl Table {
|
||||
///
|
||||
/// Same length and order as `row_ids`. Null rows are `None`. Bytes are not
|
||||
/// read from disk until a call to [`BlobFile::read`].
|
||||
/// Remote blob requests share a connection-wide concurrency limit. Set
|
||||
/// `ClientConfig::blob_request_concurrency` or the
|
||||
/// `LANCE_CLIENT_BLOB_REQUEST_CONCURRENCY` environment variable to tune it.
|
||||
///
|
||||
/// `_rowid` values stay valid after compaction when the table has stable
|
||||
/// row ids.
|
||||
/// row ids. One missing or deleted row id rejects the whole request; no
|
||||
/// partial handles are returned.
|
||||
///
|
||||
/// ```
|
||||
/// # use lancedb::Table;
|
||||
@@ -2590,6 +2602,9 @@ pub struct NativeTable {
|
||||
pub(crate) pushdown_operations: HashSet<NamespaceClientPushdownOperation>,
|
||||
// Read-freshness baseline; `Some` only for namespace-backed tables.
|
||||
freshness: Option<TableFreshness>,
|
||||
// Memtable index kinds this handle's LSM writes maintain, shared with its
|
||||
// clones and branch handles.
|
||||
mem_index_registry: Arc<std::sync::RwLock<MemIndexRegistry>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NativeTable {
|
||||
@@ -2756,9 +2771,51 @@ impl NativeTable {
|
||||
namespace_client,
|
||||
pushdown_operations,
|
||||
freshness: None,
|
||||
mem_index_registry: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Maintain the memtable index kinds in `registry` when this table writes
|
||||
/// through its [`LsmWriteSpec`], and accept them when
|
||||
/// [`LsmWriteSpec::with_maintained_indexes`] names one.
|
||||
///
|
||||
/// The registry belongs to this handle, its clones and its branch handles,
|
||||
/// and is not stored with the table: a handle opened separately starts from
|
||||
/// Lance's built-ins. Set it before the handle's first LSM write; writers
|
||||
/// already open keep the registry they were opened with. Local tables only,
|
||||
/// reached through [`Table::as_native`].
|
||||
///
|
||||
/// ```
|
||||
/// # use std::sync::Arc;
|
||||
/// # use lancedb::Table;
|
||||
/// # use lancedb::table::{LsmWriteSpec, MemIndexRegistry, mem_index::MemIndexPlugin};
|
||||
/// # async fn example(
|
||||
/// # table: &Table,
|
||||
/// # plugin: Arc<dyn MemIndexPlugin>,
|
||||
/// # ) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let registry = MemIndexRegistry::default().with_plugin(plugin)?;
|
||||
/// if let Some(native) = table.as_native() {
|
||||
/// native.set_mem_index_registry(registry);
|
||||
/// }
|
||||
/// let spec = LsmWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()]);
|
||||
/// table.set_lsm_write_spec(spec).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn set_mem_index_registry(&self, registry: MemIndexRegistry) {
|
||||
*self
|
||||
.mem_index_registry
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = registry;
|
||||
}
|
||||
|
||||
pub(crate) fn mem_index_registry(&self) -> MemIndexRegistry {
|
||||
self.mem_index_registry
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Set the namespace client for server-side query execution.
|
||||
///
|
||||
/// When set, queries will be executed on the namespace server instead of locally.
|
||||
@@ -2786,6 +2843,7 @@ impl NativeTable {
|
||||
namespace_client: self.namespace_client.clone(),
|
||||
pushdown_operations: self.pushdown_operations.clone(),
|
||||
freshness: self.freshness.clone(),
|
||||
mem_index_registry: self.mem_index_registry.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2893,6 +2951,7 @@ impl NativeTable {
|
||||
namespace_client: stored_namespace_client,
|
||||
pushdown_operations,
|
||||
freshness: None,
|
||||
mem_index_registry: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2984,6 +3043,7 @@ impl NativeTable {
|
||||
namespace_client,
|
||||
pushdown_operations,
|
||||
freshness: None,
|
||||
mem_index_registry: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3117,6 +3177,7 @@ impl NativeTable {
|
||||
namespace_client: stored_namespace_client,
|
||||
pushdown_operations,
|
||||
freshness: None,
|
||||
mem_index_registry: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ pub struct BaseTableAdapter {
|
||||
table: Arc<dyn BaseTable>,
|
||||
schema: Arc<ArrowSchema>,
|
||||
fts_query: Option<FullTextSearchQuery>,
|
||||
use_lsm: Option<bool>,
|
||||
}
|
||||
|
||||
impl BaseTableAdapter {
|
||||
@@ -181,9 +182,16 @@ impl BaseTableAdapter {
|
||||
table,
|
||||
schema: Arc::new(schema),
|
||||
fts_query: None,
|
||||
use_lsm: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the MemWAL read routing of every scan; see [`QueryRequest::use_lsm`].
|
||||
pub fn with_use_lsm(mut self, use_lsm: Option<bool>) -> Self {
|
||||
self.use_lsm = use_lsm;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new adapter with an FTS query applied.
|
||||
pub fn with_fts_query(&self, fts_query: FullTextSearchQuery) -> Self {
|
||||
// Add _score column to the schema
|
||||
@@ -196,6 +204,7 @@ impl BaseTableAdapter {
|
||||
table: self.table.clone(),
|
||||
schema,
|
||||
fts_query: Some(fts_query),
|
||||
use_lsm: self.use_lsm,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +232,7 @@ impl TableProvider for BaseTableAdapter {
|
||||
let mut query = QueryRequest {
|
||||
full_text_search: self.fts_query.clone(),
|
||||
disable_scoring_autoprojection: disable_scoring,
|
||||
use_lsm: self.use_lsm,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -9,16 +9,22 @@ use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::{Array, BooleanArray, RecordBatch};
|
||||
use arrow_array::{
|
||||
Array, ArrayRef, BooleanArray, FixedSizeListArray, LargeListArray, ListArray, RecordBatch,
|
||||
};
|
||||
use arrow_buffer::OffsetBuffer;
|
||||
use arrow_cast::cast;
|
||||
use arrow_schema::{DataType, Field, FieldRef, Fields, Schema};
|
||||
use arrow_select::nullif::nullif;
|
||||
use datafusion::functions::core::{get_field, named_struct};
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_common::config::ConfigOptions;
|
||||
use datafusion_common::{DataFusionError, ScalarValue};
|
||||
use datafusion_expr::ColumnarValue;
|
||||
use datafusion_physical_expr::ScalarFunctionExpr;
|
||||
use datafusion_physical_expr::expressions::{CastExpr, Literal};
|
||||
use datafusion_physical_plan::PhysicalExpr;
|
||||
use datafusion_physical_plan::expressions::Column;
|
||||
use lance_arrow::FieldExt;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -157,6 +163,198 @@ pub(super) fn coerce_blob_expr(
|
||||
Ok((expr, table_field.clone()))
|
||||
}
|
||||
|
||||
/// Apply the normal blob coercion to list values while retaining the list's
|
||||
/// offsets and validity. A child batch lets the existing blob expression handle
|
||||
/// binary, null, and descriptor inputs exactly as it does for top-level fields.
|
||||
pub(super) fn coerce_blob_list_expr(
|
||||
input_expr: Arc<dyn PhysicalExpr>,
|
||||
input_field: &Field,
|
||||
table_field: &FieldRef,
|
||||
config: &Arc<ConfigOptions>,
|
||||
) -> Result<Option<(Arc<dyn PhysicalExpr>, FieldRef)>> {
|
||||
let (DataType::List(input_item)
|
||||
| DataType::LargeList(input_item)
|
||||
| DataType::FixedSizeList(input_item, _)) = input_field.data_type()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (DataType::List(table_item)
|
||||
| DataType::LargeList(table_item)
|
||||
| DataType::FixedSizeList(table_item, _)) = table_field.data_type()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !table_item.is_blob_v2() || input_item == table_item {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (item_expr, _) = coerce_blob_expr(
|
||||
Arc::new(Column::new(input_item.name(), 0)),
|
||||
input_item,
|
||||
table_item,
|
||||
config,
|
||||
)?;
|
||||
let expr: Arc<dyn PhysicalExpr> = Arc::new(CoerceBlobList {
|
||||
source: input_expr,
|
||||
input_item: input_item.clone(),
|
||||
item_expr,
|
||||
field: table_field.clone(),
|
||||
});
|
||||
Ok(Some((expr, table_field.clone())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CoerceBlobList {
|
||||
source: Arc<dyn PhysicalExpr>,
|
||||
input_item: FieldRef,
|
||||
item_expr: Arc<dyn PhysicalExpr>,
|
||||
field: FieldRef,
|
||||
}
|
||||
|
||||
impl fmt::Display for CoerceBlobList {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "coerce_blob_list({})", self.source)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CoerceBlobList {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.source.eq(&other.source)
|
||||
&& self.input_item == other.input_item
|
||||
&& self.item_expr.eq(&other.item_expr)
|
||||
&& self.field == other.field
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CoerceBlobList {}
|
||||
|
||||
impl Hash for CoerceBlobList {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.source.hash(state);
|
||||
self.input_item.hash(state);
|
||||
self.item_expr.hash(state);
|
||||
self.field.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PhysicalExpr for CoerceBlobList {
|
||||
fn return_field(&self, _input_schema: &Schema) -> datafusion_common::Result<FieldRef> {
|
||||
Ok(self.field.clone())
|
||||
}
|
||||
|
||||
fn nullable(&self, _input_schema: &Schema) -> datafusion_common::Result<bool> {
|
||||
Ok(self.field.is_nullable())
|
||||
}
|
||||
|
||||
fn evaluate(&self, batch: &RecordBatch) -> datafusion_common::Result<ColumnarValue> {
|
||||
let source = self.source.evaluate(batch)?.into_array(batch.num_rows())?;
|
||||
// First convert the outer list kind, if needed, while keeping its binary
|
||||
// item type. PyArrow commonly infers List even for a LargeList column.
|
||||
let intermediate_type = match self.field.data_type() {
|
||||
DataType::List(_) => DataType::List(self.input_item.clone()),
|
||||
DataType::LargeList(_) => DataType::LargeList(self.input_item.clone()),
|
||||
DataType::FixedSizeList(_, size) => {
|
||||
DataType::FixedSizeList(self.input_item.clone(), *size)
|
||||
}
|
||||
_ => unreachable!("validated list type when building the expression"),
|
||||
};
|
||||
let source = if source.data_type() == &intermediate_type {
|
||||
source
|
||||
} else {
|
||||
cast(source.as_ref(), &intermediate_type)?
|
||||
};
|
||||
let values = match source.data_type() {
|
||||
DataType::List(_) => {
|
||||
let list = source.as_any().downcast_ref::<ListArray>().unwrap();
|
||||
let first = list.offsets()[0] as usize;
|
||||
let last = *list.offsets().last().unwrap() as usize;
|
||||
list.values().slice(first, last - first)
|
||||
}
|
||||
DataType::LargeList(_) => {
|
||||
let list = source.as_any().downcast_ref::<LargeListArray>().unwrap();
|
||||
let first = list.offsets()[0] as usize;
|
||||
let last = *list.offsets().last().unwrap() as usize;
|
||||
list.values().slice(first, last - first)
|
||||
}
|
||||
DataType::FixedSizeList(_, _) => source
|
||||
.as_any()
|
||||
.downcast_ref::<FixedSizeListArray>()
|
||||
.unwrap()
|
||||
.values()
|
||||
.clone(),
|
||||
other => {
|
||||
return Err(DataFusionError::Internal(format!(
|
||||
"coerce_blob_list expected a list, got {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let item_batch = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![self.input_item.clone()])),
|
||||
vec![values.clone()],
|
||||
)?;
|
||||
let coerced = self
|
||||
.item_expr
|
||||
.evaluate(&item_batch)?
|
||||
.into_array(values.len())?;
|
||||
|
||||
let rebuilt: ArrayRef = match (source.data_type(), self.field.data_type()) {
|
||||
(DataType::List(_), DataType::List(item)) => {
|
||||
let list = source.as_any().downcast_ref::<ListArray>().unwrap();
|
||||
Arc::new(ListArray::try_new(
|
||||
item.clone(),
|
||||
OffsetBuffer::from_lengths(list.offsets().lengths()),
|
||||
coerced,
|
||||
list.nulls().cloned(),
|
||||
)?)
|
||||
}
|
||||
(DataType::LargeList(_), DataType::LargeList(item)) => {
|
||||
let list = source.as_any().downcast_ref::<LargeListArray>().unwrap();
|
||||
Arc::new(LargeListArray::try_new(
|
||||
item.clone(),
|
||||
OffsetBuffer::from_lengths(list.offsets().lengths()),
|
||||
coerced,
|
||||
list.nulls().cloned(),
|
||||
)?)
|
||||
}
|
||||
(DataType::FixedSizeList(_, _), DataType::FixedSizeList(item, size)) => {
|
||||
let list = source
|
||||
.as_any()
|
||||
.downcast_ref::<FixedSizeListArray>()
|
||||
.unwrap();
|
||||
Arc::new(FixedSizeListArray::try_new_with_length(
|
||||
item.clone(),
|
||||
*size,
|
||||
coerced,
|
||||
list.nulls().cloned(),
|
||||
list.len(),
|
||||
)?)
|
||||
}
|
||||
_ => unreachable!("validated list types when building the expression"),
|
||||
};
|
||||
Ok(ColumnarValue::Array(rebuilt))
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<&Arc<dyn PhysicalExpr>> {
|
||||
vec![&self.source]
|
||||
}
|
||||
|
||||
fn with_new_children(
|
||||
self: Arc<Self>,
|
||||
children: Vec<Arc<dyn PhysicalExpr>>,
|
||||
) -> datafusion_common::Result<Arc<dyn PhysicalExpr>> {
|
||||
Ok(Arc::new(Self {
|
||||
source: children[0].clone(),
|
||||
input_item: self.input_item.clone(),
|
||||
item_expr: self.item_expr.clone(),
|
||||
field: self.field.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn fmt_sql(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{self}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the source column's nullity onto the struct built for it.
|
||||
///
|
||||
/// This is its own expression rather than a `CASE` because the projection
|
||||
@@ -253,9 +451,11 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::blob::blob;
|
||||
use arrow_array::{
|
||||
Array, ArrayRef, BinaryArray, BinaryViewArray, Int32Array, Int64Array, LargeBinaryArray,
|
||||
NullArray, RecordBatch, StringArray, StringViewArray, StructArray, UInt8Array, UInt64Array,
|
||||
Array, ArrayRef, BinaryArray, BinaryViewArray, FixedSizeListArray, Int32Array, Int64Array,
|
||||
LargeBinaryArray, LargeListArray, ListArray, NullArray, RecordBatch, StringArray,
|
||||
StringViewArray, StructArray, UInt8Array, UInt64Array,
|
||||
};
|
||||
use arrow_buffer::{NullBuffer, OffsetBuffer};
|
||||
use arrow_schema::Schema;
|
||||
use datafusion::prelude::SessionContext;
|
||||
use datafusion_catalog::MemTable;
|
||||
@@ -366,6 +566,207 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_list_items_coerce_to_blobs_without_losing_list_shape() {
|
||||
for kind in [
|
||||
"list",
|
||||
"large_list",
|
||||
"large_list_from_list",
|
||||
"fixed_size_list",
|
||||
] {
|
||||
let input_item = Arc::new(Field::new("item", DataType::Binary, true));
|
||||
let table_item = Arc::new(wide_blob_field("item"));
|
||||
let values: ArrayRef = Arc::new(BinaryArray::from_iter(vec![
|
||||
Some(b"a".as_slice()),
|
||||
None,
|
||||
Some(b"".as_slice()),
|
||||
Some(b"ignored".as_slice()),
|
||||
None,
|
||||
None,
|
||||
]));
|
||||
let (input_type, table_type, list): (DataType, DataType, ArrayRef) = match kind {
|
||||
"list" => (
|
||||
DataType::List(input_item.clone()),
|
||||
DataType::List(table_item.clone()),
|
||||
Arc::new(ListArray::new(
|
||||
input_item,
|
||||
OffsetBuffer::new(vec![0, 3, 3].into()),
|
||||
values,
|
||||
Some(NullBuffer::from(vec![true, false])),
|
||||
)),
|
||||
),
|
||||
"large_list" => (
|
||||
DataType::LargeList(input_item.clone()),
|
||||
DataType::LargeList(table_item.clone()),
|
||||
Arc::new(LargeListArray::new(
|
||||
input_item,
|
||||
OffsetBuffer::new(vec![0_i64, 3, 3].into()),
|
||||
values,
|
||||
Some(NullBuffer::from(vec![true, false])),
|
||||
)),
|
||||
),
|
||||
"large_list_from_list" => (
|
||||
DataType::List(input_item.clone()),
|
||||
DataType::LargeList(table_item.clone()),
|
||||
Arc::new(ListArray::new(
|
||||
input_item,
|
||||
OffsetBuffer::new(vec![0, 3, 3].into()),
|
||||
values,
|
||||
Some(NullBuffer::from(vec![true, false])),
|
||||
)),
|
||||
),
|
||||
"fixed_size_list" => (
|
||||
DataType::FixedSizeList(input_item.clone(), 3),
|
||||
DataType::FixedSizeList(table_item.clone(), 3),
|
||||
Arc::new(FixedSizeListArray::new(
|
||||
input_item,
|
||||
3,
|
||||
values,
|
||||
Some(NullBuffer::from(vec![true, false])),
|
||||
)),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let batch = batch_with_image(Field::new("image", input_type, true), list);
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("id", DataType::Int64, false),
|
||||
Field::new("image", table_type, true),
|
||||
]);
|
||||
let coerced = coerce(batch, &schema).await;
|
||||
let image = coerced.column_by_name("image").unwrap();
|
||||
let output_schema = coerced.schema();
|
||||
let (DataType::List(item_field)
|
||||
| DataType::LargeList(item_field)
|
||||
| DataType::FixedSizeList(item_field, _)) =
|
||||
output_schema.field_with_name("image").unwrap().data_type()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(item_field.is_blob_v2(), "{kind} lost blob metadata");
|
||||
let (offsets, items): (Vec<i64>, &StructArray) = match kind {
|
||||
"list" => {
|
||||
let array = image.as_any().downcast_ref::<ListArray>().unwrap();
|
||||
assert!(array.is_null(1));
|
||||
(
|
||||
array.offsets().iter().map(|v| i64::from(*v)).collect(),
|
||||
array.values().as_any().downcast_ref().unwrap(),
|
||||
)
|
||||
}
|
||||
"large_list" | "large_list_from_list" => {
|
||||
let array = image.as_any().downcast_ref::<LargeListArray>().unwrap();
|
||||
assert!(array.is_null(1));
|
||||
(
|
||||
array.offsets().iter().copied().collect(),
|
||||
array.values().as_any().downcast_ref().unwrap(),
|
||||
)
|
||||
}
|
||||
"fixed_size_list" => {
|
||||
let array = image.as_any().downcast_ref::<FixedSizeListArray>().unwrap();
|
||||
assert!(array.is_null(1));
|
||||
(
|
||||
vec![0, 3, 6],
|
||||
array.values().as_any().downcast_ref().unwrap(),
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(
|
||||
offsets,
|
||||
if kind == "fixed_size_list" {
|
||||
vec![0, 3, 6]
|
||||
} else {
|
||||
vec![0, 3, 3]
|
||||
}
|
||||
);
|
||||
assert_eq!(items.len(), if kind == "fixed_size_list" { 6 } else { 3 });
|
||||
let data: &LargeBinaryArray = items
|
||||
.column_by_name("data")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref()
|
||||
.unwrap();
|
||||
assert_eq!(data.value(0), b"a");
|
||||
assert!(items.is_null(1));
|
||||
assert_eq!(data.value(2), b"");
|
||||
assert!(!items.is_null(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sliced_binary_lists_only_coerce_visible_items() {
|
||||
for large in [false, true] {
|
||||
let input_item = Arc::new(Field::new("item", DataType::Binary, true));
|
||||
let table_item = Arc::new(wide_blob_field("item"));
|
||||
let values: ArrayRef = Arc::new(BinaryArray::from_iter(vec![
|
||||
Some(b"before".as_slice()),
|
||||
Some(b"a".as_slice()),
|
||||
None,
|
||||
Some(b"after".as_slice()),
|
||||
Some(b"tail".as_slice()),
|
||||
]));
|
||||
let (input_type, table_type, list): (DataType, DataType, ArrayRef) = if large {
|
||||
(
|
||||
DataType::LargeList(input_item.clone()),
|
||||
DataType::LargeList(table_item),
|
||||
Arc::new(
|
||||
LargeListArray::new(
|
||||
input_item,
|
||||
OffsetBuffer::new(vec![0_i64, 1, 3, 4, 5].into()),
|
||||
values,
|
||||
None,
|
||||
)
|
||||
.slice(1, 2),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
DataType::List(input_item.clone()),
|
||||
DataType::List(table_item),
|
||||
Arc::new(
|
||||
ListArray::new(
|
||||
input_item,
|
||||
OffsetBuffer::new(vec![0, 1, 3, 4, 5].into()),
|
||||
values,
|
||||
None,
|
||||
)
|
||||
.slice(1, 2),
|
||||
),
|
||||
)
|
||||
};
|
||||
let batch = batch_with_image(Field::new("image", input_type, true), list);
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("id", DataType::Int64, false),
|
||||
Field::new("image", table_type, true),
|
||||
]);
|
||||
let coerced = coerce(batch, &schema).await;
|
||||
let image = coerced.column_by_name("image").unwrap();
|
||||
let (offsets, items): (Vec<i64>, &StructArray) = if large {
|
||||
let list = image.as_any().downcast_ref::<LargeListArray>().unwrap();
|
||||
(
|
||||
list.offsets().iter().copied().collect(),
|
||||
list.values().as_any().downcast_ref().unwrap(),
|
||||
)
|
||||
} else {
|
||||
let list = image.as_any().downcast_ref::<ListArray>().unwrap();
|
||||
(
|
||||
list.offsets().iter().map(|v| i64::from(*v)).collect(),
|
||||
list.values().as_any().downcast_ref().unwrap(),
|
||||
)
|
||||
};
|
||||
assert_eq!(offsets, [0, 2, 3]);
|
||||
assert_eq!(items.len(), 3);
|
||||
let data: &LargeBinaryArray = items
|
||||
.column_by_name("data")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref()
|
||||
.unwrap();
|
||||
assert_eq!(data.value(0), b"a");
|
||||
assert!(items.is_null(1));
|
||||
assert_eq!(data.value(2), b"after");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_view_coerces_to_declared_blob_struct() {
|
||||
let batch = batch_with_image(
|
||||
|
||||
@@ -20,7 +20,7 @@ use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr};
|
||||
use lance_arrow::json::{ARROW_JSON_EXT_NAME, has_json_fields, is_arrow_json_field, is_json_field};
|
||||
use lance_arrow::{ARROW_EXT_NAME_KEY, FieldExt};
|
||||
|
||||
use super::blob_coerce::coerce_blob_expr;
|
||||
use super::blob_coerce::{coerce_blob_expr, coerce_blob_list_expr};
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub fn cast_to_table_schema(
|
||||
@@ -113,6 +113,14 @@ fn build_field_exprs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blob values in lists need the same coercion as top-level blob columns.
|
||||
if let Some(coerced) =
|
||||
coerce_blob_list_expr(input_expr.clone(), input_field, table_field, &config)?
|
||||
{
|
||||
result.push(coerced);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A column whose values are all null infers as `Null` (pyarrow does this for a list of
|
||||
// dicts), so there is no input type to cast from. Emit typed nulls carrying the table
|
||||
// field verbatim: a plain cast would drop the field metadata, and extension columns
|
||||
|
||||
+472
-21
@@ -1349,6 +1349,41 @@ mod lsm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_table_adapter_use_lsm_false_scans_the_base_only() {
|
||||
use crate::table::datafusion::BaseTableAdapter;
|
||||
use datafusion::prelude::SessionContext;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let table = id_value_table(&dir).await; // base: ids 1,2,3
|
||||
table
|
||||
.set_lsm_write_spec(LsmWriteSpec::unsharded())
|
||||
.await
|
||||
.unwrap();
|
||||
// Ids 4,5 land in the active memtable, not the base table.
|
||||
lsm_upsert(&table, vec![4, 5]).await;
|
||||
|
||||
let adapter = BaseTableAdapter::try_new(table.base_table().clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.with_use_lsm(Some(false));
|
||||
let ctx = SessionContext::new();
|
||||
ctx.register_table("base_only", Arc::new(adapter)).unwrap();
|
||||
let batches = ctx
|
||||
.sql("SELECT id FROM base_only")
|
||||
.await
|
||||
.unwrap()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<i64> = batches
|
||||
.iter()
|
||||
.flat_map(|b| b.column(0).as_primitive::<Int64Type>().values().to_vec())
|
||||
.collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_snapshot_preserves_lsm_read_semantics() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -1782,8 +1817,13 @@ mod lsm_tests {
|
||||
assert_eq!(rows, 1, "LSM FTS must still see base rows");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lsm_read_vector_search() {
|
||||
/// A table whose vector index the LSM maintains: base rows filled with
|
||||
/// their id, and one memtable row filled with 1000, nearest to `[1000; 8]`.
|
||||
/// `registry` replaces the table's memtable kinds.
|
||||
async fn lsm_vector_table(
|
||||
dir: &std::path::Path,
|
||||
registry: Option<lance::dataset::mem_wal::MemIndexRegistry>,
|
||||
) -> Table {
|
||||
use crate::index::Index;
|
||||
use crate::index::vector::IvfPqIndexBuilder;
|
||||
use arrow::array::{FixedSizeListBuilder, Float32Builder};
|
||||
@@ -1815,12 +1855,7 @@ mod lsm_tests {
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = connect(dir.path().to_str().unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
// Base rows fill each vector with its own id (0..256); all far from 1000.
|
||||
let conn = connect(dir.to_str().unwrap()).execute().await.unwrap();
|
||||
let base = make_batch((0..N).map(|i| (i, i as f32)).collect());
|
||||
let base_reader: Box<dyn RecordBatchReader + Send> =
|
||||
Box::new(RecordBatchIterator::new(vec![Ok(base)], schema.clone()));
|
||||
@@ -1838,13 +1873,15 @@ mod lsm_tests {
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(registry) = registry {
|
||||
table.as_native().unwrap().set_mem_index_registry(registry);
|
||||
}
|
||||
let vec_index = table.list_indices().await.unwrap()[0].name.clone();
|
||||
table
|
||||
.set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes(vec![vec_index]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Insert a vector (filled with 1000) that is nearest to the query.
|
||||
let mut builder = table.merge_insert(&[]);
|
||||
builder
|
||||
.when_matched_update_all(None)
|
||||
@@ -1854,20 +1891,21 @@ mod lsm_tests {
|
||||
schema.clone(),
|
||||
));
|
||||
builder.execute(insert_reader).await.unwrap();
|
||||
table
|
||||
}
|
||||
|
||||
// KNN near [1000; DIM]: the default (auto-routed) read surfaces the
|
||||
// memtable row.
|
||||
let stream = table
|
||||
.query()
|
||||
.nearest_to(&[1000.0_f32; 8])
|
||||
.unwrap()
|
||||
.limit(1)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let ids = collect_ids(stream).await;
|
||||
/// The ids of the nearest row to `[1000; 8]` through the default read.
|
||||
async fn nearest_ids(table: &Table) -> Vec<i64> {
|
||||
let query = table.query().nearest_to(&[1000.0_f32; 8]).unwrap().limit(1);
|
||||
collect_ids(query.execute().await.unwrap()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lsm_read_vector_search() {
|
||||
let dir = tempdir().unwrap();
|
||||
let table = lsm_vector_table(dir.path(), None).await;
|
||||
assert_eq!(
|
||||
ids,
|
||||
nearest_ids(&table).await,
|
||||
vec![9999],
|
||||
"LSM vector search must rank the memtable row first"
|
||||
);
|
||||
@@ -1893,4 +1931,417 @@ mod lsm_tests {
|
||||
assert_eq!(ids, vec![255]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in vector plugin, except that its index answers no search.
|
||||
#[derive(Debug)]
|
||||
struct AnswersNothing;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AnswersNothingIndex(Arc<dyn lance::dataset::mem_wal::index::MemIndex>);
|
||||
|
||||
fn vector_plugin() -> Arc<dyn lance::dataset::mem_wal::index::MemIndexPlugin> {
|
||||
Arc::new(lance::dataset::mem_wal::index::HnswMemIndexPlugin)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl lance::dataset::mem_wal::index::MemIndexPlugin for AnswersNothing {
|
||||
fn name(&self) -> &str {
|
||||
"AnswersNothing"
|
||||
}
|
||||
fn details_message(&self) -> &str {
|
||||
"VectorIndexDetails"
|
||||
}
|
||||
fn flush_index_type(&self) -> lance_index::IndexType {
|
||||
vector_plugin().flush_index_type()
|
||||
}
|
||||
fn training_criteria(&self) -> lance_index::scalar::registry::TrainingCriteria {
|
||||
vector_plugin().training_criteria()
|
||||
}
|
||||
async fn resolve(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::ResolveContext<'_>,
|
||||
) -> lance_core::Result<lance::dataset::mem_wal::index::ResolvedIndex> {
|
||||
vector_plugin().resolve(ctx).await
|
||||
}
|
||||
fn validate(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::MemIndexBuildContext<'_>,
|
||||
) -> lance_core::Result<()> {
|
||||
vector_plugin().validate(ctx)
|
||||
}
|
||||
fn create(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::MemIndexBuildContext<'_>,
|
||||
) -> lance_core::Result<Arc<dyn lance::dataset::mem_wal::index::MemIndex>> {
|
||||
Ok(Arc::new(AnswersNothingIndex(vector_plugin().create(ctx)?)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl lance::dataset::mem_wal::index::MemIndex for AnswersNothingIndex {
|
||||
fn columns(&self) -> &[String] {
|
||||
self.0.columns()
|
||||
}
|
||||
fn can_answer(&self, _query: &dyn lance::dataset::mem_wal::index::MemQuery) -> bool {
|
||||
false
|
||||
}
|
||||
fn insert(&self, batch: &RecordBatch, row_offset: u64) -> lance_core::Result<()> {
|
||||
self.0.insert(batch, row_offset)
|
||||
}
|
||||
fn insert_batches(
|
||||
&self,
|
||||
batches: &[lance::dataset::mem_wal::write::StoredBatch],
|
||||
) -> lance_core::Result<()> {
|
||||
self.0.insert_batches(batches)
|
||||
}
|
||||
fn resident_bytes(&self) -> usize {
|
||||
self.0.resident_bytes()
|
||||
}
|
||||
fn search(
|
||||
&self,
|
||||
_query: &dyn lance::dataset::mem_wal::index::MemQuery,
|
||||
_ctx: &lance::dataset::mem_wal::index::SearchContext,
|
||||
) -> lance_core::Result<Option<lance::dataset::mem_wal::index::MemMatches>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn flush(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::FlushContext<'_>,
|
||||
) -> lance_core::Result<lance::dataset::mem_wal::index::FlushOutcome> {
|
||||
self.0.flush(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
/// A resident memtable whose vector index answers nothing still serves an
|
||||
/// LSM vector search: Lance compares every row instead.
|
||||
#[tokio::test]
|
||||
async fn lsm_vector_search_compares_every_row_when_no_resident_index_answers() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut registry = lance::dataset::mem_wal::MemIndexRegistry::default();
|
||||
registry.replace_plugin(Arc::new(AnswersNothing)).unwrap();
|
||||
let table = lsm_vector_table(dir.path(), Some(registry)).await;
|
||||
assert_eq!(nearest_ids(&table).await, vec![9999]);
|
||||
|
||||
// The second page starts after the memtable row.
|
||||
let page = table
|
||||
.query()
|
||||
.nearest_to(&[1000.0_f32; 8])
|
||||
.unwrap()
|
||||
.limit(2)
|
||||
.offset(1);
|
||||
assert_eq!(
|
||||
collect_ids(page.execute().await.unwrap()).await,
|
||||
vec![255, 254]
|
||||
);
|
||||
}
|
||||
|
||||
/// Claims the base table's bitmap index and keeps it as a B-tree, whose
|
||||
/// flush rows are in the shape the bitmap trainer takes.
|
||||
#[derive(Debug)]
|
||||
struct BitmapAsBTree;
|
||||
|
||||
fn btree_plugin() -> Arc<dyn lance::dataset::mem_wal::index::MemIndexPlugin> {
|
||||
lance::dataset::mem_wal::MemIndexRegistry::default()
|
||||
.plugin_for_details_url("/lance.table.BTreeIndexDetails")
|
||||
.unwrap()
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl lance::dataset::mem_wal::index::MemIndexPlugin for BitmapAsBTree {
|
||||
fn name(&self) -> &str {
|
||||
"BitmapAsBTree"
|
||||
}
|
||||
fn details_message(&self) -> &str {
|
||||
"BitmapIndexDetails"
|
||||
}
|
||||
fn flush_index_type(&self) -> lance_index::IndexType {
|
||||
lance_index::IndexType::Bitmap
|
||||
}
|
||||
fn training_criteria(&self) -> lance_index::scalar::registry::TrainingCriteria {
|
||||
btree_plugin().training_criteria()
|
||||
}
|
||||
fn validate(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::MemIndexBuildContext<'_>,
|
||||
) -> lance_core::Result<()> {
|
||||
btree_plugin().validate(ctx)
|
||||
}
|
||||
fn create(
|
||||
&self,
|
||||
ctx: &lance::dataset::mem_wal::index::MemIndexBuildContext<'_>,
|
||||
) -> lance_core::Result<Arc<dyn lance::dataset::mem_wal::index::MemIndex>> {
|
||||
btree_plugin().create(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind Lance has no memtable index for is refused when named until the
|
||||
/// table's registry maintains it; then it is maintained, flushed with each
|
||||
/// generation, and answers searches on the flushed generations.
|
||||
#[tokio::test]
|
||||
async fn lsm_maintains_an_index_kind_from_the_tables_registry() {
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = connect(dir.path().to_str().unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("t", id_region_reader(vec![(1, "us"), (2, "eu"), (3, "us")]))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.set_unenforced_primary_key(["id"]).await.unwrap();
|
||||
table
|
||||
.create_index(&["region"], crate::index::Index::Bitmap(Default::default()))
|
||||
.name("region_bitmap".to_string())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let spec =
|
||||
|| LsmWriteSpec::unsharded().with_maintained_indexes(vec!["region_bitmap".to_string()]);
|
||||
|
||||
let refused = table.set_lsm_write_spec(spec()).await.unwrap_err();
|
||||
assert!(
|
||||
refused.to_string().contains("region_bitmap")
|
||||
&& refused
|
||||
.to_string()
|
||||
.contains("no registered plugin maintains"),
|
||||
"{refused:?}"
|
||||
);
|
||||
|
||||
let registry = lance::dataset::mem_wal::MemIndexRegistry::default()
|
||||
.with_plugin(Arc::new(BitmapAsBTree))
|
||||
.unwrap();
|
||||
table.as_native().unwrap().set_mem_index_registry(registry);
|
||||
table.set_lsm_write_spec(spec()).await.unwrap();
|
||||
|
||||
// The registry is not stored with the table.
|
||||
let other = conn.open_table("t").execute().await.unwrap();
|
||||
let mut builder = other.merge_insert(&["id"]);
|
||||
builder
|
||||
.when_matched_update_all(None)
|
||||
.when_not_matched_insert_all();
|
||||
let refused = builder
|
||||
.execute(id_region_reader(vec![(20, "us")]))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
refused
|
||||
.to_string()
|
||||
.contains("no registered plugin maintains"),
|
||||
"{refused:?}"
|
||||
);
|
||||
|
||||
let mut builder = table.merge_insert(&["id"]);
|
||||
builder
|
||||
.when_matched_update_all(None)
|
||||
.when_not_matched_insert_all();
|
||||
builder
|
||||
.execute(id_region_reader(vec![
|
||||
(10, "fresh"),
|
||||
(11, "fresh"),
|
||||
(12, "eu"),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fresh = || async {
|
||||
let query = table.query().only_if("region = 'fresh'");
|
||||
let mut ids = collect_ids(query.execute().await.unwrap()).await;
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
assert_eq!(fresh().await, vec![10, 11]);
|
||||
let (_, _, memtables) = table
|
||||
.as_native()
|
||||
.unwrap()
|
||||
.dataset
|
||||
.shard_writer()
|
||||
.read_snapshot()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the merge opened a writer");
|
||||
let active = memtables.expect("the writer keeps memtables").active;
|
||||
assert!(
|
||||
active
|
||||
.index_store
|
||||
.index_names()
|
||||
.contains(&"region_bitmap".to_string()),
|
||||
"the memtable maintains the plugin's index: {:?}",
|
||||
active.index_store.index_names()
|
||||
);
|
||||
|
||||
table.close_lsm_writers().await.unwrap();
|
||||
assert_eq!(fresh().await, vec![10, 11]);
|
||||
assert_generations_answer_through_region_bitmap(&table, vec![10, 11]).await;
|
||||
}
|
||||
|
||||
/// The generations each shard's manifest publishes.
|
||||
async fn published_generations(table: &Table) -> Vec<lance::Dataset> {
|
||||
use lance::dataset::mem_wal::ShardManifestStore;
|
||||
|
||||
let uri = table.uri().await.unwrap();
|
||||
let (store, base) = lance_io::object_store::ObjectStore::from_uri(&uri)
|
||||
.await
|
||||
.unwrap();
|
||||
let mem_wal = std::path::Path::new(&uri).join("_mem_wal");
|
||||
let mut generations = Vec::new();
|
||||
for shard in std::fs::read_dir(&mem_wal).unwrap() {
|
||||
let Ok(shard_id) = shard.unwrap().file_name().to_string_lossy().parse() else {
|
||||
continue;
|
||||
};
|
||||
let manifest = ShardManifestStore::new(store.clone(), &base, shard_id, 2)
|
||||
.latest()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("a shard directory has a manifest");
|
||||
for sstable in manifest.sstables {
|
||||
let path = mem_wal.join(shard_id.to_string()).join(&sstable.path);
|
||||
generations.push(lance::Dataset::open(path.to_str().unwrap()).await.unwrap());
|
||||
}
|
||||
}
|
||||
generations
|
||||
}
|
||||
|
||||
/// Every published generation carries `region_bitmap` as a bitmap index on
|
||||
/// `region`, and a search for `region = 'fresh'` on it uses that index.
|
||||
async fn assert_generations_answer_through_region_bitmap(table: &Table, fresh: Vec<i64>) {
|
||||
use lance::index::DatasetIndexExt;
|
||||
|
||||
let generations = published_generations(table).await;
|
||||
assert!(
|
||||
!generations.is_empty(),
|
||||
"closing the writer flushed a generation"
|
||||
);
|
||||
let mut found = Vec::new();
|
||||
for generation in generations {
|
||||
let region = generation.schema().field("region").unwrap().id;
|
||||
let indices = generation.load_indices().await.unwrap();
|
||||
let index = indices
|
||||
.iter()
|
||||
.find(|index| index.name == "region_bitmap")
|
||||
.expect("the generation carries the index the plugin trained");
|
||||
assert_eq!(index.fields, vec![region]);
|
||||
assert!(
|
||||
index
|
||||
.index_details
|
||||
.as_ref()
|
||||
.is_some_and(|details| details.type_url.ends_with("BitmapIndexDetails")),
|
||||
"{:?}",
|
||||
index.index_details
|
||||
);
|
||||
|
||||
let mut scan = generation.scan();
|
||||
scan.filter("region = 'fresh'").unwrap();
|
||||
let plan = scan.explain_plan(false).await.unwrap();
|
||||
assert!(plan.contains("ScalarIndexQuery"), "{plan}");
|
||||
let batch = scan.try_into_batch().await.unwrap();
|
||||
found.extend(
|
||||
batch["id"]
|
||||
.as_primitive::<Int64Type>()
|
||||
.values()
|
||||
.iter()
|
||||
.copied(),
|
||||
);
|
||||
}
|
||||
found.sort_unstable();
|
||||
assert_eq!(found, fresh);
|
||||
}
|
||||
|
||||
/// Under the default set, a kind only the registry maintains is picked up
|
||||
/// when its index is created after the writer opened, and the table's
|
||||
/// clones share the registry.
|
||||
#[tokio::test]
|
||||
async fn lsm_default_set_maintains_a_registry_kind_created_while_writing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = connect(dir.path().to_str().unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("t", id_region_reader(vec![(1, "us"), (2, "eu")]))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.set_unenforced_primary_key(["id"]).await.unwrap();
|
||||
let registry = lance::dataset::mem_wal::MemIndexRegistry::default()
|
||||
.with_plugin(Arc::new(BitmapAsBTree))
|
||||
.unwrap();
|
||||
table.as_native().unwrap().set_mem_index_registry(registry);
|
||||
let clone = table.clone();
|
||||
assert!(
|
||||
clone
|
||||
.as_native()
|
||||
.unwrap()
|
||||
.mem_index_registry()
|
||||
.plugin_for_details_url("/lance.table.BitmapIndexDetails")
|
||||
.is_some(),
|
||||
"a clone shares the handle's registry"
|
||||
);
|
||||
table
|
||||
.set_lsm_write_spec(LsmWriteSpec::unsharded())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let upsert = |rows: Vec<(i64, &'static str)>| {
|
||||
let table = table.clone();
|
||||
async move {
|
||||
let mut builder = table.merge_insert(&["id"]);
|
||||
builder
|
||||
.when_matched_update_all(None)
|
||||
.when_not_matched_insert_all();
|
||||
builder.execute(id_region_reader(rows)).await.unwrap();
|
||||
}
|
||||
};
|
||||
// Opens the writer before the table has a bitmap index.
|
||||
upsert(vec![(3, "us")]).await;
|
||||
table
|
||||
.create_index(&["region"], crate::index::Index::Bitmap(Default::default()))
|
||||
.name("region_bitmap".to_string())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
upsert(vec![(10, "fresh"), (11, "fresh")]).await;
|
||||
|
||||
let (_, _, memtables) = table
|
||||
.as_native()
|
||||
.unwrap()
|
||||
.dataset
|
||||
.shard_writer()
|
||||
.read_snapshot()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the merge opened a writer");
|
||||
let active = memtables.expect("the writer keeps memtables").active;
|
||||
assert!(
|
||||
active
|
||||
.index_store
|
||||
.index_names()
|
||||
.contains(&"region_bitmap".to_string()),
|
||||
"the open writer picked up the new index: {:?}",
|
||||
active.index_store.index_names()
|
||||
);
|
||||
|
||||
table.close_lsm_writers().await.unwrap();
|
||||
// Only the generation written after the index exists carries it.
|
||||
let generations = published_generations(&table).await;
|
||||
let mut carrying = 0;
|
||||
for generation in &generations {
|
||||
use lance::index::DatasetIndexExt;
|
||||
if generation
|
||||
.load_indices()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|index| index.name == "region_bitmap")
|
||||
{
|
||||
carrying += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
carrying >= 1,
|
||||
"{} generations, none carry the index",
|
||||
generations.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use arrow_schema::{DataType, Schema as ArrowSchema, SchemaRef};
|
||||
use lance::Dataset;
|
||||
use lance::dataset::mem_wal::{
|
||||
DatasetMemWalExt, ShardWriter, ShardWriterConfig, evaluate_sharding_spec,
|
||||
validate_maintained_indexes,
|
||||
validate_maintained_indexes_with,
|
||||
};
|
||||
use lance::index::DatasetIndexExt;
|
||||
use lance_core::datatypes::Schema as LanceSchema;
|
||||
@@ -103,7 +103,8 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
|
||||
}
|
||||
}
|
||||
let dataset = table.dataset.get().await?;
|
||||
validate_maintained_indexes(&dataset, requested).await?;
|
||||
validate_maintained_indexes_with(&dataset, requested, &table.mem_index_registry())
|
||||
.await?;
|
||||
Some(requested.to_vec())
|
||||
}
|
||||
None => None,
|
||||
@@ -126,7 +127,9 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
let mut builder = dataset.initialize_mem_wal();
|
||||
let mut builder = dataset
|
||||
.initialize_mem_wal()
|
||||
.mem_index_registry(table.mem_index_registry());
|
||||
let writer_config_defaults = match spec {
|
||||
LsmWriteSpec::Bucket {
|
||||
column,
|
||||
@@ -725,7 +728,8 @@ pub(crate) async fn execute_lsm_merge_insert(
|
||||
return Ok(lsm_merge_result(0));
|
||||
};
|
||||
|
||||
let config = shard_writer_config_from_defaults(&plan.writer_config_defaults);
|
||||
let mut config = shard_writer_config_from_defaults(&plan.writer_config_defaults);
|
||||
config.mem_index_registry = table.mem_index_registry();
|
||||
let writer = table
|
||||
.dataset
|
||||
.shard_writer()
|
||||
|
||||
@@ -33,7 +33,6 @@ use datafusion_physical_plan::expressions::Column;
|
||||
use datafusion_physical_plan::projection::ProjectionExec;
|
||||
use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr};
|
||||
use lance::Dataset;
|
||||
use lance::dataset::mem_wal::index::{IndexStore, VectorMemQuery};
|
||||
use lance::dataset::mem_wal::scanner::{InMemoryMemTableRef, InMemoryMemTables};
|
||||
use lance::dataset::mem_wal::{
|
||||
DatasetMemWalExt, LsmScanner, ShardManifestStore, ShardSnapshot, ShardWriterConfig,
|
||||
@@ -711,17 +710,14 @@ async fn vector_plan(
|
||||
|
||||
// The base arm relies on the column's vector index (`fast_search`). Unmaintained,
|
||||
// its catch-up is untracked and exclusion falls back to the compaction watermark,
|
||||
// dropping compacted SSTables the lagging base index has not re-indexed; and a
|
||||
// resident MemTable built before the index joined the set carries none either.
|
||||
// dropping compacted SSTables the lagging base index has not re-indexed.
|
||||
// Reject rather than silently omit rows, mirroring the FTS arm.
|
||||
if !index_maintained(dataset, &column, details, "VectorIndexDetails").await?
|
||||
|| !resident_memtables_carry(&in_memory, |memtable| {
|
||||
carries_vector_index(&memtable.index_store, &column)
|
||||
})
|
||||
{
|
||||
// A resident MemTable needs no index of its own: one that cannot answer
|
||||
// compares every row.
|
||||
if !index_maintained(dataset, &column, details, "VectorIndexDetails").await? {
|
||||
return Err(Error::NotSupported {
|
||||
message: format!(
|
||||
"the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes) and carried by every resident MemTable; otherwise compacted rows not yet re-indexed are omitted. set use_lsm(false) to read the base table only"
|
||||
"the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise compacted rows not yet re-indexed are omitted. set use_lsm(false) to read the base table only"
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -764,22 +760,6 @@ async fn vector_plan(
|
||||
Ok(scanner.create_plan().await?)
|
||||
}
|
||||
|
||||
/// Probe for a resident vector index without imposing a distance metric, matching
|
||||
/// the presence check used before Lance introduced query-based index lookup.
|
||||
fn carries_vector_index(store: &IndexStore, column: &str) -> bool {
|
||||
let probe = VectorMemQuery {
|
||||
vector: arrow_array::FixedSizeListArray::new_null(
|
||||
Arc::new(arrow_schema::Field::new("item", DataType::Float32, true)),
|
||||
1,
|
||||
1,
|
||||
),
|
||||
k: 1,
|
||||
ef: None,
|
||||
distance_type: None,
|
||||
};
|
||||
store.index_answering(column, &probe).is_some()
|
||||
}
|
||||
|
||||
/// Whether `column` stores binary (uint8) vectors, which the LSM vector planner
|
||||
/// does not support.
|
||||
fn is_binary_vector_column(schema: &ArrowSchema, column: &str) -> bool {
|
||||
@@ -838,9 +818,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_index_checks_distinguish_columns_and_query_types() {
|
||||
fn resident_fts_check_distinguishes_columns() {
|
||||
use arrow_schema::Field;
|
||||
use lance::dataset::mem_wal::index::MemIndexSpec;
|
||||
use lance::dataset::mem_wal::index::{IndexStore, MemIndexSpec};
|
||||
|
||||
let schema = ArrowSchema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
@@ -863,16 +843,11 @@ mod tests {
|
||||
),
|
||||
];
|
||||
let store = IndexStore::from_specs(&specs, &schema, 1_000, 16).unwrap();
|
||||
assert!(carries_vector_index(&store, "vector"));
|
||||
for column in ["id", "text", "missing"] {
|
||||
assert!(!carries_vector_index(&store, column));
|
||||
}
|
||||
assert!(!store.fts_granularities_on("text").is_empty());
|
||||
for column in ["id", "vector", "missing"] {
|
||||
assert!(store.fts_granularities_on(column).is_empty());
|
||||
}
|
||||
let empty = IndexStore::from_specs(&[], &schema, 1_000, 16).unwrap();
|
||||
assert!(!carries_vector_index(&empty, "vector"));
|
||||
assert!(empty.fts_granularities_on("text").is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1847,8 +1847,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
layouts,
|
||||
vec![
|
||||
(1, Some(BlobKind::Managed)),
|
||||
(2, Some(BlobKind::Managed)),
|
||||
(1, Some(BlobKind::Dedicated)),
|
||||
(2, Some(BlobKind::Packed)),
|
||||
(3, Some(BlobKind::Inline)),
|
||||
(4, None),
|
||||
]
|
||||
|
||||
@@ -501,17 +501,23 @@ async fn collect_id_rowid(table: &Table) -> Result<Vec<(i64, u64)>> {
|
||||
fn assert_missing_blob_row_ids(err: &Error) {
|
||||
assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("row ids"), "{message}");
|
||||
assert!(
|
||||
message.contains("row ids but some do not exist in the table"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(!message.contains("rowaddr"), "{message}");
|
||||
assert!(!message.contains("fragment"), "{message}");
|
||||
}
|
||||
|
||||
async fn assert_fetch_apis_reject_missing_row_ids(table: &Table, row_ids: &[u64]) -> Result<()> {
|
||||
let expected = format!("first missing row ids: [{}]", row_ids[0]);
|
||||
let err = table.fetch_blobs("image", row_ids).await.unwrap_err();
|
||||
assert_missing_blob_row_ids(&err);
|
||||
assert!(err.to_string().contains(&expected), "{err}");
|
||||
|
||||
let err = table.fetch_blob_files("image", row_ids).await.unwrap_err();
|
||||
assert_missing_blob_row_ids(&err);
|
||||
assert!(err.to_string().contains(&expected), "{err}");
|
||||
|
||||
let err = table
|
||||
.fetch_blob_ranges(
|
||||
@@ -524,6 +530,7 @@ async fn assert_fetch_apis_reject_missing_row_ids(table: &Table, row_ids: &[u64]
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_missing_blob_row_ids(&err);
|
||||
assert!(err.to_string().contains(&expected), "{err}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -611,6 +618,47 @@ async fn blob_columns_lists_nested_dotted_paths() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_columns_excludes_list_elements_and_fetches_explain_limitation() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
let db = connect(tmp.path().to_str().unwrap()).execute().await?;
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int64, false),
|
||||
blob("thumbnail", true),
|
||||
Field::new("imgs", DataType::List(Arc::new(blob("item", true))), true),
|
||||
]));
|
||||
let table = db
|
||||
.create_empty_table("list_blobs", schema)
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
assert_eq!(table.blob_columns().await?, vec!["thumbnail"]);
|
||||
for column in ["imgs", "imgs.item"] {
|
||||
let err = table.fetch_blobs(column, &[0]).await.unwrap_err();
|
||||
assert!(matches!(err, Error::InvalidInput { .. }));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
|
||||
let err = table.fetch_blob_files(column, &[0]).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
|
||||
let err = table
|
||||
.fetch_blob_ranges(column, [BlobRangeRequest::new(0, 0, 1)])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("blobs inside lists cannot be fetched")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_columns_lists_blob_fields_in_order() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -790,6 +838,30 @@ async fn fetch_blobs_out_of_range_id_errors_without_panic() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_blob_apis_reject_row_past_fragment_end() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
let db = connect(tmp.path().to_str().unwrap()).execute().await?;
|
||||
let table =
|
||||
create_inline_blob_table(&db, "t", &[1, 2, 3], &[Some(b"a"), Some(b"b"), Some(b"c")])
|
||||
.await?;
|
||||
|
||||
assert_fetch_apis_reject_missing_row_ids(&table, &[3]).await?;
|
||||
assert_fetch_apis_reject_missing_row_ids(&table, &[3, 0]).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_blobs_identifies_multiple_missing_ids_without_partial_results() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
let db = connect(tmp.path().to_str().unwrap()).execute().await?;
|
||||
let table = create_inline_blob_table(&db, "t", &[1, 2], &[Some(b"a"), Some(b"b")]).await?;
|
||||
|
||||
let err = table.fetch_blobs("image", &[0, 2, 3]).await.unwrap_err();
|
||||
assert_missing_blob_row_ids(&err);
|
||||
assert!(err.to_string().contains("first missing row ids: [2, 3]"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_blob_files_rejects_missing_fragment_row_addr() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -831,6 +903,23 @@ async fn fetch_blob_apis_reject_deleted_row_ids() -> Result<()> {
|
||||
assert_fetch_apis_reject_missing_row_ids(&table, &[deleted_row_addr, live_row_addr]).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_blob_apis_reject_deleted_stable_row_ids() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
let db = connect(tmp.path().to_str().unwrap())
|
||||
.storage_option(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true")
|
||||
.execute()
|
||||
.await?;
|
||||
let table = create_inline_blob_table(&db, "t", &[1, 2], &[Some(b"one"), Some(b"two")]).await?;
|
||||
let pairs = collect_id_rowid(&table).await?;
|
||||
let deleted_row_id = pairs.iter().find(|(id, _)| *id == 2).unwrap().1;
|
||||
let live_row_id = pairs.iter().find(|(id, _)| *id == 1).unwrap().1;
|
||||
|
||||
table.delete("id = 2").await?;
|
||||
|
||||
assert_fetch_apis_reject_missing_row_ids(&table, &[deleted_row_id, live_row_id]).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_blobs_rejects_non_blob_column() -> Result<()> {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user