Merge main into gatekeeper/fix-4260-1

Preserve sliced child validity with Lance v14.0.0-beta.10 and its JSONB dependency.
This commit is contained in:
Gatefixer
2026-10-09 20:43:58 +00:00
85 changed files with 4319 additions and 835 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "0.41.0-beta.0"
current_version = "0.41.0-beta.2"
parse = """(?x)
(?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\.
Generated
+67 -69
View File
@@ -3588,8 +3588,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsst"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"rand 0.9.5",
@@ -5095,26 +5095,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonb"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953"
dependencies = [
"byteorder",
"ethnum",
"fast-float2",
"itoa",
"jiff",
"nom 8.0.0",
"num-traits",
"ordered-float 5.3.0",
"rand 0.9.5",
"serde",
"serde_json",
"zmij",
]
[[package]]
name = "kanaria"
version = "0.2.0"
@@ -5143,8 +5123,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
[[package]]
name = "lance"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arc-swap",
"arrow",
@@ -5217,7 +5197,7 @@ dependencies = [
[[package]]
name = "lance-arrow"
version = "14.0.0-beta.7"
version = "14.0.0-beta.10"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5231,7 +5211,7 @@ dependencies = [
"futures",
"getrandom 0.2.17",
"half",
"jsonb",
"lance-jsonb",
"num-traits",
"rand 0.9.5",
]
@@ -5239,7 +5219,7 @@ dependencies = [
[[package]]
name = "lance-arrow-scalar"
version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5253,7 +5233,7 @@ dependencies = [
[[package]]
name = "lance-arrow-stats"
version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5262,8 +5242,8 @@ dependencies = [
[[package]]
name = "lance-bitpacking"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrayref",
"crunchy",
@@ -5273,8 +5253,8 @@ dependencies = [
[[package]]
name = "lance-core"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5311,8 +5291,8 @@ dependencies = [
[[package]]
name = "lance-datafusion"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow",
"arrow-array",
@@ -5329,10 +5309,10 @@ dependencies = [
"datafusion-physical-expr",
"futures",
"half",
"jsonb",
"lance-arrow",
"lance-core",
"lance-geo",
"lance-jsonb",
"log",
"pin-project",
"prost",
@@ -5343,8 +5323,8 @@ dependencies = [
[[package]]
name = "lance-datagen"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow",
"arrow-array",
@@ -5361,8 +5341,8 @@ dependencies = [
[[package]]
name = "lance-derive"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"proc-macro2",
"quote",
@@ -5371,8 +5351,8 @@ dependencies = [
[[package]]
name = "lance-encoding"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -5405,8 +5385,8 @@ dependencies = [
[[package]]
name = "lance-file"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -5438,8 +5418,8 @@ dependencies = [
[[package]]
name = "lance-geo"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"datafusion",
"geo-traits",
@@ -5453,8 +5433,8 @@ dependencies = [
[[package]]
name = "lance-index"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arc-swap",
"arrow",
@@ -5485,7 +5465,6 @@ dependencies = [
"hex",
"itertools 0.14.0",
"jieba-rs",
"jsonb",
"lance-arrow",
"lance-arrow-stats",
"lance-bitpacking",
@@ -5496,6 +5475,7 @@ dependencies = [
"lance-geo",
"lance-index-core",
"lance-io",
"lance-jsonb",
"lance-linalg",
"lance-select",
"lance-table",
@@ -5525,8 +5505,8 @@ dependencies = [
[[package]]
name = "lance-index-core"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5549,8 +5529,8 @@ dependencies = [
[[package]]
name = "lance-io"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow",
"arrow-array",
@@ -5590,10 +5570,28 @@ dependencies = [
"uuid",
]
[[package]]
name = "lance-jsonb"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"byteorder",
"ethnum",
"fast-float2",
"itoa",
"jiff",
"nom 8.0.0",
"num-traits",
"ordered-float 5.3.0",
"serde",
"serde_json",
"zmij",
]
[[package]]
name = "lance-linalg"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5607,8 +5605,8 @@ dependencies = [
[[package]]
name = "lance-namespace"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow",
"async-trait",
@@ -5622,8 +5620,8 @@ dependencies = [
[[package]]
name = "lance-namespace-impls"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arc-swap",
"arrow",
@@ -5677,8 +5675,8 @@ dependencies = [
[[package]]
name = "lance-select"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5692,8 +5690,8 @@ dependencies = [
[[package]]
name = "lance-table"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow",
"arrow-array",
@@ -5733,8 +5731,8 @@ dependencies = [
[[package]]
name = "lance-testing"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"arrow-array",
"arrow-schema",
@@ -5747,8 +5745,8 @@ dependencies = [
[[package]]
name = "lance-tokenizer"
version = "14.0.0-beta.7"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.7#0fd26effc79c62c244a5baf92cdda06e301baf21"
version = "14.0.0-beta.10"
source = "git+https://github.com/lance-format/lance.git?tag=v14.0.0-beta.10#fa3638fd816ab2ff9a2ecbba7e51465d8879958f"
dependencies = [
"frostem",
"icu_segmenter",
@@ -5761,7 +5759,7 @@ dependencies = [
[[package]]
name = "lancedb"
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
dependencies = [
"ahash",
"anyhow",
@@ -5858,7 +5856,7 @@ dependencies = [
[[package]]
name = "lancedb-nodejs"
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5883,7 +5881,7 @@ dependencies = [
[[package]]
name = "lancedb-python"
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
dependencies = [
"arc-swap",
"arrow",
+15 -14
View File
@@ -13,20 +13,21 @@ categories = ["database-implementations"]
rust-version = "1.94.0"
[workspace.dependencies]
lance = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=14.0.0-beta.7", default-features = false, "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=14.0.0-beta.7", "tag" = "v14.0.0-beta.7", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { version = "=14.0.0-beta.7", path = "python/vendor/lance-arrow" }
lance = { "version" = "=14.0.0-beta.10", default-features = false, "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=14.0.0-beta.10", default-features = false, "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-jsonb = { version = "=14.0.0-beta.10", tag = "v14.0.0-beta.10", git = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=14.0.0-beta.10", default-features = false, "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=14.0.0-beta.10", "tag" = "v14.0.0-beta.10", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { version = "=14.0.0-beta.10", path = "python/vendor/lance-arrow" }
lancedb = { path = "rust/lancedb", default-features = false }
ahash = "0.8"
# Note that this one does not include pyarrow
+1 -1
View File
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
<dependency>
<groupId>com.lancedb</groupId>
<artifactId>lancedb-core</artifactId>
<version>0.41.0-beta.0</version>
<version>0.41.0-beta.2</version>
</dependency>
```
+5 -6
View File
@@ -460,17 +460,16 @@ exhaustiely searched to find matches. This parameter controls how many
partitions should be searched.
Increasing this value will increase the recall of your query but will
also increase the latency of your query. The default value is 20. This
default is good for many cases but the best value to use will depend on
your data and the recall that you need to achieve.
also increase the latency of your query. If this method is not called,
Lance's adaptive probe defaults are used.
For best results we recommend tuning this parameter with a benchmark against
your actual data to find the smallest possible value that will still give
you the desired recall.
For more fine grained control over behavior when you have a very narrow filter
you can use `minimumNprobes` and `maximumNprobes`. This method sets both
the minimum and maximum to the same value.
The value is retained as `nprobes` through client and server request
construction. Lance sets both probe bounds to this value. Explicit minimum
or maximum settings can override their respective bounds.
#### Parameters
+2
View File
@@ -37,6 +37,8 @@ The following tokenizers are available:
"raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token.
"code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens.
"icu" - ICU dictionary-based word segmentation.
"icu/split" - ICU segmentation with simple-style delimiter splitting.
@@ -15,6 +15,7 @@ optional limit: number;
```
An upper bound on how many tables to return.
Defaults to 100. Zero returns an empty page without a continuation token.
A page may hold fewer than this and still not be the last one, so keep
going while the response carries a page token rather than while pages are
+1 -1
View File
@@ -19,7 +19,7 @@ instead.
optional limit: number;
```
An optional limit to the number of results to return.
The maximum number of names to return. Omitted returns all names; zero returns none.
***
@@ -12,6 +12,7 @@ type BaseTokenizer:
| "whitespace"
| "raw"
| "ngram"
| "code"
| "icu"
| "icu/split"
| `jieba/${string}`
+11
View File
@@ -86,6 +86,15 @@ print(query.describe().status)
for batch in query.reader():
print(batch.num_rows)
# Values bind to $1 / $name placeholders and travel as Arrow, so a float32
# stays a float32 and a vector stays a compact fixed-size list:
import numpy as np
reader = db.execute_query(
"SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k",
parameters={"vector": np.random.rand(768).astype(np.float32), "k": 10},
)
# The async connection exposes the same lifecycle without blocking:
# async_db = await lancedb.connect_async(
# "db://analytics",
@@ -201,6 +210,8 @@ listing a storage directory.
::: lancedb.sql.QueryDescription
::: lancedb.sql.QueryParameters
## Materialized Views (Synchronous)
::: lancedb.materialized_view.MaterializedView
+5 -5
View File
@@ -139,9 +139,9 @@ Learn how to configure, build, and search LanceDB vector indexes, including buil
| Parameter | Description |
| :--- | :--- |
| `limit` | Number of results to return (the `k` in `k-ANN`). |
| `nprobes` | Shorthand that sets both `minimum_nprobes` and `maximum_nprobes` to the same value. LanceDB defaults this to 20 which is a good default for many cases. At very high scales you will likely need to experiment to find your preferred recall / latency balance. |
| `minimum_nprobes` | Minimum number of partitions scanned. |
| `maximum_nprobes` | Maximum number of partitions scanned. Only scans more than `minimum_nprobes` if an initial pass does not return enough results — useful for narrow filters. Set to `0` to remove the cap. |
| `nprobes` | Sets both `minimum_nprobes` and `maximum_nprobes` to the same value in Lance. Leave unset to use Lance's adaptive defaults. At very high scales you will likely need to experiment to find your preferred recall / latency balance. |
| `minimum_nprobes` | Minimum number of partitions scanned. Leave unset to use Lance's default. |
| `maximum_nprobes` | Maximum number of partitions scanned. Only scans more than `minimum_nprobes` if an initial pass does not return enough results — useful for narrow filters. Leave unset, or set to `0`, to remove the cap. |
| `ef` | HNSW search-time exploration factor. Start around `1.5 * k` and increase up to `10 * k` for higher recall. |
| `refine_factor` | Reads and reranks additional candidates in memory to recover recall lost to quantization. Start between `5` and `50` (a factor of `25` reads and reranks the top `25 * k` candidates) and measure the latency cost, typically a few milliseconds, against the recall gained. |
| `distance_range(lower_bound, upper_bound)` | Return only rows whose distance falls within `[lower_bound, upper_bound)`. Either bound is optional. Useful for near-duplicate detection or "close-enough" matching. |
@@ -165,8 +165,8 @@ older client.
| Index type | Guidance |
| :--- | :--- |
| `IVF_HNSW_FLAT`, `IVF_HNSW_SQ` | Keep the default `nprobes`, then tune `ef` first. Expect higher latency variance under filtered search. |
| `IVF_RQ`, `IVF_PQ` | Keep the default `nprobes` and raise when recall is insufficient. If using a selective filter you may need to increase `maximum_nprobes` if you do not received enough results. |
| `IVF_HNSW_FLAT`, `IVF_HNSW_SQ` | Leave the probe settings unset, then tune `ef` first. Expect higher latency variance under filtered search. |
| `IVF_RQ`, `IVF_PQ` | Leave the probe settings unset and configure them only when recall is insufficient. If using a selective filter you may need to increase `maximum_nprobes` if you do not receive enough results. |
<Note>
+1 -1
View File
@@ -8,7 +8,7 @@
<parent>
<groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId>
<version>0.41.0-beta.0</version>
<version>0.41.0-beta.2</version>
<relativePath>../pom.xml</relativePath>
</parent>
+2 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId>
<version>0.41.0-beta.0</version>
<version>0.41.0-beta.2</version>
<packaging>pom</packaging>
<name>${project.artifactId}</name>
<description>LanceDB Java SDK Parent POM</description>
@@ -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.7</lance-core.version>
<lance-core.version>14.0.0-beta.10</lance-core.version>
<spotless.skip>false</spotless.skip>
<spotless.version>2.30.0</spotless.version>
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "lancedb-nodejs"
edition.workspace = true
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
publish = false
license.workspace = true
description.workspace = true
+28 -1
View File
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { readdirSync } from "fs";
import { mkdirSync, readdirSync } from "fs";
import { join } from "path";
import { Field, Float64, Schema } from "apache-arrow";
import * as tmp from "tmp";
import {
@@ -180,6 +181,32 @@ describe("given a connection", () => {
expect(seen).toEqual(created);
});
it("should list all names and use a default page size of 100", async () => {
const names = Array.from(
{ length: 130 },
(_, i) => `t${i.toString().padStart(3, "0")}`,
);
for (const name of names) {
mkdirSync(join(tmpDir.name, `${name}.lance`));
}
await expect(db.tableNames()).resolves.toEqual(names);
await expect(db.tableNames({ limit: 0 })).resolves.toEqual([]);
const first = await db.listTables();
expect(first.tables).toEqual(names.slice(0, 100));
expect(first.pageToken).toBeDefined();
const second = await db.listTables({ pageToken: first.pageToken });
expect(second.tables).toEqual(names.slice(100));
expect(second.pageToken).toBeUndefined();
await expect(db.listTables({ limit: 200 })).resolves.toEqual({
tables: names,
pageToken: undefined,
});
await expect(db.listTables({ limit: 0 })).resolves.toEqual({
tables: [],
pageToken: undefined,
});
});
it("should list tables in a namespace", async () => {
const db = await connect(tmpDir.name, {
// biome-ignore lint/style/useNamingConvention: opaque backend property key, must match Rust
+53
View File
@@ -28,6 +28,7 @@ import {
StaticHeaderProvider,
} from "../lancedb/header";
import { Index } from "../lancedb/indices";
import { LocalTable } from "../lancedb/table";
// Test-only header providers
class CustomProvider extends HeaderProvider {
@@ -93,6 +94,58 @@ async function withMockDatabase(
}
describe("remote connection", () => {
it("reports unsupported operations with backend-neutral errors", async () => {
await withMockDatabase(
(req, res) => {
expect(req.url).toBe("/v1/table/test/describe/");
res.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
version: 1,
schema: {
fields: [
{ name: "id", type: { type: "int64" }, nullable: false },
{
name: "vector",
type: {
type: "fixed_size_list",
fields: [
{ name: "item", type: { type: "float" }, nullable: true },
],
length: 2,
},
nullable: false,
},
],
},
}),
);
},
async (db) => {
const table = (await db.openTable("test")) as LocalTable;
const cases: [() => Promise<unknown>, string][] = [
[() => table.usesV2ManifestPaths(), "uses_v2_manifest_paths"],
[() => table.migrateManifestPathsV2(), "migrate_manifest_paths_v2"],
[
() => table.setUnenforcedPrimaryKey("id"),
"set_unenforced_primary_key",
],
[() => table.optimize(), "optimize"],
[
() => table.createIndex("vector", { config: Index.hnswPq() }),
"IVF_HNSW_PQ",
],
];
for (const [run, operation] of cases) {
let message = `LanceDBError: not supported: ${operation} is not supported for remote tables.`;
if (operation === "IVF_HNSW_PQ") {
message += " Please use IVF_HNSW_SQ instead.";
}
await expect(run()).rejects.toThrow(new Error(message));
}
},
);
});
it("rejects nprobes(0) before sending a query", async () => {
const requests: string[] = [];
await withMockDatabase(
+39 -1
View File
@@ -1231,7 +1231,9 @@ describe("When creating an index", () => {
expect(() => tbl.query().nearestTo(queryVec).minimumNprobes(0)).toThrow(
"Invalid input, minimum_nprobes must be greater than 0",
);
expect(() => tbl.query().nearestTo(queryVec).maximumNprobes(5)).toThrow(
expect(() =>
tbl.query().nearestTo(queryVec).minimumNprobes(10).maximumNprobes(5),
).toThrow(
"Invalid input, maximum_nprobes must be greater than or equal to minimum_nprobes",
);
@@ -4050,6 +4052,28 @@ describe.each(arrowVersions)(
);
});
test.each(["Klingon", "english"])(
"rejects unsupported full text language %s with a catchable error",
(language) => {
expect(() => Index.fts({ language })).toThrow(
new Error(
`LanceDB does not support the requested language: '${language}'`,
),
);
},
);
test("full text search with a supported language", async () => {
const db = await connect(tmpDir.name);
const table = await db.createTable("test", [{ text: "running" }]);
await table.createIndex("text", {
config: Index.fts({ language: "English", stem: true }),
});
const results = await table.search("run").toArray();
expect(results.map((row) => row.text)).toEqual(["running"]);
});
test("full text search without lowercase", async () => {
const db = await connect(tmpDir.name);
const data = [
@@ -4180,6 +4204,20 @@ describe.each(arrowVersions)(
expect(mustNotResults.length).toBe(1);
});
test("full text search code tokenizer", async () => {
const db = await connect(tmpDir.name);
const table = await db.createTable("test", [
{ id: 1, text: "def getUserName(user_id): return user_id" },
{ id: 2, text: "def parseFile(file_path): return file_path" },
]);
await table.createIndex("text", {
config: Index.fts({ baseTokenizer: "code" }),
});
const results = await table.search("getUserName", "fts").toArray();
expect(results.map((row) => row.id)).toEqual([1]);
});
test("full text search ngram", async () => {
const db = await connect(tmpDir.name);
const data = [
+2 -1
View File
@@ -150,7 +150,7 @@ export interface TableNamesOptions {
* the last table name from the previous page.
*/
startAfter?: string;
/** An optional limit to the number of results to return. */
/** The maximum number of names to return. Omitted returns all names; zero returns none. */
limit?: number;
}
@@ -164,6 +164,7 @@ export interface ListTablesOptions {
pageToken?: string;
/**
* An upper bound on how many tables to return.
* Defaults to 100. Zero returns an empty page without a continuation token.
*
* A page may hold fewer than this and still not be the last one, so keep
* going while the response carries a page token rather than while pages are
+3
View File
@@ -491,6 +491,7 @@ export type BaseTokenizer =
| "whitespace"
| "raw"
| "ngram"
| "code"
| "icu"
| "icu/split"
| `jieba/${string}`
@@ -520,6 +521,8 @@ export interface FtsOptions {
*
* "raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token.
*
* "code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens.
*
* "icu" - ICU dictionary-based word segmentation.
*
* "icu/split" - ICU segmentation with simple-style delimiter splitting.
+5 -6
View File
@@ -532,17 +532,16 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
* partitions should be searched.
*
* Increasing this value will increase the recall of your query but will
* also increase the latency of your query. The default value is 20. This
* default is good for many cases but the best value to use will depend on
* your data and the recall that you need to achieve.
* also increase the latency of your query. If this method is not called,
* Lance's adaptive probe defaults are used.
*
* For best results we recommend tuning this parameter with a benchmark against
* your actual data to find the smallest possible value that will still give
* you the desired recall.
*
* For more fine grained control over behavior when you have a very narrow filter
* you can use `minimumNprobes` and `maximumNprobes`. This method sets both
* the minimum and maximum to the same value.
* The value is retained as `nprobes` through client and server request
* construction. Lance sets both probe bounds to this value. Explicit minimum
* or maximum settings can override their respective bounds.
*/
nprobes(nprobes: number): VectorQuery {
this.doVectorCall((inner) => inner.nprobes(nprobes));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-darwin-arm64",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["darwin"],
"cpu": ["arm64"],
"main": "lancedb.darwin-arm64.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-arm64-gnu",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["linux"],
"cpu": ["arm64"],
"main": "lancedb.linux-arm64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-arm64-musl",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["linux"],
"cpu": ["arm64"],
"main": "lancedb.linux-arm64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-x64-gnu",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["linux"],
"cpu": ["x64"],
"main": "lancedb.linux-x64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-linux-x64-musl",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["linux"],
"cpu": ["x64"],
"main": "lancedb.linux-x64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-win32-arm64-msvc",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": [
"win32"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lancedb/lancedb-win32-x64-msvc",
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"os": ["win32"],
"cpu": ["x64"],
"main": "lancedb.win32-x64-msvc.node",
+1 -1
View File
@@ -11,7 +11,7 @@
"ann"
],
"private": false,
"version": "0.41.0-beta.0",
"version": "0.41.0-beta.2",
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
+6 -1
View File
@@ -239,7 +239,12 @@ impl Index {
opts = opts.base_tokenizer(base_tokenizer);
}
if let Some(language) = language {
opts = opts.language(&language).unwrap();
opts = opts.language(&language).map_err(|_| {
napi::Error::from_reason(format!(
"LanceDB does not support the requested language: '{}'",
language
))
})?;
}
if let Some(max_token_length) = max_token_length {
opts = opts.max_token_length(Some(max_token_length as usize));
+8 -2
View File
@@ -836,7 +836,10 @@ impl Table {
pub async fn uses_v2_manifest_paths(&self) -> napi::Result<bool> {
self.inner_ref()?
.as_native()
.ok_or_else(|| napi::Error::from_reason("This cannot be run on a remote table"))?
.ok_or_else(|| lancedb::Error::NotSupported {
message: "uses_v2_manifest_paths is not supported for remote tables.".into(),
})
.default_error()?
.uses_v2_manifest_paths()
.await
.default_error()
@@ -846,7 +849,10 @@ impl Table {
pub async fn migrate_manifest_paths_v2(&self) -> napi::Result<()> {
self.inner_ref()?
.as_native()
.ok_or_else(|| napi::Error::from_reason("This cannot be run on a remote table"))?
.ok_or_else(|| lancedb::Error::NotSupported {
message: "migrate_manifest_paths_v2 is not supported for remote tables.".into(),
})
.default_error()?
.migrate_manifest_paths_v2()
.await
.default_error()
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lancedb-python"
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
publish = false
edition.workspace = true
description = "Python bindings for LanceDB"
+2 -2
View File
@@ -10,7 +10,7 @@ dependencies = [
"pyarrow>=16",
"pydantic>=2.7.4,<3",
"tqdm>=4.27.0",
"lance-namespace>=0.3.2"
"lance-namespace>=0.13.0"
]
description = "lancedb"
authors = [{ name = "LanceDB Devs", email = "dev@lancedb.com" }]
@@ -63,7 +63,7 @@ tests = [
"polars>=0.19, <=1.32.3",
"pyarrow<25",
"pyarrow-stubs>=16.0",
"pylance==14.0.0b6",
"pylance==14.0.0b10",
"requests>=2.31.0",
"datafusion>=54,<55",
"opentelemetry-sdk>=1.30.0",
+4 -2
View File
@@ -321,8 +321,8 @@ def tokenize(
language: str = "English",
max_token_length: Optional[int] = 40,
lower_case: bool = True,
stem: bool = True,
remove_stop_words: bool = True,
stem: Optional[bool] = None,
remove_stop_words: Optional[bool] = None,
custom_stop_words: Optional[List[str]] = None,
ascii_folding: bool = True,
ngram_min_length: int = 3,
@@ -333,6 +333,8 @@ def tokenize(
This does not require an FTS index. The tokenizer options match
:class:`lancedb.index.FTS`. ``custom_stop_words`` accepts a list of strings.
When ``stem`` or ``remove_stop_words`` is None, the base tokenizer's default
applies: False for ``code`` and ``ngram``, True otherwise.
"""
return _tokenize(
+5 -2
View File
@@ -58,8 +58,8 @@ def tokenize(
language: str = "English",
max_token_length: Optional[int] = 40,
lower_case: bool = True,
stem: bool = True,
remove_stop_words: bool = True,
stem: Optional[bool] = None,
remove_stop_words: Optional[bool] = None,
custom_stop_words: Optional[List[str]] = None,
ascii_folding: bool = True,
ngram_min_length: int = 3,
@@ -217,6 +217,7 @@ class Connection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[pa.RecordBatch] = None,
) -> SqlQuery: ...
async def describe_query(self, query_id: UUID) -> QueryDescription: ...
async def create_table(
@@ -422,6 +423,7 @@ class Table:
def name(self) -> str: ...
def __repr__(self) -> str: ...
def is_open(self) -> bool: ...
def _is_native(self) -> bool: ...
def close(self) -> None: ...
async def schema(self) -> pa.Schema: ...
async def add(
@@ -776,6 +778,7 @@ class PyQueryRequest:
use_lsm: Optional[bool]
column: Optional[str]
query_vector: Optional[List[pa.Array]]
nprobes: Optional[int]
minimum_nprobes: Optional[int]
maximum_nprobes: Optional[int]
lower_bound: Optional[float]
+54 -17
View File
@@ -53,7 +53,7 @@ from .functions import FunctionVersion, UdfDefinition
from .job import AsyncJob, Job, _typed_job
from .sql import AsyncQuery as AsyncSqlQuery
from .sql import Query as SqlQuery
from .sql import QueryDescription
from .sql import QueryDescription, QueryParameters, to_parameter_batch
from .materialized_view import (
AsyncMaterializedView,
MaterializedView,
@@ -317,7 +317,7 @@ class DBConnection(EnforceOverrides):
page_token: Optional[str] = None,
limit: Optional[int] = None,
) -> ListTablesResponse:
"""List all tables in this database with pagination support.
"""List a page of tables in this database.
Parameters
----------
@@ -328,7 +328,8 @@ class DBConnection(EnforceOverrides):
Token for pagination. Use the token from a previous response
to get the next page of results.
limit: int, optional
The maximum number of results to return.
The maximum number of results to return, default 100.
Zero returns an empty page without a continuation token.
Returns
-------
@@ -343,7 +344,7 @@ class DBConnection(EnforceOverrides):
def table_names(
self,
page_token: Optional[str] = None,
limit: int = 10,
limit: Optional[int] = None,
*,
namespace_path: Optional[List[str]] = None,
) -> Iterable[str]:
@@ -357,8 +358,9 @@ class DBConnection(EnforceOverrides):
page_token: str, optional
The token to use for pagination. If not present, start from the beginning.
Typically, this token is last table name from the previous page.
limit: int, default 10
The size of the page to return.
limit: int, optional
The maximum number of tables to return. None returns all tables;
zero returns an empty list.
Returns
-------
@@ -1178,6 +1180,7 @@ class DBConnection(EnforceOverrides):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> pa.RecordBatchReader:
"""Execute SQL and return a blocking Arrow reader.
@@ -1188,6 +1191,7 @@ class DBConnection(EnforceOverrides):
return self.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
).reader()
def execute_query_async(
@@ -1195,9 +1199,32 @@ class DBConnection(EnforceOverrides):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> SqlQuery:
"""Start executing SQL and return its query handle.
``parameters`` binds values to the statement's ``$1`` / ``$name``
placeholders. They travel as Arrow rather than as SQL text, so a
float keeps its exact value and type and a vector stays a compact
fixed-size list. A list binds by position, a dict binds by name, and
a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds both
ways. A 1-D numpy or pyarrow array becomes a fixed-size list of its
element type, the shape of a vector column:
.. code-block:: python
db.execute_query(
"SELECT id FROM docs ORDER BY distance(vector, $1) LIMIT $2",
parameters=[np.array([0.1, 0.2, 0.3], dtype=np.float32), 10],
)
Parameters are supported in queries, not in DDL or DML. A
parameterized query runs on the call that returns its rows:
cancelling it, or dropping its handle before reading, stops it on the
server. Its status follows that stream as well: ``describe()``
reports ``running`` until the reader has received every row, so open
``reader()`` directly rather than waiting for ``finished``.
Local connections do not support SQL.
"""
raise NotImplementedError("SQL is not supported for this connection type")
@@ -1465,7 +1492,7 @@ class LanceDBConnection(DBConnection):
page_token: Optional[str] = None,
limit: Optional[int] = None,
) -> ListTablesResponse:
"""List all tables in this database with pagination support.
"""List a page of tables in this database.
Parameters
----------
@@ -1476,7 +1503,8 @@ class LanceDBConnection(DBConnection):
Token for pagination. Use the token from a previous response
to get the next page of results.
limit: int, optional
The maximum number of results to return.
The maximum number of results to return, default 100.
Zero returns an empty page without a continuation token.
Returns
-------
@@ -1495,7 +1523,7 @@ class LanceDBConnection(DBConnection):
def table_names(
self,
page_token: Optional[str] = None,
limit: int = 10,
limit: Optional[int] = None,
*,
namespace_path: Optional[List[str]] = None,
) -> Iterable[str]:
@@ -1510,8 +1538,9 @@ class LanceDBConnection(DBConnection):
The namespace to list tables in.
page_token: str, optional
The token to use for pagination.
limit: int, default 10
The maximum number of tables to return.
limit: int, optional
The maximum number of tables to return. None returns all tables;
zero returns an empty list.
Returns
-------
@@ -2341,7 +2370,7 @@ class AsyncConnection(object):
page_token: Optional[str] = None,
limit: Optional[int] = None,
) -> ListTablesResponse:
"""List all tables in this database with pagination support.
"""List a page of tables in this database.
Parameters
----------
@@ -2352,7 +2381,8 @@ class AsyncConnection(object):
Token for pagination. Use the token from a previous response
to get the next page of results.
limit: int, optional
The maximum number of results to return.
The maximum number of results to return, default 100.
Zero returns an empty page without a continuation token.
Returns
-------
@@ -2389,8 +2419,9 @@ class AsyncConnection(object):
This can be combined with limit to implement pagination by setting this to
the last table name from the previous page.
limit: int, default 10
The number of results to return.
limit: int, optional
The maximum number of tables to return. None returns all tables;
zero returns an empty list.
Returns
-------
@@ -3267,6 +3298,7 @@ class AsyncConnection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL and return an asynchronous Arrow reader.
@@ -3277,6 +3309,7 @@ class AsyncConnection(object):
submitted = await self.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
return await submitted.reader()
@@ -3285,17 +3318,21 @@ class AsyncConnection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncSqlQuery:
"""Start executing SQL and return its query handle.
The database from ``connect_async`` is used for unqualified database
references. The namespace defaults to ``["public"]``. Local
connections raise ``NotImplementedError``.
references. The namespace defaults to ``["public"]``. ``parameters``
binds values to ``$1`` / ``$name`` placeholders; see
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
Local connections raise ``NotImplementedError``.
"""
return AsyncSqlQuery(
await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=to_parameter_batch(parameters),
)
)
+1 -1
View File
@@ -372,7 +372,7 @@ class FunctionVersion(_RemoteValue):
image: FunctionImage
signature: FunctionSignature
secret_bindings: tuple[SecretBinding, ...] = ()
created_at: str
created_at_millis: int
metadata: Mapping[str, str]
disabled: bool
+44 -6
View File
@@ -112,9 +112,42 @@ class Fm:
class FTS:
"""Describe a FTS index configuration.
`FTS` is a full-text search index that can be used on `String` columns
`FTS` supports string columns, lists of strings, and JSON columns created
from PyArrow ``pa.json_()``. JSON columns use Lance's JSON-aware tokenizer
and are stored as ``LargeBinary`` with the ``lance.json`` logical type.
Arbitrary binary columns are not supported.
For example, it works with `title`, `description`, `content`, etc.
JSON queries use ``path,str,value`` triples. Paths are relative to the JSON
column and use dots for nested object keys. For example,
``meta.tag,str,nature`` matches the string value at ``$.meta.tag``;
it does not search other paths or JSON key names. The value is processed
with the configured text tokenizer. Use ``str``, not ``string``.
Create and query a JSON index through the modern API::
import pyarrow as pa
from lancedb.index import FTS
table = db.create_table(
"documents",
pa.table({"doc": pa.array(
['{"meta":{"tag":"nature"}}'], type=pa.json_()
)}),
)
table.create_index("doc", config=FTS())
table.search(
"meta.tag,str,nature", query_type="fts", fts_columns="doc"
).to_list()
Create the index on the JSON column itself, not a JSON subpath such as
``doc.meta.tag``. JSON-path scalar indexes do not provide FTS path scoping.
Plain text such as ``nature`` is not a valid JSON FTS query. The current
Lance tokenizer can panic on malformed triples; see
https://github.com/lance-format/lance/issues/8812.
Remote tables use the same ``FTS()`` configuration and require a server
that supports JSON FTS. Wait for ``table.wait_for_index(["doc_idx"])``
before searching a newly created remote index with its default name.
Examples
--------
@@ -138,6 +171,7 @@ class FTS:
- "simple": Splits text by whitespace and punctuation.
- "whitespace": Split text by whitespace, but not punctuation.
- "raw": No tokenization. The entire text is treated as a single token.
- "code": Tokenizes source code and identifiers.
- "ngram": N-gram tokenizer for substring-style matching.
- "icu": ICU dictionary-based word segmentation.
- "icu/split": ICU segmentation with simple-style delimiter splitting.
@@ -151,12 +185,16 @@ class FTS:
ignored.
lower_case : bool, default True
Whether to convert the token to lower case. This makes queries case-insensitive.
stem : bool, default True
stem : bool, optional
Whether to stem the token. Stemming reduces words to their root form.
For example, in English "running" and "runs" would both be reduced to "run".
remove_stop_words : bool, default True
``None`` uses the base tokenizer's default: False for ``code`` and
``ngram``, True otherwise.
remove_stop_words : bool, optional
Whether to remove stop words. Stop words are common words that are often
removed from text before indexing. For example, in English "the" and "and".
``None`` uses the base tokenizer's default: False for ``code`` and
``ngram``, True otherwise.
custom_stop_words : list of str, optional
Custom words replace the built-in language stop words
and only take effect when ``remove_stop_words`` is True. ``None`` uses
@@ -197,8 +235,8 @@ class FTS:
language: str = "English"
max_token_length: Optional[int] = 40
lower_case: bool = True
stem: bool = True
remove_stop_words: bool = True
stem: Optional[bool] = None
remove_stop_words: Optional[bool] = None
ascii_folding: bool = True
ngram_min_length: int = 3
ngram_max_length: int = 3
+23 -4
View File
@@ -53,7 +53,7 @@ from lancedb.db import AsyncConnection, DBConnection
from lancedb.job import AsyncJob, Job
from lancedb.listing import AsyncListing, Listing
from lancedb.sql import AsyncQuery as AsyncSqlQuery
from lancedb.sql import QueryDescription
from lancedb.sql import QueryDescription, QueryParameters
from lance_namespace import (
LanceNamespace,
connect as namespace_connect,
@@ -190,8 +190,12 @@ def _query_to_namespace_request(
kwargs["with_row_id"] = query.with_row_id
if query.ef is not None:
kwargs["ef"] = query.ef
if query.nprobes is not None:
kwargs["nprobes"] = query.nprobes
if query.minimum_nprobes is not None:
kwargs["nprobes"] = query.minimum_nprobes
kwargs["minimum_nprobes"] = query.minimum_nprobes
if query.maximum_nprobes is not None:
kwargs["maximum_nprobes"] = query.maximum_nprobes
if query.refine_factor is not None:
kwargs["refine_factor"] = query.refine_factor
if query.lower_bound is not None:
@@ -205,6 +209,13 @@ def _query_to_namespace_request(
if query.distance_type is not None:
kwargs["distance_type"] = query.distance_type
if kwargs.get("maximum_nprobes") == 0:
# The generated 0.13 model rejects LanceDB's unbounded sentinel;
# model_copy preserves direct wire pass-through to the Lance receiver.
maximum_nprobes = kwargs.pop("maximum_nprobes")
return QueryTableRequest(**kwargs).model_copy(
update={"maximum_nprobes": maximum_nprobes}
)
return QueryTableRequest(**kwargs)
@@ -522,13 +533,15 @@ class LanceNamespaceDBConnection(DBConnection):
def table_names(
self,
page_token: Optional[str] = None,
limit: int = 10,
limit: Optional[int] = None,
*,
namespace_path: Optional[List[str]] = None,
) -> Iterable[str]:
"""
List table names in the database.
If limit is None, return all table names; zero returns an empty list.
.. deprecated::
Use :meth:`list_tables` instead, which provides proper pagination support.
"""
@@ -1157,13 +1170,15 @@ class AsyncLanceNamespaceDBConnection:
async def table_names(
self,
page_token: Optional[str] = None,
limit: int = 10,
limit: Optional[int] = None,
*,
namespace_path: Optional[List[str]] = None,
) -> Iterable[str]:
"""
List table names in the namespace.
If limit is None, return all table names; zero returns an empty list.
.. deprecated::
Use :meth:`list_tables` instead, which provides proper pagination support.
"""
@@ -1584,11 +1599,13 @@ class AsyncLanceNamespaceDBConnection:
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL when supported by the underlying connection."""
return await self._inner.execute_query(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
async def execute_query_async(
@@ -1596,6 +1613,7 @@ class AsyncLanceNamespaceDBConnection:
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncSqlQuery:
"""Start executing SQL when supported by the underlying connection.
@@ -1604,6 +1622,7 @@ class AsyncLanceNamespaceDBConnection:
return await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
async def describe_query(self, query_id: UUID) -> QueryDescription:
+69 -25
View File
@@ -39,7 +39,7 @@ from .expr import Expr
from .rerankers.base import Reranker
from .rerankers.rrf import RRFReranker
from .rerankers.util import check_reranker_result
from .schema import is_blob_like_field, schema_has_blob_field
from .schema import blob_column_paths, is_blob_like_field, schema_has_blob_field
from .util import _validate_query_vector, flatten_columns
from . import _wal_hybrid # WAL-PK-FUSION: delete.
from ._blob import (
@@ -51,6 +51,7 @@ from ._blob import (
finalize_blob_query_table,
replace_v2_blob_columns_with_bytes,
replace_v2_blob_columns_with_bytes_sync,
strip_auto_row_ids,
validate_blob_mode,
)
from .types import BlobMode, QueryProjection
@@ -479,7 +480,7 @@ class MatchQuery(FullTextQuery):
boost : float, default 1.0
The boost factor for the query.
The score of each matching document is multiplied by this value.
fuzziness : int, optional
fuzziness : int or None, default 0
The maximum edit distance for each term in the match query.
Defaults to 0 (exact match).
If None, fuzziness is applied automatically by the rules:
@@ -506,7 +507,7 @@ class MatchQuery(FullTextQuery):
query: str
column: str
boost: float = pydantic.Field(1.0, kw_only=True)
fuzziness: int = pydantic.Field(0, kw_only=True)
fuzziness: Optional[int] = pydantic.Field(0, kw_only=True)
max_expansions: int = pydantic.Field(50, kw_only=True)
operator: FullTextOperator = pydantic.Field(FullTextOperator.OR, kw_only=True)
prefix_length: int = pydantic.Field(0, kw_only=True)
@@ -692,8 +693,9 @@ class Query(pydantic.BaseModel):
if True then apply the filter after vector / FTS search. This is ignored for
plain SQL filtering.
nprobes : Optional[int]
The number of IVF partitions to search. If this is None then a default
number of partitions will be used.
The legacy number of IVF partitions to search. Lance sets both probe
bounds to this value. If this is None then Lance's default probe settings
will be used.
- A higher number makes search more accurate but also slower.
@@ -772,20 +774,23 @@ class Query(pydantic.BaseModel):
# distance type to use for vector search
distance_type: Optional[str] = None
# legacy number of IVF partitions to search
#
# Lance sets both probe bounds to this value. Explicit bounds can override it.
nprobes: Optional[int] = None
# which columns to return in the results (dict values may be str or Expr)
columns: QueryProjection = None
# minimum number of IVF partitions to search
#
# If None then a default value (20) will be used.
# If None then Lance's default will be used.
minimum_nprobes: Optional[int] = None
# maximum number of IVF partitions to search
#
# If None then a default value (20) will be used.
#
# If 0 then no limit will be applied and all partitions could be searched
# if needed to satisfy the limit.
# If None then Lance's default will be used. If 0 then no limit will be applied
# and all partitions could be searched if needed to satisfy the limit.
maximum_nprobes: Optional[int] = None
# lower bound for distance search
@@ -846,6 +851,7 @@ class Query(pydantic.BaseModel):
query.vector_column = req.column
query.vector = req.query_vector
query.distance_type = req.distance_type
query.nprobes = req.nprobes
query.minimum_nprobes = req.minimum_nprobes
query.maximum_nprobes = req.maximum_nprobes
query.lower_bound = req.lower_bound
@@ -1626,6 +1632,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
super().__init__(table)
self._query = query
self._distance_type = None
self._nprobes = None
self._minimum_nprobes = None
self._maximum_nprobes = None
self._lower_bound = None
@@ -1690,9 +1697,9 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
See discussion in [Querying an ANN Index](https://lancedb.com/docs/indexing/)
for tuning advice.
This method sets both the minimum and maximum number of probes to the same
value. See `minimum_nprobes` and `maximum_nprobes` for more fine-grained
control.
The value is retained as `nprobes` through client and server request
construction. Lance sets both probe bounds to this value. Explicit minimum
or maximum settings can override their respective bounds.
Parameters
----------
@@ -1706,8 +1713,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
"""
if nprobes <= 0:
raise ValueError("Invalid input, nprobes must be greater than 0")
self._minimum_nprobes = nprobes
self._maximum_nprobes = nprobes
self._nprobes = nprobes
return self
def minimum_nprobes(self, minimum_nprobes: int) -> LanceVectorQueryBuilder:
@@ -1849,6 +1855,7 @@ class LanceVectorQueryBuilder(LanceQueryBuilder):
limit=self._limit,
distance_type=self._distance_type,
columns=self._columns,
nprobes=self._nprobes,
minimum_nprobes=self._minimum_nprobes,
maximum_nprobes=self._maximum_nprobes,
lower_bound=self._lower_bound,
@@ -2207,6 +2214,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
self._fts_columns = fts_columns
self._norm = None
self._reranker = None
self._nprobes = None
self._minimum_nprobes = None
self._maximum_nprobes = None
self._refine_factor = None
@@ -2498,6 +2506,9 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
Higher values will yield better recall (more likely to find vectors if
they exist) at the expense of latency.
The value is retained as `nprobes` until Lance sets both probe bounds to it.
Explicit minimum or maximum settings can override their respective bounds.
Parameters
----------
nprobes: int
@@ -2510,8 +2521,7 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
"""
if nprobes <= 0:
raise ValueError("Invalid input, nprobes must be greater than 0")
self._minimum_nprobes = nprobes
self._maximum_nprobes = nprobes
self._nprobes = nprobes
return self
def minimum_nprobes(self, minimum_nprobes: int) -> LanceHybridQueryBuilder:
@@ -2790,6 +2800,8 @@ class LanceHybridQueryBuilder(LanceQueryBuilder):
self._fts_query.phrase_query(True)
if self._distance_type:
self._vector_query.metric(self._distance_type)
if self._nprobes is not None:
self._vector_query.nprobes(self._nprobes)
if self._minimum_nprobes is not None:
self._vector_query.minimum_nprobes(self._minimum_nprobes)
if self._maximum_nprobes is not None:
@@ -3073,14 +3085,18 @@ 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 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. Remote queries support
"descriptions"; "bytes" and "lazy" are not yet supported.
**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():
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):
@@ -3108,6 +3124,31 @@ class AsyncQueryBase(object):
)
return tbl.to_pandas(**kwargs)
async def _remote_to_pandas(
self,
*,
flatten: Optional[Union[int, bool]],
timeout: Optional[timedelta],
blob_mode: BlobMode,
**kwargs,
) -> "pd.DataFrame":
# A live remote table can be replaced between query and blob fetch.
# Row ids and version numbers do not identify the producing table.
tbl = await self.to_arrow(timeout=timeout)
projected_blob_paths = set(blob_column_paths(tbl.schema))
if not projected_blob_paths:
return flatten_columns(tbl, flatten).to_pandas(**kwargs)
if blob_mode == "descriptions":
tbl = strip_auto_row_ids(tbl, self._blob_paths)
return flatten_columns(tbl, flatten).to_pandas(**kwargs)
raise NotImplementedError(
f"remote to_pandas(blob_mode={blob_mode!r}) cannot safely materialize "
"blob columns without a stable table snapshot; "
"use blob_mode='descriptions'"
)
async def _plain_scan_to_pandas(
self,
blob_mode: BlobMode,
@@ -3660,7 +3701,7 @@ class AsyncVectorQueryBase:
def nprobes(self, nprobes: int) -> Self:
"""
Set the number of partitions to search (probe)
Set the legacy IVF probe parameter
The number of probes must be greater than 0.
@@ -3672,16 +3713,19 @@ class AsyncVectorQueryBase:
The partition whose centroids are closest to the query vector will be
exhaustiely searched to find matches. This parameter controls how many
partitions should be searched.
partitions will be searched.
Increasing this value will increase the recall of your query but will
also increase the latency of your query. The default value is 20. This
default is good for many cases but the best value to use will depend on
your data and the recall that you need to achieve.
also increase the latency of your query. If this method is not called,
Lance's adaptive probe defaults are used.
For best results we recommend tuning this parameter with a benchmark against
your actual data to find the smallest possible value that will still give
you the desired recall.
LanceDB retains this as `nprobes` through local and remote request
construction. Lance sets both probe bounds to this value. Explicit minimum
or maximum settings can override their respective bounds.
"""
self._inner.nprobes(nprobes)
return self
+14 -8
View File
@@ -41,7 +41,7 @@ from ..db import DBConnection, LOOP
from ..functions import FunctionVersion, UdfDefinition
from ..job import AsyncJob, Job
from ..sql import Query as SqlQuery
from ..sql import QueryDescription
from ..sql import QueryDescription, QueryParameters
from ..materialized_view import MaterializedView, MaterializedViewSource, SelectArg
from ..secrets import EnvVarSecret, SecretInfo
from ..view import ViewDescription
@@ -379,7 +379,7 @@ class RemoteDBConnection(DBConnection):
page_token: Optional[str] = None,
limit: Optional[int] = None,
) -> ListTablesResponse:
"""List all tables in this database with pagination support.
"""List a page of tables in this database.
Parameters
----------
@@ -390,7 +390,8 @@ class RemoteDBConnection(DBConnection):
Token for pagination. Use the token from a previous response
to get the next page of results.
limit: int, optional
The maximum number of results to return.
The maximum number of results to return, default 100.
Zero returns an empty page without a continuation token.
Returns
-------
@@ -409,7 +410,7 @@ class RemoteDBConnection(DBConnection):
def table_names(
self,
page_token: Optional[str] = None,
limit: int = 10,
limit: Optional[int] = None,
*,
namespace_path: Optional[List[str]] = None,
) -> Iterable[str]:
@@ -425,8 +426,9 @@ class RemoteDBConnection(DBConnection):
Empty list represents root namespace.
page_token: str
The last token to start the new page.
limit: int, default 10
The maximum number of tables to return for each page.
limit: int, optional
The maximum number of tables to return. None returns all tables;
zero returns an empty list.
Returns
-------
@@ -486,12 +488,12 @@ class RemoteDBConnection(DBConnection):
namespace_path = []
if storage_options is not None:
logging.info(
"storage_options is ignored in LanceDb Cloud"
"storage_options is ignored for remote tables"
" (storage is managed; set storage_options on connect() instead)"
)
if index_cache_size is not None:
logging.info(
"index_cache_size is ignored in LanceDb Cloud"
"index_cache_size is ignored for remote tables"
" (there is no local cache to configure)"
)
@@ -1090,18 +1092,22 @@ class RemoteDBConnection(DBConnection):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> SqlQuery:
"""Start executing SQL through this remote connection.
Unqualified tables use this connection's database and the
``["public"]`` namespace by default. Fully qualified table names may
reference other databases available to the same deployment.
``parameters`` binds values to ``$1`` / ``$name`` placeholders; see
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
"""
return SqlQuery(
LOOP.run(
self._conn.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
)
)
+33 -52
View File
@@ -42,6 +42,7 @@ from lancedb.index import (
BTree,
Bitmap,
HnswFlat,
HnswPq,
HnswSq,
IvfFlat,
IvfPq,
@@ -239,12 +240,12 @@ class RemoteTable(Table):
return LOOP.run(self._table.list_versions())
def to_arrow(self) -> pa.Table:
"""to_arrow() is not yet supported on LanceDB cloud."""
raise NotImplementedError("to_arrow() is not yet supported on LanceDB cloud.")
"""to_arrow() is not supported for remote tables."""
raise NotImplementedError("to_arrow() is not supported for remote tables.")
def to_pandas(self, blob_mode: BlobMode = "lazy", **kwargs):
"""to_pandas() is not yet supported on LanceDB cloud."""
raise NotImplementedError("to_pandas() is not yet supported on LanceDB cloud.")
"""to_pandas() is not supported for remote tables."""
raise NotImplementedError("to_pandas() is not supported for remote tables.")
def checkout(self, version: Union[int, str]):
result = LOOP.run(self._table.checkout(version))
@@ -353,8 +354,8 @@ class RemoteTable(Table):
language: str = "English",
max_token_length: Optional[int] = 40,
lower_case: bool = True,
stem: bool = True,
remove_stop_words: bool = True,
stem: Optional[bool] = None,
remove_stop_words: Optional[bool] = None,
custom_stop_words: Optional[List[str]] = None,
ascii_folding: bool = True,
ngram_min_length: int = 3,
@@ -501,14 +502,8 @@ class RemoteTable(Table):
if accelerator is not None:
logging.warning(
"GPU accelerator is not yet supported on LanceDB cloud."
"If you have 100M+ vectors to index,"
"please contact us at contact@lancedb.com"
)
if replace is not None:
logging.warning(
"replace is not supported on LanceDB cloud."
"Existing indexes will always be replaced."
"GPU accelerator is not supported for remote tables "
"and will be ignored."
)
idx_type = index_type.upper()
@@ -528,9 +523,11 @@ class RemoteTable(Table):
elif idx_type == "IVF_SQ":
config = IvfSq(distance_type=metric, num_partitions=num_partitions)
elif idx_type == "IVF_HNSW_PQ":
raise ValueError(
"IVF_HNSW_PQ is not supported on LanceDB cloud."
"Please use IVF_HNSW_SQ instead."
config = HnswPq(
distance_type=metric,
num_partitions=num_partitions,
num_sub_vectors=num_sub_vectors,
num_bits=num_bits,
)
elif idx_type == "IVF_HNSW_SQ":
config = HnswSq(distance_type=metric, num_partitions=num_partitions)
@@ -659,7 +656,7 @@ class RemoteTable(Table):
capped at the number of CPU cores. Lower this if bulk ingestion is
using too much memory.
allow_external_blob_outside_bases: bool, default False
Not supported on LanceDB Cloud. Setting this raises.
Not supported for remote tables. Setting this raises.
Returns
-------
@@ -689,7 +686,7 @@ class RemoteTable(Table):
vector_column_name: Optional[str] = None,
query_type="auto",
fts_columns: Optional[Union[str, List[str]]] = None,
fast_search: bool = False,
fast_search: Optional[bool] = None,
) -> LanceVectorQueryBuilder:
"""Create a search query to find the nearest neighbors
of the given query vector. We currently support
@@ -746,7 +743,7 @@ class RemoteTable(Table):
Skip a flat search of unindexed data. This may improve
search performance but search results will not include unindexed data.
- *default False*.
- *default None*, which leaves fast search disabled.
Returns
-------
@@ -781,7 +778,7 @@ class RemoteTable(Table):
query,
query_type,
vector_column_name=vector_column_name,
fts_columns=fts_columns,
fts_columns=fts_columns or [],
fast_search=fast_search,
)
@@ -956,26 +953,20 @@ class RemoteTable(Table):
def cleanup_old_versions(self, *_):
"""
cleanup_old_versions() is a no-op on LanceDB Cloud.
Tables are automatically cleaned up and optimized.
cleanup_old_versions() is a no-op for remote tables.
"""
warnings.warn(
"cleanup_old_versions() is a no-op on LanceDB Cloud. "
"Tables are automatically cleaned up and optimized.",
"cleanup_old_versions() is a no-op for remote tables.",
stacklevel=2,
)
pass
def compact_files(self, *_):
"""
compact_files() is a no-op on LanceDB Cloud.
Tables are automatically compacted and optimized.
compact_files() is a no-op for remote tables.
"""
warnings.warn(
"compact_files() is a no-op on LanceDB Cloud. "
"Tables are automatically compacted and optimized.",
"compact_files() is a no-op for remote tables.",
stacklevel=2,
)
pass
@@ -986,17 +977,13 @@ class RemoteTable(Table):
cleanup_older_than: Optional[timedelta] = None,
delete_unverified: bool = False,
):
"""
optimize() is a no-op on LanceDB Cloud.
Indices are optimized automatically.
"""
warnings.warn(
"optimize() is a no-op on LanceDB Cloud. "
"Indices are optimized automatically.",
stacklevel=2,
"""optimize() is not supported for remote tables."""
return LOOP.run(
self._table.optimize(
cleanup_older_than=cleanup_older_than,
delete_unverified=delete_unverified,
)
)
pass
def count_rows(self, filter: Optional[str] = None) -> int:
return LOOP.run(self._table.count_rows(filter))
@@ -1044,7 +1031,7 @@ class RemoteTable(Table):
return LOOP.run(self._table.drop_columns(columns))
def set_unenforced_primary_key(self, columns: Union[str, Iterable[str]]) -> None:
"""Not supported on LanceDB Cloud."""
"""Not supported for remote tables."""
return LOOP.run(self._table.set_unenforced_primary_key(columns))
def set_lsm_write_spec(self, spec: "LsmWriteSpec") -> None:
@@ -1090,7 +1077,7 @@ class RemoteTable(Table):
)
def close_lsm_writers(self) -> None:
"""No-op on LanceDB Cloud (no local shard writers)."""
"""No-op for remote tables (no local shard writers)."""
return LOOP.run(self._table.close_lsm_writers())
def drop_index(self, index_name: str):
@@ -1158,14 +1145,10 @@ class RemoteTable(Table):
return LanceTakeQueryBuilder(self._table.take_row_ids(row_ids))
def uses_v2_manifest_paths(self) -> bool:
raise NotImplementedError(
"uses_v2_manifest_paths() is not supported on the LanceDB Cloud"
)
return LOOP.run(self._table.uses_v2_manifest_paths())
def migrate_v2_manifest_paths(self):
raise NotImplementedError(
"migrate_v2_manifest_paths() is not supported on the LanceDB Cloud"
)
return LOOP.run(self._table.migrate_manifest_paths_v2())
def blob_columns(self) -> list[str]:
return LOOP.run(self._table.blob_columns())
@@ -1176,9 +1159,7 @@ class RemoteTable(Table):
return LOOP.run(self._table.fetch_blobs(column, row_ids))
def fetch_blob_ranges(self, column: str, requests) -> pa.LargeBinaryArray:
raise NotImplementedError(
"fetch_blob_ranges() is not supported on LanceDB Cloud"
)
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]
+79 -1
View File
@@ -3,8 +3,10 @@
"""Handles to SQL queries running on a remote database."""
from typing import Any, Mapping, Optional, Sequence, Union
from uuid import UUID
import numpy as np
import pyarrow as pa
from lancedb.background_loop import LOOP
@@ -14,6 +16,82 @@ from .arrow import AsyncRecordBatchReader
QueryDescription = _lancedb.QueryDescription
QueryParameters = Union[pa.RecordBatch, pa.Table, Sequence[Any], Mapping[str, Any]]
"""Values for a statement's ``$1`` / ``$name`` placeholders.
A sequence binds by position (``$1``, ``$2``, ...), a mapping binds by name
(``$name``), and a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds
both ways: column ``i`` is ``$<i + 1>`` and, when its name is not a number,
also ``$<name>``.
"""
def _parameter_array(value: Any) -> pa.Array:
"""One parameter as a one-element array of its own type."""
if isinstance(value, pa.Scalar):
return pa.array([value.as_py()], type=value.type)
# A 1-D array is a vector: keep its element type and make it the
# fixed-size list a vector column stores, rather than a list of Python
# floats that would come back as float64.
if isinstance(value, pa.ChunkedArray):
value = value.combine_chunks()
if isinstance(value, np.ndarray):
if value.ndim == 0:
return pa.array(value.reshape(1))
if value.ndim != 1:
raise ValueError(
"a numpy query parameter must be a scalar or a 1-D vector, "
f"got {value.ndim} dimensions"
)
value = pa.array(value)
if isinstance(value, pa.Array):
return pa.FixedSizeListArray.from_arrays(value, len(value))
return pa.array([value])
def to_parameter_batch(
parameters: Optional[QueryParameters],
) -> Optional[pa.RecordBatch]:
"""Turn ``parameters`` into the one-row batch a parameterized query sends.
Scalars keep the type pyarrow infers for them (numpy scalars keep their
dtype), a ``pyarrow.Scalar`` keeps its own type, and a 1-D numpy or
pyarrow array becomes a ``FixedSizeList`` of its element type, which is
what a vector column stores.
"""
if parameters is None:
return None
batch: pa.RecordBatch
if isinstance(parameters, pa.RecordBatch):
batch = parameters
elif isinstance(parameters, pa.Table):
batches = parameters.combine_chunks().to_batches()
batch = (
batches[0]
if len(batches) == 1
else pa.RecordBatch.from_pylist([], schema=parameters.schema)
)
elif isinstance(parameters, Mapping):
batch = pa.RecordBatch.from_arrays(
[_parameter_array(value) for value in parameters.values()],
names=[str(name) for name in parameters.keys()],
)
elif isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)):
batch = pa.RecordBatch.from_arrays(
[_parameter_array(value) for value in parameters],
names=[str(position + 1) for position in range(len(parameters))],
)
else:
raise TypeError(
"query parameters must be a sequence, a mapping, or a one-row "
f"pyarrow RecordBatch or Table, not {type(parameters).__name__}"
)
if batch.num_rows != 1:
raise ValueError(
f"query parameters must be exactly one row, got {batch.num_rows}"
)
return batch
class AsyncQuery:
"""A handle to a submitted SQL query on an asynchronous connection."""
@@ -85,4 +163,4 @@ class Query:
LOOP.run(self._inner.cancel())
__all__ = ["AsyncQuery", "Query", "QueryDescription"]
__all__ = ["AsyncQuery", "Query", "QueryDescription", "QueryParameters"]
+30 -10
View File
@@ -1602,8 +1602,8 @@ class Table(ABC):
language: str = "English",
max_token_length: Optional[int] = 40,
lower_case: bool = True,
stem: bool = True,
remove_stop_words: bool = True,
stem: Optional[bool] = None,
remove_stop_words: Optional[bool] = None,
custom_stop_words: Optional[List[str]] = None,
ascii_folding: bool = True,
ngram_min_length: int = 3,
@@ -1653,6 +1653,7 @@ class Table(ABC):
- "simple": Splits text by whitespace and punctuation.
- "whitespace": Split text by whitespace, but not punctuation.
- "raw": No tokenization. The entire text is treated as a single token.
- "code": Tokenizes source code and identifiers.
- "ngram": N-Gram tokenizer.
- "icu": ICU dictionary-based word segmentation.
- "icu/split": ICU segmentation with simple-style delimiter splitting.
@@ -1667,12 +1668,16 @@ class Table(ABC):
lower_case : bool, default True
Whether to convert the token to lower case. This makes queries
case-insensitive.
stem : bool, default True
stem : bool, optional
Whether to stem the token. Stemming reduces words to their root form.
For example, in English "running" and "runs" would both be reduced to "run".
remove_stop_words : bool, default True
``None`` uses the base tokenizer's default: False for ``code`` and
``ngram``, True otherwise.
remove_stop_words : bool, optional
Whether to remove stop words. Stop words are common words that are often
removed from text before indexing. For example, in English "the" and "and".
``None`` uses the base tokenizer's default: False for ``code`` and
``ngram``, True otherwise.
custom_stop_words : list of str, optional
Custom words that replace the built-in language stop words. ``None``
uses the built-in list; an empty list explicitly uses no stop words.
@@ -3808,8 +3813,8 @@ class LanceTable(Table):
language: str = "English",
max_token_length: Optional[int] = 40,
lower_case: bool = True,
stem: bool = True,
remove_stop_words: bool = True,
stem: Optional[bool] = None,
remove_stop_words: Optional[bool] = None,
custom_stop_words: Optional[List[str]] = None,
ascii_folding: bool = True,
ngram_min_length: int = 3,
@@ -5597,7 +5602,11 @@ class AsyncTable:
return AsyncQuery(self._inner.query(), self)
async def to_lance(self, **kwargs) -> lance.LanceDataset:
"""Return the Lance dataset backing this table.
"""Return the Lance dataset backing a local table.
Remote tables cannot be opened as Lance datasets by the client. Use
[to_arrow][lancedb.table.AsyncTable.to_arrow] or
[query][lancedb.table.AsyncTable.query] to read them through the server.
Parameters
----------
@@ -5614,6 +5623,12 @@ class AsyncTable:
>>> async def get_lance_dataset(table):
... return await table.to_lance()
"""
if not self._inner._is_native():
raise NotImplementedError(
"to_lance() is not supported for remote tables; "
"query the server instead"
)
try:
import lance
except ImportError:
@@ -5643,7 +5658,8 @@ class AsyncTable:
Parameters
----------
blob_mode: str, default "lazy"
Controls how Lance blob columns are returned.
Controls how Lance blob columns are returned. Remote tables support
"descriptions"; "bytes" and "lazy" are not yet supported.
**kwargs
Forwarded to PyArrow / Lance pandas conversion.
@@ -5661,6 +5677,9 @@ class AsyncTable:
)
return arrow_tbl.to_pandas(**kwargs)
if not self._inner._is_native():
return await self.query().to_pandas(blob_mode=blob_mode, **kwargs)
if blob_mode == "lazy" and get_uri_scheme(await self.uri()) == "memory":
return (await self.to_arrow()).to_pandas(**kwargs)
if blob_mode == "bytes" and blob_v2_column_paths(schema):
@@ -6420,9 +6439,10 @@ class AsyncTable:
)
if query.distance_type is not None:
async_query = async_query.distance_type(query.distance_type)
if query.nprobes is not None:
async_query = async_query.nprobes(query.nprobes)
if query.minimum_nprobes is not None and query.maximum_nprobes is not None:
# Set both to the minimum first to avoid min > max error.
async_query = async_query.nprobes(
async_query = async_query.minimum_nprobes(
query.minimum_nprobes
).maximum_nprobes(query.maximum_nprobes)
elif query.minimum_nprobes is not None:
+34
View File
@@ -245,6 +245,40 @@ def test_db_contains_and_len_include_all_table_name_pages(tmp_db: lancedb.DBConn
assert "does_not_exist" not in tmp_db
@pytest.mark.asyncio
async def test_table_listing_defaults_and_zero_limit(tmp_path):
names = [f"t{i:03}" for i in range(130)]
for name in names:
(tmp_path / f"{name}.lance").mkdir()
db = lancedb.connect(tmp_path)
assert db.table_names() == names
assert db.table_names(limit=None) == names
assert db.table_names(limit=0) == []
first = db.list_tables()
assert first.tables == names[:100]
assert first.page_token
second = db.list_tables(page_token=first.page_token)
assert second.tables == names[100:]
assert second.page_token is None
assert db.list_tables(limit=200).tables == names
empty = db.list_tables(limit=0)
assert empty.tables == []
assert empty.page_token is None
adb = await lancedb.connect_async(tmp_path)
assert await adb.table_names() == names
assert await adb.table_names(limit=0) == []
first = await adb.list_tables()
assert first.tables == names[:100]
second = await adb.list_tables(page_token=first.page_token)
assert second.tables == names[100:]
assert second.page_token is None
empty = await adb.list_tables(limit=0)
assert empty.tables == []
assert empty.page_token is None
def test_db_contains_stops_after_matching_table_page(
tmp_db: lancedb.DBConnection, monkeypatch
):
+65
View File
@@ -2,13 +2,78 @@
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import os
from datetime import timedelta
from uuid import uuid4
import lancedb
import numpy as np
import pyarrow as pa
import pytest
from lancedb import LanceDBConnection
from lancedb.index import FTS
# TODO: setup integ test mark and script
@pytest.mark.skipif(
not os.environ.get("LANCEDB_URI"), reason="Set LANCEDB_URI to a JSON FTS server"
)
def test_json_fts_against_server():
"""Requires a writable database and capacity to run the distributed indexer.
Configure LANCEDB_URI, LANCEDB_API_KEY, and optionally LANCEDB_REGION and
LANCEDB_HOST_OVERRIDE before running this test.
"""
uri = os.environ["LANCEDB_URI"]
assert uri.startswith("db://"), "LANCEDB_URI must identify a remote database"
conn = lancedb.connect(
uri,
api_key=os.environ.get("LANCEDB_API_KEY"),
region=os.environ.get("LANCEDB_REGION", "us-east-1"),
host_override=os.environ.get("LANCEDB_HOST_OVERRIDE"),
)
name = f"json_fts_{uuid4().hex}"
table = conn.create_table(
name,
pa.table(
{
"id": [0, 1, 2],
"doc": pa.array(
[
'{"meta":{"tag":"nature"},"author":"alice"}',
'{"meta":{"tag":"city"},"author":"nature"}',
'{"meta":{"tag":"nature"},"author":"bob"}',
],
type=pa.json_(),
),
"raw": pa.array([b"nature"] * 3, type=pa.large_binary()),
}
),
)
try:
table.create_index("doc", config=FTS(), name="doc_fts")
table.wait_for_index(["doc_fts"], timeout=timedelta(minutes=5))
stats = table.index_stats("doc_fts")
assert stats.num_indexed_rows == 3
assert stats.num_unindexed_rows == 0
assert stats.index_type == "FTS"
for query, expected in [
("meta.tag,str,nature", [0, 2]),
("meta.tag,str,alice", []),
("meta.tag,str,tag", []),
]:
search = table.search(query, query_type="fts", fts_columns="doc")
assert sorted(row["id"] for row in search.to_list()) == expected
plan = search.explain_plan(True)
assert "MatchQuery" in plan, plan
assert "FlatMatchQuery" not in plan, plan
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
table.create_index("raw", config=FTS())
finally:
conn.drop_table(name)
@pytest.mark.skip(reason="Need to set up a local server")
def test_against_local_server():
conn = LanceDBConnection("lancedb+http://localhost:10024")
@@ -1555,7 +1555,7 @@ def _mock_remote_function_catalog():
)["image"],
"signature": body["signature"],
"secret_bindings": body.get("secret_bindings", []),
"created_at": "2026-08-21T00:00:00Z",
"created_at_millis": 1787270400000, # 2026-08-21T00:00:00Z
}
response = {"job_id": "job-register"}
status = 202
+170
View File
@@ -13,6 +13,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import os
import random
import shutil
@@ -44,6 +45,75 @@ from utils import exception_output
TEST_LANGUAGE_MODEL_HOME = Path(__file__).parent / "models"
@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type")
@pytest.mark.parametrize("block_size", [128, 256])
def test_json_fts(tmp_path, block_size):
db = ldb.connect(tmp_path)
data = pa.table(
{
"id": [0, 1, 2, 3],
"doc": pa.array(
[
'{"title":"brown fox","meta":{"tag":"nature"},"author":"alice"}',
'{"title":"blue car","meta":{"tag":"city"}}',
'{"title":"brown bear","meta":{"tag":"nature"}}',
'{"title":"red fox","author":"alice"}',
],
type=pa.json_(),
),
}
)
table = db.create_table("json_fts", data)
table.create_index("doc", config=FTS(block_size=block_size), name="doc_fts")
table = db.open_table("json_fts")
index = table.list_indices()[0]
assert index.name == "doc_fts"
assert index.index_type == "FTS"
assert index.columns == ["doc"]
stats = table.index_stats("doc_fts")
assert stats.num_indexed_rows == 4
assert stats.num_unindexed_rows == 0
for query, expected in [
("title,str,brown", [0, 2]),
("meta.tag,str,nature", [0, 2]),
("author,str,alice", [0, 3]),
("title,str,alice", []),
("title,str,title", []),
]:
search = table.search(query, query_type="fts", fts_columns="doc")
assert sorted(row["id"] for row in search.to_list()) == expected
plan = search.explain_plan(True)
assert "MatchQuery" in plan, plan
assert "FlatMatchQuery" not in plan, plan
@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type")
@pytest.mark.asyncio
async def test_json_fts_async(tmp_path):
db = await ldb.connect_async(tmp_path)
table = await db.create_table(
"json_fts",
pa.table({"doc": pa.array(['{"meta":{"tag":"nature"}}'], type=pa.json_())}),
)
await table.create_index("doc", config=FTS())
search = table.query().nearest_to_text(MatchQuery("meta.tag,str,nature", "doc"))
assert len(await search.to_list()) == 1
plan = await search.explain_plan(True)
assert "MatchQuery" in plan, plan
assert "FlatMatchQuery" not in plan, plan
@pytest.mark.parametrize("dtype", [pa.binary(), pa.large_binary()])
def test_fts_rejects_raw_binary(tmp_path, dtype):
table = ldb.connect(tmp_path).create_table(
"binary_fts", pa.table({"doc": pa.array([b'{"title":"brown"}'], type=dtype)})
)
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
table.create_index("doc", config=FTS())
assert table.list_indices() == []
@pytest.fixture
def table(tmp_path) -> ldb.table.LanceTable:
# Use local random state to avoid affecting other tests
@@ -894,6 +964,78 @@ def test_tokenize_uses_simple_index_tokenizer(mem_db: DBConnection):
]
@pytest.mark.parametrize("legacy", [False, True])
@pytest.mark.parametrize(
("base_tokenizer", "options", "expected_filters", "expected_tokens"),
[
(
"code",
{},
(False, False),
[("running", 0), ("in", 1), ("cafe", 2)],
),
(
"code",
{"stem": None, "remove_stop_words": None},
(False, False),
[("running", 0), ("in", 1), ("cafe", 2)],
),
(
"code",
{"stem": True},
(True, False),
[("run", 0), ("in", 1), ("cafe", 2)],
),
(
"code",
{"remove_stop_words": True},
(False, True),
[("running", 0), ("cafe", 2)],
),
(
"code",
{"stem": True, "remove_stop_words": True},
(True, True),
[("run", 0), ("cafe", 2)],
),
(
"simple",
{},
(True, True),
[("run", 0), ("cafe", 2)],
),
(
"simple",
{"stem": False, "remove_stop_words": False},
(False, False),
[("running", 0), ("in", 1), ("cafe", 2)],
),
],
)
def test_fts_tokenizer_filter_defaults(
mem_db, legacy, base_tokenizer, options, expected_filters, expected_tokens
):
text = "Running in café"
table = mem_db.create_table("test_filter_defaults", data=[{"text": text}])
if legacy:
with pytest.warns(DeprecationWarning, match="create_fts_index"):
table.create_fts_index("text", base_tokenizer=base_tokenizer, **options)
else:
table.create_index("text", config=FTS(base_tokenizer=base_tokenizer, **options))
details = table.list_indices()[0].index_details
assert (details["stem"], details["remove_stop_words"]) == expected_filters
assert [
(token.text, token.position) for token in table.tokenize(text, column="text")
] == expected_tokens
assert [
(token.text, token.position)
for token in ldb.tokenize(text, base_tokenizer=base_tokenizer, **options)
] == expected_tokens
if base_tokenizer == "code" and not expected_filters[1]:
assert table.search("in", query_type="fts").to_list()[0]["text"] == text
def test_tokenize_uses_icu_index_tokenizer_by_name(mem_db: DBConnection):
data = pa.table({"text": ["Hello, こんにちは世界!"]})
table = mem_db.create_table("test_tokenize_icu", data=data)
@@ -1137,6 +1279,34 @@ def test_fts_lindera_tokenizer(
assert [row["text"] for row in results] == ["成田国際空港"]
@pytest.mark.parametrize("fuzziness", [None, 0, 1, 2])
def test_match_query_fuzziness_to_json(fuzziness):
query = MatchQuery("puppy", "text", fuzziness=fuzziness)
assert query.fuzziness == fuzziness
assert json.loads(query.to_json())["match"]["fuzziness"] == fuzziness
@pytest.mark.parametrize(
"options, expected",
[
pytest.param({}, ["ab"], id="default-exact"),
pytest.param({"fuzziness": 0}, ["ab"], id="explicit-exact"),
pytest.param({"fuzziness": None}, ["ab"], id="automatic"),
pytest.param({"fuzziness": 1}, ["ab", "ac"], id="one-edit"),
pytest.param({"fuzziness": 2}, ["ab", "ac", "zz"], id="two-edits"),
],
)
def test_match_query_fuzziness_search(tmp_db, options, expected):
# Automatic fuzziness is zero for two-character terms.
table = tmp_db.create_table("fuzzy", pa.table({"text": ["ab", "ac", "zz"]}))
table.create_index("text", config=FTS(stem=False, remove_stop_words=False))
results = table.search(MatchQuery("ab", "text", **options)).to_list()
assert sorted(row["text"] for row in results) == sorted(expected)
def test_fts_query_to_json():
"""Test that FTS query to_json() produces valid JSON strings with exact format."""
+14
View File
@@ -355,6 +355,20 @@ def test_hybrid_query_minimum_nprobes_zero_raises(sync_table: Table):
)
def test_hybrid_query_nprobes_remains_independent(sync_table: Table):
query = sync_table.search(query_type="hybrid").nprobes(20)
assert query._nprobes == 20
assert query._minimum_nprobes is None
assert query._maximum_nprobes is None
query = query.minimum_nprobes(5).nprobes(20)
assert query._nprobes == 20
assert query._minimum_nprobes == 5
assert query._maximum_nprobes is None
def test_hybrid_query_distance_range(sync_table: Table):
reranker = RRFReranker(return_score="all")
result = (
+155 -1
View File
@@ -6,12 +6,17 @@
import tempfile
import shutil
import importlib
import http.server
import json
import threading
from urllib.parse import parse_qs, unquote, urlparse
import pytest
import pyarrow as pa
import lancedb
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from lancedb.listing import AsyncListing, Listing
from lancedb.namespace import _MAX_QUERY_K
from lancedb.namespace import _MAX_QUERY_K, _query_to_namespace_request
from lancedb.query import Query
from lancedb.table import AsyncTable, LanceTable
@@ -73,6 +78,132 @@ def _namespace_lance_table(namespace_client: _NamespaceClient) -> LanceTable:
return table
@pytest.mark.asyncio
@pytest.mark.parametrize("namespace_path", [[], ["test_ns"]])
async def test_namespace_table_names_defaults_and_limits(tmp_path, namespace_path):
db = lancedb.connect_namespace("dir", {"root": str(tmp_path)})
if namespace_path:
db.create_namespace(namespace_path)
names = [f"t{idx:03}" for idx in range(12)]
schema = pa.schema([pa.field("id", pa.int64())])
for name in names:
db.create_table(name, schema=schema, namespace_path=namespace_path)
assert list(db.table_names(namespace_path=namespace_path)) == names
assert list(db.table_names(limit=None, namespace_path=namespace_path)) == names
assert list(db.table_names(limit=0, namespace_path=namespace_path)) == []
assert list(db.table_names(limit=2, namespace_path=namespace_path)) == names[:2]
assert (
list(
db.table_names(page_token=names[1], limit=2, namespace_path=namespace_path)
)
== names[2:4]
)
adb = lancedb.connect_namespace_async("dir", {"root": str(tmp_path)})
assert list(await adb.table_names(namespace_path=namespace_path)) == names
assert (
list(await adb.table_names(limit=None, namespace_path=namespace_path)) == names
)
assert list(await adb.table_names(limit=0, namespace_path=namespace_path)) == []
assert (
list(await adb.table_names(limit=2, namespace_path=namespace_path)) == names[:2]
)
assert (
list(
await adb.table_names(
page_token=names[1], limit=2, namespace_path=namespace_path
)
)
== names[2:4]
)
@pytest.fixture
def paginated_rest_namespace():
names = [f"t{idx:03}" for idx in reversed(range(12))]
requests = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
query = parse_qs(url.query)
requests.append((unquote(url.path), query))
token = query.get("page_token", [None])[0]
if token not in {None, "opaque-next"}:
self.send_error(400, "Unknown page token")
return
start = 0 if token is None else 10
body = {"tables": names[start : start + 10]}
if start == 0:
body["page_token"] = "opaque-next"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def log_message(self, *args):
pass
with http.server.HTTPServer(("127.0.0.1", 0), Handler) as server:
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
yield {"uri": f"http://127.0.0.1:{server.server_port}"}, requests
finally:
server.shutdown()
thread.join()
@pytest.mark.asyncio
@pytest.mark.parametrize("namespace_path", [[], ["n"]])
async def test_rest_namespace_table_names_collects_pages(
paginated_rest_namespace, namespace_path
):
properties, requests = paginated_rest_namespace
names = [f"t{idx:03}" for idx in range(12)]
db = lancedb.connect_namespace("rest", properties)
first = db.list_tables(namespace_path=namespace_path)
second = db.list_tables(namespace_path=namespace_path, page_token=first.page_token)
assert len(first.tables) == 10
assert first.page_token == "opaque-next"
assert sorted(first.tables + second.tables) == names
assert second.page_token is None
assert list(db.table_names(namespace_path=namespace_path)) == names
assert list(db.table_names(limit=None, namespace_path=namespace_path)) == names
assert list(db.table_names(limit=11, namespace_path=namespace_path)) == names[:11]
assert (
list(db.table_names(page_token="t005", limit=2, namespace_path=namespace_path))
== names[6:8]
)
adb = lancedb.connect_namespace_async("rest", properties)
assert list(await adb.table_names(namespace_path=namespace_path)) == names
assert (
list(await adb.table_names(limit=None, namespace_path=namespace_path)) == names
)
assert (
list(await adb.table_names(limit=11, namespace_path=namespace_path))
== names[:11]
)
assert (
list(
await adb.table_names(
page_token="t005", limit=2, namespace_path=namespace_path
)
)
== names[6:8]
)
request_count = len(requests)
assert list(db.table_names(limit=0, namespace_path=namespace_path)) == []
assert list(await adb.table_names(limit=0, namespace_path=namespace_path)) == []
assert len(requests) == request_count
identifier = "$" if not namespace_path else "n"
assert all(path == f"/v1/namespace/{identifier}/table/list" for path, _ in requests)
class TestNamespaceConnection:
"""Test namespace-based LanceDB connection using DirectoryNamespace."""
@@ -981,6 +1112,29 @@ class TestPushdownOperations:
]
assert all(r.k <= 2**31 - 1 for r in namespace_client.requests)
def test_probe_fields_pass_through_independently(self):
request = _query_to_namespace_request(
["geneva", "hist"],
Query(
vector=[1.0, 2.0],
nprobes=20,
minimum_nprobes=3,
maximum_nprobes=10,
),
)
assert request.nprobes == 20
assert request.minimum_nprobes == 3
assert request.maximum_nprobes == 10
request = _query_to_namespace_request(
["geneva", "hist"],
Query(vector=[1.0, 2.0], nprobes=5, maximum_nprobes=0),
)
assert request.nprobes == 5
assert request.maximum_nprobes == 0
assert request.to_dict()["maximum_nprobes"] == 0
@pytest.mark.asyncio
class TestAsyncPushdownOperations:
+88 -28
View File
@@ -995,12 +995,22 @@ def test_invalid_nprobes_sync(table):
ValueError,
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
):
LanceVectorQueryBuilder(table, [0, 0], "vector").maximum_nprobes(5).to_list()
(
LanceVectorQueryBuilder(table, [0, 0], "vector")
.minimum_nprobes(10)
.maximum_nprobes(5)
.to_list()
)
with pytest.raises(
ValueError,
match="minimum_nprobes must be less than or equal to maximum_nprobes",
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
):
LanceVectorQueryBuilder(table, [0, 0], "vector").minimum_nprobes(100).to_list()
(
LanceVectorQueryBuilder(table, [0, 0], "vector")
.maximum_nprobes(5)
.minimum_nprobes(100)
.to_list()
)
def test_nprobes_works_sync(table):
@@ -1039,12 +1049,22 @@ async def test_invalid_nprobes_async(table_async: AsyncTable):
ValueError,
match="maximum_nprobes must be greater than or equal to minimum_nprobes",
):
await table_async.vector_search([0, 0]).maximum_nprobes(5).to_list()
await (
table_async.vector_search([0, 0])
.minimum_nprobes(10)
.maximum_nprobes(5)
.to_list()
)
with pytest.raises(
ValueError,
match="minimum_nprobes must be less than or equal to maximum_nprobes",
):
await table_async.vector_search([0, 0]).minimum_nprobes(100).to_list()
await (
table_async.vector_search([0, 0])
.maximum_nprobes(5)
.minimum_nprobes(100)
.to_list()
)
def test_query_builder_with_prefilter(table):
@@ -1635,8 +1655,9 @@ def test_query_serialization_sync(table: lancedb.table.Table):
q,
vector_column="vector",
vector=[5.0, 6.0],
minimum_nprobes=10,
maximum_nprobes=10,
nprobes=10,
minimum_nprobes=None,
maximum_nprobes=None,
refine_factor=5,
)
@@ -1654,8 +1675,19 @@ def test_query_serialization_sync(table: lancedb.table.Table):
q,
vector_column="vector",
vector=[5.0, 6.0],
minimum_nprobes=50,
maximum_nprobes=50,
nprobes=50,
minimum_nprobes=None,
maximum_nprobes=None,
)
q = table.search([5.0, 6.0]).minimum_nprobes(5).nprobes(50).to_query_object()
check_set_props(
q,
vector_column="vector",
vector=[5.0, 6.0],
nprobes=50,
minimum_nprobes=5,
maximum_nprobes=None,
)
q = table.search([5.0, 6.0]).maximum_nprobes(10).to_query_object()
@@ -1667,6 +1699,15 @@ def test_query_serialization_sync(table: lancedb.table.Table):
minimum_nprobes=None,
)
q = table.search([5.0, 6.0]).nprobes(5).maximum_nprobes(0).to_query_object()
check_set_props(
q,
vector_column="vector",
vector=[5.0, 6.0],
nprobes=5,
maximum_nprobes=0,
)
q = table.search([5.0, 6.0]).distance_range(0.0, 1.0).to_query_object()
check_set_props(
q, vector_column="vector", vector=[5.0, 6.0], lower_bound=0.0, upper_bound=1.0
@@ -1716,8 +1757,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
limit=10,
vector=sample_vector,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
)
@@ -1727,8 +1768,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
q,
vector=sample_vector,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
@@ -1739,8 +1780,9 @@ async def test_query_serialization_async(table_async: AsyncTable):
q,
vector=sample_vector,
postfilter=False,
minimum_nprobes=50,
maximum_nprobes=50,
nprobes=50,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
@@ -1759,8 +1801,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
filter="id = 1",
postfilter=True,
vector=sample_vector,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
)
@@ -1774,8 +1816,9 @@ async def test_query_serialization_async(table_async: AsyncTable):
check_set_props(
q,
vector=sample_vector,
minimum_nprobes=10,
maximum_nprobes=10,
nprobes=10,
minimum_nprobes=None,
maximum_nprobes=None,
refine_factor=5,
postfilter=False,
with_row_id=False,
@@ -1788,7 +1831,24 @@ async def test_query_serialization_async(table_async: AsyncTable):
q,
vector=sample_vector,
minimum_nprobes=5,
maximum_nprobes=20,
maximum_nprobes=None,
postfilter=False,
with_row_id=False,
bypass_vector_index=False,
limit=10,
)
q = (
(await table_async.search([5.0, 6.0]))
.nprobes(5)
.maximum_nprobes(0)
.to_query_object()
)
check_set_props(
q,
vector=sample_vector,
nprobes=5,
maximum_nprobes=0,
postfilter=False,
with_row_id=False,
bypass_vector_index=False,
@@ -1806,8 +1866,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
lower_bound=0.0,
upper_bound=1.0,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
@@ -1819,8 +1879,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
distance_type="cosine",
vector=sample_vector,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
@@ -1832,8 +1892,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
ef=7,
vector=sample_vector,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
bypass_vector_index=False,
limit=10,
@@ -1845,8 +1905,8 @@ async def test_query_serialization_async(table_async: AsyncTable):
bypass_vector_index=True,
vector=sample_vector,
postfilter=False,
minimum_nprobes=20,
maximum_nprobes=20,
minimum_nprobes=None,
maximum_nprobes=None,
with_row_id=False,
limit=10,
)
+588 -26
View File
@@ -13,13 +13,19 @@ import threading
import time
from unittest.mock import MagicMock, patch
import uuid
from urllib.parse import parse_qs, urlparse
from packaging.version import Version
import lancedb
import numpy as np
from lancedb.conftest import MockNonNormTextEmbeddingFunction, MockTextEmbeddingFunction
from lancedb.embeddings import EmbeddingFunctionConfig, EmbeddingFunctionRegistry
from lancedb.query import AsyncQuery, ColumnOrdering, LanceVectorQueryBuilder
from lancedb.query import (
AsyncQuery,
ColumnOrdering,
LanceVectorQueryBuilder,
MatchQuery,
)
from lancedb.remote import ClientConfig
from lancedb.remote.errors import HttpError, RetryError
import pytest
@@ -100,6 +106,67 @@ async def mock_lancedb_connection_async(handler, **client_config):
handle.join()
def paginated_table_listing_handler(request):
url = urlparse(request.path)
assert url.path in {
"/v1/table/",
"/v1/namespace/$/table/list",
"/v1/namespace/n/table/list",
}
query = parse_qs(url.query)
limit = int(query.get("limit", [10])[0])
assert limit > 0
token = query.get("page_token", ["page-0"])[0]
start = int(token.removeprefix("page-"))
end = min(start + limit, 130)
body = {"tables": [f"t{i:03}" for i in range(start, end)]}
if end < 130:
body["page_token"] = f"page-{end}"
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(json.dumps(body).encode())
@pytest.mark.parametrize("namespace", [[], ["n"]])
def test_remote_table_listing_defaults_and_zero_limit(namespace):
names = [f"t{i:03}" for i in range(130)]
with mock_lancedb_connection(paginated_table_listing_handler) as db:
assert db.table_names(namespace_path=namespace) == names
assert db.table_names(limit=None, namespace_path=namespace) == names
assert db.table_names(limit=0, namespace_path=namespace) == []
first = db.list_tables(namespace_path=namespace)
assert first.tables == names[:100]
assert first.page_token == "page-100"
second = db.list_tables(namespace_path=namespace, page_token=first.page_token)
assert second.tables == names[100:]
assert second.page_token is None
assert db.list_tables(namespace_path=namespace, limit=200).tables == names
empty = db.list_tables(namespace_path=namespace, limit=0)
assert empty.tables == []
assert empty.page_token is None
@pytest.mark.asyncio
@pytest.mark.parametrize("namespace", [[], ["n"]])
async def test_async_remote_table_listing_defaults_and_zero_limit(namespace):
names = [f"t{i:03}" for i in range(130)]
async with mock_lancedb_connection_async(paginated_table_listing_handler) as db:
assert await db.table_names(namespace_path=namespace) == names
assert await db.table_names(limit=0, namespace_path=namespace) == []
first = await db.list_tables(namespace_path=namespace)
assert first.tables == names[:100]
assert first.page_token == "page-100"
second = await db.list_tables(
namespace_path=namespace, page_token=first.page_token
)
assert second.tables == names[100:]
assert second.page_token is None
empty = await db.list_tables(namespace_path=namespace, limit=0)
assert empty.tables == []
assert empty.page_token is None
@pytest.mark.asyncio
async def test_async_remote_db():
def handler(request):
@@ -1018,6 +1085,77 @@ def test_table_create_indices():
table.drop_index("custom_fts_idx")
@pytest.mark.parametrize("legacy", [False, True])
@pytest.mark.parametrize(
("base_tokenizer", "options", "expected_filters"),
[
("code", {}, (False, False)),
("code", {"stem": None, "remove_stop_words": None}, (False, False)),
("code", {"stem": True}, (True, False)),
("code", {"remove_stop_words": True}, (False, True)),
("code", {"stem": True, "remove_stop_words": True}, (True, True)),
("simple", {}, (True, True)),
("simple", {"stem": False, "remove_stop_words": False}, (False, False)),
],
)
def test_remote_fts_tokenizer_filter_defaults(
legacy, base_tokenizer, options, expected_filters
):
from lancedb.index import FTS
received_requests = []
def handler(request):
if request.path == "/v1/table/test/create_index/":
content_len = int(request.headers["Content-Length"])
received_requests.append(json.loads(request.rfile.read(content_len)))
request.send_response(200)
request.end_headers()
elif request.path == "/v1/table/test/create/?mode=create":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b"{}")
elif request.path == "/v1/table/test/describe/":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
{
"version": 1,
"schema": {
"fields": [
{
"name": "text",
"type": {"type": "string"},
"nullable": False,
}
]
},
}
).encode()
)
else:
request.send_response(404)
request.end_headers()
with mock_lancedb_connection(handler) as db:
table = db.create_table("test", [{"text": "Running in cafes"}])
if legacy:
with pytest.warns(DeprecationWarning, match="create_fts_index"):
table.create_fts_index("text", base_tokenizer=base_tokenizer, **options)
else:
table.create_index(
"text", config=FTS(base_tokenizer=base_tokenizer, **options)
)
assert len(received_requests) == 1
params = received_requests[0]
assert params["base_tokenizer"] == base_tokenizer
assert (params["stem"], params["remove_stop_words"]) == expected_filters
def test_remote_create_index_async_returns_job():
from lancedb.index import BTree
@@ -1272,7 +1410,73 @@ def test_remote_job_wait_raises_on_failure():
job.wait()
def test_remote_create_index_new_api():
@pytest.mark.parametrize("server_version", ["0.2.0", "0.6.0"])
def test_remote_json_fts_config(server_version):
from lancedb.index import FTS
received_requests = []
def handler(request):
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.send_header("phalanx-version", server_version)
request.end_headers()
if request.path == "/v1/table/test/describe/":
fields = [
{
"name": "doc",
"type": {"type": "large_binary"},
"nullable": True,
"metadata": {"ARROW:extension:name": "lance.json"},
},
{
"name": "raw",
"type": {"type": "large_binary"},
"nullable": True,
},
]
request.wfile.write(
json.dumps({"version": 1, "schema": {"fields": fields}}).encode()
)
elif request.path == "/v1/table/test/create_index/":
received_requests.append(read_json_body(request))
request.wfile.write(b"{}")
else:
raise AssertionError(f"Unexpected request: {request.path}")
with mock_lancedb_connection(handler) as db:
table = db.open_table("test")
table.create_index(
"doc",
config=FTS(
with_position=True,
base_tokenizer="whitespace",
stem=False,
remove_stop_words=False,
lower_case=False,
block_size=256,
),
name="doc_fts",
replace=False,
)
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
table.create_index("raw", config=FTS())
assert len(received_requests) == 1
request = received_requests[0]
assert request["column"] == "doc"
assert request["index_type"] == "FTS"
assert request["name"] == "doc_fts"
assert request["replace"] is False
assert request["with_position"] is True
assert request["base_tokenizer"] == "whitespace"
assert request["stem"] is False
assert request["remove_stop_words"] is False
assert request["lower_case"] is False
assert request["block_size"] == 256
def test_remote_create_index_new_api(caplog):
received_requests = []
def handler(request):
@@ -1333,7 +1537,8 @@ def test_remote_create_index_new_api():
from lancedb.index import BTree, FTS, IvfPq, IvfRq
with mock_lancedb_connection(handler) as db:
table = db.create_table("test", [{"id": 1}])
table = db.create_table("test", [{"id": 1}], embedding_functions=[])
assert "embedding_functions is not yet supported" not in caplog.text
# New API: column-first, config= kwarg. Should NOT emit DeprecationWarning.
import warnings as _warnings
@@ -1355,6 +1560,8 @@ def test_remote_create_index_new_api():
vector_column_name="vector",
index_type="IVF_RQ",
num_partitions=8,
replace=False,
accelerator="cuda",
)
assert len(received_requests) == 6
@@ -1368,6 +1575,160 @@ def test_remote_create_index_new_api():
]
assert received_requests[2]["block_size"] == 256
assert received_requests[4]["replace"] is False
assert received_requests[5]["replace"] is False
assert "replace is not supported" not in caplog.text
assert caplog.messages == [
"GPU accelerator is not supported for remote tables and will be ignored."
]
def remote_unsupported_handler(request):
assert request.path == "/v1/table/test/describe/"
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
dict(
version=1,
schema=dict(
fields=[
dict(name="id", type={"type": "int64"}, nullable=False),
dict(
name="vector",
type={
"type": "fixed_size_list",
"fields": [
dict(
name="item",
type={"type": "float"},
nullable=True,
)
],
"length": 2,
},
nullable=False,
),
]
),
)
).encode()
)
def remote_unsupported_operations():
from lancedb.index import HnswPq
return [
("uses_v2_manifest_paths", (), {}, "uses_v2_manifest_paths"),
("migrate_v2_manifest_paths", (), {}, "migrate_manifest_paths_v2"),
("set_unenforced_primary_key", (["id"],), {}, "set_unenforced_primary_key"),
("fetch_blob_ranges", ("image", [(1, 0, 1)]), {}, "fetch_blob_ranges"),
("optimize", (), {}, "optimize"),
("create_index", ("vector",), {"config": HnswPq()}, "IVF_HNSW_PQ"),
]
@pytest.mark.parametrize(
"method,args,kwargs,operation", remote_unsupported_operations()
)
def test_remote_unsupported_operations_sync(method, args, kwargs, operation):
with mock_lancedb_connection(remote_unsupported_handler) as db:
table = db.open_table("test")
with pytest.raises(NotImplementedError) as error:
getattr(table, method)(*args, **kwargs)
expected = f"LanceDBError: not supported: {operation} is not supported "
expected += "for remote tables."
if operation == "IVF_HNSW_PQ":
expected += " Please use IVF_HNSW_SQ instead."
assert str(error.value) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,args,kwargs,operation", remote_unsupported_operations()
)
async def test_remote_unsupported_operations_async(method, args, kwargs, operation):
async with mock_lancedb_connection_async(remote_unsupported_handler) as db:
table = await db.open_table("test")
if method == "migrate_v2_manifest_paths":
method = "migrate_manifest_paths_v2"
with pytest.raises(NotImplementedError) as error:
await getattr(table, method)(*args, **kwargs)
expected = f"LanceDBError: not supported: {operation} is not supported "
expected += "for remote tables."
if operation == "IVF_HNSW_PQ":
expected += " Please use IVF_HNSW_SQ instead."
assert str(error.value) == expected
def test_remote_unsupported_legacy_hnsw_pq():
with mock_lancedb_connection(remote_unsupported_handler) as db:
table = db.open_table("test")
with pytest.warns(DeprecationWarning, match="create_index"):
with pytest.raises(
NotImplementedError,
match="IVF_HNSW_PQ is not supported for remote tables. "
"Please use IVF_HNSW_SQ instead.",
):
table.create_index(
vector_column_name="vector", index_type="IVF_HNSW_PQ"
)
def test_remote_python_only_messages(caplog):
with mock_lancedb_connection(remote_unsupported_handler) as db:
with caplog.at_level("INFO"):
table = db.open_table("test", storage_options={}, index_cache_size=1)
assert "storage_options is ignored for remote tables" in caplog.text
assert "index_cache_size is ignored for remote tables" in caplog.text
for method in ["to_arrow", "to_pandas"]:
with pytest.raises(NotImplementedError) as error:
getattr(table, method)()
assert str(error.value) == f"{method}() is not supported for remote tables."
for method in ["compact_files", "cleanup_old_versions"]:
with pytest.warns(UserWarning) as warning:
getattr(table, method)()
assert (
str(warning[0].message) == f"{method}() is a no-op for remote tables."
)
def test_remote_embedding_config_does_not_warn_or_drop_metadata(caplog):
received_tables = []
def handler(request):
assert request.path == "/v1/table/test/create/?mode=create"
if request.headers.get("Transfer-Encoding") == "chunked":
body = bytearray()
while size := int(request.rfile.readline().split(b";")[0], 16):
body.extend(request.rfile.read(size))
assert request.rfile.read(2) == b"\r\n"
assert request.rfile.read(2) == b"\r\n"
else:
body = request.rfile.read(int(request.headers["Content-Length"]))
received_tables.append(pa.ipc.open_stream(body).read_all())
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b"{}")
config = EmbeddingFunctionConfig(
source_column="text",
vector_column="vector",
function=MockNonNormTextEmbeddingFunction.create(),
)
schema = pa.schema(
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
)
with mock_lancedb_connection(handler) as db:
db.create_table("test", schema=schema, embedding_functions=[config])
assert caplog.messages == []
assert len(received_tables) == 1
metadata = EmbeddingFunctionRegistry.get_instance().get_table_metadata([config])
assert (
received_tables[0].schema.metadata == pa.schema([], metadata=metadata).metadata
)
def test_table_wait_for_index_timeout():
@@ -1674,6 +2035,108 @@ def query_test_table(
yield table
@pytest.mark.parametrize(
"query,search_kwargs,expected_column",
[
pytest.param([0.5] * 8, {}, "vector", id="vector-8"),
pytest.param([0.5] * 4, {}, "vec2", id="vector-4"),
pytest.param(np.array([0.5] * 4), {}, "vec2", id="numpy-vector"),
pytest.param([[0.5] * 4, [0.2] * 4], {}, "vec2", id="batch-vectors"),
pytest.param(
[0.5] * 4, {"vector_column_name": "vec2"}, "vec2", id="explicit-column"
),
pytest.param(None, {}, None, id="scan"),
pytest.param("hello", {"query_type": "fts"}, None, id="fts"),
pytest.param(
"hello", {"query_type": "fts", "fts_columns": "text"}, None, id="fts-column"
),
pytest.param(MatchQuery("hello", "text"), {}, None, id="structured-fts"),
],
)
def test_query_object_sync_matches_local(mem_db, query, search_kwargs, expected_column):
vector_columns = {"vector": 8, "vec2": 4}
schema = pa.schema(
[
pa.field(name, pa.list_(pa.float32(), dim))
for name, dim in vector_columns.items()
]
)
local = mem_db.create_table("test", schema=schema)
local_query = local.search(query, **search_kwargs).limit(2).to_query_object()
assert local_query.vector_column == expected_column
assert local_query.fast_search is None
with query_test_table(None, vector_columns=vector_columns) as remote:
remote_query = remote.search(query, **search_kwargs).limit(2).to_query_object()
assert remote_query == local_query
@pytest.mark.parametrize("fast_search", [None, False, True])
@pytest.mark.parametrize("query,query_type", [([1, 2, 3], "vector"), ("hello", "fts")])
def test_query_object_sync_preserves_fast_search(fast_search, query, query_type):
vector_columns = {"vector": 3} if query_type == "vector" else {}
with query_test_table(None, vector_columns=vector_columns) as table:
query_obj = table.search(
query, query_type=query_type, fast_search=fast_search
).to_query_object()
assert query_obj.fast_search is fast_search
def test_query_sync_rejects_ambiguous_vector_column(mem_db):
vector_columns = {"vector": 4, "vec2": 4}
schema = pa.schema(
[
pa.field(name, pa.list_(pa.float32(), dim))
for name, dim in vector_columns.items()
]
)
local = mem_db.create_table("test", schema=schema)
with pytest.raises(ValueError, match="Candidates:.*vector.*vec2") as local_error:
local.search([0.5] * 4)
with query_test_table(None, vector_columns=vector_columns) as remote:
with pytest.raises(ValueError) as remote_error:
remote.search([0.5] * 4)
assert str(remote_error.value) == str(local_error.value)
@pytest.mark.parametrize("vector_columns", [{}, {"v1": 4, "v2": 8}])
@pytest.mark.parametrize("query_type", ["auto", "fts"])
def test_query_sync_fts_without_embeddings(vector_columns, query_type):
seen = []
def handler(body):
seen.append(body)
assert body["full_text_query"] == {"query": "hello", "columns": []}
assert body["vector"] == []
assert "vector_column" not in body
return pa.table({"id": [1]})
with query_test_table(handler, vector_columns=vector_columns) as table:
assert table.search("hello", query_type=query_type).to_list() == [{"id": 1}]
assert len(seen) == 1
def test_query_sync_auto_with_embedding_function():
def handler(body):
assert body["vector_column"] == "vector"
assert body["vector"] == [0.0] * 10
assert "full_text_query" not in body
return pa.table({"id": [1]})
config = EmbeddingFunctionConfig(
source_column="text",
vector_column="vector",
function=MockTextEmbeddingFunction.create(),
)
with query_test_table(
handler, vector_columns={"vector": 10}, embedding_functions=[config]
) as table:
query = table.search("hello")
assert query.to_query_object().vector_column == "vector"
assert query.to_list() == [{"id": 1}]
def test_head():
def handler(body):
assert body == {
@@ -1701,9 +2164,6 @@ def test_query_sync_minimal():
"ef": None,
"vector": [1.0, 2.0, 3.0],
"vector_column": "vector",
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"version": None,
}
@@ -1875,8 +2335,6 @@ def test_query_sync_maximal():
"refine_factor": 10,
"vector": [1.0, 2.0, 3.0],
"nprobes": 5,
"minimum_nprobes": 5,
"maximum_nprobes": 5,
"lower_bound": None,
"upper_bound": None,
"ef": None,
@@ -1972,7 +2430,7 @@ async def test_query_async_nprobes_zero(hybrid):
assert requests == ["/v1/table/test/describe/"]
def test_query_sync_nprobes():
def test_query_sync_probe_bounds():
def handler(body):
assert body == {
"k": 10,
@@ -1984,7 +2442,6 @@ def test_query_sync_nprobes():
"upper_bound": None,
"ef": None,
"vector": [1.0, 2.0, 3.0],
"nprobes": 5,
"minimum_nprobes": 5,
"maximum_nprobes": 15,
"version": None,
@@ -2001,7 +2458,28 @@ def test_query_sync_nprobes():
)
def test_query_sync_no_max_nprobes():
def test_query_sync_nprobes_passthrough():
def handler(body):
assert body["nprobes"] == 20
assert "minimum_nprobes" not in body
assert "maximum_nprobes" not in body
return pa.table({"id": [1]})
with query_test_table(handler) as table:
table.search([1, 2, 3]).nprobes(20).to_list()
def test_query_sync_nprobes_with_unbounded_maximum():
def handler(body):
assert body["nprobes"] == 20
assert body["maximum_nprobes"] == 0
return pa.table({"id": [1]})
with query_test_table(handler) as table:
table.search([1, 2, 3]).nprobes(20).maximum_nprobes(0).to_list()
def test_query_sync_minimum_nprobes_only():
def handler(body):
assert body == {
"k": 10,
@@ -2013,9 +2491,7 @@ def test_query_sync_no_max_nprobes():
"upper_bound": None,
"ef": None,
"vector": [1.0, 2.0, 3.0],
"nprobes": 5,
"minimum_nprobes": 5,
"maximum_nprobes": 0,
"version": None,
}
@@ -2025,7 +2501,6 @@ def test_query_sync_no_max_nprobes():
(
table.search([1, 2, 3], vector_column_name="vector2", fast_search=True)
.minimum_nprobes(5)
.maximum_nprobes(0)
.to_list()
)
@@ -2180,9 +2655,6 @@ def test_query_sync_hybrid():
"refine_factor": None,
"vector": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"vector_column": "vector",
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"lower_bound": None,
"upper_bound": None,
"ef": None,
@@ -2878,14 +3350,18 @@ def blob_remote_table(*, server_version=Version("0.5.0")):
elif request.path == "/v1/table/test/query/":
content_len = int(request.headers.get("Content-Length", 0))
body = json.loads(request.rfile.read(content_len))
assert body["with_row_id"] is True
if body["columns"] == ["image"]:
# fetch_blob_files sizes its handles from a descriptor take.
assert body["filter"].startswith("_rowid IN")
response_table = blob_descriptor_take_table()
columns = body.get("columns")
assert columns in (None, ["id", "image"], ["id"], ["image"])
if columns == ["id"]:
response_table = blob_query_response_table().select(["id"])
else:
assert body["columns"] == ["id", "image"]
response_table = blob_query_response_table()
assert body["with_row_id"] is True
if columns == ["image"]:
# fetch_blob_files sizes its handles from a descriptor take.
assert body["filter"].startswith("_rowid IN")
response_table = blob_descriptor_take_table()
else:
response_table = blob_query_response_table()
request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
request.end_headers()
@@ -2919,6 +3395,86 @@ def test_remote_blob_columns_and_fetch():
assert blobs.to_pylist() == [b"alpha", None, b"gamma"]
@pytest.mark.asyncio
async def test_async_remote_to_lance_rejects_storage_access(monkeypatch):
with blob_remote_table() as remote_table:
table = remote_table._table
async def fail_uri():
raise AssertionError("remote table URI must not be requested")
monkeypatch.setattr(table, "uri", fail_uri)
with pytest.raises(NotImplementedError, match="remote tables"):
await table.to_lance()
@pytest.mark.asyncio
async def test_async_remote_blob_descriptions_to_pandas_uses_server(monkeypatch):
with blob_remote_table() as remote_table:
table = remote_table._table
async def fail_uri():
raise AssertionError("remote table URI must not be requested")
async def fail_to_lance():
raise AssertionError("remote table storage must not be opened")
monkeypatch.setattr(table, "uri", fail_uri)
monkeypatch.setattr(table, "to_lance", fail_to_lance)
query_df = await (
table.query().select(["id", "image"]).to_pandas(blob_mode="descriptions")
)
table_df = await table.to_pandas(blob_mode="descriptions")
for df in (query_df, table_df):
assert df["id"].tolist() == [1, 2, 3]
assert "_rowid" not in df.columns
images = df["image"].tolist()
assert images[1] is None
assert images[0]["size"] == 5
assert "_lance_row_id" not in images[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("blob_mode", ["bytes", "lazy"])
async def test_async_remote_blob_pandas_rejects_unpinned_fetch(monkeypatch, blob_mode):
with blob_remote_table() as remote_table:
table = remote_table._table
async def fail_uri():
raise AssertionError("remote table URI must not be requested")
async def fail_to_lance():
raise AssertionError("remote table storage must not be opened")
async def fail_fetch(*args, **kwargs):
raise AssertionError("blob fetch must not use an unpinned remote table")
monkeypatch.setattr(table, "uri", fail_uri)
monkeypatch.setattr(table, "to_lance", fail_to_lance)
monkeypatch.setattr(table, "fetch_blobs", fail_fetch)
monkeypatch.setattr(table, "fetch_blob_files", fail_fetch)
pandas_kwargs = {} if blob_mode == "lazy" else {"blob_mode": blob_mode}
with pytest.raises(NotImplementedError, match="stable table snapshot"):
await table.query().select(["id", "image"]).to_pandas(**pandas_kwargs)
with pytest.raises(NotImplementedError, match="stable table snapshot"):
await table.to_pandas(**pandas_kwargs)
@pytest.mark.asyncio
@pytest.mark.parametrize("blob_mode", ["bytes", "lazy"])
async def test_async_remote_nonblob_projection_to_pandas(blob_mode):
with blob_remote_table() as remote_table:
df = await (
remote_table._table.query().select(["id"]).to_pandas(blob_mode=blob_mode)
)
assert df["id"].tolist() == [1, 2, 3]
assert list(df.columns) == ["id"]
def test_remote_blob_files_are_lazy_seekable_handles():
with blob_remote_table() as table:
files = table.fetch_blob_files("image", [10, 20, 30, 40])
@@ -2995,9 +3551,15 @@ def test_remote_blob_query_survives_a_server_that_ignores_the_row_id_request():
def test_remote_blob_byte_apis_not_supported_on_old_server():
with blob_remote_table(server_version=Version("0.1.0")) as table:
assert table.blob_columns() == ["image"]
with pytest.raises(NotImplementedError, match="not supported"):
with pytest.raises(
NotImplementedError,
match="fetch_blobs is not supported by this LanceDB server",
):
table.fetch_blobs("image", [1])
with pytest.raises(NotImplementedError, match="not supported"):
with pytest.raises(
NotImplementedError,
match="fetch_blob_files requires LanceDB server 0.5.0 or newer",
):
table.fetch_blob_files("image", [1])
+110 -3
View File
@@ -1,8 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import threading
from uuid import UUID
import numpy as np
import pytest
import pyarrow as pa
@@ -11,7 +13,7 @@ from lancedb import _lancedb
from lancedb.arrow import AsyncRecordBatchReader
from lancedb.db import AsyncConnection
from lancedb.remote.db import RemoteDBConnection
from lancedb.sql import AsyncQuery, Query
from lancedb.sql import AsyncQuery, Query, to_parameter_batch
NIL_QUERY_ID = UUID(int=0)
@@ -24,12 +26,20 @@ class FakeNativeQuery:
class FakeNativeConnection:
async def execute_query_async(self, query, *, default_namespace_path=None):
def __init__(self):
self.parameters = []
async def execute_query_async(
self, query, *, default_namespace_path=None, parameters=None
):
self.parameters.append(parameters)
return FakeNativeQuery()
class FakeAsyncConnection:
async def execute_query_async(self, query, *, default_namespace_path=None):
async def execute_query_async(
self, query, *, default_namespace_path=None, parameters=None
):
return AsyncQuery(FakeNativeQuery())
@@ -160,3 +170,100 @@ def test_execute_query_async_rejects_invalid_namespace_components(
remote_connection().execute_query_async(
"SELECT 1", default_namespace_path=default_namespace_path
)
def test_positional_parameters_keep_their_types():
vector = np.array([0.1, 0.2, 0.3], dtype=np.float32)
batch = to_parameter_batch(
[42, 1.5, "a", np.float32(0.25), vector, pa.scalar(7, pa.int16())]
)
assert batch.num_rows == 1
assert batch.schema.names == ["1", "2", "3", "4", "5", "6"]
assert batch.schema.types == [
pa.int64(),
pa.float64(),
pa.string(),
pa.float32(),
pa.list_(pa.float32(), 3),
pa.int16(),
]
sent = batch.column(4).flatten().to_numpy()
assert sent.dtype == np.float32
assert sent.tobytes() == vector.tobytes()
def test_named_parameters_and_batches():
half = pa.array(np.array([1.0, 2.0], dtype=np.float16))
batch = to_parameter_batch({"vector": half, "k": 3})
assert batch.schema.names == ["vector", "k"]
assert batch.schema.field("vector").type == pa.list_(pa.float16(), 2)
row = pa.record_batch({"id": [1]})
assert to_parameter_batch(row) is row
assert to_parameter_batch(pa.table({"id": [1]})).equals(row)
assert to_parameter_batch(None) is None
@pytest.mark.parametrize(
"parameters",
[pa.record_batch({"id": [1, 2]}), pa.table({"id": pa.array([], pa.int64())})],
)
def test_parameters_are_exactly_one_row(parameters):
with pytest.raises(ValueError, match="exactly one row"):
to_parameter_batch(parameters)
def test_parameters_must_be_a_collection():
with pytest.raises(TypeError, match="query parameters"):
to_parameter_batch("SELECT 1")
@pytest.mark.asyncio
async def test_async_execute_query_sends_parameters_as_a_batch():
native = FakeNativeConnection()
connection = AsyncConnection(native)
await connection.execute_query("SELECT $1", parameters=[1])
await connection.execute_query_async("SELECT 1")
sent, unparameterized = native.parameters
assert sent.equals(pa.record_batch({"1": [1]}))
assert unparameterized is None
def test_parameters_reach_the_server_as_the_values_that_were_sent():
"""A round trip through the bindings against a Flight server that answers
each exchange with the parameter row it received."""
flight = pytest.importorskip("pyarrow.flight")
class EchoServer(flight.FlightServerBase):
def __init__(self):
super().__init__("grpc://127.0.0.1:0")
self.commands = []
self.lock = threading.Lock()
def do_exchange(self, context, descriptor, reader, writer):
parameters = reader.read_all()
with self.lock:
self.commands.append(descriptor.command)
writer.begin(parameters.schema)
writer.write_table(parameters)
writer.close()
vector = np.array([0.1, 1e-45, -0.0], dtype=np.float32)
with EchoServer() as server:
db = lancedb.connect(
"db://analytics",
api_key="test-key",
host_override="http://localhost:10024",
sql_host_override=f"grpc://127.0.0.1:{server.port}",
)
echoed = db.execute_query(
"SELECT echo", parameters={"vector": vector, "k": 10}
).read_all()
(command,) = server.commands
assert b"CommandStatementQuery" in command
assert b"SELECT echo" in command
assert echoed.schema.field("vector").type == pa.list_(pa.float32(), 3)
received = echoed.column("vector").combine_chunks().flatten().to_numpy()
assert received.view(np.uint32).tolist() == vector.view(np.uint32).tolist()
assert echoed.column("k").to_pylist() == [10]
+8 -3
View File
@@ -14,9 +14,10 @@ use crate::{
table::Table,
};
use arrow::{
array::RecordBatch,
datatypes::Schema,
ffi_stream::ArrowArrayStreamReader,
pyarrow::{FromPyArrow, ToPyArrow},
pyarrow::{FromPyArrow, PyArrowType, ToPyArrow},
};
use lancedb::{
connection::Connection as LanceConnection,
@@ -148,18 +149,22 @@ impl Connection {
self.get_inner().map(|inner| inner.uri().to_string())
}
#[pyo3(signature = (query, *, default_namespace_path=None))]
#[pyo3(signature = (query, *, default_namespace_path=None, parameters=None))]
pub fn execute_query_async<'a>(
self_: PyRef<'a, Self>,
query: String,
default_namespace_path: Option<Bound<'_, PyAny>>,
parameters: Option<PyArrowType<RecordBatch>>,
) -> PyResult<Bound<'a, PyAny>> {
let inner = self_.get_inner()?.clone();
let default_namespace_path = parse_default_namespace_path(default_namespace_path)?;
future_into_py(self_.py(), async move {
let operation = inner
let mut operation = inner
.execute_query_async(query)
.default_namespace_path(default_namespace_path);
if let Some(PyArrowType(parameters)) = parameters {
operation = operation.parameters(parameters);
}
operation
.execute()
.await
+8 -4
View File
@@ -54,8 +54,6 @@ pub fn extract_index_params(source: &Option<Bound<'_, PyAny>>) -> PyResult<Lance
.with_position(params.with_position)
.lower_case(params.lower_case)
.max_token_length(params.max_token_length)
.remove_stop_words(params.remove_stop_words)
.stem(params.stem)
.ascii_folding(params.ascii_folding)
.ngram_min_length(params.ngram_min_length)
.ngram_max_length(params.ngram_max_length)
@@ -65,6 +63,12 @@ pub fn extract_index_params(source: &Option<Bound<'_, PyAny>>) -> PyResult<Lance
DocumentGranularity::try_from(params.document_granularity.as_str())
.map_err(|err| PyValueError::new_err(err.to_string()))?,
);
if let Some(stem) = params.stem {
inner_opts = inner_opts.stem(stem);
}
if let Some(remove_stop_words) = params.remove_stop_words {
inner_opts = inner_opts.remove_stop_words(remove_stop_words);
}
if let Some(memory_limit) = params.memory_limit {
inner_opts = inner_opts.memory_limit_mb(memory_limit);
}
@@ -215,8 +219,8 @@ struct FtsParams {
language: String,
max_token_length: Option<usize>,
lower_case: bool,
stem: bool,
remove_stop_words: bool,
stem: Option<bool>,
remove_stop_words: Option<bool>,
custom_stop_words: Option<Vec<String>>,
ascii_folding: bool,
ngram_min_length: u32,
+5 -7
View File
@@ -336,9 +336,8 @@ pub struct PyQueryRequest {
pub use_lsm: Option<bool>,
pub column: Option<String>,
pub query_vector: Option<PyQueryVectors>,
pub nprobes: Option<usize>,
pub minimum_nprobes: Option<usize>,
// None means user did not set it and default should be used (currently 20)
// Some(0) means user set it to None and there is no limit
pub maximum_nprobes: Option<usize>,
pub lower_bound: Option<f32>,
pub upper_bound: Option<f32>,
@@ -369,6 +368,7 @@ impl From<AnyQuery> for PyQueryRequest {
use_lsm: query_request.use_lsm,
column: None,
query_vector: None,
nprobes: None,
minimum_nprobes: None,
maximum_nprobes: None,
lower_bound: None,
@@ -396,11 +396,9 @@ impl From<AnyQuery> for PyQueryRequest {
use_lsm: vector_query.base.use_lsm,
column: vector_query.column,
query_vector: Some(PyQueryVectors(vector_query.query_vector)),
minimum_nprobes: Some(vector_query.minimum_nprobes),
maximum_nprobes: match vector_query.maximum_nprobes {
None => Some(0),
Some(value) => Some(value),
},
nprobes: vector_query.nprobes,
minimum_nprobes: vector_query.minimum_nprobes,
maximum_nprobes: vector_query.maximum_nprobes,
lower_bound: vector_query.lower_bound,
upper_bound: vector_query.upper_bound,
ef: vector_query.ef,
+24 -9
View File
@@ -823,8 +823,8 @@ impl From<LanceDbFtsToken> for FtsToken {
language = "English".to_string(),
max_token_length = Some(40),
lower_case = true,
stem = true,
remove_stop_words = true,
stem = None,
remove_stop_words = None,
custom_stop_words = None,
ascii_folding = true,
ngram_min_length = 3,
@@ -838,15 +838,15 @@ pub fn tokenize(
language: String,
max_token_length: Option<u32>,
lower_case: bool,
stem: bool,
remove_stop_words: bool,
stem: Option<bool>,
remove_stop_words: Option<bool>,
custom_stop_words: Option<Vec<String>>,
ascii_folding: bool,
ngram_min_length: u32,
ngram_max_length: u32,
prefix_only: bool,
) -> PyResult<Vec<FtsToken>> {
let params = FtsIndexBuilder::default()
let mut params = FtsIndexBuilder::default()
.base_tokenizer(base_tokenizer)
.language(&language)
.map_err(|_| {
@@ -857,13 +857,17 @@ pub fn tokenize(
})?
.max_token_length(max_token_length.map(|value| value as usize))
.lower_case(lower_case)
.stem(stem)
.remove_stop_words(remove_stop_words)
.ascii_folding(ascii_folding)
.ngram_min_length(ngram_min_length)
.ngram_max_length(ngram_max_length)
.ngram_prefix_only(prefix_only)
.custom_stop_words(custom_stop_words);
if let Some(stem) = stem {
params = params.stem(stem);
}
if let Some(remove_stop_words) = remove_stop_words {
params = params.remove_stop_words(remove_stop_words);
}
let tokens = lancedb_tokenize(&query, &params).infer_error()?;
Ok(tokens.into_iter().map(FtsToken::from).collect())
}
@@ -913,6 +917,11 @@ impl Table {
self.inner.is_some()
}
/// Whether this table has a local Lance dataset that the client may open.
pub fn _is_native(&self) -> PyResult<bool> {
Ok(self.inner_ref()?.as_native().is_some())
}
/// Closes the table, releasing any resources associated with it.
pub fn close(&mut self) {
self.inner.take();
@@ -1708,7 +1717,10 @@ impl Table {
future_into_py(self_.py(), async move {
inner
.as_native()
.ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))?
.ok_or_else(|| lancedb::Error::NotSupported {
message: "uses_v2_manifest_paths is not supported for remote tables.".into(),
})
.infer_error()?
.uses_v2_manifest_paths()
.await
.infer_error()
@@ -1720,7 +1732,10 @@ impl Table {
future_into_py(self_.py(), async move {
inner
.as_native()
.ok_or_else(|| PyValueError::new_err("This cannot be run on a remote table"))?
.ok_or_else(|| lancedb::Error::NotSupported {
message: "migrate_manifest_paths_v2 is not supported for remote tables.".into(),
})
.infer_error()?
.migrate_manifest_paths_v2()
.await
.infer_error()
+15 -15
View File
@@ -1849,19 +1849,19 @@ wheels = [
[[package]]
name = "lance-namespace"
version = "0.11.1"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lance-namespace-urllib3-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/e7/d5d46594678ee479c0eda830c47b2f5c46133bd100e84e9aa6e01306eca7/lance_namespace-0.13.0.tar.gz", hash = "sha256:24554a0997bdb39595c6e4cb3ac6722069f6cd3bd1a74e7acbba7bfaf774a40d", size = 11636, upload-time = "2026-09-14T02:47:03.871523Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" },
{ url = "https://files.pythonhosted.org/packages/e7/47/cfa33cca1ba7c749fd2918cfc5c8ded788f378cc6424d23e4fead5a14125/lance_namespace-0.13.0-py3-none-any.whl", hash = "sha256:438c7b17aef421c21c138196e715f2510d62f07e865372047f87c1e75e618c7a", size = 13507, upload-time = "2026-09-14T02:47:01.765655Z" },
]
[[package]]
name = "lance-namespace-urllib3-client"
version = "0.11.1"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
@@ -1869,9 +1869,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/25/9aaa4a5e8999693fb0f227c2c0c4b97bd8f0539066408cdff0b89d41b2d5/lance_namespace_urllib3_client-0.13.0.tar.gz", hash = "sha256:1e8a79c6e4e6277033597fd76aa0e2f33d909ca1436c935936e9e569221f43ef", size = 238348, upload-time = "2026-09-14T02:47:04.521878Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/60/4c/7b8f0712a7fe1b8655b711342bc4989696635a79e7027ee56ba3cae23e99/lance_namespace_urllib3_client-0.13.0-py3-none-any.whl", hash = "sha256:fb361eb4f6c7f2d1f9e92809609657b73e38241393e9c4516e12d6b6ee643a0a", size = 406848, upload-time = "2026-09-14T02:47:02.715954Z" },
]
[[package]]
@@ -1980,7 +1980,7 @@ requires-dist = [
{ name = "huggingface-hub", marker = "extra == 'embeddings'", specifier = ">=0.19.0" },
{ name = "ibm-watsonx-ai", marker = "python_full_version >= '3.10' and extra == 'embeddings'", specifier = ">=1.1.2" },
{ name = "instructorembedding", marker = "extra == 'embeddings'", specifier = ">=1.0.1" },
{ name = "lance-namespace", specifier = ">=0.3.2" },
{ name = "lance-namespace", specifier = ">=0.13.0" },
{ name = "mkdocs", marker = "extra == 'docs'" },
{ name = "mkdocs-jupyter", marker = "extra == 'docs'" },
{ name = "mkdocs-material", marker = "extra == 'docs'" },
@@ -2005,7 +2005,7 @@ requires-dist = [
{ name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3" },
{ name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" },
{ name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b6" },
{ name = "pylance", marker = "extra == 'tests'", specifier = "==14.0.0b10" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" },
{ name = "pytest", marker = "extra == 'tests'", specifier = ">=7.0" },
{ name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.21" },
@@ -3912,7 +3912,7 @@ crypto = [
[[package]]
name = "pylance"
version = "14.0.0b6"
version = "14.0.0b10"
source = { registry = "https://pypi.fury.io/lance-format/" }
dependencies = [
{ name = "lance-namespace" },
@@ -3922,12 +3922,12 @@ dependencies = [
{ name = "pyarrow" },
]
wheels = [
{ url = "https://pypi.fury.io/lance-format/-/ver_DuQEn/pylance-14.0.0b6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3eade86fa30d9ff3248ee7ffb0b27ce29495394d12327da4d22b5c54bc2b59fe" },
{ url = "https://pypi.fury.io/lance-format/-/ver_K5sqj/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10e291974ee738a1d5d7e9dad17a634394a91cb60080b5ceac209077fbaec43c" },
{ url = "https://pypi.fury.io/lance-format/-/ver_EnbkT/pylance-14.0.0b6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61bac10f5081c1210edd017f81c8c784d800b89446bd0ed05d4a24e7001965bc" },
{ url = "https://pypi.fury.io/lance-format/-/ver_19ffYg/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c627ad4a9870611becf84409d8e5ecddc6be64e1dd53bee4bd5fb10ab561e5c" },
{ url = "https://pypi.fury.io/lance-format/-/ver_2eLLY5/pylance-14.0.0b6-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bc603a7dd690233f1fe000600171e1c092f153bc9060cdf198825c4bbe1418aa" },
{ url = "https://pypi.fury.io/lance-format/-/ver_t8ggc/pylance-14.0.0b6-cp310-abi3-win_amd64.whl", hash = "sha256:1d32df32a3d157611450def07eae8ace3a7401286eac68cad9a24e0d5deed2db" },
{ url = "https://pypi.fury.io/lance-format/-/ver_4hvE/pylance-14.0.0b10-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:415b6b1b9fe735fbae7c755eb9774ae2fe0feddc90bf80bee24087cbfbfdf8c1" },
{ url = "https://pypi.fury.io/lance-format/-/ver_pM1km/pylance-14.0.0b10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87317cad22f4d35fea11eb70aa5c0d71a0e1ea3b96de38f3deca933daa749fff" },
{ url = "https://pypi.fury.io/lance-format/-/ver_24jdyj/pylance-14.0.0b10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6da554a2fc2197ba254676b12824d8120b48d65937697e0661a61302458a4740" },
{ url = "https://pypi.fury.io/lance-format/-/ver_XclaZ/pylance-14.0.0b10-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:36c80a801675bb9dcbd681fa99d0cdd5ca642dfb3ca08d71ac980278a2f7748b" },
{ url = "https://pypi.fury.io/lance-format/-/ver_L0yh/pylance-14.0.0b10-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e50ad0b866faefb2fa3ae87328b93fa0d06b2bdd5bb5d0c1a6c74b907cc2fd93" },
{ url = "https://pypi.fury.io/lance-format/-/ver_2gbNr8/pylance-14.0.0b10-cp310-abi3-win_amd64.whl", hash = "sha256:3d86451fe00f71510005bb700737fb1a4f9361554541cbf802f3c5072554d014" },
]
[[package]]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "lance-arrow"
version = "14.0.0-beta.7"
version = "14.0.0-beta.10"
edition = "2024"
authors = ["Lance Devs <dev@lance.org>"]
license = "Apache-2.0"
@@ -25,7 +25,7 @@ bytemuck = { version = "1", default-features = false, features = ["extern_crate_
bytes.workspace = true
futures.workspace = true
half = { workspace = true, features = ["std", "bytemuck"] }
jsonb = { version = "0.5.3", default-features = false, features = ["databend"] }
lance-jsonb.workspace = true
num-traits.workspace = true
rand = { workspace = true, features = ["small_rng"] }
+2 -2
View File
@@ -1,5 +1,5 @@
This directory contains `rust/lance-arrow` from Lance tag `v14.0.0-beta.7`
(commit `0fd26effc79c62c244a5baf92cdda06e301baf21`), licensed under Apache-2.0.
This directory contains `rust/lance-arrow` from Lance tag `v14.0.0-beta.10`
(commit `fa3638fd816ab2ff9a2ecbba7e51465d8879958f`), licensed under Apache-2.0.
LanceDB carries a local fix in `src/lib.rs` for sliced child validity bitmaps
until a Lance release includes the fix. The patch is selected in the root
+8 -8
View File
@@ -14,8 +14,8 @@ use arrow_array::{
LargeStringArray, ListArray, MapArray, OffsetSizeTrait, RecordBatch, StringArray, StructArray,
};
use arrow_schema::{ArrowError, DataType, Field as ArrowField, Fields, Schema};
use jsonb::OwnedJsonb;
use jsonb::jsonpath::{JsonPath, Selector};
use lance_jsonb::OwnedJsonb;
use lance_jsonb::jsonpath::{JsonPath, Selector};
use crate::ARROW_EXT_NAME_KEY;
@@ -336,19 +336,19 @@ impl TryFrom<ArrayRef> for JsonArray {
/// Encode JSON string to JSONB format
pub fn encode_json(json_str: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let value = jsonb::parse_value(json_str.as_bytes())?;
let value = lance_jsonb::parse_value(json_str.as_bytes())?;
Ok(value.to_vec())
}
/// Decode JSONB bytes to JSON string
pub fn decode_json(jsonb_bytes: &[u8]) -> String {
let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes);
let raw_jsonb = lance_jsonb::RawJsonb::new(jsonb_bytes);
raw_jsonb.to_string()
}
/// Parse a JSONPath expression.
pub fn parse_json_path(path: &str) -> Result<JsonPath<'_>, ArrowError> {
jsonb::jsonpath::parse_json_path(path.as_bytes())
lance_jsonb::jsonpath::parse_json_path(path.as_bytes())
.map_err(|e| ArrowError::InvalidArgumentError(format!("Invalid JSONPath '{path}': {e}")))
}
@@ -360,7 +360,7 @@ pub fn select_json_path(
jsonb_bytes: &[u8],
path: &JsonPath<'_>,
) -> Result<Option<OwnedJsonb>, ArrowError> {
Selector::new(jsonb::RawJsonb::new(jsonb_bytes))
Selector::new(lance_jsonb::RawJsonb::new(jsonb_bytes))
.select_value(path)
.map_err(|e| {
ArrowError::InvalidArgumentError(format!("Failed to select JSONPath {path}: {e}"))
@@ -369,7 +369,7 @@ pub fn select_json_path(
/// Whether `path` matches any value in a JSONB value.
pub fn json_path_exists(jsonb_bytes: &[u8], path: &JsonPath<'_>) -> Result<bool, ArrowError> {
Selector::new(jsonb::RawJsonb::new(jsonb_bytes))
Selector::new(lance_jsonb::RawJsonb::new(jsonb_bytes))
.exists(path)
.map_err(|e| {
ArrowError::InvalidArgumentError(format!("Failed to match JSONPath {path}: {e}"))
@@ -381,7 +381,7 @@ pub fn select_json_path_values(
jsonb_bytes: &[u8],
path: &JsonPath<'_>,
) -> Result<Vec<OwnedJsonb>, ArrowError> {
Selector::new(jsonb::RawJsonb::new(jsonb_bytes))
Selector::new(lance_jsonb::RawJsonb::new(jsonb_bytes))
.select_values(path)
.map_err(|e| {
ArrowError::InvalidArgumentError(format!("Failed to select JSONPath {path}: {e}"))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lancedb"
version = "0.41.0-beta.0"
version = "0.41.0-beta.2"
edition.workspace = true
description = "LanceDB: A serverless, low-latency vector database for AI applications"
license.workspace = true
+136 -22
View File
@@ -23,8 +23,8 @@ use crate::connection::create_table::CreateTableBuilder;
use crate::data::scannable::Scannable;
use crate::database::listing::ListingDatabase;
use crate::database::{
CloneTableRequest, Database, DatabaseOptions, JobInfo, OpenTableRequest, PauseJobStatus,
ReadConsistency, ResumeJobStatus, TableNamesRequest,
CloneTableRequest, Database, DatabaseOptions, ExecuteQueryRequest, JobInfo, OpenTableRequest,
PauseJobStatus, ReadConsistency, ResumeJobStatus, TableNamesRequest,
};
use crate::embeddings::{EmbeddingRegistry, MemoryRegistry};
use crate::error::{Error, Result};
@@ -38,10 +38,7 @@ use crate::remote::{
},
};
use crate::secrets::SecretInfo;
use crate::utils::{
validate_namespace, validate_secret_component, validate_secret_reference,
validate_view_reference,
};
use crate::utils::{validate_namespace, validate_secret_reference, validate_view_reference};
use crate::view::ViewDescription;
use lance::io::ObjectStoreParams;
pub use lance_file::version::LanceFileVersion;
@@ -106,7 +103,9 @@ impl TableNamesBuilder {
self
}
/// The maximum number of table names to return
/// The maximum number of table names to return.
///
/// Without a limit, all names are returned. Zero returns an empty list.
pub fn limit(mut self, limit: u32) -> Self {
self.request.limit = Some(limit);
self
@@ -335,16 +334,14 @@ pub struct CloneTableBuilder {
/// Builder for asynchronously executing a SQL statement on a remote database.
pub struct ExecuteQueryAsyncBuilder {
parent: Arc<dyn Database>,
query: String,
default_namespace_path: Vec<String>,
request: ExecuteQueryRequest,
}
impl ExecuteQueryAsyncBuilder {
fn new(parent: Arc<dyn Database>, query: String) -> Self {
Self {
parent,
query,
default_namespace_path: vec!["public".to_string()],
request: ExecuteQueryRequest::new(query),
}
}
@@ -357,15 +354,63 @@ impl ExecuteQueryAsyncBuilder {
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.default_namespace_path = path.into_iter().map(Into::into).collect();
self.request.default_namespace_path = path.into_iter().map(Into::into).collect();
self
}
/// Bind values to the statement's placeholders.
///
/// `parameters` is a single-row batch. Column `i` binds `$<i + 1>`, and a
/// column whose name is not a number also binds `$<name>`, so
/// `WHERE id = $1` and `WHERE id = $id` both work. The values travel as
/// Arrow rather than as SQL text, so a float keeps its exact bits and type
/// and a vector stays a compact `FixedSizeList`.
///
/// Every placeholder needs a value and every parameter must be used by a
/// placeholder. Parameters are supported in queries (`SELECT`, and
/// `EXPLAIN` of one), not in DDL or DML, nor in table-function arguments.
/// Do not follow a named placeholder directly with `$`: `$a$` starts a
/// dollar-quoted string.
///
/// A parameterized statement runs on the call that returns its rows rather
/// than detached from it: cancelling the query, or dropping its handle
/// before reading it, stops the statement on the server. Its status follows
/// that stream too -- it reports `Running` until the reader has received
/// every row -- so open the reader directly rather than waiting for
/// `Finished`.
///
/// # Example
///
/// ```no_run
/// # use std::sync::Arc;
/// # use arrow_array::{ArrayRef, FixedSizeListArray, Int64Array, RecordBatch};
/// # use arrow_array::types::Float32Type;
/// # async fn query(db: &lancedb::Connection) -> lancedb::Result<()> {
/// let vector = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
/// [Some([0.1_f32, 0.2, 0.3].map(Some))],
/// 3,
/// );
/// let parameters = RecordBatch::try_from_iter([
/// ("vector", Arc::new(vector) as ArrayRef),
/// ("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef),
/// ])?;
/// let query = db
/// .execute_query_async("SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k")
/// .parameters(parameters)
/// .execute()
/// .await?;
/// let rows = query.reader().await?;
/// # Ok(())
/// # }
/// ```
pub fn parameters(mut self, parameters: RecordBatch) -> Self {
self.request.parameters = Some(parameters);
self
}
/// Start the statement and return its asynchronous query handle.
pub async fn execute(self) -> Result<crate::sql::Query> {
self.parent
.execute_query_async(&self.query, &self.default_namespace_path)
.await
self.parent.execute_query_async(self.request).await
}
}
@@ -456,8 +501,9 @@ impl Connection {
///
/// The query can reference tables in other databases with SQL dot notation.
/// Use [`ExecuteQueryAsyncBuilder::default_namespace_path`] to avoid qualifying
/// tables in the default namespace. Local connections return
/// [`Error::NotSupported`].
/// tables in the default namespace, and [`ExecuteQueryAsyncBuilder::parameters`]
/// to bind Arrow values to `$1` / `$name` placeholders instead of writing them
/// into the SQL text. Local connections return [`Error::NotSupported`].
///
/// # Example
///
@@ -506,7 +552,8 @@ impl Connection {
/// under creation, may contain only uncommitted storage, or may be concurrently
/// dropped before it is opened.
///
/// The parameters `page_token` and `limit` can be used to paginate the results
/// Without a limit, all names are returned. The parameters `start_after` and
/// `limit` can be used to paginate the results.
pub fn table_names(&self) -> TableNamesBuilder {
TableNamesBuilder::new(self.internal.clone())
}
@@ -761,9 +808,7 @@ impl Connection {
let database = database.clone();
let namespace_path = namespace_path.clone();
async move {
for segment in &namespace_path {
validate_secret_component("Secret namespace path segment", segment)?;
}
validate_namespace(&namespace_path)?;
database.list_secrets(&namespace_path, options).await
}
})
@@ -1138,7 +1183,21 @@ impl Connection {
}
/// List tables with pagination support
pub async fn list_tables(&self, request: ListTablesRequest) -> Result<ListTablesResponse> {
///
/// The default limit is 100 tables per page for both local and remote connections,
/// including namespaces. Zero returns an empty page without a continuation token.
/// Follow the response's opaque `page_token` until it is absent to retrieve every table;
/// a page can contain fewer than the limit even when more tables remain.
pub async fn list_tables(&self, mut request: ListTablesRequest) -> Result<ListTablesResponse> {
// Apply the SDK's page size here so every backend receives the same request.
let limit = request.limit.get_or_insert(100);
if *limit <= 0 {
return Ok(ListTablesResponse {
context: None,
tables: Vec::new(),
page_token: None,
});
}
self.internal.list_tables(request).await
}
@@ -2224,6 +2283,61 @@ mod tests {
assert_eq!(seen, names);
}
#[tokio::test]
async fn test_table_listing_defaults_and_zero_limit() {
let tempdir = tempfile::tempdir().unwrap();
// Listing discovers physical entries without opening the datasets.
let names: Vec<_> = (0..130).map(|i| format!("t{i:03}")).collect();
for name in &names {
std::fs::create_dir(tempdir.path().join(format!("{name}.lance"))).unwrap();
}
let db = connect(tempdir.path().to_str().unwrap())
.execute()
.await
.unwrap();
assert_eq!(db.table_names().execute().await.unwrap(), names);
assert!(
db.table_names()
.limit(0)
.execute()
.await
.unwrap()
.is_empty()
);
let first = db.list_tables(ListTablesRequest::default()).await.unwrap();
assert_eq!(first.tables, names[..100]);
assert!(first.page_token.is_some());
let second = db
.list_tables(ListTablesRequest {
page_token: first.page_token,
..Default::default()
})
.await
.unwrap();
assert_eq!(second.tables, names[100..]);
assert!(second.page_token.is_none());
let all = db
.list_tables(ListTablesRequest {
limit: Some(200),
..Default::default()
})
.await
.unwrap();
assert_eq!(all.tables, names);
assert!(all.page_token.is_none());
let empty = db
.list_tables(ListTablesRequest {
limit: Some(0),
..Default::default()
})
.await
.unwrap();
assert!(empty.tables.is_empty());
assert!(empty.page_token.is_none());
}
#[tokio::test]
async fn test_open_table() {
let tc = new_test_connection().await.unwrap();
+28 -2
View File
@@ -18,6 +18,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use arrow_array::RecordBatch;
use lance::dataset::ReadParams;
use lance_namespace::LanceNamespace;
use lance_namespace::models::{
@@ -191,6 +192,32 @@ impl CloneTableRequest {
}
}
/// A request to start a SQL statement on a remote database.
#[derive(Clone, Debug)]
pub struct ExecuteQueryRequest {
/// The SQL text.
pub query: String,
/// The namespace unqualified table names resolve in. Empty is `public`,
/// the SQL name of the root namespace.
pub default_namespace_path: Vec<String>,
/// Values for the statement's placeholders, as a single-row batch.
///
/// Column `i` binds `$<i + 1>`, and a column whose name is not a number
/// also binds `$<name>`. See
/// [`ExecuteQueryAsyncBuilder::parameters`](crate::connection::ExecuteQueryAsyncBuilder::parameters).
pub parameters: Option<RecordBatch>,
}
impl ExecuteQueryRequest {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
default_namespace_path: vec!["public".to_string()],
parameters: None,
}
}
}
/// How long until a change is reflected from one Table instance to another
///
/// Tables are always internally consistent. If a write method is called on
@@ -555,8 +582,7 @@ pub trait Database:
/// Start executing a SQL statement on a remote database.
async fn execute_query_async(
&self,
_query: &str,
_default_namespace_path: &[String],
_request: ExecuteQueryRequest,
) -> Result<crate::sql::Query> {
Err(crate::error::Error::NotSupported {
message: "SQL is not supported by this database".to_string(),
+165 -8
View File
@@ -288,16 +288,44 @@ impl Database for LanceNamespaceDatabase {
}
async fn table_names(&self, request: TableNamesRequest) -> Result<Vec<String>> {
let ns_request = ListTablesRequest {
id: Some(request.namespace_path),
page_token: request.start_after,
limit: request.limit.map(|l| l as i32),
..Default::default()
};
if request.limit == Some(0) {
return Ok(Vec::new());
}
let response = self.namespace.list_tables(ns_request).await?;
// Namespace tokens are opaque, and pages need not arrive in name order.
// Collect the listing before applying the name cursor and overall limit.
let mut names = Vec::new();
let mut page_token = None;
let mut seen_tokens = HashSet::new();
loop {
let response = self
.namespace
.list_tables(ListTablesRequest {
id: Some(request.namespace_path.clone()),
page_token,
..Default::default()
})
.await?;
names.extend(response.tables);
let Some(token) = response.page_token.filter(|token| !token.is_empty()) else {
break;
};
if !seen_tokens.insert(token.clone()) {
return Err(Error::Runtime {
message: "Namespace table listing response repeated a page_token".into(),
});
}
page_token = Some(token);
}
Ok(response.tables)
names.sort();
if let Some(ref start_after) = request.start_after {
names.retain(|name| name > start_after);
}
if let Some(limit) = request.limit {
names.truncate(limit as usize);
}
Ok(names)
}
async fn list_tables(&self, request: ListTablesRequest) -> Result<ListTablesResponse> {
@@ -589,6 +617,135 @@ mod tests {
use futures::TryStreamExt;
use tempfile::tempdir;
#[derive(Debug)]
struct ScriptedNamespace {
pages: Vec<ListTablesResponse>,
requests: Mutex<Vec<ListTablesRequest>>,
}
#[async_trait]
impl LanceNamespace for ScriptedNamespace {
fn namespace_id(&self) -> String {
"scripted".into()
}
async fn list_tables(
&self,
request: ListTablesRequest,
) -> lance_core::Result<ListTablesResponse> {
let mut requests = self.requests.lock().unwrap();
let page = self.pages[requests.len()].clone();
requests.push(request);
Ok(page)
}
}
fn namespace_with_pages(
pages: Vec<ListTablesResponse>,
) -> (LanceNamespaceDatabase, Arc<ScriptedNamespace>) {
let namespace = Arc::new(ScriptedNamespace {
pages,
requests: Mutex::new(Vec::new()),
});
let db = LanceNamespaceDatabase::from_namespace_client(
namespace.clone(),
"scripted".into(),
HashMap::new(),
HashMap::new(),
None,
None,
HashSet::new(),
);
(db, namespace)
}
fn listing_page(names: &[&str], token: Option<&str>) -> ListTablesResponse {
ListTablesResponse {
tables: names.iter().map(|name| (*name).into()).collect(),
page_token: token.map(String::from),
..Default::default()
}
}
#[tokio::test]
#[allow(deprecated)]
async fn test_table_names_collects_namespace_pages() {
for namespace_path in [vec![], vec!["child".into()]] {
for (start_after, limit, expected) in [
(None, None, vec!["a", "b", "c", "d"]),
(Some("a".into()), Some(2), vec!["b", "c"]),
] {
let (db, namespace) = namespace_with_pages(vec![
listing_page(&["d", "b"], Some("opaque-1")),
listing_page(&[], Some("opaque-2")),
listing_page(&["c", "a"], None),
]);
let names = db
.table_names(TableNamesRequest {
namespace_path: namespace_path.clone(),
start_after,
limit,
})
.await
.unwrap();
assert_eq!(names, expected);
let requests = namespace.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
for request in requests.iter() {
assert_eq!(request.id.as_ref(), Some(&namespace_path));
assert_eq!(request.limit, None);
}
assert_eq!(requests[0].page_token, None);
assert_eq!(requests[1].page_token.as_deref(), Some("opaque-1"));
assert_eq!(requests[2].page_token.as_deref(), Some("opaque-2"));
}
}
}
#[tokio::test]
#[allow(deprecated)]
async fn test_table_names_zero_limit_skips_namespace_request() {
let (db, namespace) = namespace_with_pages(vec![]);
assert!(
db.table_names(TableNamesRequest {
limit: Some(0),
..Default::default()
})
.await
.unwrap()
.is_empty()
);
assert!(namespace.requests.lock().unwrap().is_empty());
}
#[tokio::test]
#[allow(deprecated)]
async fn test_table_names_stops_on_empty_namespace_token() {
let (db, namespace) = namespace_with_pages(vec![listing_page(&["a"], Some(""))]);
assert_eq!(
db.table_names(TableNamesRequest::default()).await.unwrap(),
vec!["a"]
);
assert_eq!(namespace.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_table_names_rejects_namespace_token_cycle() {
let (db, namespace) = namespace_with_pages(vec![
listing_page(&["a"], Some("one")),
listing_page(&["b"], Some("two")),
listing_page(&["c"], Some("one")),
]);
let error = db
.table_names(TableNamesRequest::default())
.await
.unwrap_err();
assert!(error.to_string().contains("repeated a page_token"));
assert_eq!(namespace.requests.lock().unwrap().len(), 3);
}
/// Helper function to create test data
fn create_test_data() -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
+5 -3
View File
@@ -453,7 +453,8 @@ pub struct FunctionVersion {
signature: FunctionSignature,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
secret_bindings: Vec<SecretBinding>,
created_at: String,
/// When this version was created, in milliseconds since the epoch.
created_at_millis: i64,
metadata: BTreeMap<String, String>,
disabled: bool,
}
@@ -507,8 +508,9 @@ impl FunctionVersion {
&self.secret_bindings
}
pub fn created_at(&self) -> &str {
&self.created_at
/// When this version was created, in milliseconds since the epoch.
pub fn created_at_millis(&self) -> i64 {
self.created_at_millis
}
}
+11
View File
@@ -75,6 +75,17 @@ pub enum Index {
/// Full text search index using BM25.
///
/// Supports strings, lists of strings, and `lance.json` logical fields
/// stored as `LargeBinary`. Raw binary fields are not supported. Lance
/// selects its JSON-aware tokenizer from the field metadata.
///
/// JSON queries use `path,str,value` triples, where the dotted path is
/// relative to the indexed column. For example, `meta.tag,str,nature`
/// searches the string value at `$.meta.tag`, not other paths or key names.
/// Create the index on the JSON column, not on a JSON subpath. Plain text
/// queries are invalid for this tokenizer; malformed triples can panic in
/// the current Lance dependency (see [lance#8812](https://github.com/lance-format/lance/issues/8812)).
///
/// The posting block size defaults to 128. Supported values are 128 and 256;
/// a value of 256 uses the experimental FTS V3 format and may introduce
/// breaking changes.
+74 -25
View File
@@ -1114,9 +1114,18 @@ pub struct VectorQueryRequest {
pub column: Option<String>,
/// The vector(s) to search for
pub query_vector: Vec<Arc<dyn Array>>,
/// The minimum number of partitions to search
pub minimum_nprobes: usize,
/// The maximum number of partitions to search
/// The legacy number of partitions to search.
///
/// Lance sets both probe bounds to this value. Explicit bounds can override it.
pub nprobes: Option<usize>,
/// The minimum number of partitions to search.
///
/// If unset, Lance's default is used.
pub minimum_nprobes: Option<usize>,
/// The maximum number of partitions to search.
///
/// If unset, Lance's default is used. Zero represents an explicitly unbounded
/// maximum on request and wire representations.
pub maximum_nprobes: Option<usize>,
/// The lower bound (inclusive) of the distance to search for.
pub lower_bound: Option<f32>,
@@ -1141,8 +1150,9 @@ impl Default for VectorQueryRequest {
base: QueryRequest::default(),
column: None,
query_vector: Vec::new(),
minimum_nprobes: 20,
maximum_nprobes: Some(20),
nprobes: None,
minimum_nprobes: None,
maximum_nprobes: None,
lower_bound: None,
upper_bound: None,
ef: None,
@@ -1248,7 +1258,7 @@ impl VectorQuery {
Ok(self)
}
/// Set the number of partitions to search (probe)
/// Set the legacy IVF probe parameter
///
/// This argument is only used when the vector column has an IVF PQ index.
/// If there is no index then this value is ignored.
@@ -1258,19 +1268,19 @@ impl VectorQuery {
///
/// The partition whose centroids are closest to the query vector will be
/// exhaustiely searched to find matches. This parameter controls how many
/// partitions should be searched.
/// partitions may be searched.
///
/// Increasing this value will increase the recall of your query but will
/// also increase the latency of your query. The default value is 20. This
/// default is good for many cases but the best value to use will depend on
/// your data and the recall that you need to achieve.
/// also increase the latency of your query. If this method is not called,
/// Lance's adaptive probe defaults are used.
///
/// For best results we recommend tuning this parameter with a benchmark against
/// your actual data to find the smallest possible value that will still give
/// you the desired recall.
///
/// This method sets both the minimum and maximum number of partitions to search.
/// For more fine-grained control see [`VectorQuery::minimum_nprobes`] and
/// LanceDB retains this as `nprobes` through local and remote request
/// construction. Lance sets both probe bounds to this value. For more
/// fine-grained control see [`VectorQuery::minimum_nprobes`] and
/// [`VectorQuery::maximum_nprobes`].
///
/// Returns an error if `nprobes` is not greater than 0.
@@ -1280,8 +1290,7 @@ impl VectorQuery {
message: "nprobes must be greater than 0".to_string(),
});
}
self.request.minimum_nprobes = nprobes;
self.request.maximum_nprobes = Some(nprobes);
self.request.nprobes = Some(nprobes);
Ok(self)
}
@@ -1303,6 +1312,7 @@ impl VectorQuery {
});
}
if let Some(maximum_nprobes) = self.request.maximum_nprobes
&& maximum_nprobes != 0
&& minimum_nprobes > maximum_nprobes
{
return Err(Error::InvalidInput {
@@ -1310,7 +1320,7 @@ impl VectorQuery {
.to_string(),
});
}
self.request.minimum_nprobes = minimum_nprobes;
self.request.minimum_nprobes = Some(minimum_nprobes);
Ok(self)
}
@@ -1327,7 +1337,7 @@ impl VectorQuery {
/// This can be useful when there is a narrow filter to allow these queries to
/// spend more time searching and avoid potential false negatives.
///
/// Set to None to search all partitions, if needed, to satisfy the limit
/// Set to None to use Lance's default maximum probe behavior.
pub fn maximum_nprobes(mut self, maximum_nprobes: Option<usize>) -> Result<Self> {
if let Some(maximum_nprobes) = maximum_nprobes {
if maximum_nprobes == 0 {
@@ -1335,14 +1345,18 @@ impl VectorQuery {
message: "maximum_nprobes must be greater than 0".to_string(),
});
}
if maximum_nprobes < self.request.minimum_nprobes {
if let Some(minimum_nprobes) = self.request.minimum_nprobes
&& maximum_nprobes < minimum_nprobes
{
return Err(Error::InvalidInput {
message: "maximum_nprobes must be greater than or equal to minimum_nprobes"
.to_string(),
});
}
}
self.request.maximum_nprobes = maximum_nprobes;
// Preserve an explicit reset separately from an untouched maximum. The
// request protocols already use zero to represent an unbounded maximum.
self.request.maximum_nprobes = Some(maximum_nprobes.unwrap_or(0));
Ok(self)
}
@@ -2431,6 +2445,9 @@ mod tests {
let vector = Float32Array::from_iter_values([0.1, 0.2]);
let query = table.query().nearest_to(&[0.1, 0.2]).unwrap();
assert_eq!(query.request.nprobes, None);
assert_eq!(query.request.minimum_nprobes, None);
assert_eq!(query.request.maximum_nprobes, None);
assert_eq!(
*query
.request
@@ -2442,6 +2459,37 @@ mod tests {
vector
);
let minimum_only_query = query.clone().minimum_nprobes(20).unwrap();
assert_eq!(minimum_only_query.request.minimum_nprobes, Some(20));
assert_eq!(minimum_only_query.request.maximum_nprobes, None);
let maximum_only_query = query.maximum_nprobes(Some(20)).unwrap();
assert_eq!(maximum_only_query.request.minimum_nprobes, None);
assert_eq!(maximum_only_query.request.maximum_nprobes, Some(20));
let explicitly_unbounded_query = table
.query()
.nearest_to(&[0.1, 0.2])
.unwrap()
.nprobes(20)
.unwrap()
.maximum_nprobes(None)
.unwrap();
assert_eq!(explicitly_unbounded_query.request.nprobes, Some(20));
assert_eq!(explicitly_unbounded_query.request.maximum_nprobes, Some(0));
let bounded_query = table
.query()
.nearest_to(&[0.1, 0.2])
.unwrap()
.minimum_nprobes(5)
.unwrap()
.nprobes(20)
.unwrap();
assert_eq!(bounded_query.request.nprobes, Some(20));
assert_eq!(bounded_query.request.minimum_nprobes, Some(5));
assert_eq!(bounded_query.request.maximum_nprobes, None);
let new_vector = Float32Array::from_iter_values([9.8, 8.7]);
let query = table
@@ -2450,7 +2498,7 @@ mod tests {
.offset(1)
.nearest_to(&[9.8, 8.7])
.unwrap()
.nprobes(1000)
.nprobes(20)
.unwrap()
.postfilter()
.distance_type(DistanceType::Cosine)
@@ -2469,8 +2517,9 @@ mod tests {
);
assert_eq!(query.request.base.limit.unwrap(), 100);
assert_eq!(query.request.base.offset.unwrap(), 1);
assert_eq!(query.request.minimum_nprobes, 1000);
assert_eq!(query.request.maximum_nprobes, Some(1000));
assert_eq!(query.request.nprobes, Some(20));
assert_eq!(query.request.minimum_nprobes, None);
assert_eq!(query.request.maximum_nprobes, None);
assert!(query.request.use_index);
assert_eq!(query.request.distance_type, Some(DistanceType::Cosine));
assert_eq!(query.request.approx_mode, Some(ApproxMode::Accurate));
@@ -2492,13 +2541,13 @@ mod tests {
Err(Error::InvalidInput { message }) if message == "nprobes must be greater than 0"
));
// Fixed probe counts replace both bounds, even when moving below or
// above the previous range.
// Legacy probe counts remain independent until Lance resolves them.
let mut query = query;
for nprobes in [30, 1, 50] {
query = query.nprobes(nprobes).unwrap();
assert_eq!(query.request.minimum_nprobes, nprobes);
assert_eq!(query.request.maximum_nprobes, Some(nprobes));
assert_eq!(query.request.nprobes, Some(nprobes));
assert_eq!(query.request.minimum_nprobes, None);
assert_eq!(query.request.maximum_nprobes, None);
}
}
+22 -27
View File
@@ -19,6 +19,7 @@ use crate::catalog::{
ListDatabasesResponse,
};
use crate::database::Database;
use crate::utils::validate_database_name;
use crate::{Error, Result};
/// Authentication and client settings shared by a catalog and its databases.
@@ -154,29 +155,6 @@ impl RemoteCatalog {
})
}
fn validate_name(&self, name: &str) -> Result<()> {
let delimiter = self
.options
.client_config
.id_delimiter
.as_deref()
.unwrap_or("$");
if name.is_empty()
|| name.trim() != name
|| !name.is_ascii()
|| name.chars().any(char::is_control)
|| name.contains(delimiter)
|| matches!(name, "." | "..")
{
return Err(Error::InvalidInput {
message: format!(
"Invalid database name '{name}': expected a nonempty ASCII name without surrounding whitespace, control characters, or namespace delimiter '{delimiter}'"
),
});
}
Ok(())
}
fn database(&self, name: &str) -> Result<Arc<dyn Database>> {
Ok(Arc::new(RemoteDatabase::for_catalog(
&self.endpoint,
@@ -203,7 +181,7 @@ impl Catalog for RemoteCatalog {
}
async fn create_database(&self, request: CreateDatabaseRequest) -> Result<Arc<dyn Database>> {
self.validate_name(&request.name)?;
validate_database_name(&request.name)?;
self.root
.create_namespace(CreateNamespaceRequest {
id: Some(vec![request.name.clone()]),
@@ -231,7 +209,7 @@ impl Catalog for RemoteCatalog {
}
async fn drop_database(&self, request: DropDatabaseRequest) -> Result<()> {
self.validate_name(&request.name)?;
validate_database_name(&request.name)?;
let result = self
.root
.drop_namespace(DropNamespaceRequest {
@@ -286,7 +264,7 @@ impl Catalog for RemoteCatalog {
}
async fn connect_database(&self, name: &str) -> Result<Arc<dyn Database>> {
self.validate_name(name)?;
validate_database_name(name)?;
self.root
.describe_namespace(DescribeNamespaceRequest {
id: Some(vec![name.into()]),
@@ -676,7 +654,24 @@ mod tests {
}
let catalog =
RemoteCatalog::try_new("http://127.0.0.1:1", RemoteCatalogOptions::default()).unwrap();
for name in ["", "a$b", "\r\ninjected", "..", "café", " padded "] {
for name in [
"",
"/",
"//",
"a$b",
"\r\ninjected",
"..",
"café",
" padded ",
"a b",
"a?b",
"/db",
"db/",
"a//b",
"a/../b",
"a/./b",
&"a".repeat(65),
] {
assert!(matches!(
catalog.create_database(name.into()).await,
Err(Error::InvalidInput { .. })
+25
View File
@@ -12,6 +12,7 @@ use std::{collections::HashMap, future::Future, str::FromStr, sync::Arc, time::D
use crate::error::{Error, Result};
use crate::remote::db::RemoteOptions;
use crate::remote::retry::{ResolvedRetryConfig, RetryCounter};
use crate::utils::validate_database_name;
const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
@@ -383,6 +384,7 @@ pub fn parse_db_url(db_url: &str) -> Result<ParsedDbUrl> {
message: format!("Invalid encoded database name: {err}"),
})?
.into_owned();
validate_database_name(&db_name)?;
let db_prefix = {
let prefix = parsed_url.path().trim_start_matches('/');
if prefix.is_empty() {
@@ -1204,6 +1206,29 @@ mod tests {
assert_eq!(parsed.db_prefix.as_deref(), Some("prefix"));
}
#[test]
fn test_parse_database_uri_validates_decoded_name() {
for name in [
"a b",
"a$b",
"a?b",
"café",
"/db",
"db/",
"a//b",
"a/../b",
"a/./b",
&"a".repeat(65),
] {
let uri = format!("db://{}", urlencoding::encode(name));
assert!(
matches!(parse_db_url(&uri), Err(Error::InvalidInput { .. })),
"accepted {name:?}"
);
}
assert!(parse_db_url(&format!("db://{}", "a".repeat(64))).is_ok());
}
#[test]
fn test_timeout_config_default() {
let config = TimeoutConfig::default();
+328 -146
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use async_trait::async_trait;
@@ -20,8 +20,8 @@ use lance_namespace::models::{
use crate::Error;
use crate::database::{
CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, JobInfo,
OpenTableRequest, ReadConsistency, TableNamesRequest,
CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions,
ExecuteQueryRequest, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest,
};
use crate::error::Result;
use crate::function::{
@@ -36,7 +36,10 @@ use crate::remote::util::stream_as_body;
use crate::secrets::SecretBinding;
use crate::secrets::SecretInfo;
use crate::table::BaseTable;
use crate::utils::{reject_relative_segment, validate_table_name};
use crate::utils::{
validate_database_name, validate_function_name, validate_namespace, validate_secret_name,
validate_table_name, validate_view_name,
};
use crate::view::ViewDescription;
use super::client::{
@@ -230,6 +233,7 @@ pub struct RemoteDatabase<S: HttpSend = Sender> {
/// TLS configuration for mTLS support
tls_config: Option<super::client::TlsConfig>,
sql_client: Option<SqlClient>,
is_catalog_root: bool,
}
#[derive(Clone)]
@@ -320,6 +324,9 @@ impl RemoteDatabase {
name: Option<&str>,
options: &super::catalog::RemoteCatalogOptions,
) -> Result<Self> {
if let Some(name) = name {
validate_database_name(name)?;
}
let scope = super::catalog::ScopedHeaderProvider {
provider: options.client_config.header_provider.clone(),
database: name.map(str::to_string),
@@ -348,6 +355,7 @@ impl RemoteDatabase {
)?;
if name.is_none() {
db.sql_client = None;
db.is_catalog_root = true;
}
Ok(db)
}
@@ -422,11 +430,38 @@ impl RemoteDatabase {
namespace_context_provider,
tls_config: client_config.tls_config,
sql_client: Some(sql_client),
is_catalog_root: false,
})
}
}
impl<S: HttpSend> RemoteDatabase<S> {
// A catalog's root namespaces are database names, which may contain slashes.
fn namespace_identifier(&self, namespace: &[String]) -> Result<String> {
match namespace.first() {
None => Ok(ID_DELIMITER.to_string()),
Some(first) => {
if self.is_catalog_root {
// When dealing with the catalog root, the first component is a
// database name. Those need to be validated differently because
// they can contain slashes. Also, we need to use percent encoding
// for the slashes.
validate_database_name(first)?;
validate_namespace(&namespace[1..])?;
Ok(namespace
.iter()
.map(|component| urlencoding::encode(component).into_owned())
.collect::<Vec<_>>()
.join(ID_DELIMITER))
} else {
// If we're not dealing with the catalog root, build the namespace identifier in the
// usual way.
build_namespace_identifier(namespace)
}
}
}
}
/// Post a request whose body carries a credential.
///
/// Shared by the create and alter verbs, which declare their own request
@@ -537,6 +572,7 @@ mod test_utils {
namespace_context_provider: None,
tls_config: None,
sql_client: None,
is_catalog_root: false,
}
}
@@ -560,6 +596,7 @@ mod test_utils {
namespace_context_provider,
tls_config: config.tls_config.clone(),
sql_client: None,
is_catalog_root: false,
}
}
}
@@ -581,93 +618,60 @@ impl From<&CreateTableMode> for &'static str {
}
}
/// The path segment addressing one object: its namespace path and its name.
///
/// One builder for tables, Secrets, Functions and materialized views: the
/// identifier grammar belongs to the namespace spec, not to an object type. An
/// empty path addresses an object with no namespace.
///
/// Components are checked for addressability, not a character set. The name's
/// grammar is the caller's, so a table reports [`Error::InvalidTableName`], a
/// Function admits names a table may not, and a catalog database carries the
/// `/` that [`RemoteCatalog`] allows.
///
/// [`RemoteCatalog`]: super::catalog::RemoteCatalog
fn build_object_identifier(what: &str, name: &str, namespace: &[String]) -> Result<String> {
for segment in namespace {
reject_unaddressable_component("namespace segment", segment)?;
fn build_compound_identifier(name: &str, namespace: &[String]) -> Result<String> {
validate_namespace(namespace)?;
let mut identifier = namespace.join("$");
if !namespace.is_empty() {
identifier.push('$');
}
reject_unaddressable_component(what, name)?;
Ok(join_identifier(
namespace.iter().map(String::as_str).chain([name]),
))
identifier.push_str(name);
Ok(identifier)
}
/// What a component may not be if the join is to survive being split back
/// apart: empty, a segment URL parsing resolves away, or the delimiter itself.
///
/// Each erases a boundary no encoding of the joined form recovers. `["prod",
/// ""]` joins to `prod$`, which reads back as `["prod"]`, so a drop reaches the
/// parent of the namespace the caller named.
///
/// Not a character set: per-component percent-encoding makes the wider set
/// safe, since a `/` in a name reaches the service as `%2F`, still one
/// segment.
fn reject_unaddressable_component(what: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(Error::InvalidInput {
message: format!(
"{what} must not be empty: the identifier would carry two delimiters in a row, \
and splitting it back apart would name a different object"
),
});
}
reject_relative_segment(what, value)?;
if value.contains(ID_DELIMITER) {
return Err(Error::InvalidInput {
message: format!(
"{what} '{value}' contains the identifier delimiter '{ID_DELIMITER}', so the \
namespace path and the name it joins could not be told apart"
),
});
}
Ok(())
}
/// The path segment addressing one table. A wrapper for the error type:
/// callers match on [`Error::InvalidTableName`].
/// Build a full path to a table. Note that we do not need to percent-encode
/// this when using it in a URL because the validation functions ensure that
/// only URL-safe characters are present.
fn build_table_identifier(name: &str, namespace: &[String]) -> Result<String> {
validate_table_name(name)?;
build_object_identifier("table name", name, namespace)
build_compound_identifier(name, namespace)
}
/// Join components into the `{id}` a route addresses: each percent-encoded,
/// then joined by the delimiter.
///
/// Per component rather than over the joined string, so the delimiter stays a
/// delimiter and nothing inside a component can end the path segment.
///
/// A second line, not the first: a component from the object charset is all
/// unreserved and encodes to itself, so the route reads as the caller wrote it.
/// It does not cover `.` and `..`, which are unreserved too and resolve away
/// after decoding -- [`build_object_identifier`] refuses those.
fn join_identifier<'a>(components: impl Iterator<Item = &'a str>) -> String {
components
.map(|component| urlencoding::encode(component).into_owned())
.collect::<Vec<_>>()
.join(ID_DELIMITER)
}
/// The path segment addressing one namespace.
/// Translate an array of namespace components into a namespace string.
/// Note that we do not need to percent-encode this when using it in a URL
/// because validate_namespace ensures that only URL-safe characters are
/// present.
fn build_namespace_identifier(namespace: &[String]) -> Result<String> {
for segment in namespace {
reject_unaddressable_component("namespace segment", segment)?;
}
validate_namespace(namespace)?;
if namespace.is_empty() {
// According to the namespace spec, use delimiter to represent root namespace
return Ok(ID_DELIMITER.to_string());
Ok(ID_DELIMITER.to_string())
} else {
Ok(namespace.join(ID_DELIMITER))
}
Ok(join_identifier(namespace.iter().map(String::as_str)))
}
/// Build a full path to a function. Note that we do not need to percent-encode
/// this when using it in a URL because the validation functions ensure that
/// only URL-safe characters are present.
fn build_function_identifier(name: &str, namespace: &[String]) -> Result<String> {
validate_function_name(name)?;
build_compound_identifier(name, namespace)
}
/// Build a full path to a secret. Note that we do not need to percent-encode
/// this when using it in a URL because the validation functions ensure that
/// only URL-safe characters are present.
fn build_secret_identifier(name: &str, namespace: &[String]) -> Result<String> {
validate_secret_name(name)?;
build_compound_identifier(name, namespace)
}
/// Build a full path to a view. Note that we do not need to percent-encode
/// this when using it in a URL because the validation functions ensure that
/// only URL-safe characters are present.
fn build_view_identifier(name: &str, namespace: &[String]) -> Result<String> {
validate_view_name(name)?;
build_compound_identifier(name, namespace)
}
/// Build a secure cache key using length prefixes.
@@ -945,7 +949,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
request: FunctionRegistrationRequest,
namespace_path: &[String],
) -> Result<Job<FunctionVersion>> {
let function_id = build_object_identifier("Function name", &request.name, namespace_path)?;
let function_id = build_function_identifier(&request.name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/function/{function_id}/create"))
@@ -976,7 +980,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
version: &str,
namespace_path: &[String],
) -> Result<FunctionVersion> {
let function_id = build_object_identifier("Function name", name, namespace_path)?;
let function_id = build_function_identifier(name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/function/{function_id}/describe"))
@@ -1036,7 +1040,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
version: &str,
namespace_path: &[String],
) -> Result<(bool, Job)> {
let function_id = build_object_identifier("Function name", name, namespace_path)?;
let function_id = build_function_identifier(name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/function/{function_id}/drop"))
@@ -1081,7 +1085,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
value: &str,
namespace_path: &[String],
) -> Result<()> {
let secret_id = build_object_identifier("Secret name", name, namespace_path)?;
let secret_id = build_secret_identifier(name, namespace_path)?;
self.post_secret_write(
&format!("/v1/secret/{secret_id}/create"),
&RemoteCreateSecretRequest { value },
@@ -1090,7 +1094,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> {
let secret_id = build_object_identifier("Secret name", name, namespace_path)?;
let secret_id = build_secret_identifier(name, namespace_path)?;
self.post_secret_write(
&format!("/v1/secret/{secret_id}/alter"),
&RemoteAlterSecretRequest { value },
@@ -1127,7 +1131,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> {
let secret_id = build_object_identifier("Secret name", name, namespace_path)?;
let secret_id = build_secret_identifier(name, namespace_path)?;
let req = self.client.post(&format!("/v1/secret/{secret_id}/drop"));
let (request_id, response) = self.client.send(req).await?;
self.client.check_response(&request_id, response).await?;
@@ -1135,7 +1139,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result<SecretInfo> {
let secret_id = build_object_identifier("Secret name", name, namespace_path)?;
let secret_id = build_secret_identifier(name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/secret/{secret_id}/describe"));
@@ -1150,7 +1154,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
query: &str,
namespace_path: &[String],
) -> Result<ViewDescription> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let view_id = build_view_identifier(name, namespace_path)?;
let req = self
.client
.post(&format!("/v1/view/{view_id}/create"))
@@ -1167,7 +1171,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
name: &str,
namespace_path: &[String],
) -> Result<ViewDescription> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let view_id = build_view_identifier(name, namespace_path)?;
let req = self.client.post(&format!("/v1/view/{view_id}/describe"));
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
@@ -1184,7 +1188,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn drop_view_async(&self, name: &str, namespace_path: &[String]) -> Result<Job> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let view_id = build_view_identifier(name, namespace_path)?;
let req = self.client.post(&format!("/v1/view/{view_id}/drop"));
let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?;
@@ -1329,18 +1333,14 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
})
}
async fn execute_query_async(
&self,
query: &str,
default_namespace_path: &[String],
) -> Result<crate::sql::Query> {
async fn execute_query_async(&self, request: ExecuteQueryRequest) -> Result<crate::sql::Query> {
let client = self
.sql_client
.as_ref()
.ok_or_else(|| Error::NotSupported {
message: "SQL is unavailable for this remote database client".to_string(),
})?;
client.submit(query, default_namespace_path).await
client.execute(request).await
}
async fn describe_query(&self, query_id: uuid::Uuid) -> Result<crate::sql::QueryDescription> {
@@ -1354,25 +1354,49 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn table_names(&self, request: TableNamesRequest) -> Result<Vec<String>> {
// A zero limit is an empty listing, even on servers that reject limit=0.
if request.limit == Some(0) {
return Ok(Vec::new());
}
let (tables, version) = if request.namespace_path.is_empty() {
// The flat route resumes after a table name and orders by name, which is exactly
// what `start_after` means, so the server does the paging.
let mut req = self.client.get("/v1/table/");
if let Some(limit) = request.limit {
req = req.query(&[("limit", limit)]);
// what `start_after` means. Subsequent cursors come from the server and may be
// opaque. Follow them until the requested limit or the end of the listing.
let mut tables = Vec::new();
let mut version = None;
let mut page_token = request.start_after.clone();
let mut seen_tokens = HashSet::new();
if let Some(ref token) = page_token {
seen_tokens.insert(token.clone());
}
if let Some(ref start_after) = request.start_after {
req = req.query(&[("page_token", start_after)]);
loop {
let mut req = self.client.get("/v1/table/");
if let Some(limit) = request.limit {
req = req.query(&[("limit", limit as usize - tables.len())]);
}
if let Some(ref token) = page_token {
req = req.query(&[("page_token", token)]);
}
let (request_id, rsp) = self.client.send_with_retry(req, None, true).await?;
let rsp = self.client.check_response(&request_id, rsp).await?;
if version.is_none() {
version = Some(parse_server_version(&request_id, &rsp)?);
}
let response: ListTablesResponse = rsp.json().await.err_to_http(request_id)?;
tables.extend(response.tables);
if let Some(limit) = request.limit
&& tables.len() >= limit as usize
{
tables.truncate(limit as usize);
break;
}
// Empty or repeated tokens must not restart the listing or loop forever.
match response.page_token.filter(|token| !token.is_empty()) {
Some(token) if seen_tokens.insert(token.clone()) => page_token = Some(token),
_ => break,
}
}
let (request_id, rsp) = self.client.send_with_retry(req, None, true).await?;
let rsp = self.client.check_response(&request_id, rsp).await?;
let version = parse_server_version(&request_id, &rsp)?;
let tables = rsp
.json::<ListTablesResponse>()
.await
.err_to_http(request_id)?
.tables;
(tables, version)
(tables, version.unwrap_or_default())
} else {
self.table_names_in_namespace(&request).await?
};
@@ -1387,7 +1411,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
async fn list_tables(&self, request: ListTablesRequest) -> Result<ListTablesResponse> {
let namespace_parts = request.id.as_deref().unwrap_or(&[]);
let namespace_id = build_namespace_identifier(namespace_parts)?;
let namespace_id = self.namespace_identifier(namespace_parts)?;
let mut req = self
.client
.get(&format!("/v1/namespace/{}/table/list", namespace_id));
@@ -1654,7 +1678,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
request: ListNamespacesRequest,
) -> Result<ListNamespacesResponse> {
let namespace_parts = request.id.as_deref().unwrap_or(&[]);
let namespace_id = build_namespace_identifier(namespace_parts)?;
let namespace_id = self.namespace_identifier(namespace_parts)?;
let mut req = self
.client
.get(&format!("/v1/namespace/{}/list", namespace_id));
@@ -1676,7 +1700,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
request: CreateNamespaceRequest,
) -> Result<CreateNamespaceResponse> {
let namespace_parts = request.id.as_deref().unwrap_or(&[]);
let namespace_id = build_namespace_identifier(namespace_parts)?;
let namespace_id = self.namespace_identifier(namespace_parts)?;
let mut req = self
.client
.post(&format!("/v1/namespace/{}/create", namespace_id));
@@ -1707,7 +1731,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result<DropNamespaceResponse> {
let namespace_parts = request.id.as_deref().unwrap_or(&[]);
let namespace_id = build_namespace_identifier(namespace_parts)?;
let namespace_id = self.namespace_identifier(namespace_parts)?;
let mut req = self
.client
.post(&format!("/v1/namespace/{}/drop", namespace_id));
@@ -1741,7 +1765,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
request: DescribeNamespaceRequest,
) -> Result<DescribeNamespaceResponse> {
let namespace_parts = request.id.as_deref().unwrap_or(&[]);
let namespace_id = build_namespace_identifier(namespace_parts)?;
let namespace_id = self.namespace_identifier(namespace_parts)?;
let req = self
.client
.post(&format!("/v1/namespace/{}/describe", namespace_id))
@@ -1867,6 +1891,40 @@ mod tests {
remote::{ARROW_STREAM_CONTENT_TYPE, ClientConfig, HeaderProvider, JSON_CONTENT_TYPE},
};
#[tokio::test]
async fn test_catalog_namespace_identifier_validates_all_namespace_components() {
let mut db = super::RemoteDatabase::new_mock(|_| -> http::Response<String> {
panic!("identifier validation must not send a request")
});
db.is_catalog_root = true;
for (path, expected) in [
(vec![], "$"),
(vec!["team/search"], "team%2Fsearch"),
(vec!["team/search", "ns"], "team%2Fsearch$ns"),
(vec!["team/search", "ns", "child"], "team%2Fsearch$ns$child"),
] {
let path = path.into_iter().map(String::from).collect::<Vec<_>>();
assert_eq!(db.namespace_identifier(&path).unwrap(), expected);
}
for path in [
vec!["../search"],
vec!["team/search", "bad/name"],
vec!["team/search", "ns", "bad/name"],
vec!["team/search", "public", "child"],
] {
let path = path.into_iter().map(String::from).collect::<Vec<_>>();
assert!(
matches!(
db.namespace_identifier(&path),
Err(Error::InvalidInput { .. })
),
"accepted {path:?}"
);
}
}
#[test]
fn test_cache_key_security() {
// Test that cache keys are unique regardless of delimiter manipulation
@@ -2140,6 +2198,133 @@ mod tests {
assert_eq!(names, vec!["table1", "table2"]);
}
#[rstest::rstest]
#[case(None, None)]
#[case(Some("t009"), None)]
#[case(None, Some(200))]
#[case(Some("t009"), Some(15))]
#[tokio::test]
async fn test_table_names_follows_server_pages(
#[case] start_after: Option<&str>,
#[case] limit: Option<u32>,
) {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/table/");
let query: HashMap<_, _> = request.url().query_pairs().collect();
let start = query
.get("page_token")
.map(|token| token[1..4].parse::<usize>().unwrap() + 1)
.unwrap_or(0);
let limit = query
.get("limit")
.map(|limit| limit.parse::<usize>().unwrap())
.unwrap_or(10);
// A server may return a short page even when more tables remain.
let end = (start + limit.min(10)).min(130);
let tables: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect();
let page_token = (end < 130).then(|| format!("t{:03}.lance/", end - 1));
http::Response::builder()
.status(200)
.body(serde_json::json!({"tables": tables, "page_token": page_token}).to_string())
.unwrap()
});
let mut op = conn.table_names();
if let Some(start_after) = start_after {
op = op.start_after(start_after);
}
if let Some(limit) = limit {
op = op.limit(limit);
}
let start = if start_after.is_some() { 10 } else { 0 };
let end = limit.map(|limit| (start + limit).min(130)).unwrap_or(130);
let expected: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect();
assert_eq!(op.execute().await.unwrap(), expected);
}
#[rstest::rstest]
#[case(vec![])]
#[case(vec!["ns".to_string()])]
#[tokio::test]
async fn test_table_listing_zero_limit_never_sends_a_request(#[case] namespace: Vec<String>) {
let conn = Connection::new_with_handler(|_| -> http::Response<String> {
panic!("a zero limit must not be sent to the server")
});
assert!(
conn.table_names()
.namespace(namespace.clone())
.limit(0)
.execute()
.await
.unwrap()
.is_empty()
);
let page = conn
.list_tables(lance_namespace::models::ListTablesRequest {
id: Some(namespace),
limit: Some(0),
..Default::default()
})
.await
.unwrap();
assert!(page.tables.is_empty());
assert!(page.page_token.is_none());
}
#[rstest::rstest]
#[case(vec![], "/v1/namespace/$/table/list")]
#[case(vec!["ns".to_string()], "/v1/namespace/ns/table/list")]
#[tokio::test]
async fn test_list_tables_default_page_size(
#[case] namespace: Vec<String>,
#[case] path: &'static str,
) {
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.url().path(), path);
let query: HashMap<_, _> = request.url().query_pairs().collect();
assert_eq!(query.get("limit").map(|limit| limit.as_ref()), Some("100"));
let start = match query.get("page_token") {
None => 0,
Some(token) => {
assert_eq!(token, "opaque-token");
100
}
};
let end = (start + 100).min(130);
let tables: Vec<_> = (start..end).map(|i| format!("t{i:03}")).collect();
http::Response::builder()
.status(200)
.body(
serde_json::json!({
"tables": tables,
"page_token": (end < 130).then_some("opaque-token")
})
.to_string(),
)
.unwrap()
});
let first = conn
.list_tables(lance_namespace::models::ListTablesRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.unwrap();
assert_eq!(first.tables.len(), 100);
assert_eq!(first.page_token.as_deref(), Some("opaque-token"));
let second = conn
.list_tables(lance_namespace::models::ListTablesRequest {
id: Some(namespace),
page_token: first.page_token,
..Default::default()
})
.await
.unwrap();
let expected: Vec<_> = (100..130).map(|i| format!("t{i:03}")).collect();
assert_eq!(second.tables, expected);
assert!(second.page_token.is_none());
}
#[tokio::test]
async fn test_table_names_in_a_namespace_never_invents_a_page_token() {
// The namespace route's token belongs to the store, so `table_names` cannot build one
@@ -2185,8 +2370,11 @@ mod tests {
assert_eq!(names, vec!["widgets"]);
}
#[rstest::rstest]
#[case(vec![])]
#[case(vec!["ns".to_string()])]
#[tokio::test]
async fn test_table_names_in_a_namespace_stops_on_a_repeated_token() {
async fn test_table_names_stops_on_a_repeated_token(#[case] namespace: Vec<String>) {
// A server that handed back the token it was given would never finish the walk.
let conn = Connection::new_with_handler(|_request| {
http::Response::builder()
@@ -2197,7 +2385,7 @@ mod tests {
let names = conn
.table_names()
.namespace(vec!["ns".to_string()])
.namespace(namespace)
.execute()
.await
.unwrap();
@@ -2206,8 +2394,11 @@ mod tests {
assert_eq!(names, vec!["a", "a"]);
}
#[rstest::rstest]
#[case(vec![])]
#[case(vec!["ns".to_string()])]
#[tokio::test]
async fn test_table_names_in_a_namespace_stops_on_an_empty_token() {
async fn test_table_names_stops_on_an_empty_token(#[case] namespace: Vec<String>) {
// An empty token ends the listing. Sending it back would ask a server that reads it
// as "start from the beginning" for the first page a second time, and every name on
// that page would be collected twice.
@@ -2227,7 +2418,7 @@ mod tests {
let names = conn
.table_names()
.namespace(vec!["ns".to_string()])
.namespace(namespace)
.execute()
.await
.unwrap();
@@ -3892,7 +4083,7 @@ mod tests {
.expect_err("an illegal component must be refused");
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
assert!(
error.to_string().contains("Secret name"),
error.to_string().contains("secret name"),
"{name:?}: {error}"
);
}
@@ -3923,7 +4114,7 @@ mod tests {
.await
.expect_err("a dot-only name must be refused");
assert!(
by_name.to_string().contains("relative path segments"),
by_name.to_string().contains("Illegal secret name"),
"{by_name}"
);
@@ -3933,7 +4124,9 @@ mod tests {
.await
.expect_err("a dot-only namespace segment must be refused");
assert!(
by_segment.to_string().contains("relative path segments"),
by_segment
.to_string()
.contains("Illegal namespace component"),
"{by_segment}"
);
assert!(
@@ -4012,28 +4205,20 @@ mod tests {
}
}
/// A segment outside the table charset still addresses one segment: the
/// service decides whether it may exist, and percent-encoding is what keeps
/// the question reaching the right route. A catalog database is named this
/// way.
/// Slashes belong to catalog database names, not table namespace segments.
#[tokio::test]
async fn test_a_namespace_segment_outside_the_charset_is_encoded_not_refused() {
use std::sync::{Arc, Mutex};
let seen = Arc::new(Mutex::new(String::new()));
let path = seen.clone();
let conn = Connection::new_with_handler(move |request| {
*path.lock().unwrap() = request.url().path().to_string();
http::Response::builder().status(200).body("{}").unwrap()
async fn test_a_namespace_segment_outside_the_charset_is_refused() {
let conn = Connection::new_with_handler(|_| -> http::Response<String> {
panic!("invalid namespace reached the transport")
});
conn.drop_table("t", &["team/search".to_string()])
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "/v1/table/team%2Fsearch$t/drop/");
assert!(matches!(
conn.drop_table("t", &["team/search".to_string()]).await,
Err(Error::InvalidInput { .. })
));
}
/// A Function name is percent-encoded, which covers everything but the
/// relative segment: `..` is unreserved, so it survives encoding and is
/// then resolved away, posting a registration body to `/v1/create`.
/// Reject relative segments and their encoded forms before sending a request,
/// so a function name cannot change the route through URL normalization.
#[tokio::test]
async fn test_a_relative_segment_function_name_is_refused() {
use std::sync::{Arc, Mutex};
@@ -4048,10 +4233,7 @@ mod tests {
.drop_function(name, "fv_1", &[])
.await
.expect_err("a dot-only Function name must be refused");
assert!(
error.to_string().contains("relative path segments"),
"{error}"
);
assert!(matches!(error, Error::InvalidInput { .. }), "{error}");
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
}
}
@@ -4432,7 +4614,7 @@ mod tests {
};
// A view is not a table, so the refusal says so.
assert!(
error.contains("view name") || error.contains("view namespace path segment"),
error.contains("view name") || error.contains("namespace component"),
"{error}"
);
}
+89 -5
View File
@@ -27,12 +27,17 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity
use uuid::Uuid;
use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream};
use crate::database::ExecuteQueryRequest;
use crate::error::{Error, Result};
use crate::job::Job;
use crate::remote::client::{ClientConfig, TlsConfig};
use crate::remote::retry::ResolvedRetryConfig;
use crate::sql::{Query, QueryDescription, QueryHandle, QueryStatus};
mod exchange;
use exchange::{ExchangeQuery, ExchangeQueryHandle};
const DEFAULT_SQL_PORT: u16 = 10025;
const DEFAULT_SQL_TLS_PORT: u16 = 10026;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(120);
@@ -185,6 +190,23 @@ impl SqlClient {
}
}
pub(super) async fn execute(&self, request: ExecuteQueryRequest) -> Result<Query> {
match request.parameters {
Some(parameters) => {
self.submit_with_parameters(
&request.query,
parameters,
&request.default_namespace_path,
)
.await
}
None => {
self.submit(&request.query, &request.default_namespace_path)
.await
}
}
}
pub(super) async fn submit(
&self,
query: &str,
@@ -206,7 +228,8 @@ impl SqlClient {
default_namespace_path.to_vec(),
poll_info,
)?);
self.queries.insert(query_id, query.clone());
self.queries
.insert(query_id, RegisteredQuery::Polled(query.clone()));
Ok(Query::new(Arc::new(RemoteQueryHandle::new(query))))
})
.await
@@ -248,6 +271,35 @@ impl SqlClient {
})
}
/// Values cannot ride on a poll, so a statement that has them is sent on
/// its own exchange instead.
pub(super) async fn submit_with_parameters(
&self,
query: &str,
parameters: RecordBatch,
default_namespace_path: &[String],
) -> Result<Query> {
let timeout = self.inner.overall_timeout()?;
with_overall_timeout(timeout, "SQL query submission", async {
validate_namespace_path(default_namespace_path)?;
let query_id = Uuid::now_v7();
let query = Arc::new(
ExchangeQuery::open(
query_id,
self.inner.clone(),
query,
parameters,
default_namespace_path,
)
.await?,
);
self.queries
.insert(query_id, RegisteredQuery::Exchanged(query.clone()));
Ok(Query::new(Arc::new(ExchangeQueryHandle::new(query))))
})
.await
}
pub(super) async fn describe(&self, query_id: Uuid) -> Result<QueryDescription> {
let query = self
.queries
@@ -530,8 +582,40 @@ impl SqlClientInner {
}
}
/// A submitted query as the connection remembers it.
#[derive(Clone)]
enum RegisteredQuery {
Polled(Arc<RemoteQuery>),
Exchanged(Arc<ExchangeQuery>),
}
impl RegisteredQuery {
fn touch(&self) {
match self {
Self::Polled(query) => query.touch(),
Self::Exchanged(query) => query.touch(),
}
}
/// Whether the registry may forget this query. It is abandoned when the
/// registry holds the only reference to it.
fn registry_expired(&self) -> bool {
match self {
Self::Polled(query) => query.registry_expired(Arc::strong_count(query) == 1),
Self::Exchanged(query) => query.registry_expired(Arc::strong_count(query) == 1),
}
}
async fn describe(&self) -> Result<QueryDescription> {
match self {
Self::Polled(query) => query.describe().await,
Self::Exchanged(query) => query.describe(),
}
}
}
struct QueryRegistry {
queries: StdMutex<HashMap<Uuid, Arc<RemoteQuery>>>,
queries: StdMutex<HashMap<Uuid, RegisteredQuery>>,
}
impl QueryRegistry {
@@ -541,12 +625,12 @@ impl QueryRegistry {
}
}
fn insert(&self, id: Uuid, query: Arc<RemoteQuery>) {
fn insert(&self, id: Uuid, query: RegisteredQuery) {
self.remove_expired();
self.queries.lock().unwrap().insert(id, query);
}
fn get(&self, id: Uuid) -> Option<Arc<RemoteQuery>> {
fn get(&self, id: Uuid) -> Option<RegisteredQuery> {
self.remove_expired();
let query = self.queries.lock().unwrap().get(&id).cloned();
if let Some(query) = &query {
@@ -559,7 +643,7 @@ impl QueryRegistry {
self.queries
.lock()
.unwrap()
.retain(|_, query| !query.registry_expired(Arc::strong_count(query) == 1));
.retain(|_, query| !query.registry_expired());
}
}
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! SQL statements sent with parameters.
//!
//! A statement without parameters is polled: `PollFlightInfo` starts it
//! detached from the connection and `DoGet` reads its endpoints. Neither call
//! carries Arrow data, so a statement's values would have to be spelled into
//! its text. A statement with parameters is sent on one `DoExchange` instead:
//! the command and the parameter schema, then one row, and the result rows
//! come back on the same call. The call is the statement's lifetime, so
//! cancelling it -- or dropping the handle before reading -- stops the
//! statement on the server.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Instant;
use arrow_array::RecordBatch;
use arrow_flight::FlightDescriptor;
use arrow_flight::encode::FlightDataEncoderBuilder;
use arrow_flight::sql::{CommandStatementQuery, ProstMessageExt};
use arrow_schema::SchemaRef;
use prost::Message;
use tokio::sync::{Notify, mpsc};
use uuid::Uuid;
use super::{
ABANDONED_QUERY_RETENTION, DEFAULT_READ_TIMEOUT, ResultEndpointStream, ResultStartGuard,
SqlClientInner, TERMINAL_QUERY_RETENTION, resolve_timeout, sql_error, with_overall_timeout,
};
use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream};
use crate::error::{Error, Result};
use crate::sql::{QueryDescription, QueryHandle, QueryStatus};
#[derive(Clone, Debug, PartialEq, Eq)]
enum Lifecycle {
Running,
/// Every row has been received.
Finished,
Cancelled,
Failed(String),
}
/// A failed statement, keeping the message `describe` reports it with -- not
/// `Runtime error: …` again when `describe` wraps it in a runtime error.
fn failure(error: &Error) -> Lifecycle {
Lifecycle::Failed(match error {
Error::Runtime { message } => message.clone(),
other => other.to_string(),
})
}
/// A statement running on its own `DoExchange` call.
pub(super) struct ExchangeQuery {
id: Uuid,
client: Arc<SqlClientInner>,
/// The call's rows, until a reader takes them or a cancellation drops them.
response: StdMutex<Option<ResultEndpointStream>>,
lifecycle: StdMutex<Lifecycle>,
cancelled: Notify,
terminal_at: OnceLock<Instant>,
last_accessed: StdMutex<Instant>,
}
struct PreparedExchange {
schema: SchemaRef,
first: Option<RecordBatch>,
rest: Option<ResultEndpointStream>,
}
/// The call's rows while a reader waits for their first batch.
///
/// A reader whose future is dropped mid-wait -- its caller gave up, or its
/// task was cancelled -- puts them back, so the next reader finds the query as
/// this one did. A query cancelled meanwhile has its rows dropped instead,
/// which ends the call.
struct Borrowed<'a> {
query: &'a ExchangeQuery,
rows: Option<ResultEndpointStream>,
}
impl Borrowed<'_> {
fn get(&mut self) -> &mut ResultEndpointStream {
self.rows.as_mut().expect("rows are borrowed until kept")
}
fn keep(mut self) -> ResultEndpointStream {
self.rows.take().expect("rows are borrowed until kept")
}
}
impl Drop for Borrowed<'_> {
fn drop(&mut self) {
let Some(rows) = self.rows.take() else {
return;
};
// Checked under the response lock: `cancel` settles first and takes
// the response after, so rows put back here are never missed by it.
let mut response = self.query.response.lock().unwrap();
if *self.query.lifecycle.lock().unwrap() == Lifecycle::Running {
*response = Some(rows);
}
}
}
impl ExchangeQuery {
/// Send the statement and its parameters, returning once the server has
/// planned it: a statement it refuses is refused here, as a polled one is
/// on its first poll.
pub(super) async fn open(
id: Uuid,
client: Arc<SqlClientInner>,
query: &str,
parameters: RecordBatch,
default_namespace_path: &[String],
) -> Result<Self> {
if parameters.num_rows() != 1 {
return Err(Error::InvalidInput {
message: format!(
"SQL query parameters must be exactly one row, got {}",
parameters.num_rows()
),
});
}
let request_id = Uuid::new_v4().to_string();
let read_timeout = resolve_timeout(
client.client_config.timeout_config.read_timeout,
"LANCE_CLIENT_READ_TIMEOUT",
Some(DEFAULT_READ_TIMEOUT),
)?
.unwrap();
let descriptor = FlightDescriptor::new_cmd(
CommandStatementQuery {
query: query.to_string(),
transaction_id: None,
}
.as_any()
.encode_to_vec(),
);
// The descriptor rides on the schema message, and the row follows it.
let request = FlightDataEncoderBuilder::new()
.with_flight_descriptor(Some(descriptor))
.with_schema(parameters.schema())
.build(futures::stream::iter([Ok(parameters)]));
let mut flight = client
.client_with_headers(default_namespace_path, &request_id)
.await?;
let stream = tokio::time::timeout(read_timeout, flight.do_exchange(request))
.await
.map_err(|_| sql_error(&request_id, "SQL query submission timed out"))?
.map_err(|err| sql_error(&request_id, err))?;
Ok(Self {
id,
client,
response: StdMutex::new(Some(ResultEndpointStream {
stream,
request_id,
read_timeout,
})),
lifecycle: StdMutex::new(Lifecycle::Running),
cancelled: Notify::new(),
terminal_at: OnceLock::new(),
last_accessed: StdMutex::new(Instant::now()),
})
}
pub(super) fn touch(&self) {
*self.last_accessed.lock().unwrap() = Instant::now();
}
pub(super) fn registry_expired(&self, abandoned: bool) -> bool {
if let Some(finished) = self.terminal_at.get() {
return finished.elapsed() >= TERMINAL_QUERY_RETENTION;
}
abandoned && self.last_accessed.lock().unwrap().elapsed() >= ABANDONED_QUERY_RETENTION
}
pub(super) fn describe(&self) -> Result<QueryDescription> {
self.touch();
let status = match &*self.lifecycle.lock().unwrap() {
Lifecycle::Running => QueryStatus::Running,
Lifecycle::Finished => QueryStatus::Finished,
Lifecycle::Cancelled => QueryStatus::Cancelled,
Lifecycle::Failed(message) => {
return Err(Error::Runtime {
message: message.clone(),
});
}
};
Ok(QueryDescription {
id: self.id,
status,
progress: (status == QueryStatus::Finished).then_some(1.0),
expires_at: None,
})
}
/// Stop the statement. Idempotent, and a no-op once it has ended.
pub(super) fn cancel(&self) {
self.touch();
let settled = self.settle(Lifecycle::Cancelled);
// Unread rows are dropped here, which cancels the call; a query that
// already ended has none. A reader's stream is dropped by its task
// when it sees the cancellation.
self.response.lock().unwrap().take();
if settled {
self.cancelled.notify_waiters();
}
}
/// Move a running statement to `state`. `true` if this call did.
fn settle(&self, state: Lifecycle) -> bool {
let mut lifecycle = self.lifecycle.lock().unwrap();
if *lifecycle != Lifecycle::Running {
return false;
}
*lifecycle = state;
drop(lifecycle);
let _ = self.terminal_at.set(Instant::now());
true
}
fn is_cancelled(&self) -> bool {
*self.lifecycle.lock().unwrap() == Lifecycle::Cancelled
}
fn cancelled_error(&self) -> Error {
Error::JobCancelled {
job_id: Some(self.id.to_string()),
}
}
async fn wait_for_cancellation(&self) {
loop {
let cancelled = self.cancelled.notified();
if self.is_cancelled() {
return;
}
cancelled.await;
}
}
/// Take the rows and wait for the first batch, which carries the schema.
async fn prepare(&self) -> Result<PreparedExchange> {
let Some(rows) = self.response.lock().unwrap().take() else {
// Taken by an earlier attempt that failed, or dropped by a cancel.
return Err(match &*self.lifecycle.lock().unwrap() {
Lifecycle::Failed(message) => Error::Runtime {
message: message.clone(),
},
_ => self.cancelled_error(),
});
};
let mut rows = Borrowed {
query: self,
rows: Some(rows),
};
let first = tokio::select! {
biased;
_ = self.wait_for_cancellation() => return Err(self.cancelled_error()),
result = rows.get().next_batch() => result,
};
// Read or failed, the rows are this attempt's now: a failed read is
// not worth putting back.
let rows = rows.keep();
let first = first?;
let schema = first
.as_ref()
.map(RecordBatch::schema)
.or_else(|| rows.stream.schema().cloned())
.ok_or_else(|| Error::Runtime {
message: "SQL result stream did not include a schema".to_string(),
})?;
if first.is_none() {
self.settle(Lifecycle::Finished);
}
Ok(PreparedExchange {
schema,
rest: first.is_some().then_some(rows),
first,
})
}
async fn run(
self: Arc<Self>,
mut prepared: PreparedExchange,
sender: mpsc::Sender<Result<RecordBatch>>,
) -> Result<()> {
if let Some(batch) = prepared.first.take()
&& !self.send(&sender, batch).await?
{
return Ok(());
}
let Some(mut rows) = prepared.rest.take() else {
return Ok(());
};
loop {
let batch = tokio::select! {
biased;
// The reader was dropped: the rows go with this task, which
// ends the call.
_ = sender.closed() => {
self.settle(Lifecycle::Cancelled);
return Ok(());
}
_ = self.wait_for_cancellation() => return Err(self.cancelled_error()),
result = rows.next_batch() => result,
};
match batch {
Ok(Some(batch)) => {
if !self.send(&sender, batch).await? {
return Ok(());
}
}
Ok(None) => {
self.settle(Lifecycle::Finished);
return Ok(());
}
Err(error) => {
self.settle(failure(&error));
return Err(error);
}
}
}
}
async fn send(
&self,
sender: &mpsc::Sender<Result<RecordBatch>>,
batch: RecordBatch,
) -> Result<bool> {
tokio::select! {
biased;
_ = self.wait_for_cancellation() => Err(self.cancelled_error()),
result = sender.send(Ok(batch)) => {
if result.is_err() {
self.settle(Lifecycle::Cancelled);
}
Ok(result.is_ok())
}
}
}
}
pub(super) struct ExchangeQueryHandle {
query: Arc<ExchangeQuery>,
result_started: AtomicBool,
}
impl ExchangeQueryHandle {
pub(super) fn new(query: Arc<ExchangeQuery>) -> Self {
Self {
query,
result_started: AtomicBool::new(false),
}
}
}
impl Drop for ExchangeQueryHandle {
// Rows nobody took are rows nobody will read. Keeping the call open would
// hold the statement running on the server, stalled on flow control,
// until the registry forgot it.
fn drop(&mut self) {
if self.query.response.lock().unwrap().is_some() {
self.query.cancel();
}
}
}
#[async_trait::async_trait]
impl QueryHandle for ExchangeQueryHandle {
fn id(&self) -> Uuid {
self.query.touch();
self.query.id
}
async fn describe(&self) -> Result<QueryDescription> {
self.query.describe()
}
async fn reader(&self) -> Result<SendableRecordBatchStream> {
let timeout = self.query.client.overall_timeout()?;
if self
.result_started
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(Error::Runtime {
message: "SQL query results can only be consumed once".to_string(),
});
}
let result_start = ResultStartGuard::new(&self.result_started);
self.query.touch();
let started = Instant::now();
let prepared =
match with_overall_timeout(timeout, "SQL query result", self.query.prepare()).await {
Ok(prepared) => prepared,
Err(error) => {
// A failed read or the timeout ends the query, and with it
// the call, including rows a timed-out wait put back.
self.query.settle(failure(&error));
self.query.response.lock().unwrap().take();
return Err(error);
}
};
let remaining_timeout = timeout.map(|timeout| timeout.saturating_sub(started.elapsed()));
let schema = prepared.schema.clone();
let (sender, receiver) = mpsc::channel(2);
let error_sender = sender.clone();
let query = self.query.clone();
tokio::spawn(async move {
let result = with_overall_timeout(
remaining_timeout,
"SQL query result",
query.clone().run(prepared, sender),
)
.await;
if let Err(error) = result {
query.settle(failure(&error));
let _ = error_sender.send(Err(error)).await;
}
});
let stream = futures::stream::unfold(receiver, |mut receiver| async move {
receiver.recv().await.map(|item| (item, receiver))
});
result_start.commit();
Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema)))
}
async fn cancel(&self) -> Result<()> {
self.query.cancel();
Ok(())
}
}
+387 -27
View File
@@ -4,7 +4,8 @@
use std::sync::atomic::AtomicUsize;
use arrow_array::builder::StringDictionaryBuilder;
use arrow_array::{Array, Int64Array, StringArray, types::Int32Type};
use arrow_array::types::{Float32Type, Int32Type};
use arrow_array::{Array, ArrayRef, FixedSizeListArray, Float32Array, Int64Array, StringArray};
use arrow_flight::encode::FlightDataEncoderBuilder;
use arrow_flight::flight_service_server::{FlightService, FlightServiceServer};
use arrow_flight::sql::{Any, CommandStatementQuery};
@@ -15,6 +16,7 @@ use arrow_flight::{
use arrow_schema::{DataType, Field, Schema};
use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
use tonic::metadata::MetadataMap;
use tonic::{Request, Response, Status, Streaming};
use super::*;
@@ -63,6 +65,9 @@ struct CapturedHeaders {
database_prefix: String,
}
/// Every exchange's statement and the parameter row it arrived with.
type ReceivedExchanges = Arc<std::sync::Mutex<Vec<(String, Option<RecordBatch>)>>>;
#[derive(Clone)]
struct TestSqlService {
query_count: Arc<AtomicUsize>,
@@ -77,11 +82,46 @@ struct TestSqlService {
transient_poll_failures: Arc<AtomicUsize>,
headers: Arc<std::sync::Mutex<Vec<CapturedHeaders>>>,
gate_refresh_release: Arc<Notify>,
exchanges: ReceivedExchanges,
/// How many exchange responses have been dropped, finished or not.
exchange_drops: Arc<AtomicUsize>,
result: RecordBatch,
large_result: RecordBatch,
dictionary_result: RecordBatch,
}
/// Counts a response stream being dropped, which is how a server sees a call
/// end -- by the client cancelling it or by the rows running out.
struct DropSignal(Arc<AtomicUsize>);
impl Drop for DropSignal {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
impl TestSqlService {
fn capture_headers(&self, metadata: &MetadataMap) {
let header = |name| {
metadata
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap()
.to_string()
};
self.headers.lock().unwrap().push(CapturedHeaders {
database: header("database"),
namespace_path: header("namespace-path"),
request_id: header("x-request-id"),
api_key: header("x-api-key"),
database_prefix: metadata
.get("x-lancedb-database-prefix")
.map(|value| value.to_str().unwrap().to_string())
.unwrap_or_default(),
});
}
}
impl Default for TestSqlService {
fn default() -> Self {
let schema = Arc::new(Schema::new(vec![Field::new(
@@ -126,6 +166,8 @@ impl Default for TestSqlService {
transient_poll_failures: Arc::new(AtomicUsize::new(0)),
headers: Arc::new(std::sync::Mutex::new(Vec::new())),
gate_refresh_release: Arc::new(Notify::new()),
exchanges: Arc::new(std::sync::Mutex::new(Vec::new())),
exchange_drops: Arc::new(AtomicUsize::new(0)),
result,
large_result,
dictionary_result,
@@ -168,24 +210,7 @@ impl FlightService for TestSqlService {
&self,
request: Request<arrow_flight::FlightDescriptor>,
) -> std::result::Result<Response<PollInfo>, Status> {
let metadata = request.metadata();
let header = |name| {
metadata
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap()
.to_string()
};
self.headers.lock().unwrap().push(CapturedHeaders {
database: header("database"),
namespace_path: header("namespace-path"),
request_id: header("x-request-id"),
api_key: header("x-api-key"),
database_prefix: metadata
.get("x-lancedb-database-prefix")
.map(|value| value.to_str().unwrap().to_string())
.unwrap_or_default(),
});
self.capture_headers(request.metadata());
let command = Any::decode(request.get_ref().cmd.as_ref())
.ok()
@@ -394,11 +419,336 @@ impl FlightService for TestSqlService {
Err(Status::unimplemented("list_actions"))
}
/// Answers with the parameter row it was sent, so a test can compare what
/// came back with what went out.
async fn do_exchange(
&self,
_request: Request<Streaming<FlightData>>,
request: Request<Streaming<FlightData>>,
) -> std::result::Result<Response<Self::DoExchangeStream>, Status> {
Err(Status::unimplemented("do_exchange"))
self.capture_headers(request.metadata());
let mut messages = request.into_inner();
let first = messages
.message()
.await?
.ok_or_else(|| Status::invalid_argument("empty exchange"))?;
let query = first
.flight_descriptor
.as_ref()
.and_then(|descriptor| Any::decode(descriptor.cmd.as_ref()).ok())
.and_then(|any| any.unpack::<CommandStatementQuery>().ok().flatten())
.map(|command| command.query)
.ok_or_else(|| Status::invalid_argument("exchange without a statement"))?;
let data = futures::stream::once(async move { Ok(first) })
.chain(messages)
.map_err(|status| FlightError::Tonic(Box::new(status)));
let parameters = FlightRecordBatchStream::new_from_flight_data(data)
.try_next()
.await
.map_err(|error| Status::invalid_argument(error.to_string()))?;
self.exchanges
.lock()
.unwrap()
.push((query.clone(), parameters.clone()));
if query == "SELECT exchange refused" {
return Err(Status::invalid_argument(
"parameter 1 is not referenced by the statement",
));
}
if query == "SELECT exchange stall" {
return Ok(Response::new(futures::stream::pending().boxed()));
}
let rows =
parameters.ok_or_else(|| Status::invalid_argument("exchange without parameters"))?;
let schema = rows.schema();
let rest: BoxStream<'static, arrow_flight::error::Result<RecordBatch>> =
if query == "SELECT exchange slow" {
futures::stream::pending().boxed()
} else {
futures::stream::empty().boxed()
};
let delay = if query == "SELECT exchange delayed" {
Duration::from_millis(300)
} else {
Duration::ZERO
};
let dropped = DropSignal(self.exchange_drops.clone());
let stream = FlightDataEncoderBuilder::new()
.with_schema(schema)
.build(
futures::stream::once(async move {
tokio::time::sleep(delay).await;
Ok(rows)
})
.chain(rest),
)
.map_err(Status::from)
.map(move |message| {
let _dropped = &dropped;
message
});
Ok(Response::new(Box::pin(stream)))
}
}
fn parameter_row() -> RecordBatch {
let vector = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[Some([0.1_f32, 1e-45, -0.0].map(Some))],
3,
);
RecordBatch::try_from_iter([
("vector", Arc::new(vector) as ArrayRef),
("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef),
])
.unwrap()
}
fn parameterized(query: &str, parameters: RecordBatch) -> ExecuteQueryRequest {
ExecuteQueryRequest {
parameters: Some(parameters),
..ExecuteQueryRequest::new(query)
}
}
async fn start_sql_service(
service: TestSqlService,
client_config: ClientConfig,
) -> (SqlClient, tokio::sync::oneshot::Sender<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let incoming = futures::stream::try_unfold(listener, |listener| async {
let (socket, _) = listener.accept().await?;
Ok::<_, std::io::Error>(Some((socket, listener)))
});
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(FlightServiceServer::new(service))
.serve_with_incoming_shutdown(incoming, async {
let _ = shutdown_rx.await;
}),
);
let client = SqlClient::new(
"analytics".to_string(),
None,
"test-key".to_string(),
None,
Some(format!("grpc://{address}")),
client_config,
);
(client, shutdown_tx)
}
async fn wait_for(count: &AtomicUsize, expected: usize, what: &str) {
tokio::time::timeout(Duration::from_secs(5), async {
while count.load(Ordering::SeqCst) < expected {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap_or_else(|_| panic!("{what}"));
}
/// The statement and its row go out on one exchange -- no poll, no ticket --
/// and the row arrives as exactly the bytes that were sent.
#[tokio::test]
async fn parameters_travel_on_one_exchange() {
let service = TestSqlService::default();
let exchanges = service.exchanges.clone();
let query_count = service.query_count.clone();
let do_get_count = service.do_get_count.clone();
let headers = service.headers.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let parameters = parameter_row();
let query = client
.execute(ExecuteQueryRequest {
default_namespace_path: vec!["events".to_string(), "raw".to_string()],
..parameterized("SELECT echo", parameters.clone())
})
.await
.unwrap();
assert_eq!(query.describe().await.unwrap().status, QueryStatus::Running);
let rows = collect_result(&query).await.unwrap();
assert_eq!(rows, vec![parameters.clone()]);
let echoed = rows[0]
.column(0)
.as_any()
.downcast_ref::<FixedSizeListArray>()
.unwrap()
.values()
.as_any()
.downcast_ref::<Float32Array>()
.unwrap()
.values()
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>();
assert_eq!(
echoed,
[0.1_f32, 1e-45, -0.0].map(f32::to_bits),
"every float keeps its exact bits, the subnormal and the sign of zero included"
);
let finished = query.describe().await.unwrap();
assert_eq!(finished.status, QueryStatus::Finished);
assert_eq!(finished.progress, Some(1.0));
assert_eq!(
client.describe(query.id()).await.unwrap().status,
QueryStatus::Finished
);
assert!(query.reader().await.is_err());
assert_eq!(
*exchanges.lock().unwrap(),
vec![("SELECT echo".to_string(), Some(parameters))]
);
assert_eq!(query_count.load(Ordering::SeqCst), 0);
assert_eq!(do_get_count.load(Ordering::SeqCst), 0);
let headers = headers.lock().unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].database, "analytics");
assert_eq!(headers[0].namespace_path, "events$raw");
assert_eq!(headers[0].api_key, "test-key");
}
/// The server plans before it answers, so a refusal is the submission's.
#[tokio::test]
async fn a_refused_statement_fails_its_submission() {
let (client, _shutdown) =
start_sql_service(TestSqlService::default(), ClientConfig::default()).await;
let refused = client
.execute(parameterized("SELECT exchange refused", parameter_row()))
.await
.unwrap_err();
assert!(
refused
.to_string()
.contains("not referenced by the statement"),
"{refused}"
);
let two_rows =
RecordBatch::try_from_iter([("k", Arc::new(Int64Array::from(vec![1, 2])) as ArrayRef)])
.unwrap();
assert!(matches!(
client.execute(parameterized("SELECT echo", two_rows)).await,
Err(Error::InvalidInput { .. })
));
}
/// The call is the statement's lifetime: cancelling a read, or dropping a
/// handle nobody read, ends the call and so the work behind it.
#[tokio::test]
async fn cancelling_or_abandoning_an_exchange_ends_the_call() {
let service = TestSqlService::default();
let drops = service.exchange_drops.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let reading = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let mut rows = reading.reader().await.unwrap();
assert_eq!(rows.try_next().await.unwrap().unwrap(), parameter_row());
reading.cancel().await.unwrap();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(5), rows.try_next())
.await
.expect("cancellation must wake the reader"),
Err(Error::JobCancelled { .. })
));
assert_eq!(
reading.describe().await.unwrap().status,
QueryStatus::Cancelled
);
wait_for(&drops, 1, "cancelling must end the call on the server").await;
reading.cancel().await.unwrap();
let unread = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let id = unread.id();
drop(unread);
wait_for(&drops, 2, "dropping an unread handle must end the call").await;
assert_eq!(
client.describe(id).await.unwrap().status,
QueryStatus::Cancelled
);
let dropped_reader = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let rows = dropped_reader.reader().await.unwrap();
drop(rows);
wait_for(&drops, 3, "dropping a reader must end the call").await;
}
/// A reader abandoned before the first batch -- its task cancelled -- must
/// leave the rows for the next reader, as a polled query does, rather than
/// take the call down with it.
#[tokio::test]
async fn an_abandoned_reader_leaves_the_rows_for_the_next_one() {
let service = TestSqlService::default();
let drops = service.exchange_drops.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let delayed = Arc::new(
client
.execute(parameterized("SELECT exchange delayed", parameter_row()))
.await
.unwrap(),
);
let abandoned = {
let delayed = delayed.clone();
tokio::spawn(async move { delayed.reader().await.map(|_| ()) })
};
tokio::time::sleep(Duration::from_millis(50)).await;
abandoned.abort();
assert!(abandoned.await.unwrap_err().is_cancelled());
assert_eq!(
delayed.describe().await.unwrap().status,
QueryStatus::Running
);
assert_eq!(drops.load(Ordering::SeqCst), 0, "the call must survive");
assert_eq!(
collect_result(&delayed).await.unwrap(),
vec![parameter_row()]
);
assert_eq!(
delayed.describe().await.unwrap().status,
QueryStatus::Finished
);
}
/// A reader that gives up before the first batch has already taken the rows,
/// so the query must say it failed rather than report `Running` for a call
/// that is gone.
#[tokio::test]
async fn a_reader_that_times_out_reports_the_failure() {
let mut client_config = ClientConfig::default();
client_config.timeout_config.timeout = Some(Duration::from_millis(200));
let (client, _shutdown) = start_sql_service(TestSqlService::default(), client_config).await;
let stalled = client
.execute(parameterized("SELECT exchange stall", parameter_row()))
.await
.unwrap();
assert_overall_timeout(stalled.reader().await, "result");
let Err(retried) = stalled.reader().await else {
panic!("the rows are gone, so a second reader cannot succeed");
};
for error in [
stalled.describe().await.unwrap_err(),
client.describe(stalled.id()).await.unwrap_err(),
retried,
] {
assert!(
matches!(&error, Error::Runtime { message } if message == "SQL query result timed out"),
"{error}"
);
}
}
@@ -430,7 +780,10 @@ async fn catalog_connections_use_explicit_sql_endpoint_and_database_scope() {
let database =
RemoteDatabase::for_catalog("https://catalog.example", Some(name), &options).unwrap();
let query = database
.execute_query_async("SELECT 42", &[])
.execute_query_async(ExecuteQueryRequest {
default_namespace_path: vec![],
..ExecuteQueryRequest::new("SELECT 42")
})
.await
.unwrap();
assert_eq!(
@@ -813,7 +1166,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
.submit("SELECT slow", &["public".to_string()])
.await
.unwrap();
let tracked_dropped_reader = client.queries.get(dropped_reader.id()).unwrap();
let Some(RegisteredQuery::Polled(tracked_dropped_reader)) =
client.queries.get(dropped_reader.id())
else {
panic!("a submitted statement is registered as a polled query");
};
let continuation_count_before = first_continuation_count.load(Ordering::SeqCst);
let dropped_result_stream = dropped_reader.reader().await.unwrap();
tokio::time::timeout(Duration::from_millis(100), async {
@@ -1107,8 +1464,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
)
.unwrap(),
);
registry.insert(id, query.clone());
assert!(Arc::ptr_eq(&registry.get(id).unwrap(), &query));
registry.insert(id, RegisteredQuery::Polled(query.clone()));
let Some(RegisteredQuery::Polled(found)) = registry.get(id) else {
panic!("the registry returns the query it was given");
};
assert!(Arc::ptr_eq(&found, &query));
}
let expired_id = Uuid::now_v7();
@@ -1125,7 +1485,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
)
.unwrap(),
);
registry.insert(expired_id, expired_query);
registry.insert(expired_id, RegisteredQuery::Polled(expired_query));
assert!(registry.get(expired_id).is_none());
let stale_id = Uuid::now_v7();
@@ -1142,7 +1502,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
.unwrap(),
);
*stale_query.last_accessed.lock().unwrap() = Instant::now() - ABANDONED_QUERY_RETENTION;
registry.insert(stale_id, stale_query.clone());
registry.insert(stale_id, RegisteredQuery::Polled(stale_query.clone()));
drop(stale_query);
assert!(registry.get(stale_id).is_none());
+211 -46
View File
@@ -47,6 +47,7 @@ use crate::utils::background_cache::BackgroundCache;
use crate::utils::{
MaxBatchLengthStream, TimeoutStream, public_fts_field_path_by_id, resolve_arrow_field_path,
resolve_arrow_fts_field_path, supported_btree_data_type, supported_vector_data_type,
validate_fts_field,
};
use crate::{DistanceType, Error};
use crate::{
@@ -76,7 +77,6 @@ use lance::dataset::{ColumnAlteration, NewColumnTransform, Version};
use lance_datafusion::exec::{OneShotExec, execute_plan};
use reqwest::{RequestBuilder, Response};
use serde::{Deserialize, Serialize};
use serde_json::Number;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::pin::Pin;
@@ -597,6 +597,7 @@ impl<S: HttpSend> RemoteTable<S> {
Index::BloomFilter(p) => ("BLOOM_FILTER", Some(to_json(p)?)),
Index::RTree(p) => ("RTREE", Some(to_json(p)?)),
Index::FTS(p) => {
validate_fts_field(&field)?;
let mut params = to_json(p)?;
if p.get_document_granularity().is_list_element() {
params["document_granularity"] = "list_element".into();
@@ -620,9 +621,9 @@ impl<S: HttpSend> RemoteTable<S> {
});
}
}
_ => {
Index::IvfHnswPq(_) => {
return Err(Error::NotSupported {
message: "Index type not supported".into(),
message: "IVF_HNSW_PQ is not supported for remote tables. Please use IVF_HNSW_SQ instead.".into(),
});
}
};
@@ -1085,7 +1086,7 @@ impl<S: HttpSend> RemoteTable<S> {
if let Some(full_text_search) = &params.full_text_search {
if full_text_search.wand_factor.is_some() {
return Err(Error::NotSupported {
message: "Wand factor is not yet supported in LanceDB Cloud".into(),
message: "Wand factor is not supported for remote tables.".into(),
});
}
@@ -1145,17 +1146,14 @@ impl<S: HttpSend> RemoteTable<S> {
if let Some(approx_mode) = query.approx_mode {
body["approx_mode"] = serde_json::json!(approx_mode);
}
// In 0.23.1 we migrated from `nprobes` to `minimum_nprobes` and `maximum_nprobes`.
// Old client / new server: since minimum_nprobes is missing, fallback to nprobes
// New client / old server: old server will only see nprobes, make sure to set both
// nprobes and minimum_nprobes
// New client / new server: since minimum_nprobes is present, server can ignore nprobes
body["nprobes"] = query.minimum_nprobes.into();
body["minimum_nprobes"] = query.minimum_nprobes.into();
if let Some(nprobes) = query.nprobes {
body["nprobes"] = nprobes.into();
}
if let Some(minimum_nprobes) = query.minimum_nprobes {
body["minimum_nprobes"] = minimum_nprobes.into();
}
if let Some(maximum_nprobes) = query.maximum_nprobes {
body["maximum_nprobes"] = maximum_nprobes.into();
} else {
body["maximum_nprobes"] = serde_json::Value::Number(Number::from_u128(0).unwrap())
}
body["lower_bound"] = query.lower_bound.into();
body["upper_bound"] = query.upper_bound.into();
@@ -2825,6 +2823,16 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
self.fetch_blobs_impl(column, row_ids).await
}
async fn fetch_blob_ranges(
&self,
_column: &str,
_requests: &[crate::blob::BlobRangeRequest],
) -> Result<LargeBinaryArray> {
Err(Error::NotSupported {
message: "fetch_blob_ranges is not supported for remote tables.".into(),
})
}
async fn fetch_blob_files(
&self,
column: &str,
@@ -3139,7 +3147,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
async fn set_unenforced_primary_key(&self, _columns: &[&str]) -> Result<()> {
Err(Error::NotSupported {
message: "set_unenforced_primary_key is not supported on LanceDB cloud.".into(),
message: "set_unenforced_primary_key is not supported for remote tables.".into(),
})
}
@@ -3331,7 +3339,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
async fn optimize(&self, _action: OptimizeAction) -> Result<OptimizeStats> {
self.check_mutable().await?;
Err(Error::NotSupported {
message: "optimize is not supported on LanceDB cloud.".into(),
message: "optimize is not supported for remote tables.".into(),
})
}
async fn add_columns(
@@ -3931,22 +3939,14 @@ impl TryFrom<MergeInsertBuilder> for MergeInsertRequest {
let when_matched_update_all_filt = match value.when_matched_update_all_filt {
Some(MergeFilter::Sql(sql)) => Some(sql),
Some(MergeFilter::Expr(_)) => {
return Err(Error::NotSupported {
message: "DataFusion expressions are not supported on remote tables".into(),
});
}
Some(MergeFilter::Expr(expr)) => Some(expr_to_sql_string(&expr)?),
None => None,
};
let when_not_matched_by_source_delete_filt =
match value.when_not_matched_by_source_delete_filt {
Some(MergeFilter::Sql(sql)) => Some(sql),
Some(MergeFilter::Expr(_)) => {
return Err(Error::NotSupported {
message: "DataFusion expressions are not supported on remote tables".into(),
});
}
Some(MergeFilter::Expr(expr)) => Some(expr_to_sql_string(&expr)?),
None => None,
};
@@ -4819,6 +4819,69 @@ mod tests {
}
}
#[rstest]
#[case::sql(false, false)]
#[case::expr_update(true, false)]
#[case::expr_delete(false, true)]
#[case::expr_both(true, true)]
#[tokio::test]
async fn test_merge_insert_filter_expressions(
#[case] update_expr: bool,
#[case] delete_expr: bool,
) {
use datafusion_expr::{col, lit};
let batch = record_batch!(("id", Int32, [0, 1]), ("v", Int32, [100, 110])).unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
let params = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(params["on"], "id");
assert_eq!(params["when_matched_update_all"], "true");
assert_eq!(params["when_not_matched_insert_all"], "false");
assert_eq!(params["when_not_matched_by_source_delete"], "true");
assert_eq!(
params["when_matched_update_all_filt"],
if update_expr {
"(`target`.v < `source`.v)"
} else {
"target.v < source.v"
}
);
assert_eq!(
params["when_not_matched_by_source_delete_filt"],
if delete_expr { "(id > 3)" } else { "id > 3" }
);
http::Response::builder()
.status(200)
.body(r#"{"version": 2, "num_updated_rows": 2, "num_inserted_rows": 0, "num_deleted_rows": 2}"#)
.unwrap()
});
let mut merge = table.merge_insert(&["id"]);
if update_expr {
merge.when_matched_update_all_expr(col("target.v").lt(col("source.v")));
} else {
merge.when_matched_update_all(Some("target.v < source.v".into()));
}
if delete_expr {
merge.when_not_matched_by_source_delete_expr(col("id").gt(lit(3)));
} else {
merge.when_not_matched_by_source_delete(Some("id > 3".into()));
}
let result = merge.execute(data).await.unwrap();
assert_eq!(result.version, 2);
assert_eq!(result.num_updated_rows, 2);
assert_eq!(result.num_inserted_rows, 0);
assert_eq!(result.num_deleted_rows, 2);
}
#[rstest]
#[case::no_batches_insert(false, false)]
#[case::empty_batch_insert(true, false)]
@@ -5693,6 +5756,72 @@ mod tests {
}
}
#[tokio::test]
async fn test_remote_unsupported_operations() {
let table = Table::new_with_handler("my_table", |_| -> http::Response<String> {
panic!("unsupported operations must not send requests to the server");
});
let mut fts = FullTextSearchQuery::new("test".into());
fts.wand_factor = Some(1.0);
let results = [
(
"set_unenforced_primary_key",
table.set_unenforced_primary_key(["id"]).await,
),
(
"optimize",
table.optimize(OptimizeAction::All).await.map(|_| ()),
),
(
"fetch_blob_ranges",
table.fetch_blob_ranges("image", []).await.map(|_| ()),
),
(
"Wand factor",
table
.query()
.full_text_search(fts)
.execute()
.await
.map(|_| ()),
),
];
for (operation, result) in results {
match result.unwrap_err() {
Error::NotSupported { message } => assert_eq!(
message,
format!("{operation} is not supported for remote tables.")
),
error => panic!("expected not-supported error, got {error}"),
}
}
}
#[tokio::test]
async fn test_remote_unsupported_hnsw_pq() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/describe/");
let schema = Schema::new(vec![Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
false,
)]);
http::Response::builder()
.status(200)
.body(describe_response(&schema))
.unwrap()
});
let error = table
.create_index(&["vector"], Index::IvfHnswPq(Default::default()))
.execute()
.await
.unwrap_err();
assert_not_supported_error(
error,
"IVF_HNSW_PQ is not supported for remote tables. Please use IVF_HNSW_SQ instead.",
);
}
#[tokio::test]
async fn test_fetch_blobs_rejects_missing_column() {
let batch = RecordBatch::try_new(
@@ -5806,12 +5935,12 @@ mod tests {
assert_not_supported_error(
table.fetch_blobs("image", &[1]).await.unwrap_err(),
"fetch_blobs",
"fetch_blobs is not supported by this LanceDB server.",
);
assert_not_supported_error(
table.fetch_blob_files("image", &[1]).await.unwrap_err(),
"requires LanceDB Cloud server 0.5.0 or newer",
"requires LanceDB server 0.5.0 or newer",
);
}
@@ -5957,9 +6086,6 @@ mod tests {
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"prefilter": true,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"k": 10,
@@ -5991,7 +6117,7 @@ mod tests {
}
#[tokio::test]
async fn test_query_vector_approx_mode_sent_when_set() {
async fn test_query_vector_minimum_only_and_approx_mode() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
@@ -6011,9 +6137,7 @@ mod tests {
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"prefilter": true,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"minimum_nprobes": 5,
"approx_mode": "accurate",
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
@@ -6037,6 +6161,8 @@ mod tests {
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.minimum_nprobes(5)
.unwrap()
.approx_mode(crate::ApproxMode::Accurate)
.execute()
.await;
@@ -6045,6 +6171,39 @@ mod tests {
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
#[tokio::test]
async fn test_query_vector_explicitly_unbounded_maximum() {
let table = Table::new_with_handler("my_table", |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["nprobes"], 5);
assert_eq!(body["maximum_nprobes"], 0);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(write_ipc_file(&data))
.unwrap()
});
table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.nprobes(5)
.unwrap()
.maximum_nprobes(None)
.unwrap()
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_query_fts_default_values() {
let expected_data = RecordBatch::try_new(
@@ -6143,8 +6302,8 @@ mod tests {
}
],
"nprobes": 12,
"minimum_nprobes": 12,
"maximum_nprobes": 12,
"minimum_nprobes": 3,
"maximum_nprobes": 10,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"ef": Option::<usize>::None,
@@ -6184,6 +6343,10 @@ mod tests {
.distance_type(crate::DistanceType::Cosine)
.nprobes(12)
.unwrap()
.minimum_nprobes(3)
.unwrap()
.maximum_nprobes(Some(10))
.unwrap()
.refine_factor(2)
.bypass_vector_index()
.execute()
@@ -6214,9 +6377,6 @@ mod tests {
"vector_column": "image.embedding",
"prefilter": true,
"k": 10,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"ef": Option::<usize>::None,
@@ -6952,19 +7112,24 @@ mod tests {
];
for (index_type, expected_body, index) in cases {
let data_type = match &index {
Index::FTS(params) if params.get_document_granularity().is_list_element() => {
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)))
}
Index::FTS(_) => DataType::Utf8,
_ => DataType::Int32,
};
let schema = Schema::new(vec![Field::new("a", data_type, false)]);
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 6, 0),
move |request| {
assert_eq!(request.method(), "POST");
match request.url().path() {
"/v1/table/my_table/describe/" => {
let schema = Schema::new(vec![Field::new("a", DataType::Int32, false)]);
http::Response::builder()
.status(200)
.body(describe_response(&schema))
.unwrap()
}
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_response(&schema))
.unwrap(),
"/v1/table/my_table/create_index/" => {
assert_eq!(
request.headers().get("Content-Type").unwrap(),
+2 -2
View File
@@ -393,7 +393,7 @@ impl<S: HttpSend> RemoteTable<S> {
}
if !self.server_version.support_blobs() {
return Err(Error::NotSupported {
message: "fetch_blobs is not supported on this LanceDB Cloud server".into(),
message: "fetch_blobs is not supported by this LanceDB server.".into(),
});
}
let mut read_snapshot = self.snapshot_read_state().await;
@@ -716,7 +716,7 @@ impl<S: HttpSend> RemoteTable<S> {
Ok(())
} else {
Err(Error::NotSupported {
message: "fetch_blob_files requires LanceDB Cloud server 0.5.0 or newer".into(),
message: "fetch_blob_files requires LanceDB server 0.5.0 or newer.".into(),
})
}
}
+12 -1
View File
@@ -17,6 +17,10 @@ pub enum QueryStatus {
/// The server is still executing the query.
Running,
/// The server has made the complete result available.
///
/// A query submitted with parameters streams its rows on the call that
/// runs it, so it reports `Finished` only once its reader has received
/// the last batch. Read it rather than waiting for this status.
Finished,
/// The server accepted cancellation but has not confirmed it yet.
Cancelling,
@@ -60,7 +64,9 @@ pub(crate) trait QueryHandle: Send + Sync {
/// A handle to a submitted SQL query.
///
/// The handle can be inspected, opened as an Arrow reader, or cancelled.
/// Dropping it does not cancel the server-side query.
/// Dropping it does not cancel the server-side query, unless the query was
/// submitted with parameters: those run on the call that returns their rows,
/// so dropping the handle before reading, or dropping the reader, stops them.
/// Identifier lookup is scoped to the connection that submitted the query and
/// is not a durable resume mechanism.
pub struct Query {
@@ -88,6 +94,11 @@ impl Query {
}
/// Get a point-in-time description of the query.
///
/// For a query submitted with parameters the status follows its result
/// stream: it stays [`QueryStatus::Running`] until the reader has received
/// every row, so open [`Self::reader`] directly instead of polling for
/// [`QueryStatus::Finished`] first.
pub async fn describe(&self) -> Result<QueryDescription> {
self.handle.describe().await
}
+3 -3
View File
@@ -29,8 +29,8 @@ use crate::index::Index;
use crate::index::vector::{VectorIndex, suggested_num_sub_vectors};
use crate::utils::{
resolve_lance_fts_field_path, supported_bitmap_data_type, supported_btree_data_type,
supported_fm_data_type, supported_fts_data_type, supported_label_list_data_type,
supported_vector_data_type, supported_zonemap_data_type,
supported_fm_data_type, supported_label_list_data_type, supported_vector_data_type,
supported_zonemap_data_type, validate_fts_field,
};
use super::NativeTable;
@@ -292,7 +292,7 @@ impl NativeTable {
))
}
Index::FTS(fts_opts) => {
Self::validate_index_type(field, "FTS", supported_fts_data_type)?;
validate_fts_field(field)?;
Ok(Box::new(fts_opts))
}
Index::IvfFlat(index) => {
+38
View File
@@ -699,6 +699,44 @@ pub mod tests {
);
}
#[tokio::test]
async fn test_topk_late_materialization_over_table_adapter() {
use lance::io::exec::topk_late_materialization::TopKLateMaterialization;
let fixture = TestFixture::new().await;
let run = |rule: Option<TopKLateMaterialization>| {
let adapter = fixture.adapter2.clone();
async move {
let mut state =
datafusion::execution::SessionStateBuilder::new().with_default_features();
if let Some(rule) = rule {
state = state.with_physical_optimizer_rule(Arc::new(rule));
}
let ctx = SessionContext::new_with_state(state.build());
ctx.register_table("tbl2", adapter).unwrap();
let df = ctx
.sql("SELECT ints, strings, jsons FROM tbl2 WHERE floats > 10 ORDER BY ints DESC LIMIT 5")
.await
.unwrap();
let plan = df.clone().create_physical_plan().await.unwrap();
let plan = datafusion_physical_plan::displayable(plan.as_ref())
.indent(true)
.to_string();
(plan, df.collect().await.unwrap())
}
};
let (plan, expected) = run(None).await;
assert!(!plan.contains("Take"), "{plan}");
// The sort lands below `MetadataEraserExec`, so the rule needs no
// certification for it.
let (plan, batches) = run(Some(TopKLateMaterialization::new())).await;
assert!(plan.contains("Take"), "{plan}");
assert_eq!(batches, expected);
assert!(batches[0].schema().metadata().is_empty());
}
#[tokio::test]
async fn test_metadata_eraser_with_new_children_takes_child_schema() {
let fixture = TestFixture::new().await;
@@ -411,9 +411,7 @@ impl ExecutionPlan for VectorDedupExec {
.map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?;
let mut scanner = dataset.scan();
scanner
.with_row_addr_prefilter(RowAddrMask::from_block(RowAddrTreeMap::from_iter(
removed,
)))
.with_row_id_prefilter(RowAddrMask::from_block(RowAddrTreeMap::from_iter(removed)))
.blob_handling(BlobHandling::AllBinary)
.batch_size(MATERIALIZE_BATCH_ROWS)
.strict_batch_size(true);
+38 -13
View File
@@ -1296,6 +1296,21 @@ mod lsm_tests {
rows
}
async fn collect_ids(stream: SendableRecordBatchStream) -> Vec<i64> {
let batches: Vec<_> = stream.try_collect().await.unwrap();
batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int64Type>()
.values()
.to_vec()
})
.collect()
}
/// Upsert `ids` (value = 0..n) through the LSM `merge_insert` path.
async fn lsm_upsert(table: &Table, ids: Vec<i64>) {
let mut builder = table.merge_insert(&[]);
@@ -1772,7 +1787,6 @@ mod lsm_tests {
use crate::index::Index;
use crate::index::vector::IvfPqIndexBuilder;
use arrow::array::{FixedSizeListBuilder, Float32Builder};
use arrow::datatypes::Int64Type;
const DIM: i32 = 8;
const N: i64 = 256;
@@ -1817,7 +1831,7 @@ mod lsm_tests {
&["vec"],
Index::IvfPq(
IvfPqIndexBuilder::default()
.num_partitions(1)
.num_partitions(8)
.num_sub_vectors(2),
),
)
@@ -1851,21 +1865,32 @@ mod lsm_tests {
.execute()
.await
.unwrap();
let batches: Vec<_> = stream.try_collect().await.unwrap();
let ids: Vec<i64> = batches
.iter()
.flat_map(|b| {
b.column_by_name("id")
.unwrap()
.as_primitive::<Int64Type>()
.values()
.to_vec()
})
.collect();
let ids = collect_ids(stream).await;
assert_eq!(
ids,
vec![9999],
"LSM vector search must rank the memtable row first"
);
let base_query = table
.query()
.nearest_to(&[0.0_f32; 8])
.unwrap()
.only_if("id = 255")
.limit(1);
for query in [
base_query.clone(),
base_query.clone().nprobes(8).unwrap(),
base_query.clone().minimum_nprobes(8).unwrap(),
base_query.clone().maximum_nprobes(Some(8)).unwrap(),
base_query
.nprobes(1)
.unwrap()
.maximum_nprobes(None)
.unwrap(),
] {
let ids = collect_ids(query.execute().await.unwrap()).await;
assert_eq!(ids, vec![255]);
}
}
}
+80 -12
View File
@@ -106,19 +106,16 @@ async fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> R
}
fn requires_local_namespace_execution(query: &AnyQuery) -> bool {
// The namespace QueryTable request has no approx_mode or use_lsm field yet, so
// pushing these down would silently ignore the user's setting. For use_lsm that
// is worse than a tuning miss: MemWAL read routing lives only in `create_plan`,
// so a pushed-down query would return stale base-only data with no error.
// The namespace QueryTable request cannot represent approx_mode or use_lsm.
// For use_lsm that is worse than a tuning miss: MemWAL read routing lives only
// in `create_plan`, so a pushed-down query would return stale base-only data.
if query.base().use_lsm.is_some() || query.base().take_offsets.is_some() {
return true;
}
matches!(
query,
AnyQuery::VectorQuery(VectorQueryRequest {
approx_mode: Some(_),
..
})
AnyQuery::VectorQuery(vector_query)
if vector_query.approx_mode.is_some()
)
}
@@ -296,8 +293,17 @@ pub async fn create_plan(
scanner.approx_mode(approx_mode.into());
}
scanner.minimum_nprobes(query.minimum_nprobes);
if let Some(maximum_nprobes) = query.maximum_nprobes {
if let Some(nprobes) = query.nprobes {
if query.maximum_nprobes == Some(0) {
scanner.minimum_nprobes(nprobes);
} else {
scanner.nprobes(nprobes);
}
}
if let Some(minimum_nprobes) = query.minimum_nprobes {
scanner.minimum_nprobes(minimum_nprobes);
}
if let Some(maximum_nprobes) = query.maximum_nprobes.filter(|nprobes| *nprobes != 0) {
scanner.maximum_nprobes(maximum_nprobes);
}
}
@@ -630,7 +636,9 @@ fn convert_to_namespace_query(query: &AnyQuery) -> Result<NsQueryTableRequest> {
columns,
offset: vq.base.offset.map(|o| o as i32),
distance_type: vq.distance_type.map(|dt| dt.to_string()),
nprobes: Some(vq.minimum_nprobes as i32),
nprobes: vq.nprobes.map(|nprobes| nprobes as i32),
minimum_nprobes: vq.minimum_nprobes.map(|nprobes| nprobes as i32),
maximum_nprobes: vq.maximum_nprobes.map(|nprobes| nprobes as i32),
ef: vq.ef.map(|e| e as i32),
refine_factor: vq.refine_factor.map(|r| r as i32),
lower_bound: vq.lower_bound,
@@ -919,7 +927,6 @@ mod tests {
column: Some("vector".to_string()),
// We cast here to satisfy the struct definition
query_vector: vec![query_vector as Arc<dyn Array>],
minimum_nprobes: 20,
distance_type: Some(crate::DistanceType::L2),
..Default::default()
};
@@ -940,6 +947,7 @@ mod tests {
);
assert_eq!(ns_request.vector_column, Some("vector".to_string()));
assert_eq!(ns_request.distance_type, Some("l2".to_string()));
assert_eq!(ns_request.nprobes, None);
// Verify the vector data was extracted correctly
assert!(ns_request.vector.single_vector.is_some());
@@ -949,6 +957,66 @@ mod tests {
);
}
#[test]
fn test_namespace_query_probe_pushdown_compatibility() {
let default_query = AnyQuery::VectorQuery(VectorQueryRequest::default());
assert!(!requires_local_namespace_execution(&default_query));
let nprobes_query = AnyQuery::VectorQuery(VectorQueryRequest {
query_vector: vec![Arc::new(Float32Array::from(vec![1.0, 2.0]))],
nprobes: Some(20),
..Default::default()
});
assert!(!requires_local_namespace_execution(&nprobes_query));
assert_eq!(
convert_to_namespace_query(&nprobes_query).unwrap().nprobes,
Some(20)
);
let minimum_query = AnyQuery::VectorQuery(VectorQueryRequest {
query_vector: vec![Arc::new(Float32Array::from(vec![1.0, 2.0]))],
minimum_nprobes: Some(5),
..Default::default()
});
assert!(!requires_local_namespace_execution(&minimum_query));
let request = convert_to_namespace_query(&minimum_query).unwrap();
assert_eq!(request.minimum_nprobes, Some(5));
assert_eq!(request.maximum_nprobes, None);
let maximum_query = AnyQuery::VectorQuery(VectorQueryRequest {
query_vector: vec![Arc::new(Float32Array::from(vec![1.0, 2.0]))],
maximum_nprobes: Some(10),
..Default::default()
});
assert!(!requires_local_namespace_execution(&maximum_query));
let request = convert_to_namespace_query(&maximum_query).unwrap();
assert_eq!(request.minimum_nprobes, None);
assert_eq!(request.maximum_nprobes, Some(10));
let exact_query = AnyQuery::VectorQuery(VectorQueryRequest {
query_vector: vec![Arc::new(Float32Array::from(vec![1.0, 2.0]))],
nprobes: Some(8),
minimum_nprobes: Some(20),
maximum_nprobes: Some(20),
..Default::default()
});
assert!(!requires_local_namespace_execution(&exact_query));
let request = convert_to_namespace_query(&exact_query).unwrap();
assert_eq!(request.nprobes, Some(8));
assert_eq!(request.minimum_nprobes, Some(20));
assert_eq!(request.maximum_nprobes, Some(20));
let explicitly_unbounded_query = AnyQuery::VectorQuery(VectorQueryRequest {
query_vector: vec![Arc::new(Float32Array::from(vec![1.0, 2.0]))],
nprobes: Some(8),
maximum_nprobes: Some(0),
..Default::default()
});
let request = convert_to_namespace_query(&explicitly_unbounded_query).unwrap();
assert_eq!(request.nprobes, Some(8));
assert_eq!(request.maximum_nprobes, Some(0));
}
#[test]
fn test_convert_to_namespace_query_plain_query() {
let q = QueryRequest {
+15 -4
View File
@@ -170,9 +170,8 @@ fn reject_unsupported(query: &VectorQueryRequest) -> Result<()> {
// Vector-only knobs the LSM scanner cannot honor. Both change results rather
// than just recall, so error instead of silently ignoring them: distance_range
// would return rows outside the bound, and use_index(false) asks for a
// brute-force search the index-only base arm can't do. (ef / approx_mode /
// maximum_nprobes are recall/speed knobs and are left to no-op — and
// maximum_nprobes defaults to Some, so it cannot be rejected on presence.)
// brute-force search the index-only base arm can't do. (ef / approx_mode are
// recall/speed knobs and are left to no-op.)
if !query.query_vector.is_empty() {
if query.lower_bound.is_some() || query.upper_bound.is_some() {
return unsupported("distance_range on vector search");
@@ -740,8 +739,20 @@ async fn vector_plan(
let mut scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)?
.with_overfetch_factor(LSM_OVERFETCH_FACTOR)
.nearest(&column, query_vector.as_ref(), k)?
.nprobes(query.minimum_nprobes)
.distance_metric(distance_type.into());
if let Some(nprobes) = query.nprobes {
scanner = if query.maximum_nprobes == Some(0) {
scanner.minimum_nprobes(nprobes)
} else {
scanner.nprobes(nprobes)
};
}
if let Some(minimum_nprobes) = query.minimum_nprobes {
scanner = scanner.minimum_nprobes(minimum_nprobes);
}
if let Some(maximum_nprobes) = query.maximum_nprobes.filter(|nprobes| *nprobes != 0) {
scanner = scanner.maximum_nprobes(maximum_nprobes);
}
if let Some(refine_factor) = query.refine_factor {
scanner = scanner.refine(refine_factor);
}
+289 -122
View File
@@ -19,15 +19,27 @@ use std::pin::Pin;
use crate::error::{Error, Result};
use datafusion_physical_plan::SendableRecordBatchStream;
/// The characters any object name may contain: a table, a namespace segment, a
/// Secret, a materialized view.
///
/// No positional rule on top of it -- a name may begin with `_`, `-` or `.`,
/// as LanceDB namespaces already do. `.` and `..` are excluded separately, by
/// [`reject_relative_segment`]: that is a property of where a name sits in a
/// URL, not of the name. Length is the service's to bound.
static OBJECT_NAME_REGEX: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"^[A-Za-z0-9_.\-]+$").unwrap());
/// The maximum length of a table name. Table names are used in URLs and in the keys used with the
/// object store.
const MAX_TABLE_NAME_CHARS: usize = 128;
/// The maximum length of a component of a namespace.
const MAX_NAMESPACE_NAME_CHARS: usize = 64;
/// The maximum number of namespace components that are allowed.
const MAX_NAMESPACE_COMPONENTS: usize = 10;
/// The maximum length of a view name.
const MAX_VIEW_NAME_CHARS: usize = 128;
/// The maximum length of a secret name.
const MAX_SECRET_NAME_CHARS: usize = 128;
/// The maximum length of a function name.
const MAX_FUNCTION_NAME_CHARS: usize = 128;
/// The maximum length of a database name.
const MAX_DB_NAME_CHARS: usize = 64;
pub trait PatchStoreParam {
fn patch_with_store_wrapper(
@@ -86,129 +98,190 @@ impl PatchReadParam for ReadParams {
}
}
/// The reason `.` and `..` are refused wherever a name becomes a path segment.
const RELATIVE_SEGMENT_REASON: &str =
"'.' and '..' are read as relative path segments and cannot address an object";
/// Whether URL parsing would resolve this component away rather than keep it.
///
/// Exactly `.` and `..`, and their percent-encoded spellings -- resolution
/// happens after decoding, so `%2E%2E` collapses as surely as `..` does, and
/// `drop_table("..")` would reach `/v1/drop/`. No wider than that: `...` is an
/// ordinary segment that addresses fine.
fn is_relative_segment(value: &str) -> bool {
let decoded = value.replace("%2e", ".").replace("%2E", ".");
decoded == "." || decoded == ".."
/// Translate a string into something that can be safely logged. This involves removing control
/// characters that could be used in an exploit like CVE-2003-0063.
fn sanitize(input: &str) -> String {
input.chars().filter(|c| !c.is_control()).collect()
}
/// Refuse a path component that URL parsing resolves as a relative segment.
///
/// Reachable on its own for an identifier with no other validator: a Function
/// name has no client-side grammar, so this is the only rule that applies.
pub(crate) fn reject_relative_segment(what: &str, value: &str) -> Result<()> {
if is_relative_segment(value) {
return Err(Error::InvalidInput {
message: format!("invalid {what} '{value}': {RELATIVE_SEGMENT_REASON}"),
pub fn validate_table_name(name: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." {
return Err(Error::InvalidTableName {
name: name.to_string(),
reason: "Illegal table name.".to_string(),
});
}
if name.len() > MAX_TABLE_NAME_CHARS {
return Err(Error::InvalidTableName {
name: sanitize(name),
reason: format!(
"Table name cannot be more than {MAX_TABLE_NAME_CHARS} characters long."
),
});
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(Error::InvalidTableName {
name: sanitize(name),
reason: "Only alphanumeric characters, dashes, underscores, and\
periods are allowed in table names."
.to_string(),
});
}
Ok(())
}
/// Every rule an object name obeys: non-empty, inside [`OBJECT_NAME_REGEX`],
/// and addressable as a path segment.
///
/// Returns the reason rather than an [`Error`], because the error type is each
/// API's own -- a table reports [`Error::InvalidTableName`], the rest
/// [`Error::InvalidInput`]. Sharing the rules but not the error keeps a table,
/// a namespace segment and a Secret from drifting apart.
fn check_object_name(name: &str) -> std::result::Result<(), &'static str> {
if name.is_empty() {
return Err("it must not be empty");
/// Validate that an input string contains alphanumerics, or dashes, underscores, and periods.
fn validate_view_or_namespace_or_secret(input: &str, kind: &str, max_len: usize) -> Result<()> {
if input.is_empty() || input == "." || input == ".." {
return Err(Error::InvalidInput {
message: format!("Illegal {} '{}'.", kind, input),
});
}
if !OBJECT_NAME_REGEX.is_match(name) {
return Err(
"it may contain only alphanumeric characters, underscores, hyphens and periods",
);
if input.len() > max_len {
return Err(Error::InvalidInput {
message: format!(
"Invalid {} '{}' is more than {} characters long.",
kind,
sanitize(input),
max_len
),
});
}
if is_relative_segment(name) {
return Err(RELATIVE_SEGMENT_REASON);
if !input
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(Error::InvalidInput {
message: format!(
"Invalid {} '{}': only alphanumeric characters, \
dashes, underscores, and periods are allowed.",
kind,
sanitize(input)
),
});
}
Ok(())
}
/// Validate a table name.
pub fn validate_table_name(name: &str) -> Result<()> {
check_object_name(name).map_err(|reason| Error::InvalidTableName {
name: name.to_string(),
reason: reason.to_string(),
})
}
/// Validate one component of a namespace path -- a single segment, not the
/// whole path. [`validate_namespace`] covers a path.
pub fn validate_namespace_name(name: &str) -> Result<()> {
check_object_name(name).map_err(|reason| Error::InvalidInput {
message: format!("invalid namespace name '{name}': {reason}"),
})
pub fn validate_namespace_component(name: &str) -> Result<()> {
validate_view_or_namespace_or_secret(name, "namespace component", MAX_NAMESPACE_NAME_CHARS)
}
/// Validate one component of a Secret identifier: a Secret name, or one segment
/// of the namespace path holding it.
///
/// The join decides identity, and the service only sees what the split
/// produced. `"a$b"` is not a name the service accepts, but joined and split it
/// reads as the namespace `a` and the name `b` -- a different Secret that may
/// already exist. This is not a second opinion on the name; it is what lets the
/// service have one.
pub fn validate_secret_component(what: &str, value: &str) -> Result<()> {
check_object_name(value).map_err(|reason| Error::InvalidInput {
message: format!("invalid {what} '{value}': {reason}"),
})
/// Validate all components of a namespace
pub fn validate_namespace(namespace: &[String]) -> Result<()> {
if namespace.len() > 1 && namespace.first().map(String::as_str) == Some("public") {
return Err(Error::InvalidInput {
message: "Namespaces that begin with public cannot have multiple namespace components."
.to_string(),
});
}
if namespace.len() > MAX_NAMESPACE_COMPONENTS {
return Err(Error::InvalidInput {
message: format!(
"Cannot have a namespace with more than {MAX_NAMESPACE_COMPONENTS} components."
),
});
}
for component in namespace {
validate_namespace_component(component)?;
}
Ok(())
}
/// Validate a secret name.
pub fn validate_secret_name(name: &str) -> Result<()> {
validate_view_or_namespace_or_secret(name, "secret name", MAX_SECRET_NAME_CHARS)
}
/// Validate a Secret name and every segment of the namespace path holding it.
pub fn validate_secret_reference(name: &str, namespace_path: &[String]) -> Result<()> {
for segment in namespace_path {
validate_secret_component("Secret namespace path segment", segment)?;
}
validate_secret_component("Secret name", name)
validate_namespace(namespace_path)?;
validate_secret_name(name)
}
pub fn validate_view_name(name: &str) -> Result<()> {
validate_view_or_namespace_or_secret(name, "view name", MAX_VIEW_NAME_CHARS)
}
/// Validate a view name and every segment of the namespace path holding it.
///
/// Worded for a view rather than deferring to [`validate_table_name`]: a view
/// is not a table, and a caller who mistypes one should not be told their
/// table name is invalid. The rule is the same one every object name follows,
/// which is what lets the `$`-joined identifier split back apart.
pub fn validate_view_reference(name: &str, namespace_path: &[String]) -> Result<()> {
for segment in namespace_path {
validate_view_component("view namespace path segment", segment)?;
validate_namespace(namespace_path)?;
validate_view_name(name)
}
/// Validate a function name. These do not allow dashes or periods.
pub fn validate_function_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidInput {
message: format!("Invalid function name '{}'.", name),
});
}
validate_view_component("view name", name)
if name.len() > MAX_FUNCTION_NAME_CHARS {
return Err(Error::InvalidInput {
message: format!(
"Invalid function name '{}' is more than {} characters long.",
sanitize(name),
MAX_FUNCTION_NAME_CHARS
),
});
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(Error::InvalidInput {
message: format!(
"Invalid function name '{}': only alphanumeric \
characters and underscores are allowed.",
sanitize(name)
),
});
}
Ok(())
}
/// Validate one component of a view identifier: a view name, or one segment of
/// the namespace path holding it.
pub fn validate_view_component(what: &str, value: &str) -> Result<()> {
check_object_name(value).map_err(|reason| Error::InvalidInput {
message: format!("invalid {what} '{value}': {reason}"),
})
}
/// Validate all components of a namespace
///
/// Iterates through all namespace components and validates each one.
/// Returns an error if any component is invalid.
///
/// # Arguments
/// * `namespace` - The namespace components to validate
///
/// # Returns
/// * `Ok(())` if all namespace components are valid
/// * `Err(Error)` if any component is invalid
pub fn validate_namespace(namespace: &[String]) -> Result<()> {
for component in namespace {
validate_namespace_name(component)?;
/// Validate a database name, including slash-separated components.
pub fn validate_database_name(name: &str) -> Result<()> {
if name.len() > MAX_DB_NAME_CHARS {
return Err(Error::InvalidInput {
message: format!(
"Invalid database name '{}' is more than {} characters long.",
sanitize(name),
MAX_DB_NAME_CHARS
),
});
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
{
return Err(Error::InvalidInput {
message: format!(
"Invalid database name '{}': only alphanumeric characters, dashes, \
underscores, periods, and slashes are allowed.",
sanitize(name)
),
});
}
for component in name.split('/') {
if component.is_empty() {
return Err(Error::InvalidInput {
message: format!(
"Invalid database name '{}': invalid empty path component.",
sanitize(name)
),
});
}
if component == "." || component == ".." {
return Err(Error::InvalidInput {
message: format!(
"Invalid database name '{}': illegal path component.",
sanitize(name)
),
});
}
}
Ok(())
}
@@ -494,6 +567,26 @@ pub fn supported_fts_data_type(dtype: &DataType) -> bool {
supported_fts_data_type_impl(dtype, false)
}
/// Validate FTS input without losing the logical type in field metadata.
pub(crate) fn validate_fts_field(field: &Field) -> Result<()> {
if lance_arrow::json::is_json_field(field) || supported_fts_data_type(field.data_type()) {
return Ok(());
}
let logical_type = field
.metadata()
.get("ARROW:extension:name")
.map(|name| format!(" (logical type {name})"))
.unwrap_or_default();
Err(Error::Schema {
message: format!(
"A FTS index cannot be created on the field `{}` which has data type {}{}. FTS supports strings, lists of strings, and lance.json fields stored as LargeBinary; raw binary is not supported",
field.name(),
field.data_type(),
logical_type,
),
})
}
fn supported_fts_data_type_impl(dtype: &DataType, in_list: bool) -> bool {
match (dtype, in_list) {
(DataType::Utf8 | DataType::LargeUtf8, _) => true,
@@ -707,6 +800,43 @@ mod tests {
use super::*;
#[test]
fn test_fts_field_logical_type_validation() {
let json = lance_arrow::json::json_field("doc", true);
assert!(validate_fts_field(&json).is_ok());
for dtype in [DataType::Binary, DataType::LargeBinary] {
let raw = Field::new("doc", dtype.clone(), true);
let err = validate_fts_field(&raw).unwrap_err().to_string();
assert!(err.contains("raw binary is not supported"), "{err}");
assert!(err.contains("lance.json"), "{err}");
let other_extension = raw.with_metadata(std::collections::HashMap::from([(
"ARROW:extension:name".into(),
"other.json".into(),
)]));
let err = validate_fts_field(&other_extension)
.unwrap_err()
.to_string();
assert!(err.contains("logical type other.json"), "{err}");
}
// A JSON marker on the wrong binary storage type is insufficient.
let wrong_storage =
Field::new("doc", DataType::Binary, true).with_metadata(json.metadata().clone());
assert!(validate_fts_field(&wrong_storage).is_err());
for dtype in [DataType::Utf8, DataType::LargeUtf8] {
let text = Field::new("text", dtype, true);
assert!(validate_fts_field(&text).is_ok());
for list_type in [
DataType::List(Arc::new(text.clone())),
DataType::LargeList(Arc::new(text.clone())),
] {
assert!(validate_fts_field(&Field::new("texts", list_type, true)).is_ok());
}
}
}
#[test]
fn test_public_fts_field_path_prefers_exact_case() {
let text_list = || {
@@ -870,6 +1000,37 @@ mod tests {
assert!(err.contains("text.embedding"));
}
#[test]
fn test_validate_database_name() {
for name in [
"db",
"team/search",
"Team_1/db-2.3",
&"a".repeat(MAX_DB_NAME_CHARS),
] {
assert!(validate_database_name(name).is_ok(), "rejected {name:?}");
}
for name in [
"",
"/",
"//",
".",
"..",
"/db",
"db/",
"a//b",
"a/./b",
"a/../b",
"a$b",
"a b",
"a?b",
"café",
&"a".repeat(MAX_DB_NAME_CHARS + 1),
] {
assert!(validate_database_name(name).is_err(), "accepted {name:?}");
}
}
#[test]
fn test_validate_table_name() {
assert!(validate_table_name("my_table").is_ok());
@@ -890,27 +1051,27 @@ mod tests {
}
#[test]
fn test_validate_namespace_name() {
fn test_validate_namespace_component() {
// Valid namespace names
assert!(validate_namespace_name("ns1").is_ok());
assert!(validate_namespace_name("namespace_123").is_ok());
assert!(validate_namespace_name("my-namespace").is_ok());
assert!(validate_namespace_name("my.namespace").is_ok());
assert!(validate_namespace_name("NS_1.2.3").is_ok());
assert!(validate_namespace_name("a").is_ok());
assert!(validate_namespace_name("123").is_ok());
assert!(validate_namespace_name("_underscore").is_ok());
assert!(validate_namespace_name("-hyphen").is_ok());
assert!(validate_namespace_name(".period").is_ok());
assert!(validate_namespace_component("ns1").is_ok());
assert!(validate_namespace_component("namespace_123").is_ok());
assert!(validate_namespace_component("my-namespace").is_ok());
assert!(validate_namespace_component("my.namespace").is_ok());
assert!(validate_namespace_component("NS_1.2.3").is_ok());
assert!(validate_namespace_component("a").is_ok());
assert!(validate_namespace_component("123").is_ok());
assert!(validate_namespace_component("_underscore").is_ok());
assert!(validate_namespace_component("-hyphen").is_ok());
assert!(validate_namespace_component(".period").is_ok());
// Invalid namespace names
assert!(validate_namespace_name("").is_err());
assert!(validate_namespace_name("namespace with spaces").is_err());
assert!(validate_namespace_name("namespace/with/slashes").is_err());
assert!(validate_namespace_name("namespace\\with\\backslashes").is_err());
assert!(validate_namespace_name("namespace$with$delimiter").is_err());
assert!(validate_namespace_name("namespace@special").is_err());
assert!(validate_namespace_name("namespace#hash").is_err());
assert!(validate_namespace_component("").is_err());
assert!(validate_namespace_component("namespace with spaces").is_err());
assert!(validate_namespace_component("namespace/with/slashes").is_err());
assert!(validate_namespace_component("namespace\\with\\backslashes").is_err());
assert!(validate_namespace_component("namespace$with$delimiter").is_err());
assert!(validate_namespace_component("namespace@special").is_err());
assert!(validate_namespace_component("namespace#hash").is_err());
}
#[test]
@@ -926,6 +1087,9 @@ mod tests {
// Empty namespace (root) is valid
assert!(validate_namespace(&[]).is_ok());
// Valid public namespace (which is equivalent to the root namespace.)
assert!(validate_namespace(&["public".to_string()]).is_ok());
// Invalid: contains empty component
assert!(validate_namespace(&["ns1".to_string(), "".to_string()]).is_err());
@@ -937,6 +1101,9 @@ mod tests {
assert!(validate_namespace(&["ns1".to_string(), "ns/2".to_string()]).is_err());
assert!(validate_namespace(&["ns1".to_string(), "ns$2".to_string()]).is_err());
// Invalid: starts with public but contains other components.
assert!(validate_namespace(&["public".to_string(), "foo".to_string()]).is_err());
// Valid: underscores, hyphens, and periods are allowed
assert!(
validate_namespace(&["ns_1".to_string(), "ns-2".to_string(), "ns.3".to_string()])
@@ -23,7 +23,7 @@
"nullable": false
}
},
"created_at": "2026-08-21T00:00:00Z",
"created_at_millis": 1787270400000,
"image": {
"manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6",
"descriptor": {
@@ -1 +1 @@
{"created_at":"2026-08-21T00:00:00Z","disabled":false,"image":{"descriptor":{"behavior":{"result_stability":"input_and_context","side_effects":"non_idempotent"},"entrypoint":"app.function:create","format_version":1,"interface":{"type":"lance.scalar","version":1},"python":{"abi_tag":"cp312","executable":"/usr/local/bin/python3","implementation":"cpython","import_paths":["/opt/function/code"],"version":"3.12.14"},"python_api":1,"requires":{"capabilities":[],"kernel_min":"4.18.0"},"schemas":{"initialization":"/opt/function/schemas/initialization.arrow","input":"/opt/function/schemas/input.arrow","output":"/opt/function/schemas/output.arrow"}},"manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","source":false},"location":"memory:///fixture","metadata":{},"name":"embed","object_id":"fixture","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"1"}
{"created_at_millis":1787270400000,"disabled":false,"image":{"descriptor":{"behavior":{"result_stability":"input_and_context","side_effects":"non_idempotent"},"entrypoint":"app.function:create","format_version":1,"interface":{"type":"lance.scalar","version":1},"python":{"abi_tag":"cp312","executable":"/usr/local/bin/python3","implementation":"cpython","import_paths":["/opt/function/code"],"version":"3.12.14"},"python_api":1,"requires":{"capabilities":[],"kernel_min":"4.18.0"},"schemas":{"initialization":"/opt/function/schemas/initialization.arrow","input":"/opt/function/schemas/input.arrow","output":"/opt/function/schemas/output.arrow"}},"manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","source":false},"location":"memory:///fixture","metadata":{},"name":"embed","object_id":"fixture","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"1"}