From bb0d0fc6c056caae41c8543de4c467600910e303 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:27:36 -0700 Subject: [PATCH] fix(search): migrate off whoosh-compat's removed QueryEmitError/UnsupportedQueryError whoosh-compat replaced both exception classes with a single QueryError carrying a structured Diagnostic (kind/cause/field_kind); the old message-text regex stripping is now dead weight since the library no longer embeds host-facing wording (DIVERGENCES refs, fast=True advice) in Diagnostic.message. Branch on diagnostic.kind instead. Also trims a test that was re-asserting whoosh-compat's own message contract (now covered by its own test_kind_matrix.py) down to the one rewrite paperless still owns: EXISTS_REQUIRES_FAST's user-facing message. Co-Authored-By: Claude Sonnet 5 --- src/documents/search/_query.py | 42 +++++++++++----------- src/documents/tests/search/test_query.py | 45 +++++++++++++----------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/documents/search/_query.py b/src/documents/search/_query.py index 793deabe3..65b358a92 100644 --- a/src/documents/search/_query.py +++ b/src/documents/search/_query.py @@ -11,8 +11,7 @@ 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 QueryEmitError -from whoosh_compat.errors import UnsupportedQueryError +from whoosh_compat.errors import QueryError from documents.search._errors import InvalidDateQuery from documents.search._errors import InvalidNumberQuery @@ -112,21 +111,19 @@ def _rewrite_bare_json_field_prefixes(raw_query: str) -> str: return raw_query -# whoosh-compat's emit() error messages are written for the HOST: they -# cite the library's own divergence ledger and give registry-configuration -# advice. Neither belongs in a message shown to a searching user. -_DIVERGENCE_REF_RE: Final = regex.compile(r"\s*\(DIVERGENCES\.md entry \d+\)") +def _user_facing_emit_message(d: Diagnostic) -> str: + """A user-safe message for an emit-time QueryError's Diagnostic. - -def _user_facing_emit_message(exc: Exception) -> str: - """A user-safe message for a QueryEmitError/UnsupportedQueryError.""" - message = _DIVERGENCE_REF_RE.sub("", str(exc)) - if "fast=True" in message: - # The exists-check message advises marking the field fast=True, a - # host configuration action; the user just needs to know the - # search form is unsupported here. + whoosh-compat's own Diagnostic.message is developer/log output with no + stability guarantee (branch on kind, never parse the message). Only + EXISTS_REQUIRES_FAST needs a distinct user-facing rewrite: its message + advises marking the field fast=True, a host configuration action the + user can't act on; the user just needs to know the search form is + unsupported here. + """ + if d.kind is DiagnosticKind.EXISTS_REQUIRES_FAST: return "existence searches (field:*) are not supported for this field" - return message + return d.message def _has_cjk(text: str) -> bool: @@ -321,8 +318,8 @@ def parse_user_query( 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. + round-trip). QueryError (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) builds a plain word string from the parsed AST's free-text tokens (whoosh_compat.free_text_tokens) and feeds THAT to @@ -348,10 +345,13 @@ def parse_user_query( try: exact = tantivy_emit(result.ast, index=index, registry=registry) - except (QueryEmitError, UnsupportedQueryError) as e: - # emit()'s documented host contract: BOTH of these are user-input - # errors, exactly like a parse diagnostic, and both map to a 400. - raise SearchQueryError(_user_facing_emit_message(e)) from e + except QueryError as e: + # emit()'s documented host contract: every reachable-from-query-text + # kind here (TEXT_RANGE, PATTERN_TOO_COMPLEX, EXISTS_REQUIRES_FAST) + # is a user-input error, exactly like a parse diagnostic, and maps + # to a 400. The AST_*/BACKEND_REJECTED backstop kinds cannot occur + # here: whoosh-compat only ever hands us the AST it parsed itself. + raise SearchQueryError(_user_facing_emit_message(e.diagnostic)) from e cjk_query = ( _build_cjk_query(index, raw_query, _CJK_ALL_FIELDS) diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index 39c661692..f0eba61f8 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -313,43 +313,46 @@ class TestSearchQueryErrors: class TestEmitErrorContract: - """A diagnostics list, or a QueryEmitError/UnsupportedQueryError from - emit(), are both user-input errors and must surface as - SearchQueryError (HTTP 400), with library-internal wording stripped - from the message.""" + """A diagnostics list, or a QueryError from emit(), are both user-input + errors and must surface as SearchQueryError (HTTP 400).""" def test_query_emit_error_maps_to_search_query_error( self, query_index: tantivy.Index, monkeypatch: pytest.MonkeyPatch, ) -> None: - from whoosh_compat.errors import QueryEmitError + from whoosh_compat.errors import Cause + from whoosh_compat.errors import Diagnostic + from whoosh_compat.errors import DiagnosticKind + from whoosh_compat.errors import QueryError import documents.search._query as query_mod def raise_emit_error(*args: object, **kwargs: object) -> None: - raise QueryEmitError("synthetic emit failure") + raise QueryError( + Diagnostic( + kind=DiagnosticKind.TEXT_RANGE, + cause=Cause.UNSUPPORTED, + message="synthetic emit failure", + ), + ) monkeypatch.setattr(query_mod, "tantivy_emit", raise_emit_error) with pytest.raises(SearchQueryError): parse_user_query(query_index, "invoice", UTC) - @pytest.mark.parametrize( - ("query", "leaked_fragment"), - [ - pytest.param("title:[a TO b]", "DIVERGENCES", id="text-range-doc-ref"), - pytest.param("notes.note:wild*", "DIVERGENCES", id="json-wildcard-doc-ref"), - pytest.param("notes.user:*", "fast=True", id="exists-host-advice"), - ], - ) - def test_unsupported_messages_carry_no_internal_vocabulary( + def test_exists_requires_fast_gets_the_user_facing_rewrite( self, query_index: tantivy.Index, - query: str, - leaked_fragment: str, ) -> None: + # The only emit-time diagnostic kind paperless rewrites itself + # (_user_facing_emit_message): whoosh-compat's own message advises + # a host-side fast=True config change, which the user can't act + # on, so this checks OUR rewrite, not whoosh-compat's wording + # (that's whoosh-compat's own tests/emitter/test_kind_matrix.py's + # job now). with pytest.raises(SearchQueryError) as exc_info: - parse_user_query(query_index, query, UTC) - assert leaked_fragment not in str(exc_info.value) - # The message must still say something useful, not be blanked. - assert str(exc_info.value).strip() + parse_user_query(query_index, "notes.user:*", UTC) + assert str(exc_info.value) == ( + "existence searches (field:*) are not supported for this field" + )