mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-21 02:03:30 +00:00
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:
committed by
stumpylog
co-authored by
Claude Fable 5
parent
98edd75220
commit
efb6e4b0f1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user