From 4577a0a00acd68fb5f9b3b7cdc4645cef14b733e Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:01:37 -0700 Subject: [PATCH] refactor(search): move SearchQueryError family to _query.py, add InvalidNumberQuery/MultipleSearchQueryErrors Move SearchQueryError and InvalidDateQuery from _translate.py to _query.py and add two new exception classes: InvalidNumberQuery and MultipleSearchQueryErrors. Update _translate.py to re-export the exceptions for backward compatibility until the translation module is removed. Update __init__.py to export all four exception classes from _query.py. Co-Authored-By: Claude Sonnet 5 --- src/documents/search/__init__.py | 8 +++-- src/documents/search/_query.py | 43 ++++++++++++++++++++++-- src/documents/search/_translate.py | 21 ++---------- src/documents/tests/search/test_query.py | 40 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/documents/search/__init__.py b/src/documents/search/__init__.py index f0e65e5cd..d7a4db786 100644 --- a/src/documents/search/__init__.py +++ b/src/documents/search/__init__.py @@ -6,13 +6,17 @@ from documents.search._backend import TantivyRelevanceList from documents.search._backend import WriteBatch from documents.search._backend import get_backend from documents.search._backend import reset_backend +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._schema import needs_rebuild from documents.search._schema import wipe_index -from documents.search._translate import InvalidDateQuery -from documents.search._translate import SearchQueryError __all__ = [ "InvalidDateQuery", + "InvalidNumberQuery", + "MultipleSearchQueryErrors", "SearchHit", "SearchIndexLockError", "SearchMode", diff --git a/src/documents/search/_query.py b/src/documents/search/_query.py index 3584767bc..26aa557f4 100644 --- a/src/documents/search/_query.py +++ b/src/documents/search/_query.py @@ -9,15 +9,54 @@ import tantivy from django.conf import settings from documents.search._tokenizer import simple_search_tokens -from documents.search._translate import SearchQueryError -from documents.search._translate import translate_query if TYPE_CHECKING: from collections.abc import Iterable + from collections.abc import Sequence from datetime import tzinfo from django.contrib.auth.base_user import AbstractBaseUser + +class SearchQueryError(ValueError): + """ + Base for user-fixable search query errors. + + Carries a message safe to surface to the user (no internal details). The + view layer catches this and returns an HTTP 400, so any future subclass + gets the same treatment. + """ + + +class InvalidDateQuery(SearchQueryError): + """Raised when a date field value or range bound cannot be parsed.""" + + def __init__(self, field: str | None, value: str | None) -> None: + self.field = field + self.value = value + super().__init__(f"Invalid date value {value!r} for field {field!r}.") + + +class InvalidNumberQuery(SearchQueryError): + """Raised when a numeric field value or range bound cannot be parsed.""" + + def __init__(self, field: str | None, value: str | None) -> None: + self.field = field + self.value = value + super().__init__(f"Invalid numeric value {value!r} for field {field!r}.") + + +class MultipleSearchQueryErrors(SearchQueryError): + """Aggregates every user-fixable error from one parse, not just the first.""" + + def __init__(self, errors: Sequence[SearchQueryError]) -> None: + self.errors = tuple(errors) + 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. diff --git a/src/documents/search/_translate.py b/src/documents/search/_translate.py index f020f1c89..a99965c3e 100644 --- a/src/documents/search/_translate.py +++ b/src/documents/search/_translate.py @@ -18,6 +18,8 @@ from documents.search._dates import _field_range_from_dates from documents.search._dates import _fmt from documents.search._dates import _precision_bounds from documents.search._dates import _utc_bounds_for_field +from documents.search._query import InvalidDateQuery +from documents.search._query import SearchQueryError # noqa: F401 # Compiled regex that matches any known multi-word (or single-word) date keyword # at the start of a match position, longest alternatives first so "previous week" @@ -322,25 +324,6 @@ def resolve_commas(tokens: list) -> list: return out -class SearchQueryError(ValueError): - """ - Base for user-fixable search query errors. - - Carries a message safe to surface to the user (no internal details). The view - layer catches this and returns an HTTP 400, so any future subclass (unknown - field, malformed range, wrapped parser errors) gets the same treatment. - """ - - -class InvalidDateQuery(SearchQueryError): - """Raised when a date field value or range bound cannot be parsed.""" - - def __init__(self, field: str, value: str) -> None: - self.field = field - self.value = value - super().__init__(f"Invalid date value {value!r} for field {field!r}.") - - _DIGITS_RE = regex.compile(r"^\d{4}(?:\d{2}){0,2}$") _ISO_RE = regex.compile(r"^\d{4}(?:-\d{2}(?:-\d{2})?)?$") diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index 6260b67af..20bf31a0c 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -884,3 +884,43 @@ class TestPermissionFilter: user = django_user_model(pk=20) perm = build_permission_filter(perm_index.schema, user) assert perm_index.searcher().search(perm, limit=10).count == 1 # only unowned + + +class TestSearchQueryErrors: + def test_invalid_date_query_is_a_search_query_error(self) -> None: + from documents.search._query import InvalidDateQuery + from documents.search._query import SearchQueryError + + err = InvalidDateQuery("created", "notadate") + assert isinstance(err, SearchQueryError) + assert err.field == "created" + assert err.value == "notadate" + assert "created" in str(err) + assert "notadate" in str(err) + + def test_invalid_number_query_is_a_search_query_error(self) -> None: + from documents.search._query import InvalidNumberQuery + from documents.search._query import SearchQueryError + + err = InvalidNumberQuery("asn", "notanumber") + assert isinstance(err, SearchQueryError) + assert err.field == "asn" + assert err.value == "notanumber" + assert "asn" in str(err) + assert "notanumber" in str(err) + + def test_multiple_search_query_errors_aggregates(self) -> None: + from documents.search._query import InvalidDateQuery + from documents.search._query import InvalidNumberQuery + from documents.search._query import MultipleSearchQueryErrors + from documents.search._query import SearchQueryError + + sub_errors = [ + InvalidDateQuery("created", "notadate"), + InvalidNumberQuery("asn", "notanumber"), + ] + err = MultipleSearchQueryErrors(sub_errors) + assert isinstance(err, SearchQueryError) + assert err.errors == tuple(sub_errors) + assert "created" in str(err) + assert "asn" in str(err)