fix(python): allow phrase queries without a column (#4653)

Quoted async full-text queries without `columns` carry an unset phrase
column, but converting them to Python unwrapped it and raised
`PanicException`. Python also required `PhraseQuery.column`, preventing
callers from constructing the equivalent query object.

Make the Python column optional and preserve `Option<String>` in both
Rust conversion directions, leaving column selection to query planning.
Regression tests cover quoted strings and structured queries with
omitted, `None`, and explicit columns, query reuse, JSON serialization,
slop, and document granularity.

Validation:

- Reproduced both missing-column failures before the fix;
explicit-column controls passed.
- Rebuilt the extension with `uv run --extra tests --extra dev maturin
develop --extras tests,dev`.
- `cd python && uv run --no-sync --extra tests pytest
python/tests/test_fts.py
python/tests/test_query.py::test_query_serialization_async -q`: 85
passed.
- `cargo fmt --all -- --check` and `ruff check python` pass; touched
Python files pass formatting checks.
- `cargo clippy --quiet -p lancedb-python --lib --features remote`
passes.
- Root-wide `ruff check .` reports 49 existing findings in unchanged
documentation examples.

Fixes #4651

<!-- lance-gatekeeper-fix:v1 agent=62d36fad70c8717236283b8e97dd6b30
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-27 15:53:14 +08:00
committed by GitHub
co-authored by Xuanwo
parent 8887c82789
commit 1edfa6077b
3 changed files with 52 additions and 8 deletions
+3 -3
View File
@@ -591,8 +591,8 @@ class PhraseQuery(FullTextQuery):
----------
query : str
The query string to match against.
column : str
The name of the column to match against.
column : str, optional
The column to match against. None leaves column selection to query planning.
slop : int, default 0
The maximum number of intervening positions permitted in the phrase.
document_granularity : DocumentGranularity, optional
@@ -602,7 +602,7 @@ class PhraseQuery(FullTextQuery):
"""
query: str
column: str
column: Optional[str] = None
slop: int = pydantic.Field(0, kw_only=True)
document_granularity: Optional[DocumentGranularity] = pydantic.Field(
None, kw_only=True
+46
View File
@@ -584,6 +584,52 @@ async def test_search_fts_phrase_query_async(async_table):
assert len(phrase_results) > 0
@pytest.mark.asyncio
@pytest.mark.parametrize("columns", [None, ["text"]])
async def test_phrase_query_to_query_object_async(tmp_path, columns):
db = await ldb.connect_async(tmp_path)
table = await db.create_table(
"phrases", pa.table({"text": ["puppy runs fast", "cat sleeps"]})
)
await table.create_index("text", config=FTS(with_position=True))
query = table.query().nearest_to_text('"puppy runs"', columns=columns)
phrase = query.to_query_object().full_text_query.query
expected = (
PhraseQuery("puppy runs", "text") if columns else PhraseQuery("puppy runs")
)
assert phrase == expected
results = await table.query().nearest_to_text(phrase).to_list()
assert [row["text"] for row in results] == ["puppy runs fast"]
@pytest.mark.asyncio
@pytest.mark.parametrize("column", [None, "text"])
async def test_phrase_query_object_roundtrip_async(tmp_path, column):
db = await ldb.connect_async(tmp_path)
table = await db.create_table(
"phrases",
pa.table({"text": ["puppy runs fast", "puppy often runs", "cat sleeps"]}),
)
await table.create_index("text", config=FTS(with_position=True))
phrase = PhraseQuery(
"puppy runs", column, slop=1, document_granularity=DocumentGranularity.ROW
)
query = table.query().nearest_to_text(phrase)
assert query.to_query_object().full_text_query.query == phrase
serialized = json.loads(phrase.to_json())["phrase"]
assert serialized["slop"] == 1
assert serialized["document_granularity"] == "row"
assert serialized.get("column") == column
results = await query.to_list()
assert sorted(row["text"] for row in results) == [
"puppy often runs",
"puppy runs fast",
]
def test_search_fts_specify_column(table):
table.create_fts_index("text")
table.create_fts_index("text2")
+3 -5
View File
@@ -103,7 +103,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyLanceDB<FtsQuery> {
}
"PhraseQuery" => {
let query = ob.getattr("query")?.extract()?;
let column = ob.getattr("column")?.extract()?;
let column = ob.getattr("column")?.extract::<Option<String>>()?;
let slop = ob.getattr("slop")?.extract()?;
let document_granularity = ob
.getattr("document_granularity")?
@@ -114,9 +114,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyLanceDB<FtsQuery> {
})
.transpose()?;
let mut query = PhraseQuery::new(query)
.with_column(Some(column))
.with_slop(slop);
let mut query = PhraseQuery::new(query).with_column(column).with_slop(slop);
if let Some(document_granularity) = document_granularity {
query = query.with_document_granularity(document_granularity);
}
@@ -213,7 +211,7 @@ impl<'py> IntoPyObject<'py> for PyLanceDB<FtsQuery> {
}
namespace
.getattr(intern!(py, "PhraseQuery"))?
.call((query.terms, query.column.unwrap()), Some(&kwargs))
.call((query.terms, query.column), Some(&kwargs))
}
FtsQuery::Boost(query) => {
let positive = Self(query.positive.as_ref().clone()).into_pyobject(py)?;