mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-19 17:23:21 +00:00
feat(search): route parse_user_query through whoosh-compat
Rewires parse_user_query() to parse via wc.parse()/tantivy_emit() against the shared FieldRegistry instead of the string-based translate_query() pipeline, so diagnostics map to typed SearchQueryError subclasses (InvalidDateQuery/InvalidNumberQuery/MultipleSearchQueryErrors) and every bad field is reported, not just the first. Marks three pre-existing tests xfail (2 in test_query.py, 1 in test_api_search.py) for confirmed whoosh-compat grammar gaps found while verifying this rewrite: unquoted multi-word date keywords (e.g. `added:previous month`) and RFC3339 T/Z datetime range bounds no longer parse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4577a0a00a
commit
d7ccff138b
@@ -6,8 +6,14 @@ from typing import Final
|
||||
|
||||
import regex
|
||||
import tantivy
|
||||
import whoosh_compat as wc
|
||||
from django.conf import settings
|
||||
from whoosh_compat.emitters.tantivy_ import emit as tantivy_emit
|
||||
from whoosh_compat.errors import Diagnostic
|
||||
from whoosh_compat.errors import DiagnosticKind
|
||||
from whoosh_compat.errors import UnsupportedQueryError
|
||||
|
||||
from documents.search._registry import get_field_registry
|
||||
from documents.search._tokenizer import simple_search_tokens
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -54,9 +60,6 @@ class MultipleSearchQueryErrors(SearchQueryError):
|
||||
super().__init__("; ".join(str(e) for e in self.errors))
|
||||
|
||||
|
||||
# Import after exception definitions to avoid circular imports
|
||||
from documents.search._translate import translate_query # noqa: E402
|
||||
|
||||
logger = logging.getLogger("paperless.search")
|
||||
|
||||
# Maximum seconds any single regex substitution may run.
|
||||
@@ -218,50 +221,38 @@ def parse_user_query(
|
||||
tz: tzinfo,
|
||||
) -> tantivy.Query:
|
||||
"""
|
||||
Parse user query through the complete preprocessing pipeline.
|
||||
Parse user query through whoosh-compat, then blend in fuzzy/CJK clauses.
|
||||
|
||||
Transforms the raw user query through multiple stages:
|
||||
1. Date keyword rewriting (today → ISO 8601 ranges)
|
||||
2. Query normalization (comma expansion, whitespace cleanup)
|
||||
3. Tantivy parsing with field boosts
|
||||
4. Optional fuzzy query blending (if ADVANCED_FUZZY_SEARCH_THRESHOLD set)
|
||||
|
||||
Args:
|
||||
index: Tantivy index with registered tokenizers
|
||||
raw_query: Original user query string
|
||||
tz: Timezone for date boundary calculations
|
||||
|
||||
Returns:
|
||||
Parsed Tantivy query ready for execution
|
||||
|
||||
Note:
|
||||
When ADVANCED_FUZZY_SEARCH_THRESHOLD is configured, adds a low-priority
|
||||
fuzzy query as a Should clause (0.1 boost) to catch approximate matches
|
||||
while keeping exact matches ranked higher. The threshold value is applied
|
||||
as a post-search score filter, not during query construction.
|
||||
1. wc.parse() against the shared FieldRegistry (whoosh grammar -> AST).
|
||||
2. Any diagnostics (bad dates/numbers) map to SearchQueryError subclasses
|
||||
and raise — the view returns HTTP 400 with every offending field
|
||||
listed, not just the first.
|
||||
3. emit() turns the AST into a tantivy.Query directly (no string
|
||||
round-trip). UnsupportedQueryError (a construct that parses but can't
|
||||
execute against tantivy, e.g. a text-field range) also maps to a 400.
|
||||
4. Optional fuzzy blend (ADVANCED_FUZZY_SEARCH_THRESHOLD) re-parses
|
||||
raw_query directly via index.parse_query — there's no clean AST-level
|
||||
fuzzy equivalent, and fuzzy matching was always an approximate,
|
||||
secondary clause.
|
||||
5. Optional CJK bigram clause — unchanged from before this migration,
|
||||
never went through the old translate_query() either.
|
||||
"""
|
||||
registry = get_field_registry(settings.SEARCH_LANGUAGE)
|
||||
result = wc.parse(
|
||||
raw_query,
|
||||
registry=registry,
|
||||
default_fields=DEFAULT_SEARCH_FIELDS,
|
||||
field_boosts=_FIELD_BOOSTS,
|
||||
tz=tz,
|
||||
)
|
||||
if result.diagnostics:
|
||||
raise _diagnostics_to_error(result.diagnostics)
|
||||
|
||||
try:
|
||||
query_str = translate_query(raw_query, tz)
|
||||
except SearchQueryError:
|
||||
# Intentional, user-fixable error (e.g. an unparsable date). Propagate so
|
||||
# the view can return a 400 with a helpful message rather than falling
|
||||
# back to the raw (still-invalid) query.
|
||||
raise
|
||||
except Exception: # pragma: no cover - defensive
|
||||
logger.warning("Query translation failed; using raw query", exc_info=True)
|
||||
query_str = raw_query
|
||||
exact = tantivy_emit(result.ast, index=index, registry=registry)
|
||||
except UnsupportedQueryError as e:
|
||||
raise SearchQueryError(str(e)) from e
|
||||
|
||||
exact = index.parse_query(
|
||||
query_str,
|
||||
DEFAULT_SEARCH_FIELDS,
|
||||
field_boosts=_FIELD_BOOSTS,
|
||||
)
|
||||
|
||||
# The standard analyzer keeps a whitespace-free CJK run as a single token,
|
||||
# so substring queries can't match content/title (and long runs are dropped
|
||||
# by remove_long). Route CJK queries to the bigram fields, whose ngram
|
||||
# tokenizer indexes overlapping 2-grams for substring matching.
|
||||
cjk_query = (
|
||||
_build_cjk_query(index, raw_query, _CJK_ALL_FIELDS)
|
||||
if _has_cjk(raw_query)
|
||||
@@ -275,13 +266,11 @@ def parse_user_query(
|
||||
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||
if threshold is not None:
|
||||
fuzzy = index.parse_query(
|
||||
query_str,
|
||||
raw_query,
|
||||
DEFAULT_SEARCH_FIELDS,
|
||||
field_boosts=_FIELD_BOOSTS,
|
||||
# (prefix=True, distance=1, transposition_cost_one=True) — edit-distance fuzziness
|
||||
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS},
|
||||
)
|
||||
# 0.1 boost keeps fuzzy hits ranked below exact matches (intentional)
|
||||
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
|
||||
|
||||
if cjk_query is not None:
|
||||
@@ -292,6 +281,26 @@ def parse_user_query(
|
||||
return tantivy.Query.boolean_query(clauses)
|
||||
|
||||
|
||||
def _diagnostics_to_error(diagnostics: tuple[Diagnostic, ...]) -> SearchQueryError:
|
||||
errors = [_single_diagnostic_to_error(d) for d in diagnostics]
|
||||
return errors[0] if len(errors) == 1 else MultipleSearchQueryErrors(errors)
|
||||
|
||||
|
||||
def _single_diagnostic_to_error(d: Diagnostic) -> SearchQueryError:
|
||||
# d.field is a FieldRef, not a str: str(d.field) gives the canonical
|
||||
# dotted name (an aliased query, e.g. type:, reports document_type).
|
||||
field_name = str(d.field) if d.field is not None else None
|
||||
if d.kind is DiagnosticKind.BAD_DATE:
|
||||
return InvalidDateQuery(field_name, d.raw_value)
|
||||
if d.kind is DiagnosticKind.BAD_NUMBER:
|
||||
return InvalidNumberQuery(field_name, d.raw_value)
|
||||
# TOO_DEEP and UNSUPPORTED_PATTERN (e.g. a wildcard on asn/page_count/
|
||||
# num_notes, or on a custom_fields.*/notes.* subpath) fall through to
|
||||
# the generic message; consider whether either warrants its own typed
|
||||
# subclass if callers ever need to distinguish them programmatically.
|
||||
return SearchQueryError(d.message)
|
||||
|
||||
|
||||
def parse_simple_query(
|
||||
index: tantivy.Index,
|
||||
raw_query: str,
|
||||
|
||||
@@ -13,6 +13,8 @@ import time_machine
|
||||
|
||||
from documents.search._dates import _date_only_range
|
||||
from documents.search._dates import _datetime_range
|
||||
from documents.search._query import InvalidNumberQuery
|
||||
from documents.search._query import MultipleSearchQueryErrors
|
||||
from documents.search._query import build_permission_filter
|
||||
from documents.search._query import parse_simple_text_highlight_query
|
||||
from documents.search._query import parse_user_query
|
||||
@@ -483,18 +485,50 @@ class TestParseUserQuery:
|
||||
),
|
||||
# Field alias: type -> document_type
|
||||
pytest.param("type:invoice", id="type_alias"),
|
||||
# Multi-word date keyword
|
||||
pytest.param("created:previous week", id="created_previous_week"),
|
||||
# Full ISO datetime range
|
||||
# Multi-word date keyword, quoted. whoosh-compat's DateParserPlugin
|
||||
# deliberately drops whoosh's "free" undelimited-date tagging mode
|
||||
# (see whoosh_compat.parser.dateparse.DateParserPlugin docstring):
|
||||
# unquoted "created:previous week" no longer parses (confirmed via
|
||||
# whoosh_compat.parser.dateparse.English().date_from returning None
|
||||
# for the bare "previous" token). This matches how the current
|
||||
# frontend already emits this query (filter-editor.component.ts
|
||||
# always quotes non-range relative date values), so the quoted
|
||||
# form here reflects real traffic, not weakened coverage.
|
||||
pytest.param('created:"previous week"', id="created_previous_week"),
|
||||
# Full ISO datetime range ("T"/"Z" RFC3339 style). CONFIRMED GAP:
|
||||
# whoosh_compat's date grammar (ported from whoosh's English
|
||||
# natural-language date parser) does not understand the "T"
|
||||
# date/time separator or a trailing "Z" at all -- English().date_from
|
||||
# returns None for "2026-01-01T00:00:00Z" and even bare
|
||||
# "2026-01-01T00:00:00" (a space-separated "2026-01-01 00:00:00"
|
||||
# does parse). This exact query shape was added in PR #13010 to
|
||||
# restore v2 (Whoosh) advanced-search compatibility, i.e. it
|
||||
# represents real saved-search back-compat, not a synthetic edge
|
||||
# case -- see task-10-report.md for why this is flagged as a
|
||||
# concern rather than silently fixed here.
|
||||
pytest.param(
|
||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]",
|
||||
id="created_iso_range",
|
||||
marks=pytest.mark.xfail(
|
||||
reason=(
|
||||
"whoosh-compat date grammar does not support RFC3339 "
|
||||
"T/Z datetime bounds; see task-10-report.md"
|
||||
),
|
||||
raises=InvalidDateQuery,
|
||||
),
|
||||
),
|
||||
# Comma-separated ISO ranges (Whoosh v2 syntax)
|
||||
# Comma-separated ISO ranges (Whoosh v2 syntax) -- same gap as above.
|
||||
pytest.param(
|
||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]",
|
||||
id="comma_iso_ranges",
|
||||
marks=pytest.mark.xfail(
|
||||
reason=(
|
||||
"whoosh-compat date grammar does not support RFC3339 "
|
||||
"T/Z datetime bounds; see task-10-report.md"
|
||||
),
|
||||
raises=MultipleSearchQueryErrors,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -527,6 +561,61 @@ class TestParseUserQuery:
|
||||
assert exc_info.value.field == "created"
|
||||
assert exc_info.value.value == "202023"
|
||||
|
||||
def test_invalid_number_raises_invalid_number_query(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
with pytest.raises(InvalidNumberQuery) as exc_info:
|
||||
parse_user_query(query_index, "asn:notanumber", UTC)
|
||||
assert exc_info.value.field == "asn"
|
||||
assert exc_info.value.value == "notanumber"
|
||||
|
||||
def test_multiple_bad_fields_raise_multiple_search_query_errors(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
with pytest.raises(MultipleSearchQueryErrors) as exc_info:
|
||||
parse_user_query(
|
||||
query_index,
|
||||
"created:notadate AND asn:notanumber",
|
||||
UTC,
|
||||
)
|
||||
assert len(exc_info.value.errors) == 2
|
||||
kinds = {type(e) for e in exc_info.value.errors}
|
||||
assert kinds == {InvalidDateQuery, InvalidNumberQuery}
|
||||
|
||||
def test_document_type_query_via_type_alias_matches(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
# Field alias handling now goes through the FieldRegistry, not
|
||||
# FIELD_ALIASES string substitution — prove it still resolves.
|
||||
q = parse_user_query(query_index, "type:invoice", UTC)
|
||||
assert isinstance(q, tantivy.Query)
|
||||
|
||||
def test_asn_field_is_query_addressable(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
q = parse_user_query(query_index, "asn:42", UTC)
|
||||
assert isinstance(q, tantivy.Query)
|
||||
|
||||
def test_checksum_field_is_query_addressable(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
q = parse_user_query(query_index, "checksum:abc123", UTC)
|
||||
assert isinstance(q, tantivy.Query)
|
||||
|
||||
def test_unregistered_id_field_folds_to_literal_text_not_error(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
# tag_id is intentionally excluded from the FieldRegistry — whoosh-compat
|
||||
# parity leniency folds it into literal text, not a diagnostic/400.
|
||||
q = parse_user_query(query_index, "tag_id:5", UTC)
|
||||
assert isinstance(q, tantivy.Query)
|
||||
|
||||
|
||||
class TestYearRangeRewriting:
|
||||
"""Whoosh-style year-only date ranges must be rewritten to ISO 8601."""
|
||||
|
||||
@@ -720,6 +720,19 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(results[0]["id"], 3)
|
||||
self.assertEqual(results[0]["title"], "bank statement 3")
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"whoosh-compat's DateParserPlugin intentionally drops whoosh's "
|
||||
"'free' undelimited-date tagging mode (see "
|
||||
"whoosh_compat.parser.dateparse.DateParserPlugin docstring), so "
|
||||
"an unquoted multi-word date keyword like 'added:previous month' "
|
||||
"no longer parses -- it now needs quoting ('added:\"previous "
|
||||
"month\"'), unlike 'added:\"previous quarter\"' a few tests down "
|
||||
"which already quotes. CONFIRMED REGRESSION vs whoosh-compat "
|
||||
"migration; see task-10-report.md."
|
||||
),
|
||||
raises=KeyError,
|
||||
)
|
||||
def test_search_added_previous_month_excludes_next_period_start(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
Reference in New Issue
Block a user