Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 229 additions & 2 deletions rust/lance-namespace-impls/src/dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ use lance_namespace::models::{
};

use lance_core::{Error, Result, box_error};
use lance_index::scalar::inverted::query::{
BooleanQuery, BoostQuery, FtsQuery, MatchQuery, MultiMatchQuery, Occur, Operator, PhraseQuery,
};
use lance_namespace::LanceNamespace;
use lance_namespace::error::NamespaceError;
use lance_namespace::schema::arrow_schema_to_json;
Expand Down Expand Up @@ -4896,14 +4899,21 @@ impl LanceNamespace for DirectoryNamespace {
})?;
}

scanner
.full_text_search(fts)
.map_err(|e| NamespaceError::InvalidInput {
message: format!("Invalid full text search: {:?}", e),
})?;
} else if let Some(ref structured_query) = fts_query.structured_query {
// Structured FTS: map the namespace query model into the engine FtsQuery.
let engine_query = build_engine_fts_query(&structured_query.query)?;
let fts = FullTextSearchQuery::new_query(engine_query);
scanner
.full_text_search(fts)
.map_err(|e| NamespaceError::InvalidInput {
message: format!("Invalid full text search: {:?}", e),
})?;
}
// Note: structured_query would require more complex parsing
// For now, we only support string_query
}

// Apply column projection if specified
Expand Down Expand Up @@ -5319,10 +5329,227 @@ impl LanceNamespace for DirectoryNamespace {
}
}

/// Maps a namespace structured `FtsQuery` model into the engine `FtsQuery`. Mirrors the mapping the
/// JNI scanner performs, so the local `queryTable` path honors `structured_query` the same way a
/// `fragment.newScan(fullTextQuery)` does.
fn build_engine_fts_query(
query: &lance_namespace::models::FtsQuery,
) -> std::result::Result<FtsQuery, NamespaceError> {
Comment on lines +5335 to +5337

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: should build_engine_fts_query be a TryFrom impl?

@hamersaw hamersaw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do this, but TIL TryFrom is a foreign trait so it can't be used on types that are foreign to this crate. The source (ie. lance_namespace::models::FtsQuery) and the target (ie. lance_index::scalar::inverted::query::FtsQuery) both live elsewhere, so it won't compile with TryFrom. The options would be to define TryFrom on these types in either the lance_namespace or lance_index crates, but that feels like the wrong approach?

if let Some(ref m) = query.r#match {
Ok(FtsQuery::Match(build_engine_match_query(m)?))
} else if let Some(ref p) = query.phrase {
let mut phrase = PhraseQuery::new(p.terms.clone());
if let Some(ref column) = p.column {
phrase = phrase.with_column(Some(column.clone()));
}
if let Some(slop) = p.slop {
phrase = phrase.with_slop(slop as u32);
}
Ok(FtsQuery::Phrase(phrase))
Comment on lines +5340 to +5348

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unchecked signed→unsigned casts on slop/fuzziness/max_expansions/prefix_length.

p.slop as u32 (line 5346), fuzziness as u32 (line 5396), max_expansions as usize (line 5399), and prefix_length as u32 (line 5409) all cast without checking for negative input first. If these namespace-model fields are signed integers (as the presence of as casts suggests) and a caller passes e.g. -1, the value silently wraps to a huge unsigned number (u32::MAX) instead of erroring — producing nonsensical fuzzy-match/slop behavior rather than a clear validation error. Note this file already validates signed→unsigned conversions elsewhere in the same PR-adjacent code (e.g. from_version in create_table_branch, version <= 0 checks in tag/version handlers, request.k > 0 before as usize), so this is inconsistent with the rest of the file.

🛠️ Example fix pattern (repeat for each field)
     if let Some(slop) = p.slop {
-        phrase = phrase.with_slop(slop as u32);
+        let slop = u32::try_from(slop).map_err(|_| NamespaceError::InvalidInput {
+            message: format!("phrase.slop must be non-negative, got {}", slop),
+        })?;
+        phrase = phrase.with_slop(slop);
     }

As per coding guidelines, "Validate inputs and reject invalid values with descriptive errors at API boundaries; never silently clamp or adjust values." Please confirm the actual signedness of these lance_namespace::models fields.

#!/bin/bash
rg -nP 'struct (MatchQuery|PhraseQuery)\b' -A 25
rg -nP '\bfuzziness\b|\bmax_expansions\b|\bprefix_length\b|\bslop\b' -g '*.rs'

Also applies to: 5385-5412

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-namespace-impls/src/dir.rs` around lines 5340 - 5348, Validate
signed namespace-model fields before converting them in the query conversion
logic around PhraseQuery and the fuzzy-query construction: reject negative slop,
fuzziness, max_expansions, and prefix_length with descriptive errors, then
perform the existing unsigned conversions only after validation. Confirm the
actual field types in lance_namespace::models and preserve valid-value behavior
while preventing negative inputs from wrapping.

} else if let Some(ref mm) = query.multi_match {
let match_queries = mm
.match_queries
.iter()
.map(build_engine_match_query)
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(FtsQuery::MultiMatch(MultiMatchQuery { match_queries }))
} else if let Some(ref b) = query.boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): could you add test coverage for the phrase / boolean / boost query roundtrip? Looks like you just have match query.

@hamersaw hamersaw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call, added round trip tests for all of the FtsQuery variants.

let mut clauses: Vec<(Occur, FtsQuery)> = Vec::new();
for clause in &b.must {
clauses.push((Occur::Must, build_engine_fts_query(clause)?));
}
for clause in &b.should {
clauses.push((Occur::Should, build_engine_fts_query(clause)?));
}
for clause in &b.must_not {
clauses.push((Occur::MustNot, build_engine_fts_query(clause)?));
}
Ok(FtsQuery::Boolean(BooleanQuery::new(clauses)))
} else if let Some(ref boost) = query.boost {
let positive = build_engine_fts_query(&boost.positive)?;
let negative = build_engine_fts_query(&boost.negative)?;
Ok(FtsQuery::Boost(BoostQuery::new(
positive,
negative,
boost.negative_boost,
)))
} else {
Err(NamespaceError::InvalidInput {
message: "structured_query.query must set exactly one of match, phrase, multi_match, \
boolean, or boost"
.to_string(),
})
}
}
Comment on lines +5335 to +5383

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

build_engine_fts_query silently picks a variant instead of rejecting ambiguous input.

The if/else if chain over match/phrase/multi_match/boolean/boost only rejects the "none set" case (line 5376-5382). If a caller sets more than one variant (e.g. both match and phrase), the function silently uses whichever is checked first and drops the rest — even though the error message on line 5378-5380 states "must set exactly one of...". This contradicts the stated contract and can silently produce a query that doesn't match what the caller intended.

🛠️ Proposed fix: count set variants and reject >1
 fn build_engine_fts_query(
     query: &lance_namespace::models::FtsQuery,
 ) -> std::result::Result<FtsQuery, NamespaceError> {
+    let set_count = [
+        query.r#match.is_some(),
+        query.phrase.is_some(),
+        query.multi_match.is_some(),
+        query.boolean.is_some(),
+        query.boost.is_some(),
+    ]
+    .into_iter()
+    .filter(|set| *set)
+    .count();
+    if set_count > 1 {
+        return Err(NamespaceError::InvalidInput {
+            message: "structured_query.query must set exactly one of match, phrase, multi_match, \
+                      boolean, or boost, but multiple were set"
+                .to_string(),
+        });
+    }
     if let Some(ref m) = query.r#match {

As per coding guidelines, "Validate mutually exclusive builder or configuration options and report a clear error when both are set."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn build_engine_fts_query(
query: &lance_namespace::models::FtsQuery,
) -> std::result::Result<FtsQuery, NamespaceError> {
if let Some(ref m) = query.r#match {
Ok(FtsQuery::Match(build_engine_match_query(m)?))
} else if let Some(ref p) = query.phrase {
let mut phrase = PhraseQuery::new(p.terms.clone());
if let Some(ref column) = p.column {
phrase = phrase.with_column(Some(column.clone()));
}
if let Some(slop) = p.slop {
phrase = phrase.with_slop(slop as u32);
}
Ok(FtsQuery::Phrase(phrase))
} else if let Some(ref mm) = query.multi_match {
let match_queries = mm
.match_queries
.iter()
.map(build_engine_match_query)
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(FtsQuery::MultiMatch(MultiMatchQuery { match_queries }))
} else if let Some(ref b) = query.boolean {
let mut clauses: Vec<(Occur, FtsQuery)> = Vec::new();
for clause in &b.must {
clauses.push((Occur::Must, build_engine_fts_query(clause)?));
}
for clause in &b.should {
clauses.push((Occur::Should, build_engine_fts_query(clause)?));
}
for clause in &b.must_not {
clauses.push((Occur::MustNot, build_engine_fts_query(clause)?));
}
Ok(FtsQuery::Boolean(BooleanQuery::new(clauses)))
} else if let Some(ref boost) = query.boost {
let positive = build_engine_fts_query(&boost.positive)?;
let negative = build_engine_fts_query(&boost.negative)?;
Ok(FtsQuery::Boost(BoostQuery::new(
positive,
negative,
boost.negative_boost,
)))
} else {
Err(NamespaceError::InvalidInput {
message: "structured_query.query must set exactly one of match, phrase, multi_match, \
boolean, or boost"
.to_string(),
})
}
}
fn build_engine_fts_query(
query: &lance_namespace::models::FtsQuery,
) -> std::result::Result<FtsQuery, NamespaceError> {
let set_count = [
query.r#match.is_some(),
query.phrase.is_some(),
query.multi_match.is_some(),
query.boolean.is_some(),
query.boost.is_some(),
]
.into_iter()
.filter(|set| *set)
.count();
if set_count > 1 {
return Err(NamespaceError::InvalidInput {
message: "structured_query.query must set exactly one of match, phrase, multi_match, \
boolean, or boost, but multiple were set"
.to_string(),
});
}
if let Some(ref m) = query.r#match {
Ok(FtsQuery::Match(build_engine_match_query(m)?))
} else if let Some(ref p) = query.phrase {
let mut phrase = PhraseQuery::new(p.terms.clone());
if let Some(ref column) = p.column {
phrase = phrase.with_column(Some(column.clone()));
}
if let Some(slop) = p.slop {
phrase = phrase.with_slop(slop as u32);
}
Ok(FtsQuery::Phrase(phrase))
} else if let Some(ref mm) = query.multi_match {
let match_queries = mm
.match_queries
.iter()
.map(build_engine_match_query)
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(FtsQuery::MultiMatch(MultiMatchQuery { match_queries }))
} else if let Some(ref b) = query.boolean {
let mut clauses: Vec<(Occur, FtsQuery)> = Vec::new();
for clause in &b.must {
clauses.push((Occur::Must, build_engine_fts_query(clause)?));
}
for clause in &b.should {
clauses.push((Occur::Should, build_engine_fts_query(clause)?));
}
for clause in &b.must_not {
clauses.push((Occur::MustNot, build_engine_fts_query(clause)?));
}
Ok(FtsQuery::Boolean(BooleanQuery::new(clauses)))
} else if let Some(ref boost) = query.boost {
let positive = build_engine_fts_query(&boost.positive)?;
let negative = build_engine_fts_query(&boost.negative)?;
Ok(FtsQuery::Boost(BoostQuery::new(
positive,
negative,
boost.negative_boost,
)))
} else {
Err(NamespaceError::InvalidInput {
message: "structured_query.query must set exactly one of match, phrase, multi_match, \
boolean, or boost"
.to_string(),
})
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-namespace-impls/src/dir.rs` around lines 5335 - 5383, Update
build_engine_fts_query to validate that exactly one of match, phrase,
multi_match, boolean, or boost is set before constructing the engine query.
Reject inputs with multiple variants using the existing
NamespaceError::InvalidInput path and a clear message, while preserving the
current conversion logic for valid single-variant queries and the existing error
for none set.

Source: Coding guidelines


fn build_engine_match_query(
m: &lance_namespace::models::MatchQuery,
) -> std::result::Result<MatchQuery, NamespaceError> {
Comment on lines +5385 to +5387

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Should build_engine_match_query really by a TryFrom implementation instead?

@hamersaw hamersaw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately the same issue as the other, where TryFrom can't operate on foreign types.

let mut match_query = MatchQuery::new(m.terms.clone());
if let Some(ref column) = m.column {
match_query = match_query.with_column(Some(column.clone()));
}
if let Some(boost) = m.boost {
match_query = match_query.with_boost(boost);
}
if let Some(fuzziness) = m.fuzziness {
match_query = match_query.with_fuzziness(Some(fuzziness as u32));
}
if let Some(max_expansions) = m.max_expansions {
match_query = match_query.with_max_expansions(max_expansions as usize);
}
if let Some(ref operator) = m.operator {
let op =
Operator::try_from(operator.as_str()).map_err(|e| NamespaceError::InvalidInput {
message: format!("Invalid FTS operator: {:?}", e),
})?;
match_query = match_query.with_operator(op);
}
if let Some(prefix_length) = m.prefix_length {
match_query = match_query.with_prefix_length(prefix_length as u32);
}
Ok(match_query)
}

#[cfg(test)]
mod tests {
use super::*;
use arrow_ipc::reader::{FileReader, StreamReader};

#[test]
fn test_build_engine_fts_query_match() {
let mut ns_match = lance_namespace::models::MatchQuery::new("hello world".to_string());
ns_match.column = Some("body".to_string());
ns_match.operator = Some("AND".to_string());
ns_match.fuzziness = Some(1);
ns_match.max_expansions = Some(30);
ns_match.boost = Some(2.0);
ns_match.prefix_length = Some(2);

let mut ns_query = lance_namespace::models::FtsQuery::new();
ns_query.r#match = Some(Box::new(ns_match));

match build_engine_fts_query(&ns_query).unwrap() {
FtsQuery::Match(m) => {
assert_eq!(m.terms, "hello world");
assert_eq!(m.column, Some("body".to_string()));
assert_eq!(m.operator, Operator::And);
assert_eq!(m.fuzziness, Some(1));
assert_eq!(m.max_expansions, 30);
assert_eq!(m.boost, 2.0);
assert_eq!(m.prefix_length, 2);
}
other => panic!("expected Match, got {:?}", other),
}
}

/// Wraps a namespace `MatchQuery` (with a column) as an `FtsQuery` for use as a clause in
/// compound queries (boolean / boost).
fn ns_match_query(terms: &str, column: &str) -> lance_namespace::models::FtsQuery {
let mut m = lance_namespace::models::MatchQuery::new(terms.to_string());
m.column = Some(column.to_string());
let mut q = lance_namespace::models::FtsQuery::new();
q.r#match = Some(Box::new(m));
q
}

#[test]
fn test_build_engine_fts_query_phrase() {
let mut ns_phrase = lance_namespace::models::PhraseQuery::new("hello world".to_string());
ns_phrase.column = Some("body".to_string());
ns_phrase.slop = Some(2);

let mut ns_query = lance_namespace::models::FtsQuery::new();
ns_query.phrase = Some(Box::new(ns_phrase));

match build_engine_fts_query(&ns_query).unwrap() {
FtsQuery::Phrase(p) => {
assert_eq!(p.terms, "hello world");
assert_eq!(p.column, Some("body".to_string()));
assert_eq!(p.slop, 2);
}
other => panic!("expected Phrase, got {:?}", other),
}
}

#[test]
fn test_build_engine_fts_query_multi_match() {
let mut m1 = lance_namespace::models::MatchQuery::new("hello".to_string());
m1.column = Some("title".to_string());
let mut m2 = lance_namespace::models::MatchQuery::new("hello".to_string());
m2.column = Some("body".to_string());
m2.boost = Some(2.0);

let ns_multi = lance_namespace::models::MultiMatchQuery::new(vec![m1, m2]);
let mut ns_query = lance_namespace::models::FtsQuery::new();
ns_query.multi_match = Some(Box::new(ns_multi));

match build_engine_fts_query(&ns_query).unwrap() {
FtsQuery::MultiMatch(mm) => {
assert_eq!(mm.match_queries.len(), 2);
assert_eq!(mm.match_queries[0].terms, "hello");
assert_eq!(mm.match_queries[0].column, Some("title".to_string()));
assert_eq!(mm.match_queries[1].column, Some("body".to_string()));
assert_eq!(mm.match_queries[1].boost, 2.0);
}
other => panic!("expected MultiMatch, got {:?}", other),
}
}

#[test]
fn test_build_engine_fts_query_boolean() {
// BooleanQuery::new(must, must_not, should)
let ns_boolean = lance_namespace::models::BooleanQuery::new(
vec![ns_match_query("must-term", "body")],
vec![ns_match_query("must-not-term", "body")],
vec![ns_match_query("should-term", "body")],
);
let mut ns_query = lance_namespace::models::FtsQuery::new();
ns_query.boolean = Some(Box::new(ns_boolean));

match build_engine_fts_query(&ns_query).unwrap() {
FtsQuery::Boolean(b) => {
assert!(matches!(&b.must[..], [FtsQuery::Match(m)] if m.terms == "must-term"));
assert!(
matches!(&b.must_not[..], [FtsQuery::Match(m)] if m.terms == "must-not-term")
);
assert!(matches!(&b.should[..], [FtsQuery::Match(m)] if m.terms == "should-term"));
}
other => panic!("expected Boolean, got {:?}", other),
}
}

#[test]
fn test_build_engine_fts_query_boost() {
let mut ns_boost = lance_namespace::models::BoostQuery::new(
ns_match_query("positive-term", "body"),
ns_match_query("negative-term", "body"),
);
ns_boost.negative_boost = Some(0.25);

let mut ns_query = lance_namespace::models::FtsQuery::new();
ns_query.boost = Some(Box::new(ns_boost));

match build_engine_fts_query(&ns_query).unwrap() {
FtsQuery::Boost(b) => {
assert!(
matches!(b.positive.as_ref(), FtsQuery::Match(m) if m.terms == "positive-term")
);
assert!(
matches!(b.negative.as_ref(), FtsQuery::Match(m) if m.terms == "negative-term")
);
assert_eq!(b.negative_boost, 0.25);
}
other => panic!("expected Boost, got {:?}", other),
}
}

#[test]
fn test_build_engine_fts_query_requires_a_variant() {
// An FtsQuery with no variant set is rejected rather than silently ignored.
let empty = lance_namespace::models::FtsQuery::new();
assert!(build_engine_fts_query(&empty).is_err());
}
Comment on lines +5547 to +5552

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test only checks is_err(), not the error variant/message.

test_build_engine_fts_query_requires_a_variant asserts build_engine_fts_query(&empty).is_err() but never verifies it's NamespaceError::InvalidInput with the expected message.

✅ Proposed fix
     #[test]
     fn test_build_engine_fts_query_requires_a_variant() {
         // An FtsQuery with no variant set is rejected rather than silently ignored.
         let empty = lance_namespace::models::FtsQuery::new();
-        assert!(build_engine_fts_query(&empty).is_err());
+        let err = build_engine_fts_query(&empty).unwrap_err();
+        assert!(matches!(err, NamespaceError::InvalidInput { .. }));
+        assert!(err.to_string().contains("must set exactly one of"));
     }

As per coding guidelines, "Assert on both the error variant and the message content in tests; do not check only is_err()."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn test_build_engine_fts_query_requires_a_variant() {
// An FtsQuery with no variant set is rejected rather than silently ignored.
let empty = lance_namespace::models::FtsQuery::new();
assert!(build_engine_fts_query(&empty).is_err());
}
#[test]
fn test_build_engine_fts_query_requires_a_variant() {
// An FtsQuery with no variant set is rejected rather than silently ignored.
let empty = lance_namespace::models::FtsQuery::new();
let err = build_engine_fts_query(&empty).unwrap_err();
assert!(matches!(err, NamespaceError::InvalidInput { .. }));
assert!(err.to_string().contains("must set exactly one of"));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-namespace-impls/src/dir.rs` around lines 5547 - 5552, Strengthen
test_build_engine_fts_query_requires_a_variant by matching the returned error as
NamespaceError::InvalidInput and asserting its message contains the expected
missing-variant text. Replace the is_err-only assertion while preserving the
existing empty FtsQuery setup.

Source: Coding guidelines

use lance::dataset::Dataset;
use lance::index::DatasetIndexExt;
use lance_core::utils::tempfile::{TempStdDir, TempStrDir};
Expand Down
Loading