fix(search): complete the query error surface across every endpoint

Four pieces of the same surface:

whoosh-compat's emit() documents a two-part host contract: both a parse
diagnostic and the QueryEmitError/UnsupportedQueryError pair are
user-input errors. Only the latter half was caught; QueryEmitError now
maps to SearchQueryError too. Messages pass through a cleanup that
strips the library's DIVERGENCES.md references and replaces the
fast=True host-configuration advice with user language, so no
library-internal vocabulary reaches a searching user.

The bulk selection paths (bulk edit, the legacy bulk endpoint, bulk
download) reached the backend with no SearchQueryError handler, so a
bad date or number in a selection filter raised straight to a DRF 500.
They now share the search list endpoint's exact mapping (a new
search_query_error_messages helper flattens MultipleSearchQueryErrors
in one place), returning the same 400 body for the same bad query.

QueryParserError means a whoosh-compat parser bug, not user-fixable
input, per its own contract; the list endpoint's blanket handler was
converting it to a generic 400. It now re-raises and surfaces as a 500
that monitoring can see.

All behavior is pinned test-first: bulk edit and bulk download API
tests assert 400s naming the bad value (previously unhandled
exceptions), a unit test pins the QueryEmitError mapping, three
parametrized checks assert no internal vocabulary leaks for the
unsupported query shapes, and a mocked parser-bug test asserts the 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
This commit is contained in:
Trenton Holmes
2026-08-19 13:36:53 -07:00
committed by stumpylog
co-authored by Claude Fable 5
parent 98edd75220
commit efb6e4b0f1
7 changed files with 180 additions and 19 deletions
+2
View File
@@ -10,6 +10,7 @@ from documents.search._query import InvalidDateQuery
from documents.search._query import InvalidNumberQuery
from documents.search._query import MultipleSearchQueryErrors
from documents.search._query import SearchQueryError
from documents.search._query import search_query_error_messages
from documents.search._schema import needs_rebuild
from documents.search._schema import wipe_index
@@ -27,5 +28,6 @@ __all__ = [
"get_backend",
"needs_rebuild",
"reset_backend",
"search_query_error_messages",
"wipe_index",
]
+34 -2
View File
@@ -11,6 +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 documents.search._fields import PUBLIC_FIELDS
@@ -61,6 +62,18 @@ class MultipleSearchQueryErrors(SearchQueryError):
super().__init__("; ".join(str(e) for e in self.errors))
def search_query_error_messages(e: SearchQueryError) -> list[str]:
"""The user-facing message list for a SearchQueryError.
Every offending value's message, not just the first, so the user can
fix them all in one round-trip. Shared by every view that maps
SearchQueryError to an HTTP 400.
"""
if isinstance(e, MultipleSearchQueryErrors):
return [str(sub) for sub in e.errors]
return [str(e)]
logger = logging.getLogger("paperless.search")
# Maximum seconds any single regex substitution may run.
@@ -153,6 +166,23 @@ 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(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.
return "existence searches (field:*) are not supported for this field"
return message
def _has_cjk(text: str) -> bool:
"""Return True if text contains any CJK characters."""
return bool(_CJK_RE.search(text))
@@ -404,8 +434,10 @@ def parse_user_query(
try:
exact = tantivy_emit(result.ast, index=index, registry=registry)
except UnsupportedQueryError as e:
raise SearchQueryError(str(e)) from e
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
cjk_query = (
_build_cjk_query(index, raw_query, _CJK_ALL_FIELDS)