mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-28 21:47:34 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca42c27455 | ||
|
|
a626ad4627 | ||
|
|
9029b27aeb | ||
|
|
03e05e0804 | ||
|
|
2f79549f92 | ||
|
|
9e9f3f4e09 | ||
|
|
d0869bec01 |
@@ -77,6 +77,7 @@ dependencies = [
|
|||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
|
"whoosh-compat[tantivy]==0.1",
|
||||||
"zxing-cpp~=3.1.0",
|
"zxing-cpp~=3.1.0",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ from documents.search._backend import TantivyRelevanceList
|
|||||||
from documents.search._backend import WriteBatch
|
from documents.search._backend import WriteBatch
|
||||||
from documents.search._backend import get_backend
|
from documents.search._backend import get_backend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
|
from documents.search._errors import InvalidDateQuery
|
||||||
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import QueryTooLongError
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._errors import search_query_error_messages
|
||||||
from documents.search._schema import needs_rebuild
|
from documents.search._schema import needs_rebuild
|
||||||
from documents.search._schema import wipe_index
|
from documents.search._schema import wipe_index
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import SearchQueryError
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InvalidDateQuery",
|
"InvalidDateQuery",
|
||||||
|
"InvalidNumberQuery",
|
||||||
|
"MultipleSearchQueryErrors",
|
||||||
|
"QueryTooLongError",
|
||||||
"SearchHit",
|
"SearchHit",
|
||||||
"SearchIndexLockError",
|
"SearchIndexLockError",
|
||||||
"SearchMode",
|
"SearchMode",
|
||||||
@@ -23,5 +30,6 @@ __all__ = [
|
|||||||
"get_backend",
|
"get_backend",
|
||||||
"needs_rebuild",
|
"needs_rebuild",
|
||||||
"reset_backend",
|
"reset_backend",
|
||||||
|
"search_query_error_messages",
|
||||||
"wipe_index",
|
"wipe_index",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import tantivy
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import get_current_timezone
|
from django.utils.timezone import get_current_timezone
|
||||||
|
|
||||||
from documents.search._query import build_permission_filter
|
|
||||||
from documents.search._query import extract_cjk_text
|
from documents.search._query import extract_cjk_text
|
||||||
from documents.search._query import parse_simple_text_highlight_query
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
from documents.search._query import parse_simple_text_query
|
from documents.search._query import parse_simple_text_query
|
||||||
@@ -40,6 +39,7 @@ from documents.utils import QuerySetStream
|
|||||||
from documents.utils import identity
|
from documents.utils import identity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -285,6 +285,47 @@ class WriteBatch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_permission_filter(
|
||||||
|
schema: tantivy.Schema,
|
||||||
|
user: AbstractUser,
|
||||||
|
viewer_group_ids: Iterable[int] = (),
|
||||||
|
) -> tantivy.Query:
|
||||||
|
"""
|
||||||
|
Build a query filter for user document permissions.
|
||||||
|
|
||||||
|
Creates a query that matches only documents visible to the specified user
|
||||||
|
according to paperless-ngx permission rules:
|
||||||
|
- Public documents (no owner) are visible to all users
|
||||||
|
- Private documents are visible to their owner
|
||||||
|
- Documents explicitly shared with the user are visible
|
||||||
|
- Documents shared with one of the user's current groups are visible
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Tantivy schema for field validation
|
||||||
|
user: User to check permissions for
|
||||||
|
viewer_group_ids: Current group memberships for the user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tantivy query that filters results to visible documents
|
||||||
|
"""
|
||||||
|
owner_any = tantivy.Query.exists_query("owner_id")
|
||||||
|
no_owner = tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Must, tantivy.Query.all_query()),
|
||||||
|
(tantivy.Occur.MustNot, owner_any),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
|
||||||
|
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
|
||||||
|
group_shared = [
|
||||||
|
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
|
||||||
|
for group_id in viewer_group_ids
|
||||||
|
]
|
||||||
|
return tantivy.Query.disjunction_max_query(
|
||||||
|
[no_owner, owned, shared, *group_shared],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TantivyBackend:
|
class TantivyBackend:
|
||||||
"""
|
"""
|
||||||
Tantivy search backend with explicit lifecycle management.
|
Tantivy search backend with explicit lifecycle management.
|
||||||
@@ -458,7 +499,6 @@ class TantivyBackend:
|
|||||||
doc.add_text("correspondent_sort", document.correspondent.name)
|
doc.add_text("correspondent_sort", document.correspondent.name)
|
||||||
if cjk_corr := extract_cjk_text(document.correspondent.name):
|
if cjk_corr := extract_cjk_text(document.correspondent.name):
|
||||||
doc.add_text("bigram_correspondent", cjk_corr)
|
doc.add_text("bigram_correspondent", cjk_corr)
|
||||||
doc.add_unsigned("correspondent_id", document.correspondent_id)
|
|
||||||
|
|
||||||
# Document type
|
# Document type
|
||||||
if document.document_type:
|
if document.document_type:
|
||||||
@@ -466,12 +506,10 @@ class TantivyBackend:
|
|||||||
doc.add_text("type_sort", document.document_type.name)
|
doc.add_text("type_sort", document.document_type.name)
|
||||||
if cjk_type := extract_cjk_text(document.document_type.name):
|
if cjk_type := extract_cjk_text(document.document_type.name):
|
||||||
doc.add_text("bigram_document_type", cjk_type)
|
doc.add_text("bigram_document_type", cjk_type)
|
||||||
doc.add_unsigned("document_type_id", document.document_type_id)
|
|
||||||
|
|
||||||
# Storage path
|
# Storage path
|
||||||
if document.storage_path:
|
if document.storage_path:
|
||||||
doc.add_text("storage_path", document.storage_path.name)
|
doc.add_text("storage_path", document.storage_path.name)
|
||||||
doc.add_unsigned("storage_path_id", document.storage_path_id)
|
|
||||||
|
|
||||||
# Tags — collect names for autocomplete in the same pass
|
# Tags — collect names for autocomplete in the same pass
|
||||||
tag_names: list[str] = []
|
tag_names: list[str] = []
|
||||||
@@ -479,12 +517,13 @@ class TantivyBackend:
|
|||||||
doc.add_text("tag", tag.name)
|
doc.add_text("tag", tag.name)
|
||||||
if cjk_tag := extract_cjk_text(tag.name):
|
if cjk_tag := extract_cjk_text(tag.name):
|
||||||
doc.add_text("bigram_tag", cjk_tag)
|
doc.add_text("bigram_tag", cjk_tag)
|
||||||
doc.add_unsigned("tag_id", tag.pk)
|
|
||||||
tag_names.append(tag.name)
|
tag_names.append(tag.name)
|
||||||
|
|
||||||
# Notes — JSON for structured queries (notes.user:alice, notes.note:text).
|
# Notes — JSON for structured queries (notes.user:alice, notes.note:text).
|
||||||
# notes_text is a plain-text companion for snippet/highlight generation;
|
# notes_text is a plain-text companion for snippet/highlight generation;
|
||||||
# tantivy's SnippetGenerator does not support JSON fields.
|
# tantivy's SnippetGenerator does not support JSON fields. It is not in
|
||||||
|
# _DEFAULT_SEARCH_FIELDS, so an unqualified query never searches it: a
|
||||||
|
# note matches through the JSON field or not at all.
|
||||||
num_notes = 0
|
num_notes = 0
|
||||||
note_texts: list[str] = []
|
note_texts: list[str] = []
|
||||||
for note in document.notes.all():
|
for note in document.notes.all():
|
||||||
@@ -500,8 +539,9 @@ class TantivyBackend:
|
|||||||
if note_texts:
|
if note_texts:
|
||||||
doc.add_text("notes_text", " ".join(note_texts))
|
doc.add_text("notes_text", " ".join(note_texts))
|
||||||
|
|
||||||
# Custom fields — JSON for structured queries (custom_fields.name:x, custom_fields.value:y),
|
# Custom fields: JSON for structured queries (custom_fields.name:x,
|
||||||
# companion text field for default full-text search.
|
# custom_fields.value:y). There is no companion text field here, unlike
|
||||||
|
# notes: custom field values are reachable only through the JSON field.
|
||||||
for cfi in document.custom_fields.all():
|
for cfi in document.custom_fields.all():
|
||||||
search_value = cfi.value_for_search
|
search_value = cfi.value_for_search
|
||||||
# Skip fields where there is no value yet
|
# Skip fields where there is no value yet
|
||||||
@@ -668,7 +708,17 @@ class TantivyBackend:
|
|||||||
user_query = self._parse_query(query, search_mode)
|
user_query = self._parse_query(query, search_mode)
|
||||||
highlight_query = user_query
|
highlight_query = user_query
|
||||||
if search_mode is SearchMode.TEXT:
|
if search_mode is SearchMode.TEXT:
|
||||||
highlight_query = parse_simple_text_highlight_query(self._index, query)
|
try:
|
||||||
|
highlight_query = parse_simple_text_highlight_query(
|
||||||
|
self._index,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping simple text highlight query: token string is not "
|
||||||
|
"valid tantivy query syntax: %r",
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
# For notes_text snippet generation, we need a query that targets the
|
# For notes_text snippet generation, we need a query that targets the
|
||||||
# notes_text field directly. user_query may contain JSON-field terms
|
# notes_text field directly. user_query may contain JSON-field terms
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import date
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
_DATE_ONLY_FIELDS = frozenset({"created"})
|
|
||||||
|
|
||||||
_TODAY: Final[str] = "today"
|
|
||||||
_YESTERDAY: Final[str] = "yesterday"
|
|
||||||
_PREVIOUS_WEEK: Final[str] = "previous week"
|
|
||||||
_THIS_MONTH: Final[str] = "this month"
|
|
||||||
_PREVIOUS_MONTH: Final[str] = "previous month"
|
|
||||||
_THIS_YEAR: Final[str] = "this year"
|
|
||||||
_PREVIOUS_YEAR: Final[str] = "previous year"
|
|
||||||
_PREVIOUS_QUARTER: Final[str] = "previous quarter"
|
|
||||||
|
|
||||||
_DATE_KEYWORDS = frozenset(
|
|
||||||
{
|
|
||||||
_TODAY,
|
|
||||||
_YESTERDAY,
|
|
||||||
_PREVIOUS_WEEK,
|
|
||||||
_THIS_MONTH,
|
|
||||||
_PREVIOUS_MONTH,
|
|
||||||
_THIS_YEAR,
|
|
||||||
_PREVIOUS_YEAR,
|
|
||||||
_PREVIOUS_QUARTER,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(dt: datetime) -> str:
|
|
||||||
"""Format a datetime as an ISO 8601 UTC string for use in Tantivy range queries."""
|
|
||||||
return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_range(lo: datetime, hi: datetime) -> str:
|
|
||||||
"""
|
|
||||||
Format a half-open ``[lo TO hi)`` range in ISO 8601 for Tantivy query syntax.
|
|
||||||
|
|
||||||
``hi`` is always the exclusive ceiling of a computed period (the start of
|
|
||||||
the *next* day/week/month/quarter/year), so the closing bracket must be
|
|
||||||
the Tantivy exclusive-range brace ``}`` rather than ``]`` — otherwise the
|
|
||||||
first instant of the following period (e.g. the 1st of next month) is
|
|
||||||
incorrectly included in the match.
|
|
||||||
"""
|
|
||||||
return f"[{_fmt(lo)} TO {_fmt(hi)}}}"
|
|
||||||
|
|
||||||
|
|
||||||
def _quarter_start(d: date) -> date:
|
|
||||||
"""Return the first day of the calendar quarter containing ``d``."""
|
|
||||||
return date(d.year, ((d.month - 1) // 3) * 3 + 1, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _midnight(d: date, tz: tzinfo) -> datetime:
|
|
||||||
"""Convert a calendar date at local-timezone midnight to a UTC datetime."""
|
|
||||||
return datetime(d.year, d.month, d.day, tzinfo=tz).astimezone(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _keyword_bounds(keyword: str, tz: tzinfo) -> tuple[date, date]:
|
|
||||||
"""
|
|
||||||
Map a relative date keyword to ``(start, exclusive_end)`` calendar dates.
|
|
||||||
|
|
||||||
``tz`` only determines what "today" is; the caller decides how the returned
|
|
||||||
dates become UTC datetime boundaries (date-only vs. local-midnight offset).
|
|
||||||
"""
|
|
||||||
today = datetime.now(tz).date()
|
|
||||||
if keyword == _TODAY:
|
|
||||||
return today, today + timedelta(days=1)
|
|
||||||
if keyword == _YESTERDAY:
|
|
||||||
return today - timedelta(days=1), today
|
|
||||||
if keyword == _PREVIOUS_WEEK:
|
|
||||||
this_monday = today - timedelta(days=today.weekday())
|
|
||||||
return this_monday - timedelta(weeks=1), this_monday
|
|
||||||
if keyword == _THIS_MONTH:
|
|
||||||
first = today.replace(day=1)
|
|
||||||
return first, first + relativedelta(months=1)
|
|
||||||
if keyword == _PREVIOUS_MONTH:
|
|
||||||
this_first = today.replace(day=1)
|
|
||||||
return this_first - relativedelta(months=1), this_first
|
|
||||||
if keyword == _THIS_YEAR:
|
|
||||||
return date(today.year, 1, 1), date(today.year + 1, 1, 1)
|
|
||||||
if keyword == _PREVIOUS_YEAR:
|
|
||||||
return date(today.year - 1, 1, 1), date(today.year, 1, 1)
|
|
||||||
if keyword == _PREVIOUS_QUARTER:
|
|
||||||
this_quarter = _quarter_start(today)
|
|
||||||
return this_quarter - relativedelta(months=3), this_quarter
|
|
||||||
raise ValueError(f"Unknown keyword: {keyword}")
|
|
||||||
|
|
||||||
|
|
||||||
def _date_only_range(keyword: str, tz: tzinfo) -> str:
|
|
||||||
"""
|
|
||||||
For `created` (DateField): use the local calendar date, converted to
|
|
||||||
midnight UTC boundaries. No offset arithmetic — date only.
|
|
||||||
"""
|
|
||||||
start, end = _keyword_bounds(keyword, tz)
|
|
||||||
lo = datetime(start.year, start.month, start.day, tzinfo=UTC)
|
|
||||||
hi = datetime(end.year, end.month, end.day, tzinfo=UTC)
|
|
||||||
return _iso_range(lo, hi)
|
|
||||||
|
|
||||||
|
|
||||||
def _datetime_range(keyword: str, tz: tzinfo) -> str:
|
|
||||||
"""
|
|
||||||
For `added` / `modified` (DateTimeField, stored as UTC): convert local day
|
|
||||||
boundaries to UTC — full offset arithmetic required.
|
|
||||||
"""
|
|
||||||
start, end = _keyword_bounds(keyword, tz)
|
|
||||||
return _iso_range(_midnight(start, tz), _midnight(end, tz))
|
|
||||||
|
|
||||||
|
|
||||||
def _precision_bounds(digits: str) -> tuple[date, date] | None:
|
|
||||||
"""
|
|
||||||
Map a 4/6/8-digit date token to (start, exclusive_end) calendar dates.
|
|
||||||
|
|
||||||
YYYY -> whole year, YYYYMM -> whole month, YYYYMMDD -> single day.
|
|
||||||
Returns None for any unparsable or out-of-range value (e.g. month 23),
|
|
||||||
so callers can emit a no-match clause instead of erroring (Whoosh parity).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if len(digits) == 4:
|
|
||||||
year = int(digits)
|
|
||||||
return date(year, 1, 1), date(year + 1, 1, 1)
|
|
||||||
if len(digits) == 6:
|
|
||||||
year, month = int(digits[:4]), int(digits[4:6])
|
|
||||||
start = date(year, month, 1)
|
|
||||||
end = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
|
|
||||||
return start, end
|
|
||||||
if len(digits) == 8:
|
|
||||||
start = date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
|
||||||
return start, start + timedelta(days=1)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_bounds_for_field(
|
|
||||||
field: str,
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
tz: tzinfo,
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
"""
|
|
||||||
Convert calendar-date bounds to UTC datetimes per the field's storage type.
|
|
||||||
|
|
||||||
For DateField (``created``) the bounds are UTC midnight (no offset). For
|
|
||||||
DateTimeField (``added``/``modified``) the bounds are local-tz midnight
|
|
||||||
converted to UTC, matching how each field is indexed.
|
|
||||||
"""
|
|
||||||
if field in _DATE_ONLY_FIELDS:
|
|
||||||
return (
|
|
||||||
datetime(start.year, start.month, start.day, tzinfo=UTC),
|
|
||||||
datetime(end.year, end.month, end.day, tzinfo=UTC),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
datetime(start.year, start.month, start.day, tzinfo=tz).astimezone(UTC),
|
|
||||||
datetime(end.year, end.month, end.day, tzinfo=tz).astimezone(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _field_range_from_dates(field: str, start: date, end: date, tz: tzinfo) -> str:
|
|
||||||
"""Build a Tantivy ``field:[lo TO hi]`` ISO range from calendar-date bounds."""
|
|
||||||
lo, hi = _utc_bounds_for_field(field, start, end, tz)
|
|
||||||
return f"{field}:{_iso_range(lo, hi)}"
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
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 QueryTooLongError(SearchQueryError):
|
||||||
|
"""Raised when a query string exceeds the maximum allowed length.
|
||||||
|
|
||||||
|
whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an
|
||||||
|
unbounded query is a CPU-exhaustion vector against a single request
|
||||||
|
handler. This is a hard boundary, not a validation nicety.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, length: int, limit: int) -> None:
|
||||||
|
self.length = length
|
||||||
|
self.limit = limit
|
||||||
|
super().__init__(
|
||||||
|
f"The search query is too long ({length} characters). "
|
||||||
|
f"The maximum allowed length is {limit} characters.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldSpec
|
||||||
|
from whoosh_compat import SubpathSpec
|
||||||
|
|
||||||
|
# Internal-only schema fields with no query-syntax meaning of their own
|
||||||
|
# (sort shadow fields, bigram CJK fields, simple_title/simple_content,
|
||||||
|
# autocomplete_word, notes_text) are NOT represented here, they are
|
||||||
|
# declared in _schema.py's field_descriptors().
|
||||||
|
#
|
||||||
|
# analyzer/pattern_normalizer are deliberately left at FieldSpec's default
|
||||||
|
# (None): they're language-specific and only meaningful to whoosh-compat's
|
||||||
|
# parser, so _registry.py attaches them per-language via dataclasses.replace()
|
||||||
|
# rather than PUBLIC_FIELDS declaring them itself. _schema.py only reads
|
||||||
|
# name/kind/fast and never sees the analyzer at all.
|
||||||
|
PUBLIC_FIELDS: tuple[FieldSpec, ...] = (
|
||||||
|
FieldSpec("title", FieldKind.TEXT),
|
||||||
|
FieldSpec("content", FieldKind.TEXT),
|
||||||
|
FieldSpec("correspondent", FieldKind.TEXT),
|
||||||
|
FieldSpec("document_type", FieldKind.TEXT, aliases=("type",)),
|
||||||
|
FieldSpec("storage_path", FieldKind.TEXT, aliases=("path",)),
|
||||||
|
FieldSpec("original_filename", FieldKind.TEXT),
|
||||||
|
FieldSpec("tag", FieldKind.TEXT, comma_values=True),
|
||||||
|
FieldSpec("checksum", FieldKind.KEYWORD),
|
||||||
|
FieldSpec("asn", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("page_count", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("num_notes", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("created", FieldKind.DATE, date_only=True, fast=True),
|
||||||
|
FieldSpec("modified", FieldKind.DATETIME, fast=True),
|
||||||
|
FieldSpec("added", FieldKind.DATETIME, fast=True),
|
||||||
|
FieldSpec(
|
||||||
|
"notes",
|
||||||
|
FieldKind.JSON,
|
||||||
|
subpaths={"user": SubpathSpec(), "note": SubpathSpec(default=True)},
|
||||||
|
),
|
||||||
|
FieldSpec(
|
||||||
|
"custom_fields",
|
||||||
|
FieldKind.JSON,
|
||||||
|
subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)},
|
||||||
|
),
|
||||||
|
)
|
||||||
+449
-144
@@ -6,22 +6,30 @@ from typing import Final
|
|||||||
|
|
||||||
import regex
|
import regex
|
||||||
import tantivy
|
import tantivy
|
||||||
|
import whoosh_compat as wc
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from whoosh_compat.emitters.tantivy_ import emit as tantivy_emit
|
||||||
|
from whoosh_compat.errors import Cause
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
|
||||||
|
from documents.search._errors import InvalidDateQuery
|
||||||
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
from documents.search._tokenizer import simple_search_tokens
|
from documents.search._tokenizer import simple_search_tokens
|
||||||
from documents.search._translate import SearchQueryError
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import tzinfo
|
from datetime import tzinfo
|
||||||
|
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
# Maximum seconds any single regex substitution may run.
|
# Maximum seconds any single regex substitution over user-supplied query text
|
||||||
# Prevents ReDoS on adversarial user-supplied query strings.
|
# may run. The one remaining use is a character class, which cannot backtrack,
|
||||||
|
# so the bound is an upper limit on that substitution's cost, not the ReDoS
|
||||||
|
# guard it was originally written as.
|
||||||
_REGEX_TIMEOUT: Final[float] = 1.0
|
_REGEX_TIMEOUT: Final[float] = 1.0
|
||||||
|
|
||||||
# Matches CJK/Hangul characters so queries can be routed to bigram fields.
|
# Matches CJK/Hangul characters so queries can be routed to bigram fields.
|
||||||
@@ -29,6 +37,64 @@ _REGEX_TIMEOUT: Final[float] = 1.0
|
|||||||
_CJK_RE: Final = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]+")
|
_CJK_RE: Final = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_facing_emit_message(d: Diagnostic) -> str:
|
||||||
|
"""A user-safe message for an emit-time QueryError's Diagnostic.
|
||||||
|
|
||||||
|
Built from the Diagnostic's structured fields (kind, field), never from
|
||||||
|
d.message: whoosh-compat documents that as developer/log output with no
|
||||||
|
stability guarantee, and PATTERN_TOO_COMPLEX embeds the raw backend
|
||||||
|
error text in it.
|
||||||
|
"""
|
||||||
|
field = str(d.field) if d.field is not None else None
|
||||||
|
if d.kind is DiagnosticKind.EXISTS_REQUIRES_FAST:
|
||||||
|
return f"Existence searches (field:*) are not supported for field {field!r}."
|
||||||
|
if d.kind is DiagnosticKind.TEXT_RANGE:
|
||||||
|
return f"Range searches are not supported for field {field!r}."
|
||||||
|
if d.kind is DiagnosticKind.PATTERN_TOO_COMPLEX:
|
||||||
|
return f"The wildcard pattern for field {field!r} is too complex."
|
||||||
|
if d.kind is DiagnosticKind.SCHEMA_FIELD_MISSING:
|
||||||
|
return f"Field {field!r} is not available in the search index."
|
||||||
|
logger.warning("Unmapped emit diagnostic %s: %s", d.kind, d.message)
|
||||||
|
return "The search query could not be executed."
|
||||||
|
|
||||||
|
|
||||||
|
def _map_emit_error(e: QueryError) -> SearchQueryError:
|
||||||
|
"""Route an emit-time QueryError by its Diagnostic's Cause.
|
||||||
|
|
||||||
|
INVALID_INPUT/UNSUPPORTED are user-input errors, exactly like a parse
|
||||||
|
diagnostic, and map to a 400. INTERNAL means a defect in whoosh-compat
|
||||||
|
or in our own AST handling, never the user's query, so the QueryError is
|
||||||
|
re-raised rather than converted, reaching the generic 500 handler instead
|
||||||
|
of blaming the query. MISCONFIGURED is deliberately both: the registry and
|
||||||
|
the index schema disagree, which only an operator can fix, so it is logged
|
||||||
|
as an error, but a request is still waiting and the query cannot run
|
||||||
|
either way, so it also returns a 400.
|
||||||
|
|
||||||
|
EXISTS_REQUIRES_FAST is the one MISCONFIGURED kind that is not a
|
||||||
|
disagreement. whoosh-compat derives it from the registry's own FieldSpec
|
||||||
|
(kind plus fast) without ever consulting the index schema, so it fires
|
||||||
|
whenever a non-fast field of a kind that cannot answer "exists" is asked
|
||||||
|
to: for us that is only the JSON fields, which field_descriptors() builds
|
||||||
|
non-fast on purpose. "notes:*" and the five other spellings of it are
|
||||||
|
ordinary user error that no operator action can clear, so they get the
|
||||||
|
400 without the alert.
|
||||||
|
"""
|
||||||
|
d = e.diagnostic
|
||||||
|
if d.cause is Cause.INTERNAL:
|
||||||
|
raise e
|
||||||
|
if (
|
||||||
|
d.cause is Cause.MISCONFIGURED
|
||||||
|
and d.kind is not DiagnosticKind.EXISTS_REQUIRES_FAST
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
"Search index misconfiguration for field %s (%s): %s",
|
||||||
|
d.field,
|
||||||
|
d.kind.name,
|
||||||
|
d.message,
|
||||||
|
)
|
||||||
|
return SearchQueryError(_user_facing_emit_message(d))
|
||||||
|
|
||||||
|
|
||||||
def _has_cjk(text: str) -> bool:
|
def _has_cjk(text: str) -> bool:
|
||||||
"""Return True if text contains any CJK characters."""
|
"""Return True if text contains any CJK characters."""
|
||||||
return bool(_CJK_RE.search(text))
|
return bool(_CJK_RE.search(text))
|
||||||
@@ -37,14 +103,36 @@ def _has_cjk(text: str) -> bool:
|
|||||||
def extract_cjk_text(text: str) -> str:
|
def extract_cjk_text(text: str) -> str:
|
||||||
"""Join the CJK runs in ``text`` for indexing into bigram (char-ngram) fields.
|
"""Join the CJK runs in ``text`` for indexing into bigram (char-ngram) fields.
|
||||||
|
|
||||||
Mirrors the query side (``_build_cjk_query``): only CJK runs are ever searched
|
Mirrors the query side, which extracts the CJK runs of whatever it is
|
||||||
against the bigram fields, so only CJK runs are worth indexing there. Latin
|
about to search for (the raw string in simple modes, the parsed query's
|
||||||
text fed to a character-bigram field is never matched and only bloats the
|
free-text tokens in query mode): only CJK runs are ever searched against
|
||||||
|
the bigram fields, so only CJK runs are worth indexing there. Latin text
|
||||||
|
fed to a character-bigram field is never matched and only bloats the
|
||||||
index and slows indexing/merge. Returns "" when there is no CJK text.
|
index and slows indexing/merge. Returns "" when there is no CJK text.
|
||||||
"""
|
"""
|
||||||
return " ".join(_CJK_RE.findall(text))
|
return " ".join(_CJK_RE.findall(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cjk_text(
|
||||||
|
index: tantivy.Index,
|
||||||
|
cjk_text: str,
|
||||||
|
fields: list[str],
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Parse a plain CJK run string against ``fields``, or None if it won't parse."""
|
||||||
|
try:
|
||||||
|
return index.parse_query(cjk_text, fields)
|
||||||
|
except Exception:
|
||||||
|
# Broad on purpose, unlike _try_parse_fuzzy_query's narrower
|
||||||
|
# ValueError: cjk_text isn't filtered to a guaranteed-safe token
|
||||||
|
# set the way the fuzzy blend's word string is, so the exact
|
||||||
|
# failure mode tantivy could raise here isn't pinned down.
|
||||||
|
logger.debug(
|
||||||
|
"Skipping CJK search clause: could not parse CJK text: %r",
|
||||||
|
cjk_text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_cjk_query(
|
def _build_cjk_query(
|
||||||
index: tantivy.Index,
|
index: tantivy.Index,
|
||||||
raw_query: str,
|
raw_query: str,
|
||||||
@@ -52,91 +140,259 @@ def _build_cjk_query(
|
|||||||
) -> tantivy.Query | None:
|
) -> tantivy.Query | None:
|
||||||
"""Build a bigram-field query from the CJK runs in ``raw_query``.
|
"""Build a bigram-field query from the CJK runs in ``raw_query``.
|
||||||
|
|
||||||
Only the CJK character runs are extracted and parsed; ASCII field prefixes,
|
For the simple (TEXT/TITLE) modes, whose input is plain text and carries
|
||||||
boolean operators and date keywords are discarded. This keeps the CJK clause
|
no query grammar to respect. Only the CJK character runs are extracted, so
|
||||||
plain-text and consistent across query/simple modes (no leaked ``field:``
|
a stray ``field:`` prefix or ``-``/``+`` in the input can neither leak
|
||||||
semantics, no parse failures from spaced ``-``/``+``), and avoids feeding
|
field semantics nor fail the parse, and no Latin token reaches the
|
||||||
Latin tokens into the character-bigram matcher (which would produce spurious
|
character-bigram matcher (where it would produce spurious matches against
|
||||||
matches against unrelated Latin text). Returns None when there is no CJK
|
unrelated Latin text). Returns None when there is no CJK text or the parse
|
||||||
text or the parse fails.
|
fails.
|
||||||
"""
|
"""
|
||||||
cjk_text = " ".join(_CJK_RE.findall(raw_query))
|
cjk_text = extract_cjk_text(raw_query)
|
||||||
if not cjk_text:
|
if not cjk_text:
|
||||||
return None
|
return None
|
||||||
|
return _parse_cjk_text(index, cjk_text, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ast_cjk_query(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Build the bigram clause of a QUERY-mode search from the parsed AST.
|
||||||
|
|
||||||
|
Same discipline as the fuzzy clause (see _try_parse_fuzzy_query): the CJK
|
||||||
|
runs come from whoosh_compat's ``free_text_tokens`` over the parsed tree,
|
||||||
|
never from the raw query string, so a term the user negated or restricted
|
||||||
|
to a field outside the default search fields contributes nothing, instead
|
||||||
|
of resurfacing as a top-level clause matching every bigram field.
|
||||||
|
|
||||||
|
``free_text_tokens`` reports no field of its own, so the tokens are
|
||||||
|
collected one default field at a time: a bare term, which the parser has
|
||||||
|
already copied onto every default field, is therefore searched across
|
||||||
|
every bigram field, while ``title:東京`` reaches ``bigram_title`` alone.
|
||||||
|
Fields whose CJK text is identical (the bare-term case) share a single
|
||||||
|
parse over all of their bigram fields at once.
|
||||||
|
|
||||||
|
Raw (``analyzed=False``) tokens are used because the bigram fields have
|
||||||
|
their own character-ngram analyzer: the default fields' word analyzers
|
||||||
|
have no useful say over a CJK run, and running them first would only
|
||||||
|
risk dropping it (remove_long) before the run is ever extracted.
|
||||||
|
Returns None when the query has no CJK free text.
|
||||||
|
"""
|
||||||
|
fields_by_text: dict[str, list[str]] = {}
|
||||||
|
for field, bigram_field in _CJK_BIGRAM_FIELDS.items():
|
||||||
|
tokens = wc.free_text_tokens(
|
||||||
|
ast,
|
||||||
|
registry=registry,
|
||||||
|
fields=[field],
|
||||||
|
analyzed=False,
|
||||||
|
)
|
||||||
|
cjk_text = extract_cjk_text(" ".join(tokens))
|
||||||
|
if cjk_text:
|
||||||
|
fields_by_text.setdefault(cjk_text, []).append(bigram_field)
|
||||||
|
|
||||||
|
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = [
|
||||||
|
(tantivy.Occur.Should, query)
|
||||||
|
for cjk_text, bigram_fields in fields_by_text.items()
|
||||||
|
if (query := _parse_cjk_text(index, cjk_text, bigram_fields)) is not None
|
||||||
|
]
|
||||||
|
return _any_of(clauses) if clauses else None
|
||||||
|
|
||||||
|
|
||||||
|
# A joined fuzzy word string must stay plain words: it goes back through
|
||||||
|
# tantivy's own query parser, and the raw query text the clause collects
|
||||||
|
# routinely carries characters that parser reads as grammar (a colon, a
|
||||||
|
# bracket, a quote, a leading -). Each token is cut into its word runs and
|
||||||
|
# only those are kept, so no field syntax, pattern, range or grouping can
|
||||||
|
# reach the parser. Cutting rather than dropping the whole token is what
|
||||||
|
# keeps ordinary hyphenated, dotted and quoted input ("COVID-19",
|
||||||
|
# "hello@example.com", "tax reports") contributing to the clause at all.
|
||||||
|
_WORD_RUN_RE = regex.compile(r"\w+")
|
||||||
|
|
||||||
|
# The one piece of tantivy grammar that survives the cut: its boolean
|
||||||
|
# keywords are themselves word runs. Only these exact spellings are
|
||||||
|
# grammar there ("And"/"and" are ordinary terms), so lowercasing exactly
|
||||||
|
# these turns them back into the ordinary terms the field analyzer used to
|
||||||
|
# make of them, before the clause switched to raw text. Left alone, a
|
||||||
|
# quoted phrase would silently restructure the clause ("tax AND reports"
|
||||||
|
# becoming a conjunction) or fail to parse and drop it entirely
|
||||||
|
# ("tax AND", or "IN" anywhere).
|
||||||
|
#
|
||||||
|
# Only these words are touched: tantivy lowercases query terms with the
|
||||||
|
# field's own analyzer, and doing it ourselves first is not always the
|
||||||
|
# same operation (Python folds a final sigma to a different letter than
|
||||||
|
# tantivy does, and turns Turkish 'İ' into a sequence tantivy then splits
|
||||||
|
# in two), which would search for terms the index does not contain.
|
||||||
|
_TANTIVY_KEYWORDS: Final[frozenset[str]] = frozenset({"AND", "OR", "NOT", "IN"})
|
||||||
|
|
||||||
|
|
||||||
|
def _try_parse_fuzzy_query(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Build the fuzzy blend clause from the parsed query's free-text
|
||||||
|
words, or None if it has none.
|
||||||
|
|
||||||
|
The clause is built by handing tantivy's own query parser a plain
|
||||||
|
word string (there's no clean AST-level fuzzy equivalent to
|
||||||
|
whoosh-compat's parse tree, and fuzzy matching was always an
|
||||||
|
approximate, secondary, 0.1-boosted clause). The words come from
|
||||||
|
whoosh_compat's ``free_text_tokens`` over the already-parsed AST,
|
||||||
|
never from the raw query string: raw whoosh grammar (date keywords,
|
||||||
|
``[2005 to 2009]`` ranges, bracket-class wildcards) is not tantivy
|
||||||
|
syntax, and feeding it here used to knock the fuzzy clause out for
|
||||||
|
the whole query the moment any such construct appeared alongside a
|
||||||
|
typo'd word. The helper also keeps excluded terms out: a ``NOT``'d
|
||||||
|
word must not resurface through the fuzzy clause.
|
||||||
|
|
||||||
|
Chosen trade-off: a term explicitly fielded on one of the default
|
||||||
|
search fields (``correspondent:acme``) contributes its text to the
|
||||||
|
word string UNFIELDED, so the fuzzy clause searches it across all
|
||||||
|
default fields rather than just the one the user named. That is
|
||||||
|
recall-only widening on a secondary 0.1-boosted clause the score
|
||||||
|
threshold already disciplines, accepted in exchange for never feeding
|
||||||
|
field syntax to tantivy's parser. What the word string guarantees is
|
||||||
|
exactly that: no field prefix, pattern, range, grouping or quoting
|
||||||
|
survives, and the boolean keywords that do survive (they are word
|
||||||
|
runs) are lowercased into ordinary terms; see _TANTIVY_KEYWORDS.
|
||||||
|
|
||||||
|
The words are the query's RAW text, not the analyzer's output
|
||||||
|
(``analyzed=False``), because ``index.parse_query`` analyzes whatever
|
||||||
|
it is given and analysis is not idempotent: ``universities`` stems to
|
||||||
|
``univers``, and handing that back stems it again to ``univ``, a term
|
||||||
|
the index does not contain. ``prefix=True`` hid this as over-broad
|
||||||
|
matching (``univ`` also prefixes ``unicycle``) rather than as no
|
||||||
|
matches at all. Raw text is untokenized, which is why it is cut into
|
||||||
|
word runs above rather than taken whole.
|
||||||
|
|
||||||
|
The ValueError guard stays as insurance (the word string is plain
|
||||||
|
tokens, so tantivy accepting it is expected, not assumed): on a parse
|
||||||
|
failure the fuzzy clause is skipped and the exact/CJK clauses stand,
|
||||||
|
rather than the whole query failing.
|
||||||
|
"""
|
||||||
|
tokens = wc.free_text_tokens(
|
||||||
|
ast,
|
||||||
|
registry=registry,
|
||||||
|
fields=_DEFAULT_SEARCH_FIELDS,
|
||||||
|
analyzed=False,
|
||||||
|
)
|
||||||
|
words = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
word.lower() if word in _TANTIVY_KEYWORDS else word
|
||||||
|
for token in tokens
|
||||||
|
for word in _WORD_RUN_RE.findall(token)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not words:
|
||||||
|
return None
|
||||||
|
fuzzy_text = " ".join(words)
|
||||||
try:
|
try:
|
||||||
return index.parse_query(cjk_text, fields)
|
return index.parse_query(
|
||||||
except Exception:
|
fuzzy_text,
|
||||||
|
_DEFAULT_SEARCH_FIELDS,
|
||||||
|
field_boosts=_FIELD_BOOSTS,
|
||||||
|
fuzzy_fields={f: (True, 1, True) for f in _DEFAULT_SEARCH_FIELDS},
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping fuzzy search clause: token string is not valid "
|
||||||
|
"tantivy query syntax: %r",
|
||||||
|
fuzzy_text,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_permission_filter(
|
_DEFAULT_SEARCH_FIELDS: Final[list[str]] = [
|
||||||
schema: tantivy.Schema,
|
|
||||||
user: AbstractBaseUser,
|
|
||||||
viewer_group_ids: Iterable[int] = (),
|
|
||||||
) -> tantivy.Query:
|
|
||||||
"""
|
|
||||||
Build a query filter for user document permissions.
|
|
||||||
|
|
||||||
Creates a query that matches only documents visible to the specified user
|
|
||||||
according to paperless-ngx permission rules:
|
|
||||||
- Public documents (no owner) are visible to all users
|
|
||||||
- Private documents are visible to their owner
|
|
||||||
- Documents explicitly shared with the user are visible
|
|
||||||
- Documents shared with one of the user's current groups are visible
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema: Tantivy schema for field validation
|
|
||||||
user: User to check permissions for
|
|
||||||
viewer_group_ids: Current group memberships for the user
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tantivy query that filters results to visible documents
|
|
||||||
"""
|
|
||||||
owner_any = tantivy.Query.exists_query("owner_id")
|
|
||||||
no_owner = tantivy.Query.boolean_query(
|
|
||||||
[
|
|
||||||
(tantivy.Occur.Must, tantivy.Query.all_query()),
|
|
||||||
(tantivy.Occur.MustNot, owner_any),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
|
|
||||||
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
|
|
||||||
group_shared = [
|
|
||||||
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
|
|
||||||
for group_id in viewer_group_ids
|
|
||||||
]
|
|
||||||
return tantivy.Query.disjunction_max_query(
|
|
||||||
[no_owner, owned, shared, *group_shared],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SEARCH_FIELDS = [
|
|
||||||
"title",
|
"title",
|
||||||
"content",
|
"content",
|
||||||
"correspondent",
|
"correspondent",
|
||||||
"document_type",
|
"document_type",
|
||||||
"tag",
|
"tag",
|
||||||
]
|
]
|
||||||
SIMPLE_SEARCH_FIELDS = ["simple_title", "simple_content"]
|
_SIMPLE_SEARCH_FIELDS: Final[list[str]] = ["simple_title", "simple_content"]
|
||||||
TITLE_SEARCH_FIELDS = ["simple_title"]
|
_TITLE_SEARCH_FIELDS: Final[list[str]] = ["simple_title"]
|
||||||
_CJK_ALL_FIELDS: Final[list[str]] = [
|
# The bigram (character-ngram) companion of each default search field.
|
||||||
"bigram_content",
|
_CJK_BIGRAM_FIELDS: Final[dict[str, str]] = {
|
||||||
"bigram_title",
|
field: f"bigram_{field}" for field in _DEFAULT_SEARCH_FIELDS
|
||||||
"bigram_correspondent",
|
}
|
||||||
"bigram_document_type",
|
|
||||||
"bigram_tag",
|
|
||||||
]
|
|
||||||
_CJK_CONTENT_FIELDS: Final[list[str]] = ["bigram_content"]
|
_CJK_CONTENT_FIELDS: Final[list[str]] = ["bigram_content"]
|
||||||
_CJK_TITLE_FIELDS: Final[list[str]] = ["bigram_title"]
|
_CJK_TITLE_FIELDS: Final[list[str]] = ["bigram_title"]
|
||||||
_FIELD_BOOSTS = {"title": 2.0}
|
_FIELD_BOOSTS = {"title": 2.0}
|
||||||
_SIMPLE_FIELD_BOOSTS = {"simple_title": 2.0}
|
_SIMPLE_FIELD_BOOSTS = {"simple_title": 2.0}
|
||||||
|
|
||||||
|
|
||||||
def _simple_query_tokens(raw_query: str) -> list[str]:
|
class _ConjunctiveNegations(wc.ast.Visitor[tuple["wc.ast.Node", ...]]):
|
||||||
# Tokenize and fold via the same analyzer used to index simple_title /
|
"""Collect the subtrees an AST excludes from every document it matches.
|
||||||
# simple_content, so query terms fold identically to the indexed terms
|
|
||||||
# (single source of truth for ASCII folding).
|
A negation reached through ``And``/``AndNot``/``Require`` (and through
|
||||||
return simple_search_tokens(raw_query)
|
the required half of an ``AndMaybe``) constrains the whole query, so it
|
||||||
|
can be re-stated above the blend. ``Or`` is deliberately not descended
|
||||||
|
into: in ``invoice OR NOT secret`` the negation is one branch's own
|
||||||
|
condition, and hoisting it would throw away documents the other branch
|
||||||
|
matches. Nor is a collected subtree descended into, since a negation
|
||||||
|
inside a negation is not an exclusion.
|
||||||
|
|
||||||
|
Node types with no negation to contribute (every leaf, ``Or``) fall
|
||||||
|
through to ``generic_visit``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generic_visit(self, node: wc.ast.Node) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def visit_not(self, node: wc.ast.Not) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (node.child,)
|
||||||
|
|
||||||
|
def visit_andnot(self, node: wc.ast.AndNot) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (*self.visit(node.positive), node.negative)
|
||||||
|
|
||||||
|
def visit_and(self, node: wc.ast.And) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return tuple(
|
||||||
|
negation for child in node.children for negation in self.visit(child)
|
||||||
|
)
|
||||||
|
|
||||||
|
def visit_boosted(self, node: wc.ast.Boosted) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return self.visit(node.child)
|
||||||
|
|
||||||
|
def visit_andmaybe(self, node: wc.ast.AndMaybe) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return self.visit(node.required)
|
||||||
|
|
||||||
|
def visit_require(self, node: wc.ast.Require) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (*self.visit(node.scored), *self.visit(node.filter_only))
|
||||||
|
|
||||||
|
|
||||||
|
def _negation_clauses(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> list[tuple[tantivy.Occur, tantivy.Query]]:
|
||||||
|
"""MustNot clauses for everything ``ast`` excludes conjunctively.
|
||||||
|
|
||||||
|
Each excluded subtree is emitted as its own positive query and attached
|
||||||
|
with ``MustNot``, rather than emitting a negative query and hoping
|
||||||
|
tantivy accepts a bare one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
tantivy.Occur.MustNot,
|
||||||
|
tantivy_emit(negation, index=index, registry=registry),
|
||||||
|
)
|
||||||
|
for negation in _ConjunctiveNegations().visit(ast)
|
||||||
|
]
|
||||||
|
except QueryError as e:
|
||||||
|
raise _map_emit_error(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _any_of(clauses: list[tuple[tantivy.Occur, tantivy.Query]]) -> tantivy.Query:
|
||||||
|
"""Collapse a clause list: none -> empty, one -> itself (no wasted
|
||||||
|
single-clause boolean_query wrapping), many -> boolean_query(clauses)."""
|
||||||
|
if not clauses:
|
||||||
|
return tantivy.Query.empty_query()
|
||||||
|
if len(clauses) == 1:
|
||||||
|
return clauses[0][1]
|
||||||
|
return tantivy.Query.boolean_query(clauses)
|
||||||
|
|
||||||
|
|
||||||
def _build_simple_token_query(
|
def _build_simple_token_query(
|
||||||
@@ -168,9 +424,7 @@ def _build_simple_token_query(
|
|||||||
query = tantivy.Query.boost_query(query, boost)
|
query = tantivy.Query.boost_query(query, boost)
|
||||||
field_queries.append((tantivy.Occur.Should, query))
|
field_queries.append((tantivy.Occur.Should, query))
|
||||||
|
|
||||||
if len(field_queries) == 1:
|
return _any_of(field_queries)
|
||||||
return field_queries[0][1]
|
|
||||||
return tantivy.Query.boolean_query(field_queries)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_user_query(
|
def parse_user_query(
|
||||||
@@ -179,52 +433,53 @@ def parse_user_query(
|
|||||||
tz: tzinfo,
|
tz: tzinfo,
|
||||||
) -> tantivy.Query:
|
) -> 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. wc.parse() against the shared FieldRegistry (whoosh grammar -> AST).
|
||||||
1. Date keyword rewriting (today → ISO 8601 ranges)
|
Bare notes:/custom_fields: prefixes resolve to their default subpath
|
||||||
2. Query normalization (comma expansion, whitespace cleanup)
|
(notes.note:/custom_fields.value:) directly in the registry, via
|
||||||
3. Tantivy parsing with field boosts
|
each JSON field's SubpathSpec(default=True).
|
||||||
4. Optional fuzzy query blending (if ADVANCED_FUZZY_SEARCH_THRESHOLD set)
|
2. Any diagnostics (bad dates/numbers) map to SearchQueryError subclasses
|
||||||
|
and raise, the view returns HTTP 400 with every offending field
|
||||||
Args:
|
listed, not just the first.
|
||||||
index: Tantivy index with registered tokenizers
|
3. emit() turns the AST into a tantivy.Query directly (no string
|
||||||
raw_query: Original user query string
|
round-trip). A QueryError is routed by its Diagnostic's Cause
|
||||||
tz: Timezone for date boundary calculations
|
(_map_emit_error): a construct that parses but can't execute against
|
||||||
|
tantivy (e.g. a text-field range) is a 400, a registry/schema
|
||||||
Returns:
|
mismatch is logged and a 400, and an INTERNAL defect is re-raised.
|
||||||
Parsed Tantivy query ready for execution
|
4. Optional fuzzy blend (ADVANCED_FUZZY_SEARCH_THRESHOLD) builds a
|
||||||
|
plain word string from the parsed AST's free-text tokens
|
||||||
Note:
|
(whoosh_compat.free_text_tokens) and feeds THAT to
|
||||||
When ADVANCED_FUZZY_SEARCH_THRESHOLD is configured, adds a low-priority
|
index.parse_query, never raw_query, whose whoosh grammar (date
|
||||||
fuzzy query as a Should clause (0.1 boost) to catch approximate matches
|
keywords, bracket-class wildcards, etc.) tantivy's parser rejects,
|
||||||
while keeping exact matches ranked higher. The threshold value is applied
|
which used to silently knock the fuzzy clause out of any mixed
|
||||||
as a post-search score filter, not during query construction.
|
query (see _try_parse_fuzzy_query).
|
||||||
|
5. Optional CJK bigram clause, built from the same parsed AST for the
|
||||||
|
same reason (see _build_ast_cjk_query): a CJK term the query negated
|
||||||
|
or fielded must not resurface through it.
|
||||||
|
6. When any optional clause was added, the query's conjunctive
|
||||||
|
exclusions are restated as MustNot above the blend
|
||||||
|
(_negation_clauses): a clause built from positive terms cannot
|
||||||
|
express them, and as a bare Should it would undo them.
|
||||||
"""
|
"""
|
||||||
|
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:
|
try:
|
||||||
query_str = translate_query(raw_query, tz)
|
exact = tantivy_emit(result.ast, index=index, registry=registry)
|
||||||
except SearchQueryError:
|
except QueryError as e:
|
||||||
# Intentional, user-fixable error (e.g. an unparsable date). Propagate so
|
raise _map_emit_error(e) from e
|
||||||
# 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 = 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 = (
|
cjk_query = (
|
||||||
_build_cjk_query(index, raw_query, _CJK_ALL_FIELDS)
|
_build_ast_cjk_query(index, result.ast, registry)
|
||||||
if _has_cjk(raw_query)
|
if _has_cjk(raw_query)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -235,22 +490,73 @@ def parse_user_query(
|
|||||||
|
|
||||||
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||||
if threshold is not None:
|
if threshold is not None:
|
||||||
fuzzy = index.parse_query(
|
fuzzy = _try_parse_fuzzy_query(index, result.ast, registry)
|
||||||
query_str,
|
if fuzzy is not None:
|
||||||
DEFAULT_SEARCH_FIELDS,
|
clauses.append(
|
||||||
field_boosts=_FIELD_BOOSTS,
|
(tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)),
|
||||||
# (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:
|
if cjk_query is not None:
|
||||||
clauses.append((tantivy.Occur.Should, cjk_query))
|
clauses.append((tantivy.Occur.Should, cjk_query))
|
||||||
|
|
||||||
if len(clauses) == 1:
|
if len(clauses) == 1:
|
||||||
return exact
|
return exact
|
||||||
return tantivy.Query.boolean_query(clauses)
|
# The fuzzy and CJK clauses are built from positive terms only, so as
|
||||||
|
# plain Shoulds beside the exact clause they re-admit exactly the
|
||||||
|
# documents the query excluded. Restate the exclusions once, above the
|
||||||
|
# whole blend. Redundant against the exact clause, which already
|
||||||
|
# carries them, but idempotently so.
|
||||||
|
negations = _negation_clauses(index, result.ast, registry)
|
||||||
|
if not negations:
|
||||||
|
return _any_of(clauses)
|
||||||
|
return tantivy.Query.boolean_query(
|
||||||
|
[(tantivy.Occur.Must, _any_of(clauses)), *negations],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The three whoosh-compat kinds for a wildcard on a field that cannot
|
||||||
|
# carry one. d.field_kind supplies the discriminator, so naming the field's
|
||||||
|
# type needs no second trip through the registry.
|
||||||
|
_PATTERN_ON_KINDS: Final = frozenset(
|
||||||
|
{
|
||||||
|
DiagnosticKind.PATTERN_ON_NUMERIC,
|
||||||
|
DiagnosticKind.PATTERN_ON_BOOLEAN_EXISTS,
|
||||||
|
DiagnosticKind.PATTERN_ON_SUBPATH,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if d.kind is DiagnosticKind.TOO_DEEP:
|
||||||
|
return SearchQueryError("The search query is nested too deeply.")
|
||||||
|
if d.kind in _PATTERN_ON_KINDS:
|
||||||
|
kind_label = f" ({d.field_kind.name.lower()})" if d.field_kind else ""
|
||||||
|
return SearchQueryError(
|
||||||
|
f"Wildcard patterns are not supported for field "
|
||||||
|
f"{field_name!r}{kind_label}.",
|
||||||
|
)
|
||||||
|
if d.kind is DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE:
|
||||||
|
field_label = f" for field {field_name!r}" if field_name else ""
|
||||||
|
return SearchQueryError(
|
||||||
|
f"{d.raw_value!r} looks like a bracket range{field_label}, but "
|
||||||
|
"'[' is not a wildcard character on its own. Combine it with a "
|
||||||
|
"wildcard, e.g. a trailing '*', or double-quote the value to "
|
||||||
|
"search it as literal text.",
|
||||||
|
)
|
||||||
|
logger.warning("Unmapped parse diagnostic %s: %s", d.kind, d.message)
|
||||||
|
return SearchQueryError("The search query could not be executed.")
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_query(
|
def parse_simple_query(
|
||||||
@@ -268,7 +574,7 @@ def parse_simple_query(
|
|||||||
CJK substrings the simple analyzer can't (long whitespace-free runs are
|
CJK substrings the simple analyzer can't (long whitespace-free runs are
|
||||||
dropped by remove_long).
|
dropped by remove_long).
|
||||||
"""
|
"""
|
||||||
tokens = _simple_query_tokens(raw_query)
|
tokens = simple_search_tokens(raw_query)
|
||||||
|
|
||||||
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
||||||
if tokens:
|
if tokens:
|
||||||
@@ -291,23 +597,14 @@ def parse_simple_query(
|
|||||||
)
|
)
|
||||||
for token in tokens
|
for token in tokens
|
||||||
]
|
]
|
||||||
simple_query = (
|
clauses.append((tantivy.Occur.Should, _any_of(token_queries)))
|
||||||
token_queries[0][1]
|
|
||||||
if len(token_queries) == 1
|
|
||||||
else tantivy.Query.boolean_query(token_queries)
|
|
||||||
)
|
|
||||||
clauses.append((tantivy.Occur.Should, simple_query))
|
|
||||||
|
|
||||||
if cjk_fields and _has_cjk(raw_query):
|
if cjk_fields and _has_cjk(raw_query):
|
||||||
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
||||||
if cjk_q is not None:
|
if cjk_q is not None:
|
||||||
clauses.append((tantivy.Occur.Should, cjk_q))
|
clauses.append((tantivy.Occur.Should, cjk_q))
|
||||||
|
|
||||||
if not clauses:
|
return _any_of(clauses)
|
||||||
return tantivy.Query.empty_query()
|
|
||||||
if len(clauses) == 1:
|
|
||||||
return clauses[0][1]
|
|
||||||
return tantivy.Query.boolean_query(clauses)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_text_highlight_query(
|
def parse_simple_text_highlight_query(
|
||||||
@@ -322,13 +619,21 @@ def parse_simple_text_highlight_query(
|
|||||||
|
|
||||||
# Strip Tantivy operator chars before tokenizing: this is a plain-text
|
# Strip Tantivy operator chars before tokenizing: this is a plain-text
|
||||||
# highlight query, not a structured boolean query, so +/- are separators.
|
# highlight query, not a structured boolean query, so +/- are separators.
|
||||||
tokens = _simple_query_tokens(
|
tokens = simple_search_tokens(
|
||||||
regex.sub(r"[-+]", " ", raw_query, timeout=_REGEX_TIMEOUT),
|
regex.sub(r"[-+]", " ", raw_query, timeout=_REGEX_TIMEOUT),
|
||||||
)
|
)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return tantivy.Query.empty_query()
|
return tantivy.Query.empty_query()
|
||||||
|
|
||||||
return index.parse_query(" ".join(tokens), ["content"])
|
# Quote each token as its own phrase, escaping backslashes and embedded
|
||||||
|
# quotes. simple search tokens can carry arbitrary Tantivy syntax
|
||||||
|
# characters (`"`, `:`, `(`, `[`, `/`, ...) that the query-string parser
|
||||||
|
# would otherwise interpret as query grammar rather than literal text.
|
||||||
|
quoted_tokens = [
|
||||||
|
'"' + token.replace("\\", "\\\\").replace('"', '\\"') + '"' for token in tokens
|
||||||
|
]
|
||||||
|
|
||||||
|
return index.parse_query(" ".join(quoted_tokens), ["content"])
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_text_query(
|
def parse_simple_text_query(
|
||||||
@@ -342,7 +647,7 @@ def parse_simple_text_query(
|
|||||||
return parse_simple_query(
|
return parse_simple_query(
|
||||||
index,
|
index,
|
||||||
raw_query,
|
raw_query,
|
||||||
SIMPLE_SEARCH_FIELDS,
|
_SIMPLE_SEARCH_FIELDS,
|
||||||
cjk_fields=_CJK_CONTENT_FIELDS,
|
cjk_fields=_CJK_CONTENT_FIELDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -358,6 +663,6 @@ def parse_simple_title_query(
|
|||||||
return parse_simple_query(
|
return parse_simple_query(
|
||||||
index,
|
index,
|
||||||
raw_query,
|
raw_query,
|
||||||
TITLE_SEARCH_FIELDS,
|
_TITLE_SEARCH_FIELDS,
|
||||||
cjk_fields=_CJK_TITLE_FIELDS,
|
cjk_fields=_CJK_TITLE_FIELDS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._tokenizer import ascii_fold
|
||||||
|
from documents.search._tokenizer import paperless_text_analyzer
|
||||||
|
from documents.search._tokenizer import stem_pattern_text
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from whoosh_compat import PatternNormalizer
|
||||||
|
|
||||||
|
_registry_cache: dict[str | None, FieldRegistry] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_analyzer(text: str) -> list[str]:
|
||||||
|
"""Analyzer for KEYWORD fields indexed with the raw tokenizer (no splitting)."""
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_normalizer(text: str) -> str:
|
||||||
|
"""Wildcard/regex literal-run normalizer for fields indexed without stemming."""
|
||||||
|
return ascii_fold(text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pattern_normalizer(language: str | None) -> PatternNormalizer:
|
||||||
|
"""Build the wildcard/regex literal-run normalizer for a search language."""
|
||||||
|
|
||||||
|
def _pattern_normalizer(text: str) -> tuple[str, ...]:
|
||||||
|
"""Normalize a literal run into the forms a term may match.
|
||||||
|
|
||||||
|
TEXT index terms go through lowercase -> ascii_fold -> stem, so a
|
||||||
|
pattern that skips stemming can never match one: "invoice*" would look
|
||||||
|
for a term starting with "invoice" while the index holds "invoic". The
|
||||||
|
run is therefore offered stemmed as well. KEYWORD fields are indexed
|
||||||
|
raw and get _fold_normalizer instead, so their patterns stay literal.
|
||||||
|
|
||||||
|
Both forms are returned, as alternatives, because neither is a prefix
|
||||||
|
of the other in general: English stemming substitutes as well as
|
||||||
|
truncates ("copy" -> "copi"), so the stem alone loses the compounds
|
||||||
|
the typed run reaches ("copyright") while the typed run alone loses
|
||||||
|
the inflections the stem reaches ("copies"). whoosh-compat ORs the
|
||||||
|
alternatives per literal run and deduplicates them, so a run the
|
||||||
|
stemmer leaves alone costs exactly the one branch it did before.
|
||||||
|
|
||||||
|
Inside a bracket class the emitter calls this once per character and
|
||||||
|
uses the answer only if it is a single one-character form; two forms
|
||||||
|
there leave the character as typed. A stemmer does not change a lone
|
||||||
|
character, so the two forms deduplicate to one and the class body is
|
||||||
|
folded as before.
|
||||||
|
"""
|
||||||
|
folded = ascii_fold(text.lower())
|
||||||
|
stemmed = stem_pattern_text(folded, language)
|
||||||
|
return (folded, stemmed)
|
||||||
|
|
||||||
|
return _pattern_normalizer
|
||||||
|
|
||||||
|
|
||||||
|
def get_field_registry(language: str | None) -> FieldRegistry:
|
||||||
|
"""Build (or return the cached) FieldRegistry for the given search language.
|
||||||
|
|
||||||
|
Cached keyed by language, rebuilt on the same trigger register_tokenizers()
|
||||||
|
uses (settings.SEARCH_LANGUAGE change). A fresh call with a new language
|
||||||
|
builds and caches a new registry rather than mutating the old one.
|
||||||
|
"""
|
||||||
|
if language in _registry_cache:
|
||||||
|
return _registry_cache[language]
|
||||||
|
|
||||||
|
text_analyzer = paperless_text_analyzer(language).analyze
|
||||||
|
pattern_normalizer = _make_pattern_normalizer(language)
|
||||||
|
|
||||||
|
specs = [
|
||||||
|
dataclasses.replace(
|
||||||
|
field,
|
||||||
|
analyzer=_identity_analyzer
|
||||||
|
if field.kind is FieldKind.KEYWORD
|
||||||
|
else text_analyzer,
|
||||||
|
pattern_normalizer=_fold_normalizer
|
||||||
|
if field.kind is FieldKind.KEYWORD
|
||||||
|
else pattern_normalizer,
|
||||||
|
)
|
||||||
|
for field in PUBLIC_FIELDS
|
||||||
|
]
|
||||||
|
|
||||||
|
registry = FieldRegistry(specs)
|
||||||
|
_registry_cache[language] = registry
|
||||||
|
return registry
|
||||||
+217
-78
@@ -1,14 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
from typing import NamedTuple
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import tantivy
|
import tantivy
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,7 +21,185 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
# v1 - Initial tantivy schema format
|
# v1 - Initial tantivy schema format
|
||||||
SCHEMA_VERSION: Final[int] = 1
|
# v2 - build_schema() derived from PUBLIC_FIELDS, changing the field declaration
|
||||||
|
# order, and the write-only correspondent/document_type/storage_path/tag id
|
||||||
|
# columns dropped. tantivy compares schemas by ordered field list, so an
|
||||||
|
# index built by v1 rejects every write against the v2 schema.
|
||||||
|
SCHEMA_VERSION: Final[int] = 2
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDescriptor(NamedTuple):
|
||||||
|
"""One tantivy field, in declaration order.
|
||||||
|
|
||||||
|
The descriptor vocabulary is paperless', not tantivy-py's: it is both the
|
||||||
|
input to the SchemaBuilder and the input to schema_fingerprint(), so the
|
||||||
|
persisted fingerprint cannot move under a tantivy-py upgrade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
stored: bool
|
||||||
|
indexed: bool
|
||||||
|
fast: bool
|
||||||
|
tokenizer: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# (schema kind, tokenizer) for the FieldKind -> FieldDescriptor mapping that
|
||||||
|
# doesn't need special-casing. JSON is handled separately below since it can
|
||||||
|
# emit a second, synthetic descriptor.
|
||||||
|
_KIND_TABLE: Final[dict[FieldKind, tuple[str, str | None]]] = {
|
||||||
|
FieldKind.TEXT: ("text", "paperless_text"),
|
||||||
|
FieldKind.KEYWORD: ("text", "raw"),
|
||||||
|
FieldKind.U64: ("u64", None),
|
||||||
|
FieldKind.DATE: ("date", None),
|
||||||
|
FieldKind.DATETIME: ("date", None),
|
||||||
|
}
|
||||||
|
# Kinds whose fast-field flag follows FieldSpec.fast rather than always False.
|
||||||
|
_FAST_FROM_FIELD: Final[frozenset[FieldKind]] = frozenset(
|
||||||
|
{FieldKind.U64, FieldKind.DATE, FieldKind.DATETIME},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_field_descriptors() -> list[FieldDescriptor]:
|
||||||
|
"""Descriptors for the query-visible fields declared in PUBLIC_FIELDS."""
|
||||||
|
descriptors: list[FieldDescriptor] = []
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
if field.kind is FieldKind.JSON:
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
field.name,
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if field.name == "notes":
|
||||||
|
# Plain-text companion for snippet generation: tantivy's
|
||||||
|
# SnippetGenerator does not support JSON fields. Schema-only,
|
||||||
|
# no query-syntax meaning, not in PUBLIC_FIELDS.
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes_text",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
schema_kind, tokenizer = _KIND_TABLE[field.kind]
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
field.name,
|
||||||
|
schema_kind,
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=field.fast if field.kind in _FAST_FROM_FIELD else False,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return descriptors
|
||||||
|
|
||||||
|
|
||||||
|
def field_descriptors() -> list[FieldDescriptor]:
|
||||||
|
"""Every field of the document index, in the order tantivy declares them.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list, so the order here is
|
||||||
|
part of the on-disk contract: schema_fingerprint() hashes it and
|
||||||
|
needs_rebuild() acts on the result.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
FieldDescriptor(
|
||||||
|
"id",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
*_public_field_descriptors(),
|
||||||
|
# Shadow sort fields - fast, not stored
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
)
|
||||||
|
for name in ("title_sort", "correspondent_sort", "type_sort")
|
||||||
|
),
|
||||||
|
# CJK support - not stored, indexed only
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"bigram_content",
|
||||||
|
"bigram_title",
|
||||||
|
"bigram_correspondent",
|
||||||
|
"bigram_document_type",
|
||||||
|
"bigram_tag",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
# Simple substring search support for title/content - not stored,
|
||||||
|
# indexed only
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
)
|
||||||
|
for name in ("simple_title", "simple_content")
|
||||||
|
),
|
||||||
|
# Autocomplete prefix scan via terms_with_prefix, which walks the
|
||||||
|
# field's term dictionary - so the field must be indexed (term dict),
|
||||||
|
# not stored. The stored value is never read back, so storing it only
|
||||||
|
# wastes space.
|
||||||
|
FieldDescriptor(
|
||||||
|
"autocomplete_word",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
# Permission filter columns, read by build_permission_filter.
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
)
|
||||||
|
for name in ("owner_id", "viewer_id", "viewer_group_id")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def schema_fingerprint() -> str:
|
||||||
|
"""Hash of the field descriptors, stamped into .index_settings.json.
|
||||||
|
|
||||||
|
Changes whenever a field is added, removed, retyped, re-optioned or
|
||||||
|
reordered, so an index built from a different schema shape is detected
|
||||||
|
even when SCHEMA_VERSION was not bumped.
|
||||||
|
"""
|
||||||
|
payload = json.dumps([list(descriptor) for descriptor in field_descriptors()])
|
||||||
|
return hashlib.blake2b(payload.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def build_schema() -> tantivy.Schema:
|
def build_schema() -> tantivy.Schema:
|
||||||
@@ -32,85 +215,37 @@ def build_schema() -> tantivy.Schema:
|
|||||||
"""
|
"""
|
||||||
sb = tantivy.SchemaBuilder()
|
sb = tantivy.SchemaBuilder()
|
||||||
|
|
||||||
sb.add_unsigned_field("id", stored=True, indexed=True, fast=True)
|
for descriptor in field_descriptors():
|
||||||
sb.add_text_field("checksum", stored=True, tokenizer_name="raw")
|
if descriptor.kind == "text":
|
||||||
|
|
||||||
for field in (
|
|
||||||
"title",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"storage_path",
|
|
||||||
"original_filename",
|
|
||||||
"content",
|
|
||||||
):
|
|
||||||
sb.add_text_field(field, stored=True, tokenizer_name="paperless_text")
|
|
||||||
|
|
||||||
# Shadow sort fields - fast, not stored/indexed
|
|
||||||
for field in ("title_sort", "correspondent_sort", "type_sort"):
|
|
||||||
sb.add_text_field(
|
sb.add_text_field(
|
||||||
field,
|
descriptor.name,
|
||||||
stored=False,
|
stored=descriptor.stored,
|
||||||
tokenizer_name="simple_analyzer",
|
fast=descriptor.fast,
|
||||||
fast=True,
|
tokenizer_name=cast("str", descriptor.tokenizer),
|
||||||
)
|
)
|
||||||
|
elif descriptor.kind == "json":
|
||||||
# CJK support - not stored, indexed only
|
sb.add_json_field(
|
||||||
sb.add_text_field("bigram_content", stored=False, tokenizer_name="bigram_analyzer")
|
descriptor.name,
|
||||||
sb.add_text_field("bigram_title", stored=False, tokenizer_name="bigram_analyzer")
|
stored=descriptor.stored,
|
||||||
sb.add_text_field(
|
fast=descriptor.fast,
|
||||||
"bigram_correspondent",
|
tokenizer_name=cast("str", descriptor.tokenizer),
|
||||||
stored=False,
|
|
||||||
tokenizer_name="bigram_analyzer",
|
|
||||||
)
|
)
|
||||||
sb.add_text_field(
|
elif descriptor.kind == "u64":
|
||||||
"bigram_document_type",
|
sb.add_unsigned_field(
|
||||||
stored=False,
|
descriptor.name,
|
||||||
tokenizer_name="bigram_analyzer",
|
stored=descriptor.stored,
|
||||||
|
indexed=descriptor.indexed,
|
||||||
|
fast=descriptor.fast,
|
||||||
)
|
)
|
||||||
sb.add_text_field("bigram_tag", stored=False, tokenizer_name="bigram_analyzer")
|
elif descriptor.kind == "date":
|
||||||
|
sb.add_date_field(
|
||||||
# Simple substring search support for title/content - not stored, indexed only
|
descriptor.name,
|
||||||
sb.add_text_field(
|
stored=descriptor.stored,
|
||||||
"simple_title",
|
indexed=descriptor.indexed,
|
||||||
stored=False,
|
fast=descriptor.fast,
|
||||||
tokenizer_name="simple_search_analyzer",
|
|
||||||
)
|
)
|
||||||
sb.add_text_field(
|
else:
|
||||||
"simple_content",
|
raise ValueError(f"Unknown schema field kind: {descriptor.kind}")
|
||||||
stored=False,
|
|
||||||
tokenizer_name="simple_search_analyzer",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Autocomplete prefix scan via terms_with_prefix, which walks the field's
|
|
||||||
# term dictionary - so the field must be indexed (term dict), not stored.
|
|
||||||
# The stored value is never read back, so storing it only wastes space.
|
|
||||||
sb.add_text_field("autocomplete_word", stored=False, tokenizer_name="raw")
|
|
||||||
|
|
||||||
sb.add_text_field("tag", stored=True, tokenizer_name="paperless_text")
|
|
||||||
|
|
||||||
# JSON fields — structured queries: notes.user:alice, custom_fields.name:invoice
|
|
||||||
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
|
|
||||||
# Plain-text companion for notes — tantivy's SnippetGenerator does not support
|
|
||||||
# JSON fields, so highlights require a text field with the same content.
|
|
||||||
sb.add_text_field("notes_text", stored=True, tokenizer_name="paperless_text")
|
|
||||||
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
|
|
||||||
|
|
||||||
for field in (
|
|
||||||
"correspondent_id",
|
|
||||||
"document_type_id",
|
|
||||||
"storage_path_id",
|
|
||||||
"tag_id",
|
|
||||||
"owner_id",
|
|
||||||
"viewer_id",
|
|
||||||
"viewer_group_id",
|
|
||||||
):
|
|
||||||
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
|
|
||||||
|
|
||||||
for field in ("created", "modified", "added"):
|
|
||||||
sb.add_date_field(field, stored=True, indexed=True, fast=True)
|
|
||||||
|
|
||||||
for field in ("asn", "page_count", "num_notes"):
|
|
||||||
sb.add_unsigned_field(field, stored=True, indexed=True, fast=True)
|
|
||||||
|
|
||||||
return sb.build()
|
return sb.build()
|
||||||
|
|
||||||
@@ -119,9 +254,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
"""
|
"""
|
||||||
Check if the search index needs rebuilding.
|
Check if the search index needs rebuilding.
|
||||||
|
|
||||||
Reads .index_settings.json to compare the stored schema version and
|
Reads .index_settings.json to compare the stored schema version, search
|
||||||
search language against the current configuration. Returns True if the
|
language and schema fingerprint against the current configuration. Returns
|
||||||
file is missing, unparsable, or either value mismatches.
|
True if the file is missing, unparsable, or any value mismatches.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index_dir: Path to the search index directory
|
index_dir: Path to the search index directory
|
||||||
@@ -140,6 +275,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
if "language" not in data or data["language"] != settings.SEARCH_LANGUAGE:
|
if "language" not in data or data["language"] != settings.SEARCH_LANGUAGE:
|
||||||
logger.info("Search index language changed - rebuilding.")
|
logger.info("Search index language changed - rebuilding.")
|
||||||
return True
|
return True
|
||||||
|
if data.get("schema_fingerprint") != schema_fingerprint():
|
||||||
|
logger.info("Search index schema fingerprint mismatch - rebuilding.")
|
||||||
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -170,6 +308,7 @@ def _write_sentinels(index_dir: Path) -> None:
|
|||||||
{
|
{
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"language": settings.SEARCH_LANGUAGE,
|
"language": settings.SEARCH_LANGUAGE,
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import cache
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import tantivy
|
import tantivy
|
||||||
@@ -71,7 +72,7 @@ def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
|
|||||||
use fast=True and Tantivy requires fast-field tokenizers to exist
|
use fast=True and Tantivy requires fast-field tokenizers to exist
|
||||||
even for documents that omit those fields.
|
even for documents that omit those fields.
|
||||||
"""
|
"""
|
||||||
index.register_tokenizer("paperless_text", _paperless_text(language))
|
index.register_tokenizer("paperless_text", paperless_text_analyzer(language))
|
||||||
index.register_tokenizer("simple_analyzer", _simple_analyzer())
|
index.register_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
index.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
index.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
||||||
index.register_tokenizer("simple_search_analyzer", _simple_search_analyzer())
|
index.register_tokenizer("simple_search_analyzer", _simple_search_analyzer())
|
||||||
@@ -79,7 +80,7 @@ def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
|
|||||||
index.register_fast_field_tokenizer("simple_analyzer", _simple_analyzer())
|
index.register_fast_field_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
|
|
||||||
|
|
||||||
def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
|
def paperless_text_analyzer(language: str | None) -> tantivy.TextAnalyzer:
|
||||||
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(129) -> lowercase -> ascii_fold [-> stemmer]"""
|
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(129) -> lowercase -> ascii_fold [-> stemmer]"""
|
||||||
builder = (
|
builder = (
|
||||||
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
||||||
@@ -100,6 +101,54 @@ def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _pattern_stemmer(language: str | None) -> tantivy.TextAnalyzer | None:
|
||||||
|
"""The stemming tail of paperless_text_analyzer, over a whole literal run.
|
||||||
|
|
||||||
|
Same language gate and same Snowball stemmer paperless_text_analyzer
|
||||||
|
applies at index time, so query patterns follow SEARCH_LANGUAGE. Returns
|
||||||
|
None when that gate disables stemming; paperless_text_analyzer already
|
||||||
|
warns about an unsupported language, so this stays quiet.
|
||||||
|
|
||||||
|
The raw tokenizer keeps the run whole (a wildcard literal is a fragment,
|
||||||
|
not necessarily a word), and remove_long is kept so an over-long run is
|
||||||
|
treated the same way the index treats it.
|
||||||
|
"""
|
||||||
|
if not language:
|
||||||
|
return None
|
||||||
|
tantivy_lang = _LANGUAGE_MAP.get(language.lower())
|
||||||
|
if tantivy_lang is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.raw())
|
||||||
|
.filter(tantivy.Filter.remove_long(_TOKEN_REMOVE_LONG_LIMIT))
|
||||||
|
.filter(tantivy.Filter.stemmer(tantivy_lang))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stem_pattern_text(text: str, language: str | None) -> str:
|
||||||
|
"""Stem an already lowercased/ascii-folded run the way index terms are.
|
||||||
|
|
||||||
|
Returns text unchanged when stemming is disabled for language, and also
|
||||||
|
when the stem step does not yield exactly one token: remove_long drops a run
|
||||||
|
past the length limit, leaving no stem to substitute. Falling back to the
|
||||||
|
text as typed is the safe direction for a pattern prefix, since it can only
|
||||||
|
be as narrow as it was before stemming was considered.
|
||||||
|
|
||||||
|
The raw tokenizer emits one token whatever the input and the stemmer is
|
||||||
|
1-to-1, so only the zero-token case can fire today; the guard covers both
|
||||||
|
counts so a tokenizer change cannot turn this into an IndexError.
|
||||||
|
"""
|
||||||
|
analyzer = _pattern_stemmer(language)
|
||||||
|
if analyzer is None:
|
||||||
|
return text
|
||||||
|
tokens = analyzer.analyze(text)
|
||||||
|
if len(tokens) != 1:
|
||||||
|
return text
|
||||||
|
return tokens[0]
|
||||||
|
|
||||||
|
|
||||||
def _simple_analyzer() -> tantivy.TextAnalyzer:
|
def _simple_analyzer() -> tantivy.TextAnalyzer:
|
||||||
"""Tokenizer for shadow sort fields (title_sort, correspondent_sort, type_sort): simple -> lowercase -> ascii_fold."""
|
"""Tokenizer for shadow sort fields (title_sort, correspondent_sort, type_sort): simple -> lowercase -> ascii_fold."""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import TypeAlias
|
|
||||||
|
|
||||||
import regex
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
|
|
||||||
from documents.search._dates import _DATE_KEYWORDS
|
|
||||||
from documents.search._dates import _DATE_ONLY_FIELDS
|
|
||||||
from documents.search._dates import _date_only_range
|
|
||||||
from documents.search._dates import _datetime_range
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
# wins over a hypothetical shorter "previous".
|
|
||||||
_KEYWORD_VALUE_RE = regex.compile(
|
|
||||||
"|".join(sorted((regex.escape(k) for k in _DATE_KEYWORDS), key=len, reverse=True)),
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
# TODO: this module translates date queries into Tantivy *string* syntax, which
|
|
||||||
# forces a workaround for something Tantivy's string parser cannot express on
|
|
||||||
# date fields: open-ended ranges use far-past/far-future string sentinels
|
|
||||||
# (OPEN_LO/OPEN_HI). These can be replaced with a real tantivy.Query object
|
|
||||||
# (Query.range_query(..., None) for open bounds) once tantivy-py accepts Python
|
|
||||||
# datetimes in range_query/term_query on Date fields. That support exists on
|
|
||||||
# tantivy-py master (PRs #655 + #666) but postdates the pinned 0.26.0 wheel, so
|
|
||||||
# it is blocked only on a published release > 0.26.0 and a dependency bump.
|
|
||||||
# (Unparsable dates now raise InvalidDateQuery -> HTTP 400 rather than using a
|
|
||||||
# no-match string sentinel.)
|
|
||||||
|
|
||||||
# Fields that store exact, non-analyzed comma-joined tokens in the index and so
|
|
||||||
# need explicit comma->AND expansion (Whoosh KEYWORD(commas=True) set).
|
|
||||||
MULTI_VALUE_FIELDS = frozenset({"tag", "tag_id", "viewer_id"})
|
|
||||||
|
|
||||||
# Date fields whose values/ranges get rewritten to RFC3339 Tantivy ranges.
|
|
||||||
DATE_FIELDS = frozenset({"created", "modified", "added"})
|
|
||||||
|
|
||||||
# Field aliases: Whoosh (v2) field names that were renamed in the Tantivy schema.
|
|
||||||
# Preserved here so v2 queries using the old names continue to work without 400
|
|
||||||
# errors instead of silently failing. Applied by _render to non-date field tokens.
|
|
||||||
FIELD_ALIASES: dict[str, str] = {
|
|
||||||
"type": "document_type",
|
|
||||||
"type_id": "document_type_id",
|
|
||||||
"path": "storage_path",
|
|
||||||
"path_id": "storage_path_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Known schema fields: a comma immediately followed by ``<known>:`` is a clause
|
|
||||||
# separator. Restricting to known fields prevents URL-like ``http:`` misfires.
|
|
||||||
KNOWN_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"title",
|
|
||||||
"content",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"type", # v2 alias -> document_type
|
|
||||||
"storage_path",
|
|
||||||
"path", # v2 alias -> storage_path
|
|
||||||
"tag",
|
|
||||||
"tag_id",
|
|
||||||
"correspondent_id",
|
|
||||||
"document_type_id",
|
|
||||||
"type_id", # v2 alias -> document_type_id
|
|
||||||
"storage_path_id",
|
|
||||||
"path_id", # v2 alias -> storage_path_id
|
|
||||||
"owner_id",
|
|
||||||
"viewer_id",
|
|
||||||
"asn",
|
|
||||||
"page_count",
|
|
||||||
"num_notes",
|
|
||||||
"created",
|
|
||||||
"modified",
|
|
||||||
"added",
|
|
||||||
"original_filename",
|
|
||||||
"checksum",
|
|
||||||
"notes",
|
|
||||||
"custom_fields",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
_FIELD_RE = regex.compile(r"(?P<field>\w+):")
|
|
||||||
|
|
||||||
# Matches the TO separator inside a range bracket. Handles three forms:
|
|
||||||
# middle: "lo TO hi" (either lo or hi may be empty)
|
|
||||||
# trailing: "lo TO" (open upper bound)
|
|
||||||
# leading: "TO hi" (open lower bound)
|
|
||||||
# Bounds MAY contain internal spaces (e.g. "-7 days"), so we use .*? / .+?
|
|
||||||
# and split on the whitespace-delimited " TO " / " to " separator.
|
|
||||||
_RANGE_RE = regex.compile(
|
|
||||||
r"^\s*(?P<lo>.*?)\s+[Tt][Oo]\s+(?P<hi>.+?)\s*$"
|
|
||||||
r"|"
|
|
||||||
r"^\s*(?P<lo2>.+?)\s+[Tt][Oo]\s*$"
|
|
||||||
r"|"
|
|
||||||
r"^\s*[Tt][Oo]\s+(?P<hi2>.+?)\s*$",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldValue:
|
|
||||||
field: str
|
|
||||||
value: str
|
|
||||||
|
|
||||||
|
|
||||||
# Produced by the comma-resolution pass (not by scan()).
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldValueList:
|
|
||||||
field: str
|
|
||||||
values: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldRange:
|
|
||||||
field: str
|
|
||||||
open: str
|
|
||||||
lo: str
|
|
||||||
hi: str
|
|
||||||
close: str
|
|
||||||
|
|
||||||
|
|
||||||
# Produced by the comma-resolution pass (not by scan()).
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Comma:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Passthrough:
|
|
||||||
raw: str
|
|
||||||
|
|
||||||
|
|
||||||
Token: TypeAlias = FieldValue | FieldValueList | FieldRange | Comma | Passthrough
|
|
||||||
|
|
||||||
_CLOSE: dict[str, str] = {"[": "]", "{": "}"}
|
|
||||||
|
|
||||||
|
|
||||||
def scan(query: str) -> list[Token]:
|
|
||||||
"""
|
|
||||||
Tokenize a raw query into date/comma-aware tokens, leaving everything else
|
|
||||||
as verbatim ``Passthrough`` runs. Non-recursive: finds the first matching
|
|
||||||
close bracket/quote. Nested brackets are not valid Tantivy range syntax and
|
|
||||||
pass through verbatim on mismatch.
|
|
||||||
"""
|
|
||||||
tokens: list[Token] = []
|
|
||||||
buf: list[str] = [] # accumulates passthrough chars
|
|
||||||
i, n = 0, len(query)
|
|
||||||
while i < n:
|
|
||||||
matched = _match_field_token(query, i)
|
|
||||||
if matched is None:
|
|
||||||
buf.append(query[i])
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
token, i = matched
|
|
||||||
if buf and buf[-1] == ",":
|
|
||||||
buf.pop()
|
|
||||||
_flush(buf, tokens)
|
|
||||||
tokens.append(Comma())
|
|
||||||
else:
|
|
||||||
_flush(buf, tokens)
|
|
||||||
tokens.append(token)
|
|
||||||
i = _maybe_comma(query, i, tokens)
|
|
||||||
_flush(buf, tokens)
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _flush(buf: list[str], tokens: list[Token]) -> None:
|
|
||||||
"""Emit any accumulated passthrough characters as a single token."""
|
|
||||||
if buf:
|
|
||||||
tokens.append(Passthrough("".join(buf)))
|
|
||||||
buf.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _at_word_boundary(query: str, i: int) -> bool:
|
|
||||||
"""A field token may begin only at the start or after a non-word character."""
|
|
||||||
return i == 0 or not (query[i - 1].isalnum() or query[i - 1] == "_")
|
|
||||||
|
|
||||||
|
|
||||||
def _match_field_token(query: str, i: int) -> tuple[Token, int] | None:
|
|
||||||
"""
|
|
||||||
If a known ``field:`` token starts at ``i``, consume it and return
|
|
||||||
``(token, end_index)``; otherwise return None so the caller treats the
|
|
||||||
character as passthrough. Handles both ``field:[range]`` and ``field:value``,
|
|
||||||
and returns None when the range/value cannot be consumed.
|
|
||||||
"""
|
|
||||||
m = _FIELD_RE.match(query, i)
|
|
||||||
if m is None or m.group("field") not in KNOWN_FIELDS:
|
|
||||||
return None
|
|
||||||
if not _at_word_boundary(query, i):
|
|
||||||
return None
|
|
||||||
field = m.group("field")
|
|
||||||
j = m.end()
|
|
||||||
if j < len(query) and query[j] in "[{":
|
|
||||||
return _consume_range(query, j, field)
|
|
||||||
consumed = _consume_field_value(query, field, j)
|
|
||||||
if consumed is None:
|
|
||||||
return None
|
|
||||||
value, end = consumed
|
|
||||||
return FieldValue(field, value), end
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_field_value(query: str, field: str, start: int) -> tuple[str, int] | None:
|
|
||||||
"""
|
|
||||||
Consume a field value starting at ``start``: a multi-word date keyword phrase
|
|
||||||
(date fields only), or a bare/quoted value, then absorb any comma-joined
|
|
||||||
continuation that is not a clause separator. ``resolve_commas`` later splits a
|
|
||||||
multi-value field's joined value into a ``FieldValueList``; for other fields
|
|
||||||
the comma stays literal.
|
|
||||||
"""
|
|
||||||
n = len(query)
|
|
||||||
consumed = None
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
km = _KEYWORD_VALUE_RE.match(query, start)
|
|
||||||
if km is not None and (km.end() >= n or query[km.end()] in " \t),"):
|
|
||||||
consumed = (km.group(0), km.end())
|
|
||||||
if consumed is None:
|
|
||||||
consumed = _consume_value(query, start)
|
|
||||||
if consumed is None:
|
|
||||||
return None
|
|
||||||
value, k = consumed
|
|
||||||
while k < n and query[k] == ",":
|
|
||||||
if _looks_like_known_field(query, k + 1):
|
|
||||||
break # clause separator: left for _maybe_comma to emit a Comma()
|
|
||||||
more = _consume_value(query, k + 1)
|
|
||||||
if more is None:
|
|
||||||
break
|
|
||||||
value = f"{value},{more[0]}"
|
|
||||||
k = more[1]
|
|
||||||
return value, k
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_range(
|
|
||||||
query: str,
|
|
||||||
start: int,
|
|
||||||
field: str,
|
|
||||||
) -> tuple[FieldRange, int] | None:
|
|
||||||
"""Consume ``[lo TO hi]`` / ``{lo TO hi}`` from ``start`` (the bracket)."""
|
|
||||||
open_br = query[start]
|
|
||||||
close_br = _CLOSE[open_br]
|
|
||||||
end = query.find(close_br, start + 1)
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
inner = query[start + 1 : end]
|
|
||||||
m = _RANGE_RE.match(inner)
|
|
||||||
if m is not None:
|
|
||||||
if m.group("lo") is not None or m.group("hi") is not None:
|
|
||||||
# Middle form: "lo TO hi" (either may be empty string)
|
|
||||||
lo = (m.group("lo") or "").strip()
|
|
||||||
hi = (m.group("hi") or "").strip()
|
|
||||||
elif m.group("lo2") is not None:
|
|
||||||
# Trailing form: "lo TO"
|
|
||||||
lo = m.group("lo2").strip()
|
|
||||||
hi = ""
|
|
||||||
else:
|
|
||||||
# Leading form: "TO hi"
|
|
||||||
lo = ""
|
|
||||||
hi = (m.group("hi2") or "").strip()
|
|
||||||
else:
|
|
||||||
lo, hi = inner.strip(), ""
|
|
||||||
return FieldRange(field, open_br, lo, hi, close_br), end + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_value(query: str, start: int) -> tuple[str, int] | None:
|
|
||||||
"""Consume a bare or quoted field value from ``start``, stopping at comma."""
|
|
||||||
n = len(query)
|
|
||||||
if start >= n or query[start] in " \t":
|
|
||||||
return None
|
|
||||||
if query[start] in "\"'":
|
|
||||||
quote = query[start]
|
|
||||||
end = query.find(quote, start + 1)
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
return query[start : end + 1], end + 1
|
|
||||||
j = start
|
|
||||||
while j < n and query[j] not in " \t),":
|
|
||||||
j += 1
|
|
||||||
return query[start:j], j
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_known_field(query: str, pos: int) -> bool:
|
|
||||||
"""True if a known ``field:`` token starts at ``pos``."""
|
|
||||||
m = _FIELD_RE.match(query, pos)
|
|
||||||
return bool(m and m.group("field") in KNOWN_FIELDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_comma(query: str, i: int, tokens: list) -> int:
|
|
||||||
"""If a clause-separator comma follows at ``i``, emit ``Comma()`` and advance."""
|
|
||||||
if i < len(query) and query[i] == "," and _looks_like_known_field(query, i + 1):
|
|
||||||
tokens.append(Comma())
|
|
||||||
return i + 1
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_commas(tokens: list) -> list:
|
|
||||||
"""
|
|
||||||
Collapse value-list commas into ``FieldValueList`` and keep clause-separator
|
|
||||||
commas as ``Comma``. (Clause-sep commas are already emitted by ``scan`` via
|
|
||||||
the value-stop logic; this pass folds value-lists.)
|
|
||||||
"""
|
|
||||||
out: list = []
|
|
||||||
for tok in tokens:
|
|
||||||
if (
|
|
||||||
isinstance(tok, FieldValue)
|
|
||||||
and tok.field in MULTI_VALUE_FIELDS
|
|
||||||
and "," in tok.value
|
|
||||||
):
|
|
||||||
values = tuple(v for v in tok.value.split(",") if v)
|
|
||||||
out.append(FieldValueList(tok.field, values))
|
|
||||||
else:
|
|
||||||
out.append(tok)
|
|
||||||
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})?)?$")
|
|
||||||
|
|
||||||
|
|
||||||
def translate_scalar(field: str, value: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a bare date-field value to a Tantivy range string."""
|
|
||||||
bare = value.strip("\"'").lower()
|
|
||||||
if bare in _DATE_KEYWORDS:
|
|
||||||
if field in _DATE_ONLY_FIELDS:
|
|
||||||
return f"{field}:{_date_only_range(bare, tz)}"
|
|
||||||
return f"{field}:{_datetime_range(bare, tz)}"
|
|
||||||
digits = value.replace("-", "")
|
|
||||||
if _DIGITS_RE.match(value) or _ISO_RE.match(value):
|
|
||||||
bounds = _precision_bounds(digits)
|
|
||||||
if bounds is None:
|
|
||||||
raise InvalidDateQuery(field, value)
|
|
||||||
return _field_range_from_dates(field, bounds[0], bounds[1], tz)
|
|
||||||
if regex.fullmatch(r"\d{14}", value):
|
|
||||||
try:
|
|
||||||
dt = datetime(
|
|
||||||
int(value[0:4]),
|
|
||||||
int(value[4:6]),
|
|
||||||
int(value[6:8]),
|
|
||||||
int(value[8:10]),
|
|
||||||
int(value[10:12]),
|
|
||||||
int(value[12:14]),
|
|
||||||
tzinfo=UTC,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise InvalidDateQuery(field, value) from None
|
|
||||||
iso = _fmt(dt)
|
|
||||||
return f"{field}:[{iso} TO {iso}]"
|
|
||||||
# Unrecognized shape -> tell the user their date is malformed rather than
|
|
||||||
# silently matching nothing or emitting invalid Tantivy syntax.
|
|
||||||
raise InvalidDateQuery(field, value)
|
|
||||||
|
|
||||||
|
|
||||||
# Open-bound sentinels for date ranges. These far-past/far-future strings allow
|
|
||||||
# open-ended ranges to be expressed as Tantivy string queries until tantivy-py
|
|
||||||
# exposes Query.range_query(..., None) on Date fields (see module TODO).
|
|
||||||
OPEN_LO = "0001-01-01T00:00:00Z"
|
|
||||||
OPEN_HI = "9999-12-31T23:59:59Z"
|
|
||||||
|
|
||||||
|
|
||||||
# Matches compact now-offset tokens like now-7d, now+1h, now-30m.
|
|
||||||
_NOW_COMPACT_RE = regex.compile(
|
|
||||||
r"^now(?P<sign>[+-])(?P<n>\d+)(?P<unit>[dhm])$",
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Matches "±N <unit>" Whoosh-style offsets (e.g. -7 days, -1 week, +3 hours).
|
|
||||||
# Whoosh's own date parser (qparser.dateparse.PlusMinus) additionally accepted
|
|
||||||
# abbreviated unit spellings (e.g. "yrs", "yr", "y", "mos", "wks", "hrs", "mins",
|
|
||||||
# "secs"); saved views/searches created under the old Whoosh backend can still
|
|
||||||
# contain those tokens (e.g. "-999yrs"), so they are accepted here too and
|
|
||||||
# normalized to a canonical unit via _UNIT_ALIASES below.
|
|
||||||
_NOW_SPACED_RE = regex.compile(
|
|
||||||
r"^(?P<sign>[+-])(?P<n>\d+)\s*"
|
|
||||||
r"(?P<unit>years|year|yrs|yr|ys|y"
|
|
||||||
r"|months|month|mons|mon|mos|mo"
|
|
||||||
r"|weeks|week|wks|wk|ws|w"
|
|
||||||
r"|days|day|dys|dy|ds|d"
|
|
||||||
r"|hours|hour|hrs|hr|hs|h"
|
|
||||||
r"|minutes|minute|mins|min|ms|m"
|
|
||||||
r"|seconds|second|secs|sec|s)$",
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Maps every accepted unit spelling (including Whoosh-era abbreviations) to the
|
|
||||||
# canonical unit name used as a key into the delta map in _resolve_relative_bound.
|
|
||||||
_UNIT_ALIASES: dict[str, str] = {
|
|
||||||
alias: canonical
|
|
||||||
for canonical, aliases in {
|
|
||||||
"year": ("years", "year", "yrs", "yr", "ys", "y"),
|
|
||||||
"month": ("months", "month", "mons", "mon", "mos", "mo"),
|
|
||||||
"week": ("weeks", "week", "wks", "wk", "ws", "w"),
|
|
||||||
"day": ("days", "day", "dys", "dy", "ds", "d"),
|
|
||||||
"hour": ("hours", "hour", "hrs", "hr", "hs", "h"),
|
|
||||||
"minute": ("minutes", "minute", "mins", "min", "ms", "m"),
|
|
||||||
"second": ("seconds", "second", "secs", "sec", "s"),
|
|
||||||
}.items()
|
|
||||||
for alias in aliases
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_relative_bound(token: str) -> datetime | None:
|
|
||||||
"""
|
|
||||||
Resolve a relative bound token to an exact UTC instant, or return None.
|
|
||||||
|
|
||||||
Supported forms:
|
|
||||||
- ``now`` -> current UTC instant
|
|
||||||
- ``now+/-<n>d/h/m`` -> now +/- timedelta (d=days, h=hours, m=minutes)
|
|
||||||
- ``±N <unit>`` -> now +/- delta; month/year use relativedelta;
|
|
||||||
unit also accepts Whoosh-era abbreviations
|
|
||||||
(e.g. "yrs", "mos", "wks", "hrs", "mins", "secs")
|
|
||||||
"""
|
|
||||||
stripped = token.strip()
|
|
||||||
low = stripped.lower()
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
|
|
||||||
if low == "now":
|
|
||||||
return now
|
|
||||||
|
|
||||||
m = _NOW_COMPACT_RE.match(stripped)
|
|
||||||
if m:
|
|
||||||
sign = 1 if m.group("sign") == "+" else -1
|
|
||||||
n = int(m.group("n"))
|
|
||||||
unit = m.group("unit").lower()
|
|
||||||
delta = (
|
|
||||||
sign
|
|
||||||
* {
|
|
||||||
"d": timedelta(days=n),
|
|
||||||
"h": timedelta(hours=n),
|
|
||||||
"m": timedelta(minutes=n),
|
|
||||||
}[unit]
|
|
||||||
)
|
|
||||||
return now + delta
|
|
||||||
|
|
||||||
m = _NOW_SPACED_RE.match(stripped)
|
|
||||||
if m:
|
|
||||||
sign = 1 if m.group("sign") == "+" else -1
|
|
||||||
n = int(m.group("n"))
|
|
||||||
unit = _UNIT_ALIASES[m.group("unit").lower()]
|
|
||||||
delta_map: dict[str, timedelta | relativedelta] = {
|
|
||||||
"second": timedelta(seconds=n),
|
|
||||||
"minute": timedelta(minutes=n),
|
|
||||||
"hour": timedelta(hours=n),
|
|
||||||
"day": timedelta(days=n),
|
|
||||||
"week": timedelta(weeks=n),
|
|
||||||
"month": relativedelta(months=n),
|
|
||||||
"year": relativedelta(years=n),
|
|
||||||
}
|
|
||||||
return now - delta_map[unit] if sign == -1 else now + delta_map[unit]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _bound_datetimes(
|
|
||||||
field: str,
|
|
||||||
token: str,
|
|
||||||
tz: tzinfo,
|
|
||||||
) -> tuple[datetime, datetime] | None:
|
|
||||||
"""
|
|
||||||
Return (floor_dt, ceil_dt) UTC datetimes for a single range bound token, or
|
|
||||||
None if the token is unparsable. ``now`` and relative offsets resolve to the
|
|
||||||
current instant (floor == ceil == that instant; no day-flooring).
|
|
||||||
"""
|
|
||||||
token = token.strip()
|
|
||||||
|
|
||||||
# Try relative/now forms first (before stripping hyphens which would mangle them).
|
|
||||||
rel = _resolve_relative_bound(token)
|
|
||||||
if rel is not None:
|
|
||||||
return rel, rel
|
|
||||||
|
|
||||||
# Full ISO datetime token (contains "T"): parse directly and return an exact
|
|
||||||
# instant (floor == ceil). Python 3.11+ datetime.fromisoformat accepts trailing Z.
|
|
||||||
if "T" in token:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(token)
|
|
||||||
# Ensure timezone-aware UTC result.
|
|
||||||
dt = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)
|
|
||||||
return dt, dt
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
digits = token.replace("-", "")
|
|
||||||
bounds = _precision_bounds(digits)
|
|
||||||
if bounds is None:
|
|
||||||
return None
|
|
||||||
start, end = bounds
|
|
||||||
return _utc_bounds_for_field(field, start, end, tz)
|
|
||||||
|
|
||||||
|
|
||||||
def _render(tok: Token, tz: tzinfo) -> str:
|
|
||||||
"""Render a single token back to a Tantivy query string fragment."""
|
|
||||||
if isinstance(tok, Passthrough):
|
|
||||||
return tok.raw
|
|
||||||
if isinstance(tok, Comma):
|
|
||||||
return " AND "
|
|
||||||
if isinstance(tok, FieldValueList):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
return " AND ".join(f"{field}:{v}" for v in tok.values)
|
|
||||||
if isinstance(tok, FieldValue):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
return translate_scalar(field, tok.value, tz)
|
|
||||||
return f"{field}:{tok.value}"
|
|
||||||
if isinstance(tok, FieldRange):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
return translate_range(field, tok.lo, tok.hi, tz)
|
|
||||||
return f"{field}:{tok.open}{tok.lo} TO {tok.hi}{tok.close}"
|
|
||||||
return "" # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
# Post-render operator normalization patterns: collapse repeated whitespace and
|
|
||||||
# strip spaced/trailing Tantivy boolean operators that would otherwise be invalid.
|
|
||||||
_MULTI_SPACE_RE = regex.compile(r" {2,}")
|
|
||||||
_TRAILING_OP_RE = regex.compile(r"\s+[-+]+\s*$")
|
|
||||||
_SPACED_OP_RE = regex.compile(r"\s+[-+]\s+")
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_operators(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Collapse multiple spaces, strip trailing dangling operators, and replace
|
|
||||||
spaced operators (`` - `` / `` + ``) with a single space.
|
|
||||||
|
|
||||||
Applied only to Passthrough fragments (the rendered output is scanned for
|
|
||||||
operator artifacts outside bracketed ranges) via a post-render pass on the
|
|
||||||
full rendered string. This preserves date ranges (``[... TO ...]``) verbatim
|
|
||||||
while cleaning natural-language separators in the surrounding text.
|
|
||||||
"""
|
|
||||||
text = _MULTI_SPACE_RE.sub(" ", text)
|
|
||||||
text = _TRAILING_OP_RE.sub("", text).strip()
|
|
||||||
text = _SPACED_OP_RE.sub(" ", text).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def translate_query(raw: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a raw Whoosh-style query into Tantivy-compatible syntax."""
|
|
||||||
tokens = resolve_commas(scan(raw))
|
|
||||||
rendered = "".join(_render(t, tz) for t in tokens)
|
|
||||||
return _normalize_operators(rendered)
|
|
||||||
|
|
||||||
|
|
||||||
def translate_range(field: str, lo: str, hi: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a date-field ``[lo TO hi]`` range to a Tantivy ISO range string.
|
|
||||||
|
|
||||||
Handles partial-date bounds (YYYY, YYYYMM, YYYYMMDD, ISO dash variants),
|
|
||||||
open bounds (empty string -> OPEN_LO/OPEN_HI), ``now``, and reversed ranges
|
|
||||||
(swaps tokens before computing floor/ceil so the span is always correct).
|
|
||||||
"""
|
|
||||||
lo_s = lo.strip()
|
|
||||||
hi_s = hi.strip()
|
|
||||||
|
|
||||||
# Parse both bounds to (floor, ceil) pairs when present.
|
|
||||||
lo_pair: tuple[datetime, datetime] | None = None
|
|
||||||
hi_pair: tuple[datetime, datetime] | None = None
|
|
||||||
|
|
||||||
if lo_s:
|
|
||||||
lo_pair = _bound_datetimes(field, lo_s, tz)
|
|
||||||
if lo_pair is None:
|
|
||||||
raise InvalidDateQuery(field, lo_s)
|
|
||||||
if hi_s:
|
|
||||||
hi_pair = _bound_datetimes(field, hi_s, tz)
|
|
||||||
if hi_pair is None:
|
|
||||||
raise InvalidDateQuery(field, hi_s)
|
|
||||||
|
|
||||||
# Detect a reversed range: only swap when BOTH bounds are present.
|
|
||||||
if lo_pair is not None and hi_pair is not None and lo_pair[0] > hi_pair[0]:
|
|
||||||
lo_pair, hi_pair = hi_pair, lo_pair
|
|
||||||
|
|
||||||
lo_iso = _fmt(lo_pair[0]) if lo_pair is not None else OPEN_LO
|
|
||||||
|
|
||||||
# A bound resolves to (floor, ceil) where floor == ceil for an exact instant
|
|
||||||
# (a full ISO datetime, "now", or a "+/-N unit" offset) and floor != ceil for
|
|
||||||
# a coarser period token (year/month/day precision). Only the latter needs a
|
|
||||||
# half-open close: its ceil is the start of the *next* period and must be
|
|
||||||
# excluded, or that instant (e.g. the 1st of next month) wrongly matches.
|
|
||||||
if hi_pair is not None:
|
|
||||||
hi_iso = _fmt(hi_pair[1])
|
|
||||||
hi_close = "]" if hi_pair[0] == hi_pair[1] else "}"
|
|
||||||
else:
|
|
||||||
hi_iso = OPEN_HI
|
|
||||||
hi_close = "]"
|
|
||||||
|
|
||||||
return f"{field}:[{lo_iso} TO {hi_iso}{hi_close}"
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tantivy
|
|
||||||
|
|
||||||
from documents.search._backend import TantivyBackend
|
from documents.search._backend import TantivyBackend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
from documents.search._schema import build_schema
|
|
||||||
from documents.search._tokenizer import register_tokenizers
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
@@ -35,11 +31,3 @@ def backend() -> Generator[TantivyBackend, None, None]:
|
|||||||
finally:
|
finally:
|
||||||
b.close()
|
b.close()
|
||||||
reset_backend()
|
reset_backend()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def index() -> tantivy.Index:
|
|
||||||
"""A real Tantivy index for parse-acceptance tests (module scope for speed)."""
|
|
||||||
idx = tantivy.Index(build_schema(), path=tempfile.mkdtemp())
|
|
||||||
register_tokenizers(idx, "english")
|
|
||||||
return idx
|
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""Result-level acceptance corpus: real documents indexed via build_schema(),
|
||||||
|
real queries run through parse_user_query(), matched-document-ID sets
|
||||||
|
asserted, not intermediate ASTs or query strings. This is paperless-ngx's
|
||||||
|
analogue of whoosh-compat's own tests/emitter/test_acceptance_e2e.py.
|
||||||
|
|
||||||
|
Supersedes test_query.py's TestParseUserQuery result-level cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
FROZEN_NOW = datetime(2026, 6, 15, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
"""Create a Document and index it in one step, for the common case
|
||||||
|
where nothing needs to happen between the two (no related Note/
|
||||||
|
CustomFieldInstance to attach first)."""
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_documents(backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
"""Index a small fixture set, return {label: doc_id} for corpus queries."""
|
||||||
|
docs = {
|
||||||
|
"invoice_2020": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2020",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2020",
|
||||||
|
archive_serial_number=100,
|
||||||
|
),
|
||||||
|
"invoice_2021": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2021",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2021",
|
||||||
|
archive_serial_number=101,
|
||||||
|
),
|
||||||
|
"invoice_2023": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2023",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2023",
|
||||||
|
archive_serial_number=102,
|
||||||
|
),
|
||||||
|
"receipt_2022": _index(
|
||||||
|
backend,
|
||||||
|
title="Receipt 2022",
|
||||||
|
content="receipt total due",
|
||||||
|
checksum="acc-receipt-2022",
|
||||||
|
archive_serial_number=103,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {label: doc.pk for label, doc in docs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue13568BracketWildcard:
|
||||||
|
"""paperless-ngx#13568: title:202[0-3]* must keep its character class,
|
||||||
|
not fold to a prefix query that silently drops it."""
|
||||||
|
|
||||||
|
def test_bracket_class_wildcard_matches_only_in_range_years(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
# [0-1] (not [0-3]) is deliberate: the fixture's four years are
|
||||||
|
# 2020/2021/2022/2023, i.e. their trailing digit is 0/1/2/3
|
||||||
|
# respectively - a [0-3] class would match all four and the test
|
||||||
|
# would pass even if the character class were silently dropped and
|
||||||
|
# folded to an unconstrained "202*" prefix. [0-1] partitions the
|
||||||
|
# fixture into a genuine in-range/out-of-range split.
|
||||||
|
matched = _matched_ids(backend, "title:202[0-1]*")
|
||||||
|
expected = {
|
||||||
|
indexed_documents["invoice_2020"],
|
||||||
|
indexed_documents["invoice_2021"],
|
||||||
|
}
|
||||||
|
assert matched == expected, (
|
||||||
|
"title:202[0-1]* must match 2020/2021 titles and exclude 2022/2023 "
|
||||||
|
"- if this matches everything, the wildcard's character class was "
|
||||||
|
"silently dropped (issue #13568's original bug)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldBoosts:
|
||||||
|
def test_title_boost_ranks_title_match_above_content_only_match(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
title_match = _index(
|
||||||
|
backend,
|
||||||
|
title="urgent",
|
||||||
|
content="nothing else relevant",
|
||||||
|
checksum="acc-boost-title",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="nothing",
|
||||||
|
content="urgent matter here",
|
||||||
|
checksum="acc-boost-content",
|
||||||
|
)
|
||||||
|
query = parse_user_query(backend._index, "urgent", UTC)
|
||||||
|
searcher = backend._index.searcher()
|
||||||
|
results = searcher.search(query, limit=10)
|
||||||
|
ranked_ids = [
|
||||||
|
searcher.doc(addr).to_dict()["id"][0] for _score, addr in results.hits
|
||||||
|
]
|
||||||
|
assert ranked_ids[0] == title_match.pk
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonSubpaths:
|
||||||
|
def test_notes_user_matches_document_with_that_note_author(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
alice = User.objects.create_user(username="alice")
|
||||||
|
doc_with_note = Document.objects.create(
|
||||||
|
title="Has note",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-note-with",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc_with_note, user=alice, note="reminder")
|
||||||
|
backend.add_or_update(doc_with_note)
|
||||||
|
_index(backend, title="No note", content="x", checksum="acc-note-without")
|
||||||
|
matched = _matched_ids(backend, "notes.user:alice")
|
||||||
|
assert matched == {doc_with_note.pk}
|
||||||
|
|
||||||
|
def test_custom_fields_name_and_value_combine(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Contract Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
other_field = CustomField.objects.create(
|
||||||
|
name="Other Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
matching = Document.objects.create(
|
||||||
|
title="Matching",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-cf-matching",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=matching,
|
||||||
|
field=field,
|
||||||
|
value_text="policy",
|
||||||
|
)
|
||||||
|
backend.add_or_update(matching)
|
||||||
|
non_matching = Document.objects.create(
|
||||||
|
title="Non-matching",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-cf-nonmatching",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=non_matching,
|
||||||
|
field=other_field,
|
||||||
|
value_text="policy",
|
||||||
|
)
|
||||||
|
backend.add_or_update(non_matching)
|
||||||
|
matched = _matched_ids(
|
||||||
|
backend,
|
||||||
|
'custom_fields.name:"Contract Number" custom_fields.value:policy',
|
||||||
|
)
|
||||||
|
assert matched == {matching.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnregisteredIdFieldFoldsToLiteralText:
|
||||||
|
"""tag_id, owner_id, etc. are intentionally excluded from the
|
||||||
|
FieldRegistry - always internal index columns, never meant to be
|
||||||
|
query-addressable. Prove an unregistered field folds to a literal
|
||||||
|
text search that matches nothing, rather than erroring."""
|
||||||
|
|
||||||
|
def test_tag_id_query_matches_nothing(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
matched = _matched_ids(backend, "tag_id:5")
|
||||||
|
assert matched == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyBlendSurvivesWhooshGrammar:
|
||||||
|
"""A query mixing whoosh-only grammar (a date keyword) with a typo'd
|
||||||
|
free-text word must still fuzzy-match the intended document when
|
||||||
|
ADVANCED_FUZZY_SEARCH_THRESHOLD is enabled. The fuzzy clause is built
|
||||||
|
from the parsed query's free-text tokens (whoosh_compat's
|
||||||
|
free_text_tokens), never from the raw query string, so whoosh grammar
|
||||||
|
that tantivy's own parser rejects cannot knock the fuzzy clause out."""
|
||||||
|
|
||||||
|
def test_typo_fuzzy_matches_alongside_date_keyword(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings,
|
||||||
|
) -> None:
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
doc = _index(
|
||||||
|
backend,
|
||||||
|
title="Receipt March",
|
||||||
|
content="receipt total due",
|
||||||
|
checksum="fuzzy-blend-1",
|
||||||
|
archive_serial_number=900,
|
||||||
|
)
|
||||||
|
# Sanity: the exact spelling matches through the exact clause.
|
||||||
|
assert doc.pk in _matched_ids(backend, "added:today receipt")
|
||||||
|
# The regression: the misspelling (one transposition) only
|
||||||
|
# matches via the fuzzy clause, and "added:today" is
|
||||||
|
# whoosh-only grammar tantivy's parser rejects, so raw-string
|
||||||
|
# fuzzy parsing skips the clause entirely and this returns
|
||||||
|
# nothing. The typo is deliberate; keep codespell away from it.
|
||||||
|
typo_query = "added:today reciept" # codespell:ignore reciept
|
||||||
|
assert doc.pk in _matched_ids(backend, typo_query)
|
||||||
|
|
||||||
|
def test_negated_words_do_not_fuzzy_match(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings,
|
||||||
|
) -> None:
|
||||||
|
# A term the user excluded must not resurface through the fuzzy
|
||||||
|
# clause. The shape is chosen so this genuinely discriminates: the
|
||||||
|
# indexed document contains the NOT'd word but NOT the positive
|
||||||
|
# word, so nothing matches the exact clause, and a fuzzy string
|
||||||
|
# naively built from ALL words (including the NOT'd one) would
|
||||||
|
# make this document the sole hit, normalize its score to 1.0,
|
||||||
|
# and survive any threshold. (A shape with an exact-matching
|
||||||
|
# sibling document does NOT discriminate: normalization ranks the
|
||||||
|
# resurfaced doc far below the exact match and the threshold cuts
|
||||||
|
# it even for a naive implementation.)
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Receipt Archive",
|
||||||
|
content="receipt archived stack",
|
||||||
|
checksum="fuzzy-blend-2",
|
||||||
|
archive_serial_number=901,
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "added:today total NOT receipt") == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnquotedDateKeywordPhrases:
|
||||||
|
"""The unquoted spelling (added:previous month) is honored natively by
|
||||||
|
whoosh-compat's own grammar for this closed phrase vocabulary, no
|
||||||
|
app-level rewrite is involved. Pins that the historically supported
|
||||||
|
spelling keeps working now that paperless no longer pre-quotes it."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def period_documents(self, backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
in_may = _index(
|
||||||
|
backend,
|
||||||
|
title="May Doc",
|
||||||
|
content="statement",
|
||||||
|
checksum="kw-may",
|
||||||
|
archive_serial_number=910,
|
||||||
|
added=datetime(2026, 5, 20, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
in_june = _index(
|
||||||
|
backend,
|
||||||
|
title="June Doc",
|
||||||
|
content="statement",
|
||||||
|
checksum="kw-june",
|
||||||
|
archive_serial_number=911,
|
||||||
|
added=datetime(2026, 6, 10, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
return {"in_may": in_may.pk, "in_june": in_june.pk}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("added:previous month", id="unquoted"),
|
||||||
|
pytest.param('added:"previous month"', id="quoted"),
|
||||||
|
pytest.param("added:Previous Month", id="unquoted-mixed-case"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unquoted_matches_the_same_documents_as_quoted(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
assert _matched_ids(backend, query) == {period_documents["in_may"]}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("added:this month", id="this-month"),
|
||||||
|
pytest.param("added:this year", id="this-year"),
|
||||||
|
pytest.param("added:previous week", id="previous-week"),
|
||||||
|
pytest.param("added:previous quarter", id="previous-quarter"),
|
||||||
|
pytest.param("added:previous year", id="previous-year"),
|
||||||
|
pytest.param("created:previous month", id="created-field"),
|
||||||
|
pytest.param("modified:previous month", id="modified-field"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_phrase_and_date_field_parses_without_error(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
# The whole vocabulary times every date field must at least parse
|
||||||
|
# and search cleanly (no SearchQueryError -> no HTTP 400); exact
|
||||||
|
# window semantics are whoosh-compat's, pinned in its own suite.
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
_matched_ids(backend, query)
|
||||||
|
|
||||||
|
def test_text_field_keyword_words_are_ordinary_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
# "previous month" after a TEXT field (or unfielded) is ordinary
|
||||||
|
# text, not a date phrase: a title actually containing the words
|
||||||
|
# matches, and the date-window documents do not.
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
wordy = _index(
|
||||||
|
backend,
|
||||||
|
title="Notes from the previous month",
|
||||||
|
content="meeting notes",
|
||||||
|
checksum="kw-text",
|
||||||
|
archive_serial_number=912,
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "title:previous month") == {wordy.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldAliases:
|
||||||
|
"""type:/path: are registry aliases for document_type:/storage_path:.
|
||||||
|
The only other alias coverage is parse-shape; these prove resolution
|
||||||
|
end-to-end against a real index."""
|
||||||
|
|
||||||
|
def test_type_alias_and_canonical_name_match_the_same_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
invoice_type = DocumentType.objects.create(name="invoice")
|
||||||
|
# Discriminating shape: document_type is itself a default search
|
||||||
|
# field, so if alias resolution ever broke and "type:invoice"
|
||||||
|
# demoted to unfielded text, the token would STILL match the typed
|
||||||
|
# document through the field value. The decoy carries the query
|
||||||
|
# word in content, so a demoted search matches BOTH documents and
|
||||||
|
# the exact-set assertions fail. (The title avoids stemming to
|
||||||
|
# "type": english stems Typed -> type.)
|
||||||
|
typed = _index(
|
||||||
|
backend,
|
||||||
|
title="First",
|
||||||
|
content="quarterly statement",
|
||||||
|
checksum="alias-type-1",
|
||||||
|
document_type=invoice_type,
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Second",
|
||||||
|
content="invoice mentioned in body",
|
||||||
|
checksum="alias-type-2",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "type:invoice") == {typed.pk}
|
||||||
|
assert _matched_ids(backend, "document_type:invoice") == {typed.pk}
|
||||||
|
|
||||||
|
def test_path_alias_and_canonical_name_match_the_same_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
archive = StoragePath.objects.create(name="archive", path="archive/{title}")
|
||||||
|
stored = _index(
|
||||||
|
backend,
|
||||||
|
title="Stored",
|
||||||
|
content="quarterly statement",
|
||||||
|
checksum="alias-path-1",
|
||||||
|
storage_path=archive,
|
||||||
|
)
|
||||||
|
# storage_path is NOT a default search field today, so a demoted
|
||||||
|
# "path:archive" already matches nothing; the content decoy keeps
|
||||||
|
# this test discriminating even if it ever joins the defaults.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Loose",
|
||||||
|
content="archive mentioned in body",
|
||||||
|
checksum="alias-path-2",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "path:archive") == {stored.pk}
|
||||||
|
assert _matched_ids(backend, "storage_path:archive") == {stored.pk}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""``checksum`` wildcard patterns stay literal end to end, once user queries
|
||||||
|
route through whoosh-compat.
|
||||||
|
|
||||||
|
The registry-level fact (the pattern normalizer folds a KEYWORD pattern
|
||||||
|
rather than stemming it) is pinned on its own in
|
||||||
|
``test_keyword_pattern_literal.py``. This proves it actually reaches a real
|
||||||
|
query: ``checksum:ceded*`` must match only the document whose checksum
|
||||||
|
starts with "ceded", not the one whose checksum stems to the same run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
CEDEF00D = "cedef00ddeadbeef0123456789abcdef01234567"
|
||||||
|
CEDEDEAD = "cededeadbeef567801234567" + "89abcdef01234567"
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecksumPrefixQueries:
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed(self, backend: TantivyBackend) -> None:
|
||||||
|
for i, checksum in enumerate((CEDEF00D, CEDEDEAD)):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title=f"Checksum doc {i}",
|
||||||
|
content="invoices for the quarter",
|
||||||
|
checksum=checksum,
|
||||||
|
archive_serial_number=940 + i,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
def _ids(self, backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
def test_prefix_matches_only_the_document_that_starts_with_it(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed: None,
|
||||||
|
) -> None:
|
||||||
|
matched = self._ids(backend, "checksum:ceded*")
|
||||||
|
expected = Document.objects.get(checksum=CEDEDEAD).pk
|
||||||
|
assert matched == {expected}
|
||||||
|
|
||||||
|
def test_text_prefix_still_reaches_the_stemmed_index(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed: None,
|
||||||
|
) -> None:
|
||||||
|
assert len(self._ids(backend, "invoice*")) == 2
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""The CJK bigram clause blended into QUERY-mode searches.
|
||||||
|
|
||||||
|
The clause exists so CJK runs are matchable at all (the default analyzers
|
||||||
|
keep a whitespace-free CJK run as one indivisible token), but it must not
|
||||||
|
widen the query beyond what the user asked for: a CJK term the query
|
||||||
|
excludes, or restricts to one field, must not come back through it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestCjkClauseFollowsTheParsedQuery:
|
||||||
|
def test_negated_cjk_term_is_excluded(self, backend: TantivyBackend) -> None:
|
||||||
|
"""'invoice NOT 漢字' must not return the document containing 漢字."""
|
||||||
|
with_cjk = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total 漢字",
|
||||||
|
checksum="cjk-neg-1",
|
||||||
|
)
|
||||||
|
without_cjk = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice B",
|
||||||
|
content="invoice total only",
|
||||||
|
checksum="cjk-neg-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice") == {with_cjk.pk, without_cjk.pk}
|
||||||
|
assert _matched_ids(backend, "invoice NOT 漢字") == {without_cjk.pk}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("threshold", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, {"titled"}, id="fuzzy_off"),
|
||||||
|
pytest.param(0.0, {"titled", "content_only"}, id="fuzzy_on"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fielded_cjk_term_searches_only_that_field(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
threshold: float | None,
|
||||||
|
expected: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""'title:東京' must not match a document whose 東京 is in the content.
|
||||||
|
|
||||||
|
The CJK clause honours the field. The fuzzy clause, when enabled,
|
||||||
|
does not: it contributes every free-text term UNFIELDED by design
|
||||||
|
(see _try_parse_fuzzy_query), so it brings the content-only
|
||||||
|
document back on its own 0.1-boosted terms. That is the documented
|
||||||
|
trade-off, pinned here so it stays deliberate.
|
||||||
|
"""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = threshold
|
||||||
|
content_only = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo report",
|
||||||
|
content="東京都の人口は約1400万人です",
|
||||||
|
checksum="cjk-field-1",
|
||||||
|
)
|
||||||
|
titled = _index(
|
||||||
|
backend,
|
||||||
|
title="東京都の報告書",
|
||||||
|
content="an english summary",
|
||||||
|
checksum="cjk-field-2",
|
||||||
|
)
|
||||||
|
pks = {"titled": titled.pk, "content_only": content_only.pk}
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "東京") == set(pks.values())
|
||||||
|
assert _matched_ids(backend, "title:東京") == {pks[label] for label in expected}
|
||||||
|
|
||||||
|
def test_cjk_on_a_non_default_field_builds_no_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""A CJK term restricted to a field outside the default search fields
|
||||||
|
has nothing to contribute to the bigram clause: 'notes:東京' must not
|
||||||
|
fall back to matching 東京 in the content."""
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo report",
|
||||||
|
content="東京都の人口は約1400万人です",
|
||||||
|
checksum="cjk-notes-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "notes:東京") == set()
|
||||||
|
|
||||||
|
def test_bare_cjk_term_still_matches_every_default_field(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The clause's reason for existing: an unfielded CJK run matches
|
||||||
|
wherever it is indexed, and does so alongside a latin term."""
|
||||||
|
in_content = _index(
|
||||||
|
backend,
|
||||||
|
title="report",
|
||||||
|
content="本文に重要な情報",
|
||||||
|
checksum="cjk-bare-1",
|
||||||
|
)
|
||||||
|
in_title = _index(
|
||||||
|
backend,
|
||||||
|
title="重要な報告書",
|
||||||
|
content="english only",
|
||||||
|
checksum="cjk-bare-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "重要") == {in_content.pk, in_title.pk}
|
||||||
|
assert _matched_ids(backend, "重要 OR report") == {
|
||||||
|
in_content.pk,
|
||||||
|
in_title.pk,
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Pins the correctness gained by deleting the pre-parse
|
||||||
|
_quote_date_keyword_phrases rewrite.
|
||||||
|
|
||||||
|
That rewrite matched date-keyword phrases (e.g. "previous month" after a
|
||||||
|
date field) anywhere in the raw query string, including inside an
|
||||||
|
unrelated quoted string, and inserted quotes mid-phrase there too. Its
|
||||||
|
own docstring gave ``title:"see added:previous month notes"`` as the
|
||||||
|
example of what it corrupted. whoosh-compat's grammar accepts the same
|
||||||
|
phrase vocabulary unquoted natively (see TestUnquotedDateKeywordPhrases
|
||||||
|
in test_acceptance.py), so the rewrite was redundant everywhere it was
|
||||||
|
safe and actively wrong everywhere it was not. This is the one case that
|
||||||
|
tells the two apart: a literal title phrase that happens to contain
|
||||||
|
"added:previous month" as running text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuotedStringContainingDateKeywordText:
|
||||||
|
"""A quoted title phrase containing the literal text
|
||||||
|
"added:previous month" as running words must match on that literal
|
||||||
|
text alone, never spill into an unfielded search for "previous" and
|
||||||
|
"month" across the default search fields the way the deleted rewrite
|
||||||
|
would have decomposed it into."""
|
||||||
|
|
||||||
|
def test_matches_only_the_literal_phrase(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
literal = _index(
|
||||||
|
backend,
|
||||||
|
title="see added:previous month notes",
|
||||||
|
content="quarterly filing",
|
||||||
|
checksum="dkp-literal",
|
||||||
|
archive_serial_number=920,
|
||||||
|
)
|
||||||
|
# Under the deleted rewrite, this decoy would incorrectly match:
|
||||||
|
# its title contains the "see added:" and " notes" fragments the
|
||||||
|
# corrupted parse required as title phrases, and its content
|
||||||
|
# supplies "previous" and "month" as the decomposed word-match
|
||||||
|
# clauses the rewrite turned the middle of the phrase into.
|
||||||
|
decoy = _index(
|
||||||
|
backend,
|
||||||
|
title="see added: quarterly report notes",
|
||||||
|
content="we reviewed the previous statement about month end",
|
||||||
|
checksum="dkp-decoy",
|
||||||
|
archive_serial_number=921,
|
||||||
|
)
|
||||||
|
query = 'title:"see added:previous month notes"'
|
||||||
|
assert _matched_ids(backend, query) == {literal.pk}
|
||||||
|
assert decoy.pk not in _matched_ids(backend, query)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Date keyword phrases (``today``, etc.) resolved in a non-UTC timezone,
|
||||||
|
end to end.
|
||||||
|
|
||||||
|
paperless's own ``tz=get_current_timezone()`` plumbing
|
||||||
|
(``TantivyBackend._parse_query``) is exercised elsewhere only for
|
||||||
|
relative *ranges* (``added:[-1 week to now]``, in
|
||||||
|
documents/tests/test_api_search.py). This covers a date *keyword*
|
||||||
|
(``today``), whose day boundary depends on the active timezone the same
|
||||||
|
way but goes through whoosh-compat's DateParserPlugin resolution instead
|
||||||
|
of an explicit range.
|
||||||
|
|
||||||
|
Discriminating shape: frozen at 2026-06-15T02:00 UTC, which is
|
||||||
|
2026-06-14T22:00 in America/New_York -- still "today" (06-14) there, but
|
||||||
|
already "today" (06-15) in UTC. Two documents pin both directions of the
|
||||||
|
mistake a hardcoded-UTC bug would make:
|
||||||
|
|
||||||
|
- ``in_ny_today`` (added 2026-06-14T20:00 UTC = 2026-06-14T16:00 NY) is
|
||||||
|
inside New York's "today" window and outside a naive UTC-calendar-day
|
||||||
|
window. A ``tz``-ignoring bug would miss it.
|
||||||
|
- ``in_utc_calendar_day_only`` (added 2026-06-15T10:00 UTC =
|
||||||
|
2026-06-15T06:00 NY) is inside a naive UTC-calendar-day window but
|
||||||
|
outside New York's actual "today" window. A ``tz``-ignoring bug would
|
||||||
|
wrongly match it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
FROZEN_NOW = datetime(2026, 6, 15, 2, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateKeywordUsesTheActiveTimezone:
|
||||||
|
def test_today_matches_the_new_york_calendar_day_not_the_utc_one(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.TIME_ZONE = "America/New_York"
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
in_ny_today = _index(
|
||||||
|
backend,
|
||||||
|
title="NY today",
|
||||||
|
content="x",
|
||||||
|
checksum="tz-keyword-ny-today",
|
||||||
|
added=datetime(2026, 6, 14, 20, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
# Not captured: the exact-set assertion below already proves
|
||||||
|
# this document (inside a naive UTC-calendar-day window, but
|
||||||
|
# outside New York's actual "today") does not match.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="UTC calendar day only",
|
||||||
|
content="x",
|
||||||
|
checksum="tz-keyword-utc-calendar-day-only",
|
||||||
|
added=datetime(2026, 6, 15, 10, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "added:today") == {in_ny_today.pk}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""``_DEFAULT_SEARCH_FIELDS`` must stay a subset of the registered public
|
||||||
|
field names.
|
||||||
|
|
||||||
|
Nothing enforced this before: a rename in PUBLIC_FIELDS not mirrored in
|
||||||
|
``_DEFAULT_SEARCH_FIELDS`` (documents/search/_query.py) would 400 every
|
||||||
|
unfielded search at request time, since ``index.parse_query`` and the
|
||||||
|
fuzzy/CJK clause builders are handed a field name the schema no longer
|
||||||
|
has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._query import _DEFAULT_SEARCH_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultSearchFieldsAreRegistered:
|
||||||
|
def test_every_default_search_field_is_a_public_field(self) -> None:
|
||||||
|
public_field_names = {f.name for f in PUBLIC_FIELDS}
|
||||||
|
assert set(_DEFAULT_SEARCH_FIELDS) <= public_field_names
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Diagnostics route by Cause, and user-facing messages are host-owned.
|
||||||
|
|
||||||
|
whoosh-compat documents ``Diagnostic.message`` as developer output with no
|
||||||
|
stability guarantee, so it must never reach an HTTP response body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
from whoosh_compat.errors import cause_for
|
||||||
|
from whoosh_compat.fields import FieldKind
|
||||||
|
from whoosh_compat.fields import FieldRef
|
||||||
|
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._query import _map_emit_error
|
||||||
|
from documents.search._query import _single_diagnostic_to_error
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
_LIBRARY_PROSE = "INTERNAL LIBRARY WORDING WITH raw tantivy detail"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
"""An in-memory, unstemmed index; these tests only parse, never index."""
|
||||||
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic(
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
*,
|
||||||
|
field: FieldRef | None = FieldRef("title"),
|
||||||
|
field_kind: FieldKind | None = FieldKind.TEXT,
|
||||||
|
) -> Diagnostic:
|
||||||
|
"""A Diagnostic shaped like the emitter's, with the library's own
|
||||||
|
kind -> cause mapping rather than a hand-picked cause."""
|
||||||
|
return Diagnostic(
|
||||||
|
kind=kind,
|
||||||
|
cause=cause_for(kind),
|
||||||
|
message=_LIBRARY_PROSE,
|
||||||
|
field=field,
|
||||||
|
field_kind=field_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitErrorRouting:
|
||||||
|
"""Every Cause gets a distinguishable treatment, not just "a 400"."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.BACKEND_REJECTED,
|
||||||
|
DiagnosticKind.AST_INVALID_SHAPE,
|
||||||
|
DiagnosticKind.AST_UNKNOWN_FIELD,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_internal_cause_is_not_converted(self, kind: DiagnosticKind) -> None:
|
||||||
|
"""A library defect must surface as a 500 monitoring can see, not a
|
||||||
|
400 blaming the user."""
|
||||||
|
error = QueryError(_diagnostic(kind))
|
||||||
|
with pytest.raises(QueryError) as excinfo:
|
||||||
|
_map_emit_error(error)
|
||||||
|
assert excinfo.value is error
|
||||||
|
|
||||||
|
def test_misconfigured_cause_is_logged_and_becomes_a_400(
|
||||||
|
self,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
kind = DiagnosticKind.SCHEMA_FIELD_MISSING
|
||||||
|
with caplog.at_level(logging.ERROR, logger="paperless.search"):
|
||||||
|
error = _map_emit_error(
|
||||||
|
QueryError(_diagnostic(kind, field=FieldRef("asn"))),
|
||||||
|
)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
errors = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "asn" in errors[0].getMessage()
|
||||||
|
assert kind.name in errors[0].getMessage()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unsupported_cause_is_a_400_with_no_operator_log(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""A query tantivy cannot run is the user's to fix; it must not page
|
||||||
|
an operator the way a registry/schema mismatch does.
|
||||||
|
|
||||||
|
EXISTS_REQUIRES_FAST is nominally MISCONFIGURED but belongs here: it
|
||||||
|
is decided from the registry's own FieldSpec, so it never reports a
|
||||||
|
disagreement anyone could resolve."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
||||||
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert caplog.records == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_facing_message_never_echoes_library_prose(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
) -> None:
|
||||||
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
||||||
|
assert _LIBRARY_PROSE not in str(error)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_facing_message_names_the_field(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
) -> None:
|
||||||
|
"""FieldRef.__str__ yields the canonical dotted name, including a
|
||||||
|
JSON subpath, so every user-reachable emit kind can name it."""
|
||||||
|
diagnostic = _diagnostic(
|
||||||
|
kind,
|
||||||
|
field=FieldRef("custom_fields", "value"),
|
||||||
|
field_kind=FieldKind.JSON,
|
||||||
|
)
|
||||||
|
error = _map_emit_error(QueryError(diagnostic))
|
||||||
|
assert "custom_fields.value" in str(error)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDiagnosticMessages:
|
||||||
|
"""Parse-time diagnostics are host-worded too, off field_kind."""
|
||||||
|
|
||||||
|
def test_too_deep_is_a_400_without_library_prose(self) -> None:
|
||||||
|
error = _single_diagnostic_to_error(
|
||||||
|
_diagnostic(DiagnosticKind.TOO_DEEP, field=None, field_kind=None),
|
||||||
|
)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert _LIBRARY_PROSE not in str(error)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "field_kind"),
|
||||||
|
[
|
||||||
|
(DiagnosticKind.PATTERN_ON_NUMERIC, FieldKind.U64),
|
||||||
|
(DiagnosticKind.PATTERN_ON_BOOLEAN_EXISTS, FieldKind.BOOLEAN_EXISTS),
|
||||||
|
(DiagnosticKind.PATTERN_ON_SUBPATH, FieldKind.JSON),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pattern_on_kinds_name_the_field_and_its_kind(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
field_kind: FieldKind,
|
||||||
|
) -> None:
|
||||||
|
error = _single_diagnostic_to_error(
|
||||||
|
_diagnostic(kind, field=FieldRef("asn"), field_kind=field_kind),
|
||||||
|
)
|
||||||
|
message = str(error)
|
||||||
|
assert _LIBRARY_PROSE not in message
|
||||||
|
assert "asn" in message
|
||||||
|
assert field_kind.name.lower() in message
|
||||||
|
|
||||||
|
def test_single_char_bracket_range_names_the_field_and_the_value(self) -> None:
|
||||||
|
diagnostic = Diagnostic(
|
||||||
|
kind=DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE,
|
||||||
|
cause=cause_for(DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE),
|
||||||
|
message=_LIBRARY_PROSE,
|
||||||
|
field=FieldRef("title"),
|
||||||
|
field_kind=FieldKind.TEXT,
|
||||||
|
raw_value="200[1-9]",
|
||||||
|
)
|
||||||
|
error = _single_diagnostic_to_error(diagnostic)
|
||||||
|
message = str(error)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert _LIBRARY_PROSE not in message
|
||||||
|
assert "title" in message
|
||||||
|
assert "200[1-9]" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealQueriesRouteCorrectly:
|
||||||
|
"""The routing table against diagnostics emit() really produces."""
|
||||||
|
|
||||||
|
def test_text_range_is_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "title:[a to b]", UTC)
|
||||||
|
assert "title" in str(excinfo.value)
|
||||||
|
|
||||||
|
def test_wildcard_on_a_numeric_field_is_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "asn:12*", UTC)
|
||||||
|
assert "asn" in str(excinfo.value)
|
||||||
|
|
||||||
|
def test_single_char_bracket_range_is_a_400_naming_field_and_value(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "title:200[1-9]", UTC)
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "title" in message
|
||||||
|
assert "200[1-9]" in message
|
||||||
|
|
||||||
|
def test_internal_diagnostic_escapes_as_a_query_error(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The one case with no query text that reaches it: emit() reporting
|
||||||
|
a defect in itself must not be converted to a user-facing 400."""
|
||||||
|
import documents.search._query as query_mod
|
||||||
|
|
||||||
|
def raise_internal(*args: object, **kwargs: object) -> None:
|
||||||
|
raise QueryError(_diagnostic(DiagnosticKind.BACKEND_REJECTED))
|
||||||
|
|
||||||
|
monkeypatch.setattr(query_mod, "tantivy_emit", raise_internal)
|
||||||
|
with pytest.raises(QueryError):
|
||||||
|
parse_user_query(query_index, "invoice", UTC)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""``field:*`` on a JSON field is user error, not an operator alert.
|
||||||
|
|
||||||
|
whoosh-compat classifies EXISTS_REQUIRES_FAST as MISCONFIGURED, and
|
||||||
|
_map_emit_error used to route every MISCONFIGURED diagnostic to an ERROR log.
|
||||||
|
But the kind is decided from the registry's own FieldSpec (kind plus fast)
|
||||||
|
without consulting the index schema, and field_descriptors() builds the JSON
|
||||||
|
fields non-fast deliberately, so nothing is misconfigured and no operator
|
||||||
|
action can clear the condition. Any authenticated user could otherwise emit
|
||||||
|
ERROR lines in a loop by repeating ``notes:*``.
|
||||||
|
|
||||||
|
SCHEMA_FIELD_MISSING, the other MISCONFIGURED kind, does compare the registry
|
||||||
|
against the live schema, so it stays an ERROR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
from whoosh_compat.errors import cause_for
|
||||||
|
from whoosh_compat.fields import FieldKind
|
||||||
|
from whoosh_compat.fields import FieldRef
|
||||||
|
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._query import _map_emit_error
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
# Every spelling of "does this JSON field have a value" a user can type.
|
||||||
|
EXISTS_QUERIES = [
|
||||||
|
"notes:*",
|
||||||
|
"notes.note:*",
|
||||||
|
"notes.user:*",
|
||||||
|
"custom_fields:*",
|
||||||
|
"custom_fields.name:*",
|
||||||
|
"custom_fields.value:*",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonExistsIsUserError:
|
||||||
|
@pytest.mark.parametrize("query", EXISTS_QUERIES)
|
||||||
|
def test_query_is_a_400_that_emits_no_error_log(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, query, UTC)
|
||||||
|
assert query.split(":", maxsplit=1)[0] in str(excinfo.value)
|
||||||
|
assert [r for r in caplog.records if r.levelno >= logging.ERROR] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenuineMisconfigurationStillLogs:
|
||||||
|
def test_schema_field_missing_is_an_error_log(
|
||||||
|
self,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""The registry naming a field the index schema does not have is a
|
||||||
|
real mismatch an operator can fix, so it keeps the alert."""
|
||||||
|
kind = DiagnosticKind.SCHEMA_FIELD_MISSING
|
||||||
|
error = QueryError(
|
||||||
|
Diagnostic(
|
||||||
|
kind=kind,
|
||||||
|
cause=cause_for(kind),
|
||||||
|
message="field 'asn' is not defined in the index schema",
|
||||||
|
field=FieldRef("asn"),
|
||||||
|
field_kind=FieldKind.U64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="paperless.search"):
|
||||||
|
mapped = _map_emit_error(error)
|
||||||
|
assert isinstance(mapped, SearchQueryError)
|
||||||
|
records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert len(records) == 1
|
||||||
|
assert kind.name in records[0].getMessage()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicFields:
|
||||||
|
def test_json_fields_have_subpaths(self) -> None:
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
if field.kind is FieldKind.JSON:
|
||||||
|
assert field.subpaths, f"{field.name} is JSON but has no subpaths"
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""The words the fuzzy blend clause hands back to tantivy's parser.
|
||||||
|
|
||||||
|
The clause re-parses a word string through tantivy, which analyzes it
|
||||||
|
again, so the words must be the query's raw text rather than the analyzed
|
||||||
|
text (analysis is not idempotent), and must still be split into plain
|
||||||
|
words so that hyphenated, dotted and quoted terms keep contributing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fuzzy_enabled(settings: SettingsWrapper) -> None:
|
||||||
|
"""Enable the fuzzy blend clause. The threshold doubles as a minimum
|
||||||
|
score filter, so it is set to 0.0: every hit passes and the test sees
|
||||||
|
the clause's matching behaviour, not the filter's."""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyClauseWords:
|
||||||
|
def test_a_stemmed_word_is_not_stemmed_a_second_time(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'universities' stems to 'univers'; feeding that back to tantivy
|
||||||
|
stems it again to 'univ', whose fuzzy prefix reaches unrelated
|
||||||
|
words. The clause must stay wide enough for a typo and no wider."""
|
||||||
|
wanted = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="universities of europe",
|
||||||
|
checksum="fuzz-stem-1",
|
||||||
|
)
|
||||||
|
typo = _index(
|
||||||
|
backend,
|
||||||
|
title="B",
|
||||||
|
content="universties of europe",
|
||||||
|
checksum="fuzz-stem-2",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="C",
|
||||||
|
content="univalent chemical bonds",
|
||||||
|
checksum="fuzz-stem-3",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="D",
|
||||||
|
content="unicycle repair manual",
|
||||||
|
checksum="fuzz-stem-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "universities") == {wanted.pk, typo.pk}
|
||||||
|
|
||||||
|
def test_a_hyphenated_term_still_reaches_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'COVID-19' is one raw token: unless it is split into words, it
|
||||||
|
carries characters the re-parse would read as grammar, is dropped,
|
||||||
|
and the whole query loses its fuzzy clause."""
|
||||||
|
misspelled = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="covidx testing results",
|
||||||
|
checksum="fuzz-hyphen-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "COVID-19") == {misspelled.pk}
|
||||||
|
|
||||||
|
def test_a_phrase_still_reaches_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""A phrase is one raw token carrying a space, and is the whole
|
||||||
|
query's only free text here."""
|
||||||
|
near_miss = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="taxation reportage weekly",
|
||||||
|
checksum="fuzz-phrase-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, '"tax reports"') == {near_miss.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBooleanKeywordsInRawText:
|
||||||
|
"""Tantivy's boolean keywords are word runs, so they survive the cut
|
||||||
|
into words and its own parser reads them as grammar. Raw query text
|
||||||
|
reaches that parser with its case intact, so a quoted phrase can carry
|
||||||
|
them in."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def corpus(self, backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
both = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="taxation reportage weekly",
|
||||||
|
checksum="fuzz-kw-1",
|
||||||
|
)
|
||||||
|
tax_only = _index(
|
||||||
|
backend,
|
||||||
|
title="B",
|
||||||
|
content="taxation only here",
|
||||||
|
checksum="fuzz-kw-2",
|
||||||
|
)
|
||||||
|
report_only = _index(
|
||||||
|
backend,
|
||||||
|
title="C",
|
||||||
|
content="reportage only here",
|
||||||
|
checksum="fuzz-kw-3",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"both": both.pk,
|
||||||
|
"tax_only": tax_only.pk,
|
||||||
|
"report_only": report_only.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param('"tax AND reports"', id="and"),
|
||||||
|
pytest.param('"tax OR reports"', id="or"),
|
||||||
|
pytest.param('"tax NOT reports"', id="not"),
|
||||||
|
pytest.param('"tax IN reports"', id="in"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_keyword_inside_a_phrase_stays_an_ordinary_word(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
corpus: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
"""The phrase asks for three words, so the clause must stay the
|
||||||
|
disjunction it is for '"tax reports"': AND must not turn it into a
|
||||||
|
conjunction, NOT must not give it its own exclusion, IN must not
|
||||||
|
fail the parse."""
|
||||||
|
assert _matched_ids(backend, '"tax reports"') == set(corpus.values())
|
||||||
|
assert _matched_ids(backend, query) == set(corpus.values())
|
||||||
|
|
||||||
|
def test_a_trailing_keyword_does_not_drop_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
corpus: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""'tax AND' is a syntax error to tantivy's parser, which would
|
||||||
|
cost the whole query its fuzzy clause."""
|
||||||
|
assert _matched_ids(backend, '"tax AND"') == {
|
||||||
|
corpus["both"],
|
||||||
|
corpus["tax_only"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Regression coverage for the unguarded TEXT-mode highlight query.
|
||||||
|
|
||||||
|
parse_simple_text_highlight_query re-parses simple-search tokens through
|
||||||
|
Tantivy's query-string parser to build a SnippetGenerator-compatible query.
|
||||||
|
Simple-search tokens keep arbitrary punctuation (quotes, colons, brackets,
|
||||||
|
slashes), so any token carrying Tantivy query grammar raised an unguarded
|
||||||
|
ValueError. The search itself had already succeeded by the time this ran:
|
||||||
|
only the highlight step failed, and with the DocumentViewSet.list
|
||||||
|
exception handler narrowed elsewhere on this branch, that ValueError now
|
||||||
|
reaches the client as a bare 500 rather than a 400.
|
||||||
|
|
||||||
|
Covers three angles:
|
||||||
|
- the query builder itself: quoting each token as its own escaped phrase
|
||||||
|
should let it parse instead of raising, for every failure mode a plain-
|
||||||
|
text query can trigger (syntax error, unknown field, unsupported regex).
|
||||||
|
- highlight_hits: even when a token still can't be expressed as a
|
||||||
|
highlight query, the guard must fall back to a query that still
|
||||||
|
produces usable highlight HTML, not silently empty ones.
|
||||||
|
- the real API endpoint: pinning the previously-500 status to 200.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.search._backend import SearchMode
|
||||||
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
# Each spelling below trips a different Tantivy parser failure mode:
|
||||||
|
# 'a"b' -> Syntax Error (unterminated quote)
|
||||||
|
# foo:bar -> unknown field
|
||||||
|
# (a -> Syntax Error (unbalanced group)
|
||||||
|
# [a -> Syntax Error (unbalanced range)
|
||||||
|
# /a/ -> Unsupported query (regex queries disallowed)
|
||||||
|
_MALFORMED_QUERIES = [
|
||||||
|
pytest.param('a"b', id="unterminated_quote"),
|
||||||
|
pytest.param("foo:bar", id="unknown_field"),
|
||||||
|
pytest.param("(a", id="unbalanced_group"),
|
||||||
|
pytest.param("[a", id="unbalanced_range"),
|
||||||
|
pytest.param("/a/", id="unsupported_regex"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
"""An in-memory, unstemmed index for parse-only tests."""
|
||||||
|
schema = build_schema()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSimpleTextHighlightQueryDoesNotRaise:
|
||||||
|
"""The query builder itself must tolerate Tantivy syntax in its tokens."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_query", _MALFORMED_QUERIES)
|
||||||
|
def test_malformed_token_does_not_raise(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
assert isinstance(
|
||||||
|
parse_simple_text_highlight_query(query_index, raw_query),
|
||||||
|
tantivy.Query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightHitsProducesUsableHighlights:
|
||||||
|
"""highlight_hits must keep producing real <b>-wrapped snippet HTML for
|
||||||
|
these queries, not merely avoid raising."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_query",
|
||||||
|
[*_MALFORMED_QUERIES, pytest.param("plain text", id="plain_text_sanity")],
|
||||||
|
)
|
||||||
|
def test_highlight_still_contains_matched_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content=f"needle content containing {raw_query} literally here",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
hits = backend.highlight_hits(
|
||||||
|
raw_query,
|
||||||
|
[doc.pk],
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(hits) == 1
|
||||||
|
highlights = hits[0]["highlights"]
|
||||||
|
assert "content" in highlights, (
|
||||||
|
f"Expected a content highlight for {raw_query!r}, got: {highlights!r}"
|
||||||
|
)
|
||||||
|
assert "<b>" in highlights["content"], (
|
||||||
|
f"Highlight for {raw_query!r} carries no matched-term markup: "
|
||||||
|
f"{highlights['content']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightGuardDiscriminatesOnValueError:
|
||||||
|
"""The guard added to highlight_hits must catch exactly ValueError, the
|
||||||
|
same shape as the sibling notes_text guard, and let anything else
|
||||||
|
through -- so a real library defect is never mistaken for a harmless
|
||||||
|
syntax error."""
|
||||||
|
|
||||||
|
def test_non_value_error_is_not_swallowed(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_runtime_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise RuntimeError("synthetic bug, unrelated to query syntax")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_simple_text_highlight_query",
|
||||||
|
raise_runtime_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="probe", content="anything here")
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
backend.highlight_hits(
|
||||||
|
"anything",
|
||||||
|
[doc.pk],
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_search_index")
|
||||||
|
class TestApiNoLongerReturns500:
|
||||||
|
"""Pins the actual regression: a matching TEXT-mode search whose query
|
||||||
|
string carries Tantivy syntax must return results, not a server error."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_query", _MALFORMED_QUERIES)
|
||||||
|
def test_malformed_text_query_returns_200(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content=f"needle content containing {raw_query} literally here",
|
||||||
|
)
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
|
||||||
|
response = admin_client.get(f"/api/documents/?text={raw_query}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
def test_plain_text_query_still_returns_200(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""Sanity check: the guard must not mask a total failure of the
|
||||||
|
ordinary highlight path."""
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content="needle content containing plain text literally here",
|
||||||
|
)
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/?text=plain text")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Bare notes:/custom_fields: prefix resolution.
|
||||||
|
|
||||||
|
"notes:foo"/"custom_fields:foo" were valid fielded searches before the
|
||||||
|
whoosh-compat migration. The registry only exposes them as JSON subpaths, so
|
||||||
|
each JSON FieldSpec declares a default subpath (SubpathSpec(default=True)):
|
||||||
|
notes: resolves to notes.note:, custom_fields: resolves to
|
||||||
|
custom_fields.value:. This replaced an earlier regex-based rewrite
|
||||||
|
(_rewrite_bare_json_field_prefixes) that ran on the raw query string before
|
||||||
|
parsing and was blind to quoting, so a phrase like
|
||||||
|
content:"payment notes: none" was silently corrupted into a notes-field
|
||||||
|
search and matched nothing. Resolving the default subpath inside the parser
|
||||||
|
instead means quoting is already understood by the time it happens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Note
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestBareJsonFieldPrefixes:
|
||||||
|
def test_bare_notes_prefix_searches_note_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
alice = User.objects.create_user(username="alice")
|
||||||
|
with_note = Document.objects.create(
|
||||||
|
title="Has note",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-notes-with",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=with_note, user=alice, note="crocodile")
|
||||||
|
backend.add_or_update(with_note)
|
||||||
|
# This document's CONTENT contains the words a demoted text search
|
||||||
|
# would match; it must NOT match once the prefix addresses notes.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Notes about things",
|
||||||
|
content="notes crocodile mention",
|
||||||
|
checksum="bare-notes-decoy",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "notes:crocodile") == {with_note.pk}
|
||||||
|
|
||||||
|
def test_bare_custom_fields_prefix_searches_values(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Policy Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
with_value = Document.objects.create(
|
||||||
|
title="Has field",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-cf-with",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=with_value,
|
||||||
|
field=field,
|
||||||
|
value_text="crocodile",
|
||||||
|
)
|
||||||
|
backend.add_or_update(with_value)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Custom things",
|
||||||
|
content="custom fields crocodile",
|
||||||
|
checksum="bare-cf-decoy",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "custom_fields:crocodile") == {with_value.pk}
|
||||||
|
|
||||||
|
def test_subpath_spellings_are_untouched(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
bob = User.objects.create_user(username="bob")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Bob note",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-subpath",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc, user=bob, note="remark")
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
assert _matched_ids(backend, "notes.user:bob") == {doc.pk}
|
||||||
|
assert _matched_ids(backend, "notes.note:remark") == {doc.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuotedPhraseContainingNotesColonIsNotCorrupted:
|
||||||
|
"""The regex rewrite this migration removes was blind to quoting: it
|
||||||
|
matched "notes:" anywhere in the raw query string, including inside an
|
||||||
|
already-quoted phrase on an unrelated field, silently turning
|
||||||
|
content:"payment notes: none" into a notes-field search that matched
|
||||||
|
nothing. Resolving the default subpath during parsing (which is
|
||||||
|
quote-aware) fixes this."""
|
||||||
|
|
||||||
|
def test_quoted_phrase_with_notes_colon_matches_by_content(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
target = _index(
|
||||||
|
backend,
|
||||||
|
title="Statement",
|
||||||
|
content="payment notes: none",
|
||||||
|
checksum="quoted-phrase-notes-colon",
|
||||||
|
)
|
||||||
|
assert _matched_ids(
|
||||||
|
backend,
|
||||||
|
'content:"payment notes: none"',
|
||||||
|
) == {target.pk}
|
||||||
|
|
||||||
|
def test_quoted_phrase_matches_the_same_document_unquoted(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
# Same document, phrasing without the colon: this proves the fix is
|
||||||
|
# about quote-awareness, not about the words themselves being
|
||||||
|
# unsearchable.
|
||||||
|
target = _index(
|
||||||
|
backend,
|
||||||
|
title="Statement",
|
||||||
|
content="payment notes none",
|
||||||
|
checksum="quoted-phrase-no-colon",
|
||||||
|
)
|
||||||
|
assert _matched_ids(
|
||||||
|
backend,
|
||||||
|
'content:"payment notes none"',
|
||||||
|
) == {target.pk}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Every declared JSON subpath must actually be written to the index.
|
||||||
|
|
||||||
|
PUBLIC_FIELDS declares each JSON field's subpaths (e.g. ``notes`` ->
|
||||||
|
{"user", "note"}), but nothing coupled that declaration to what
|
||||||
|
``_backend.py``'s document builder actually writes into the JSON blob at
|
||||||
|
index time. A subpath declared but never written would be
|
||||||
|
queryable-but-always-empty -- syntactically valid, silently matching
|
||||||
|
nothing -- with no test failure anywhere.
|
||||||
|
|
||||||
|
This indexes one real document carrying values for every JSON field
|
||||||
|
(a Note, a CustomFieldInstance) and inspects the document's own stored
|
||||||
|
JSON payload, rather than running field-specific queries: that way a
|
||||||
|
future JSON field's subpaths are covered automatically, without a new
|
||||||
|
per-subpath query having to be added by hand each time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonSubpathsAreWrittenAtIndexTime:
|
||||||
|
def test_every_declared_json_subpath_appears_in_the_stored_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
user = User.objects.create_user(username="completeness-user")
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Completeness Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Completeness doc",
|
||||||
|
content="x",
|
||||||
|
checksum="json-subpath-completeness",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc, user=user, note="a note")
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=field,
|
||||||
|
value_text="a value",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
index = backend._index
|
||||||
|
searcher = index.searcher()
|
||||||
|
hits = searcher.search(
|
||||||
|
tantivy.Query.term_query(index.schema, "id", doc.pk),
|
||||||
|
limit=1,
|
||||||
|
).hits
|
||||||
|
assert hits, "the document was not indexed"
|
||||||
|
stored = searcher.doc(hits[0][1]).to_dict()
|
||||||
|
|
||||||
|
json_fields = [f for f in PUBLIC_FIELDS if f.kind is FieldKind.JSON]
|
||||||
|
assert json_fields, "no JSON fields declared - fixture is stale"
|
||||||
|
for field_spec in json_fields:
|
||||||
|
stored_values = stored.get(field_spec.name)
|
||||||
|
assert stored_values, (
|
||||||
|
f"{field_spec.name} was not written to the index at all"
|
||||||
|
)
|
||||||
|
written_keys = stored_values[0].keys()
|
||||||
|
for subpath in field_spec.subpaths:
|
||||||
|
assert subpath in written_keys, (
|
||||||
|
f"{field_spec.name}.{subpath} is declared in PUBLIC_FIELDS "
|
||||||
|
"but _backend.py's document builder never writes it - it "
|
||||||
|
"would be queryable but always empty"
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Wildcard patterns on KEYWORD fields must stay literal.
|
||||||
|
|
||||||
|
``checksum`` is the only KEYWORD field: it is indexed with the raw tokenizer,
|
||||||
|
so its terms are never lowercased, folded or stemmed. Running its wildcard
|
||||||
|
patterns through the stemming normalizer rewrote hex prefixes ("ceded" ->
|
||||||
|
"cede") and returned documents whose checksum did not start with what the user
|
||||||
|
typed, which for an identity field is a wrong answer.
|
||||||
|
|
||||||
|
This covers only the registry-level normalizer, which is all that exists to
|
||||||
|
prove at this point in the stack: user queries are not yet routed through
|
||||||
|
whoosh-compat (that lands with the query-layer PR), so the same fact proven
|
||||||
|
end to end against real indexed documents lives in
|
||||||
|
``test_checksum_prefix_queries.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
from whoosh_compat import PatternNormalizer
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizer(registry: FieldRegistry, name: str) -> PatternNormalizer:
|
||||||
|
ref = registry.make_ref(name)
|
||||||
|
assert ref is not None
|
||||||
|
resolved = registry.resolve(ref)
|
||||||
|
assert resolved is not None
|
||||||
|
assert resolved.spec.pattern_normalizer is not None
|
||||||
|
return resolved.spec.pattern_normalizer
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeywordPatternNormalizer:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"run",
|
||||||
|
[
|
||||||
|
pytest.param("ceded", id="stems_to_cede"),
|
||||||
|
pytest.param("added", id="stems_to_ad"),
|
||||||
|
pytest.param("cafed", id="stems_to_cafe"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_keyword_runs_are_folded_not_stemmed(self, run: str) -> None:
|
||||||
|
"""One form, the run as typed: a KEYWORD pattern must never be widened
|
||||||
|
to a stem, which would return checksums that do not start with what
|
||||||
|
the user typed."""
|
||||||
|
normalize = _normalizer(get_field_registry("en"), "checksum")
|
||||||
|
assert normalize(run) == run
|
||||||
|
|
||||||
|
def test_text_runs_still_offer_their_stem(self) -> None:
|
||||||
|
"""A TEXT field offers the stem alongside the typed run, so a term
|
||||||
|
matching either one is reachable."""
|
||||||
|
normalize = _normalizer(get_field_registry("en"), "title")
|
||||||
|
assert tuple(normalize("Running")) == ("running", "run")
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Permission filtering must hold against the real indexed document shape.
|
||||||
|
|
||||||
|
Only three of the index's unsigned ``*_id`` columns are load-bearing:
|
||||||
|
``owner_id``, ``viewer_id`` and ``viewer_group_id``, all read by
|
||||||
|
build_permission_filter. The rest (correspondent/document_type/storage_path/tag
|
||||||
|
ids) were written on every document and read by nothing, and were dropped.
|
||||||
|
|
||||||
|
These tests index real Documents through the backend's own document builder and
|
||||||
|
assert result-level visibility per user, so a mistake about which columns are
|
||||||
|
load-bearing shows up as documents leaking across users rather than as a passing
|
||||||
|
unit test over a hand-built index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owner() -> User:
|
||||||
|
return User.objects.create_user(username="owner")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stranger() -> User:
|
||||||
|
return User.objects.create_user(username="stranger")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def viewer() -> User:
|
||||||
|
return User.objects.create_user(username="viewer")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def group_member() -> User:
|
||||||
|
user = User.objects.create_user(username="group_member")
|
||||||
|
user.groups.add(Group.objects.create(name="accounting"))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionFilteringOnIndexedDocuments:
|
||||||
|
def test_unowned_document_is_visible_to_everyone(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Public Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-unowned",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == [doc.pk]
|
||||||
|
|
||||||
|
def test_owned_document_is_visible_only_to_its_owner(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Private Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-owned",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=owner) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_explicitly_shared_document_is_visible_to_the_viewer(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
viewer: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Shared Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-shared-user",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
assign_perm("view_document", viewer, doc)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=viewer) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_group_shared_document_is_visible_to_group_members(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
group_member: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Group Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-shared-group",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
assign_perm("view_document", group_member.groups.first(), doc)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=group_member) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_metadata_does_not_widen_visibility(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
"""A document carrying correspondent/type/storage-path/tag metadata is
|
||||||
|
still filtered by owner alone."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Tagged Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-metadata",
|
||||||
|
owner=owner,
|
||||||
|
correspondent=Correspondent.objects.create(name="ACME"),
|
||||||
|
document_type=DocumentType.objects.create(name="Bill"),
|
||||||
|
storage_path=StoragePath.objects.create(name="Archive", path="archive/"),
|
||||||
|
)
|
||||||
|
doc.tags.add(Tag.objects.create(name="paid"))
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=owner) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
@@ -1,448 +1,96 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import tzinfo
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tantivy
|
import tantivy
|
||||||
import time_machine
|
import time_machine
|
||||||
|
|
||||||
from documents.search._dates import _date_only_range
|
from documents.search._backend import build_permission_filter
|
||||||
from documents.search._dates import _datetime_range
|
from documents.search._errors import InvalidDateQuery
|
||||||
from documents.search._query import build_permission_filter
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
from documents.search._query import parse_simple_text_highlight_query
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
from documents.search._query import parse_user_query
|
from documents.search._query import parse_user_query
|
||||||
from documents.search._schema import build_schema
|
from documents.search._schema import build_schema
|
||||||
from documents.search._tokenizer import register_tokenizers
|
from documents.search._tokenizer import register_tokenizers
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York") # UTC-5 / UTC-4 (DST)
|
|
||||||
AUCKLAND = ZoneInfo("Pacific/Auckland") # UTC+13 in southern-hemisphere summer
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
def _range(result: str, field: str) -> tuple[str, str]:
|
def query_index() -> tantivy.Index:
|
||||||
# Half-open period ranges close with "}" (exclusive); exact-instant ranges
|
"""An in-memory, unstemmed index shared read-only across this module's
|
||||||
# (full ISO datetimes, "now", relative offsets) close with "]" (inclusive).
|
parse-only tests (none of them index documents)."""
|
||||||
m = re.search(rf"{field}:\[(.+?) TO (.+?)[\]}}]", result)
|
|
||||||
assert m, f"No range for {field!r} in: {result!r}"
|
|
||||||
return m.group(1), m.group(2)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreatedDateField:
|
|
||||||
"""
|
|
||||||
created is a Django DateField: indexed as midnight UTC of the local calendar
|
|
||||||
date. No offset arithmetic needed - the local calendar date is what matters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("tz", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(UTC, "2026-03-28T00:00:00Z", "2026-03-29T00:00:00Z", id="utc"),
|
|
||||||
pytest.param(
|
|
||||||
EASTERN,
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
"2026-03-29T00:00:00Z",
|
|
||||||
id="eastern_same_calendar_date",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
|
||||||
def test_today(self, tz: tzinfo, expected_lo: str, expected_hi: str) -> None:
|
|
||||||
lo, hi = _range(translate_query("created:today", tz), "created")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 3, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_today_auckland_ahead_of_utc(self) -> None:
|
|
||||||
# UTC 03:00 -> Auckland (UTC+13) = 16:00 same date; local date = 2026-03-28
|
|
||||||
lo, _ = _range(
|
|
||||||
translate_query("created:today", AUCKLAND),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "keyword", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"yesterday",
|
|
||||||
"2026-03-27T00:00:00Z",
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous week",
|
|
||||||
"2026-03-16T00:00:00Z",
|
|
||||||
"2026-03-23T00:00:00Z",
|
|
||||||
id="previous_week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"this month",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
id="this_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous month",
|
|
||||||
"2026-02-01T00:00:00Z",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
id="previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"this year",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
"2027-01-01T00:00:00Z",
|
|
||||||
id="this_year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous year",
|
|
||||||
"2025-01-01T00:00:00Z",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
id="previous_year",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_date_keywords(
|
|
||||||
self,
|
|
||||||
field: str,
|
|
||||||
keyword: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
# 2026-03-28 is Saturday; Mon-Sun week calculation built into expectations
|
|
||||||
query = f"{field}:{keyword}"
|
|
||||||
lo, hi = _range(translate_query(query, UTC), field)
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_this_month_december_wraps_to_next_year(self) -> None:
|
|
||||||
# December: next month must roll over to January 1 of next year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:this month", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-12-01T00:00:00Z"
|
|
||||||
assert hi == "2027-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
|
||||||
# January: last month must roll back to December 1 of previous year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:previous month", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2025-12-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 7, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_previous_quarter(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query('created:"previous quarter"', UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-04-01T00:00:00Z"
|
|
||||||
assert hi == "2026-07-01T00:00:00Z"
|
|
||||||
|
|
||||||
def test_unknown_keyword_raises(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="Unknown keyword"):
|
|
||||||
_date_only_range("bogus_keyword", UTC)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDateTimeFields:
|
|
||||||
"""
|
|
||||||
added/modified store full UTC datetimes. Natural keywords must convert
|
|
||||||
the local day boundaries to UTC - timezone offset arithmetic IS required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
|
||||||
def test_added_today_eastern(self) -> None:
|
|
||||||
# EDT = UTC-4; local midnight 2026-03-28 00:00 EDT = 2026-03-28 04:00 UTC
|
|
||||||
lo, hi = _range(translate_query("added:today", EASTERN), "added")
|
|
||||||
assert lo == "2026-03-28T04:00:00Z"
|
|
||||||
assert hi == "2026-03-29T04:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 29, 2, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_added_today_auckland_midnight_crossing(self) -> None:
|
|
||||||
# UTC 02:00 on 2026-03-29 -> Auckland (UTC+13) = 2026-03-29 15:00 local
|
|
||||||
# Auckland midnight = UTC 2026-03-28 11:00
|
|
||||||
lo, hi = _range(translate_query("added:today", AUCKLAND), "added")
|
|
||||||
assert lo == "2026-03-28T11:00:00Z"
|
|
||||||
assert hi == "2026-03-29T11:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_modified_today_utc(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("modified:today", UTC),
|
|
||||||
"modified",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T00:00:00Z"
|
|
||||||
assert hi == "2026-03-29T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"2026-03-27T00:00:00Z",
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"2026-03-16T00:00:00Z",
|
|
||||||
"2026-03-23T00:00:00Z",
|
|
||||||
id="previous_week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
id="this_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"2026-02-01T00:00:00Z",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
id="previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
"2027-01-01T00:00:00Z",
|
|
||||||
id="this_year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"2025-01-01T00:00:00Z",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
id="previous_year",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_datetime_keywords_utc(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
# 2026-03-28 is Saturday; weekday()==5 so Monday=2026-03-23
|
|
||||||
lo, hi = _range(translate_query(f"added:{keyword}", UTC), "added")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_this_month_december_wraps_to_next_year(self) -> None:
|
|
||||||
# December: next month wraps to January of next year
|
|
||||||
lo, hi = _range(translate_query("added:this month", UTC), "added")
|
|
||||||
assert lo == "2026-12-01T00:00:00Z"
|
|
||||||
assert hi == "2027-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
|
||||||
# January: last month wraps back to December of previous year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:previous month", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2025-12-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
'added:"previous quarter"',
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
id="quoted_previous_quarter",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:previous month",
|
|
||||||
"2026-06-01T00:00:00Z",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
id="bare_previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:this month",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
"2026-08-01T00:00:00Z",
|
|
||||||
id="bare_this_month",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 7, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_legacy_natural_language_aliases(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
lo, hi = _range(translate_query(query, UTC), "added")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
def test_unknown_keyword_raises(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="Unknown keyword"):
|
|
||||||
_datetime_range("bogus_keyword", UTC)
|
|
||||||
|
|
||||||
|
|
||||||
class TestWhooshQueryRewriting:
|
|
||||||
"""All Whoosh query syntax variants must be rewritten to ISO 8601 before Tantivy parses them."""
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_compact_date_shim_rewrites_to_iso(self) -> None:
|
|
||||||
result = translate_query("created:20240115120000", UTC)
|
|
||||||
assert "2024-01-15" in result
|
|
||||||
assert "20240115120000" not in result
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_relative_range_shim_removes_now(self) -> None:
|
|
||||||
result = translate_query("added:[now-7d TO now]", UTC)
|
|
||||||
assert "now" not in result
|
|
||||||
assert "2026-03-" in result
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_7_days(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-7 days to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_week(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-1 week to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_month_uses_relativedelta(self) -> None:
|
|
||||||
# relativedelta(months=1) from 2026-03-28 = 2026-02-28 (not 29)
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:[-1 month to now]", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-02-28T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_year(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("modified:[-1 year to now]", UTC),
|
|
||||||
"modified",
|
|
||||||
)
|
|
||||||
assert lo == "2025-03-28T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_plural_unit_hours(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-3 hours to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T09:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_case_insensitive(self) -> None:
|
|
||||||
result = translate_query("added:[-1 WEEK TO NOW]", UTC)
|
|
||||||
assert "now" not in result.lower()
|
|
||||||
lo, hi = _range(result, "added")
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_relative_range_swaps_bounds_when_lo_exceeds_hi(self) -> None:
|
|
||||||
# [now+1h TO now-1h] has lo > hi before substitution; they must be swapped
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[now+1h TO now-1h]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T11:00:00Z"
|
|
||||||
assert hi == "2026-03-28T13:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_created_date_field_always_uses_utc_midnight(self) -> None:
|
|
||||||
# created is a DateField: boundaries are always UTC midnight, no TZ offset
|
|
||||||
result = translate_query("created:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2023-12-01T00:00:00Z"
|
|
||||||
assert hi == "2023-12-02T00:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_added_datetime_field_converts_local_midnight_to_utc(self) -> None:
|
|
||||||
# added is DateTimeField: midnight Dec 1 Eastern (EST = UTC-5) = 05:00 UTC
|
|
||||||
result = translate_query("added:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "added")
|
|
||||||
assert lo == "2023-12-01T05:00:00Z"
|
|
||||||
assert hi == "2023-12-02T05:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_modified_datetime_field_converts_local_midnight_to_utc(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
result = translate_query("modified:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "modified")
|
|
||||||
assert lo == "2023-12-01T05:00:00Z"
|
|
||||||
assert hi == "2023-12-02T05:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_invalid_date_raises(self) -> None:
|
|
||||||
# The translation pipeline raises InvalidDateQuery for unparsable dates
|
|
||||||
# (e.g. month=13) so the API can surface a 400 telling the user the date
|
|
||||||
# is malformed instead of silently returning zero results.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_query("added:20231340", UTC)
|
|
||||||
assert exc_info.value.field == "added"
|
|
||||||
assert exc_info.value.value == "20231340"
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseUserQuery:
|
|
||||||
"""parse_user_query runs the full preprocessing pipeline."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def query_index(self) -> tantivy.Index:
|
|
||||||
schema = build_schema()
|
schema = build_schema()
|
||||||
idx = tantivy.Index(schema, path=None)
|
idx = tantivy.Index(schema, path=None)
|
||||||
register_tokenizers(idx, "")
|
register_tokenizers(idx, "")
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def populated_index() -> tantivy.Index:
|
||||||
|
"""An index holding one document, so a query matching nothing is
|
||||||
|
distinguishable from one matching everything."""
|
||||||
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
writer = idx.writer()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_unsigned("id", 1)
|
||||||
|
doc.add_text("content", "needle in indexed content")
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
idx.reload()
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def _highlight_hit_count(index: tantivy.Index, raw_query: str) -> int:
|
||||||
|
query = parse_simple_text_highlight_query(index, raw_query)
|
||||||
|
return index.searcher().search(query, limit=1).count
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseUserQuery:
|
||||||
|
"""parse_user_query runs the full preprocessing pipeline."""
|
||||||
|
|
||||||
def test_returns_tantivy_query(self, query_index: tantivy.Index) -> None:
|
def test_returns_tantivy_query(self, query_index: tantivy.Index) -> None:
|
||||||
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_query",
|
||||||
|
[
|
||||||
|
pytest.param("invoice", id="plain_text"),
|
||||||
|
pytest.param("created:today", id="date_keyword"),
|
||||||
|
pytest.param("created:[2005 to 2009]", id="whoosh_date_range"),
|
||||||
|
pytest.param('added:"previous month"', id="quoted_date_phrase"),
|
||||||
|
pytest.param("title:202[0-1]*", id="bracket_class_wildcard"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_fuzzy_mode_does_not_raise(
|
def test_fuzzy_mode_does_not_raise(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
settings,
|
settings,
|
||||||
|
raw_query: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# These are all valid whoosh grammar that tantivy's own query parser
|
||||||
|
# (used only by the fuzzy blend clause) cannot parse; the fuzzy
|
||||||
|
# clause must degrade gracefully instead of raising and failing the
|
||||||
|
# whole query. See _try_parse_fuzzy_query.
|
||||||
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
||||||
|
|
||||||
def test_date_rewriting_applied_before_tantivy_parse(
|
def test_date_keyword_resolves_without_raising(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
# created:today must be rewritten to an ISO range before Tantivy parses it;
|
# whoosh-compat's DateParserPlugin resolves "today" against the AST
|
||||||
# if passed raw, Tantivy would reject "today" as an invalid date value
|
# directly (no string rewrite to an ISO range happens anywhere in
|
||||||
|
# this pipeline); the emitted tantivy query must still build cleanly.
|
||||||
with time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False):
|
with time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False):
|
||||||
q = parse_user_query(query_index, "created:today", UTC)
|
q = parse_user_query(query_index, "created:today", UTC)
|
||||||
assert isinstance(q, tantivy.Query)
|
assert isinstance(q, tantivy.Query)
|
||||||
@@ -466,302 +114,58 @@ class TestParseUserQuery:
|
|||||||
) -> None:
|
) -> None:
|
||||||
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw_query",
|
|
||||||
[
|
|
||||||
# Partial date scalar (year only)
|
|
||||||
pytest.param("created:2020", id="created_year_scalar"),
|
|
||||||
# 8-digit compact date range in brackets
|
|
||||||
pytest.param(
|
|
||||||
"created:[20200101 TO 20201231]",
|
|
||||||
id="created_8digit_bracket_range",
|
|
||||||
),
|
|
||||||
# Comma-separated field + date range (Whoosh v2 multi-clause syntax)
|
|
||||||
pytest.param(
|
|
||||||
"title:x,created:[2020 TO 2021]",
|
|
||||||
id="title_comma_created_range",
|
|
||||||
),
|
|
||||||
# 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
|
|
||||||
pytest.param(
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]",
|
|
||||||
id="created_iso_range",
|
|
||||||
),
|
|
||||||
# Comma-separated ISO ranges (Whoosh v2 syntax)
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_advanced_search_queries_do_not_raise(
|
|
||||||
self,
|
|
||||||
query_index: tantivy.Index,
|
|
||||||
raw_query: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
End-to-end: queries that the frontend sends must parse without raising.
|
|
||||||
|
|
||||||
This tests the full pipeline: translate_query -> tantivy parse_query.
|
|
||||||
Equivalent to asserting HTTP 200 (not 400) for each query form.
|
|
||||||
"""
|
|
||||||
with time_machine.travel(datetime(2026, 6, 15, 12, 0, tzinfo=UTC), tick=False):
|
|
||||||
assert isinstance(
|
|
||||||
parse_user_query(query_index, raw_query, UTC),
|
|
||||||
tantivy.Query,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_date_propagates_not_swallowed(
|
def test_invalid_date_propagates_not_swallowed(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
# parse_user_query falls back to the raw query on unexpected translation
|
# parse_user_query never falls back to the raw query string on a parse
|
||||||
# errors, but an InvalidDateQuery is intentional and must propagate so the
|
# error: a bad date diagnostic from whoosh-compat always maps to an
|
||||||
# view can return a 400 instead of silently parsing the raw (invalid) date.
|
# InvalidDateQuery and must propagate, so the view can return a 400
|
||||||
|
# instead of silently parsing the raw (invalid) date.
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
with pytest.raises(InvalidDateQuery) as exc_info:
|
||||||
parse_user_query(query_index, "created:202023", UTC)
|
parse_user_query(query_index, "created:202023", UTC)
|
||||||
assert exc_info.value.field == "created"
|
assert exc_info.value.field == "created"
|
||||||
assert exc_info.value.value == "202023"
|
assert exc_info.value.value == "202023"
|
||||||
|
|
||||||
|
def test_invalid_number_raises_invalid_number_query(
|
||||||
class TestYearRangeRewriting:
|
|
||||||
"""Whoosh-style year-only date ranges must be rewritten to ISO 8601."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "field", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 TO 2020]",
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
id="single_year_created",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[2018 TO 2021]",
|
|
||||||
"created",
|
|
||||||
"2018-01-01T00:00:00Z",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
id="multi_year_range_created",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:[2022 TO 2023]",
|
|
||||||
"added",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
"2024-01-01T00:00:00Z",
|
|
||||||
id="added_field",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"modified:[2021 TO 2021]",
|
|
||||||
"modified",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
id="modified_field",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 to 2020]",
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
id="lowercase_to_keyword",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_year_range_rewritten(
|
|
||||||
self,
|
self,
|
||||||
query: str,
|
query_index: tantivy.Index,
|
||||||
field: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
result = translate_query(query, UTC)
|
with pytest.raises(InvalidNumberQuery) as exc_info:
|
||||||
lo, hi = _range(result, field)
|
parse_user_query(query_index, "asn:notanumber", UTC)
|
||||||
assert lo == expected_lo
|
assert exc_info.value.field == "asn"
|
||||||
assert hi == expected_hi
|
assert exc_info.value.value == "notanumber"
|
||||||
|
|
||||||
def test_reversed_year_range_is_swapped(self) -> None:
|
def test_multiple_bad_fields_raise_multiple_search_query_errors(
|
||||||
# A reversed range must not yield lo > hi, which Tantivy treats as an
|
|
||||||
# empty range (silently zero results). The bounds are swapped instead.
|
|
||||||
result = translate_query("created:[2025 TO 2020]", UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
def test_year_range_in_complex_boolean_query(self) -> None:
|
|
||||||
query = "tag:steuer AND (title:2020 OR (NOT title:2019 AND NOT title:2018 AND created:[2020 TO 2020]))"
|
|
||||||
result = translate_query(query, UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2021-01-01T00:00:00Z"
|
|
||||||
assert "title:2020" in result
|
|
||||||
assert "title:2019" in result
|
|
||||||
assert "title:2018" in result
|
|
||||||
|
|
||||||
def test_already_iso_date_range_passes_through_unchanged(self) -> None:
|
|
||||||
original = "created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z]"
|
|
||||||
assert translate_query(original, UTC) == original
|
|
||||||
|
|
||||||
def test_8digit_in_brackets_not_matched_as_year_range(self) -> None:
|
|
||||||
# [YYYYMMDD TO YYYYMMDD]: the translation layer converts 8-digit bounds to
|
|
||||||
# ISO day ranges. 20200101 -> 2020-01-01T00:00:00Z (lo of that day);
|
|
||||||
# 20201231 -> the ceil of Dec 31 = 2021-01-01T00:00:00Z (exclusive end).
|
|
||||||
# This is the correct and accepted behavior: old compact form becomes a
|
|
||||||
# proper Tantivy-parseable ISO range.
|
|
||||||
original = "created:[20200101 TO 20201231]"
|
|
||||||
result = translate_query(original, UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2021-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNonDateFieldsNotRewritten:
|
|
||||||
"""Date rewriters must only fire on the date fields (created/modified/added).
|
|
||||||
|
|
||||||
Integer fields like asn/id/page_count and unknown fields would otherwise be
|
|
||||||
rewritten into date ranges and rejected by Tantivy as type mismatches.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("asn:20240101", id="asn_8digit"),
|
|
||||||
pytest.param("id:20240101", id="id_8digit"),
|
|
||||||
pytest.param("page_count:12345678", id="page_count_8digit"),
|
|
||||||
pytest.param("num_notes:20231201", id="num_notes_8digit"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_8digit_on_integer_field_passes_through_unchanged(self, query: str) -> None:
|
|
||||||
assert translate_query(query, EASTERN) == query
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("asn:[2000 TO 2024]", id="asn_year_range"),
|
|
||||||
pytest.param("id:[2000 TO 2024]", id="id_year_range"),
|
|
||||||
pytest.param("page_count:[2000 TO 2024]", id="page_count_year_range"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_year_range_on_integer_field_passes_through_unchanged(
|
|
||||||
self,
|
self,
|
||||||
query: str,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
assert translate_query(query, UTC) == query
|
with pytest.raises(MultipleSearchQueryErrors) as exc_info:
|
||||||
|
parse_user_query(
|
||||||
def test_unknown_field_keyword_passes_through_unchanged(self) -> None:
|
query_index,
|
||||||
# foobar is not a date field: 'foobar:today' must not become a date range,
|
"created:notadate AND asn:notanumber",
|
||||||
# which Tantivy would otherwise reject as an unknown/typed field.
|
UTC,
|
||||||
assert translate_query("foobar:today", UTC) == "foobar:today"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPassthrough:
|
|
||||||
"""Queries without field prefixes or unrelated content pass through unchanged."""
|
|
||||||
|
|
||||||
def test_bare_keyword_no_field_prefix_unchanged(self) -> None:
|
|
||||||
# Bare 'today' with no field: prefix passes through unchanged
|
|
||||||
result = translate_query("bank statement today", UTC)
|
|
||||||
assert "today" in result
|
|
||||||
|
|
||||||
def test_unrelated_query_unchanged(self) -> None:
|
|
||||||
assert translate_query("title:invoice", UTC) == "title:invoice"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeQuery:
|
|
||||||
"""translate_query expands comma-separated values and collapses whitespace."""
|
|
||||||
|
|
||||||
def test_normalize_expands_comma_separated_tags(self) -> None:
|
|
||||||
assert translate_query("tag:foo,bar", UTC) == "tag:foo AND tag:bar"
|
|
||||||
|
|
||||||
def test_normalize_comma_between_range_expressions(self) -> None:
|
|
||||||
# Comma-separated field range expressions (Whoosh v2 syntax) must be
|
|
||||||
# converted to AND so Tantivy does not receive an invalid comma.
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
assert translate_query(q, UTC) == (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
" AND "
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
)
|
||||||
|
assert len(exc_info.value.errors) == 2
|
||||||
|
kinds = {type(e) for e in exc_info.value.errors}
|
||||||
|
assert kinds == {InvalidDateQuery, InvalidNumberQuery}
|
||||||
|
|
||||||
def test_normalize_expands_three_values(self) -> None:
|
def test_unregistered_id_field_folds_to_literal_text_not_error(
|
||||||
assert (
|
self,
|
||||||
translate_query("tag:foo,bar,baz", UTC) == "tag:foo AND tag:bar AND tag:baz"
|
query_index: tantivy.Index,
|
||||||
)
|
) -> None:
|
||||||
|
# tag_id is intentionally excluded from the FieldRegistry: whoosh-compat
|
||||||
def test_normalize_collapses_whitespace(self) -> None:
|
# parity leniency folds it into literal text, not a diagnostic/400.
|
||||||
assert translate_query("bank statement", UTC) == "bank statement"
|
# A result-level assertion that this fold actually matches nothing
|
||||||
|
# against real documents lives in
|
||||||
def test_normalize_no_commas_unchanged(self) -> None:
|
# test_acceptance.py::TestUnregisteredIdFieldFoldsToLiteralText.
|
||||||
assert translate_query("bank statement", UTC) == "bank statement"
|
q = parse_user_query(query_index, "tag_id:5", UTC)
|
||||||
|
assert isinstance(q, tantivy.Query)
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"h52.1 - kurzsichtigkeit",
|
|
||||||
"h52.1 kurzsichtigkeit",
|
|
||||||
id="icd_code_dash_description",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"H52.1 - asd",
|
|
||||||
"H52.1 asd",
|
|
||||||
id="icd_code_uppercase_dash",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"h52.1 -",
|
|
||||||
"h52.1",
|
|
||||||
id="trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
". -",
|
|
||||||
".",
|
|
||||||
id="dot_trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"h52. -",
|
|
||||||
"h52.",
|
|
||||||
id="partial_code_trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"foo - bar - baz",
|
|
||||||
"foo bar baz",
|
|
||||||
id="multiple_dashes",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"foo + bar",
|
|
||||||
"foo bar",
|
|
||||||
id="spaced_plus_operator",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_normalize_strips_dangling_operators(self, raw: str, expected: str) -> None:
|
|
||||||
assert translate_query(raw, UTC) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("term -other", id="adjacent_not_operator"),
|
|
||||||
pytest.param("-term", id="leading_not_operator"),
|
|
||||||
pytest.param("+term", id="leading_must_operator"),
|
|
||||||
pytest.param("foo -bar +baz", id="mixed_adjacent_operators"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_normalize_preserves_valid_operators(self, query: str) -> None:
|
|
||||||
assert translate_query(query, UTC) == query
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseSimpleTextHighlightQuery:
|
class TestParseSimpleTextHighlightQuery:
|
||||||
"""parse_simple_text_highlight_query must not raise on natural-language queries."""
|
"""parse_simple_text_highlight_query must not raise on natural-language queries."""
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def query_index(self) -> tantivy.Index:
|
|
||||||
schema = build_schema()
|
|
||||||
idx = tantivy.Index(schema, path=None)
|
|
||||||
register_tokenizers(idx, "")
|
|
||||||
return idx
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"raw_query",
|
"raw_query",
|
||||||
[
|
[
|
||||||
@@ -783,16 +187,25 @@ class TestParseSimpleTextHighlightQuery:
|
|||||||
tantivy.Query,
|
tantivy.Query,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_query_returns_empty_query(self, query_index: tantivy.Index) -> None:
|
def test_a_real_token_matches_the_corpus(
|
||||||
result = parse_simple_text_highlight_query(query_index, "")
|
|
||||||
assert isinstance(result, tantivy.Query)
|
|
||||||
|
|
||||||
def test_all_operators_returns_empty_query(
|
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
populated_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
result = parse_simple_text_highlight_query(query_index, "- +")
|
"""Without this, an empty corpus would make the two assertions below
|
||||||
assert isinstance(result, tantivy.Query)
|
pass for a query that matches every document."""
|
||||||
|
assert _highlight_hit_count(populated_index, "needle") == 1
|
||||||
|
|
||||||
|
def test_empty_query_matches_no_document(
|
||||||
|
self,
|
||||||
|
populated_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
assert _highlight_hit_count(populated_index, "") == 0
|
||||||
|
|
||||||
|
def test_all_operators_query_matches_no_document(
|
||||||
|
self,
|
||||||
|
populated_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
assert _highlight_hit_count(populated_index, "- +") == 0
|
||||||
|
|
||||||
|
|
||||||
class TestPermissionFilter:
|
class TestPermissionFilter:
|
||||||
@@ -884,3 +297,52 @@ class TestPermissionFilter:
|
|||||||
user = django_user_model(pk=20)
|
user = django_user_model(pk=20)
|
||||||
perm = build_permission_filter(perm_index.schema, user)
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
assert perm_index.searcher().search(perm, limit=10).count == 1 # only unowned
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitErrorContract:
|
||||||
|
"""A QueryError from emit() surfaces as a SearchQueryError (HTTP 400).
|
||||||
|
|
||||||
|
The Cause-based routing table itself is covered in test_error_routing.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_exists_requires_fast_gets_the_user_facing_rewrite(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
# whoosh-compat's own message advises a host-side fast=True config
|
||||||
|
# change the user can't act on, so this checks OUR wording, not
|
||||||
|
# whoosh-compat's (that's its own test suite's job now).
|
||||||
|
with pytest.raises(SearchQueryError) as exc_info:
|
||||||
|
parse_user_query(query_index, "notes.user:*", UTC)
|
||||||
|
assert str(exc_info.value) == (
|
||||||
|
"Existence searches (field:*) are not supported for field 'notes.user'."
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Negation must survive the blended query.
|
||||||
|
|
||||||
|
parse_user_query ORs an exact clause with optional fuzzy and CJK clauses.
|
||||||
|
Each of those is built from positive terms only, so unless the query's
|
||||||
|
exclusions are applied to the blend as a whole, a document the exact
|
||||||
|
clause excluded is re-admitted by whichever other clause is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fuzzy_enabled(settings: SettingsWrapper) -> None:
|
||||||
|
"""Enable the fuzzy blend clause. The threshold doubles as a minimum
|
||||||
|
score filter, so it is set to 0.0: every hit passes and the test sees
|
||||||
|
the clause's matching behaviour, not the filter's."""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegationConstrainsEveryClause:
|
||||||
|
@pytest.mark.usefixtures("fuzzy_enabled")
|
||||||
|
def test_fuzzy_clause_does_not_readmit_an_excluded_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total secret",
|
||||||
|
checksum="neg-fuzzy-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice B",
|
||||||
|
content="invoice total public",
|
||||||
|
checksum="neg-fuzzy-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice") == {secret.pk, public.pk}
|
||||||
|
assert _matched_ids(backend, "invoice NOT secret") == {public.pk}
|
||||||
|
|
||||||
|
def test_cjk_clause_does_not_readmit_an_excluded_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The CJK clause legitimately carries 東京 here, so rebuilding it
|
||||||
|
from the AST cannot help: only applying the exclusion above the
|
||||||
|
blend keeps the secret document out."""
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo A",
|
||||||
|
content="東京都の秘密です secret",
|
||||||
|
checksum="neg-cjk-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo B",
|
||||||
|
content="東京都の報告書です public",
|
||||||
|
checksum="neg-cjk-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "東京") == {secret.pk, public.pk}
|
||||||
|
assert _matched_ids(backend, "東京 NOT secret") == {public.pk}
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("fuzzy_enabled")
|
||||||
|
def test_disjunctive_negation_still_admits_the_other_branch(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'invoice OR NOT secret' excludes nothing on its own: a document
|
||||||
|
matching the left branch stays in even though it contains secret."""
|
||||||
|
secret_invoice = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total secret",
|
||||||
|
checksum="neg-or-1",
|
||||||
|
)
|
||||||
|
unrelated = _index(
|
||||||
|
backend,
|
||||||
|
title="Recipe",
|
||||||
|
content="flour and water",
|
||||||
|
checksum="neg-or-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice OR NOT secret") == {
|
||||||
|
secret_invoice.pk,
|
||||||
|
unrelated.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_negation_under_or_does_not_constrain_the_cjk_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The limit of the hoist, pinned deliberately.
|
||||||
|
|
||||||
|
An exclusion that is one branch's own condition cannot be restated
|
||||||
|
above the blend without dropping documents the other branch
|
||||||
|
matches, so it is left where it is and the CJK clause stays
|
||||||
|
unconstrained by it. That shows through here in a way it does not
|
||||||
|
for latin text: the exact clause cannot match a CJK run at all, so
|
||||||
|
the CJK clause is the only thing matching the tokyo documents, and
|
||||||
|
the secret one comes with it.
|
||||||
|
"""
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo A",
|
||||||
|
content="東京都の秘密です secret",
|
||||||
|
checksum="neg-or-cjk-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo B",
|
||||||
|
content="東京都の報告書です public",
|
||||||
|
checksum="neg-or-cjk-2",
|
||||||
|
)
|
||||||
|
bill = _index(
|
||||||
|
backend,
|
||||||
|
title="Bill",
|
||||||
|
content="bill payment received",
|
||||||
|
checksum="neg-or-cjk-3",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "(東京 AND NOT secret) OR bill") == {
|
||||||
|
bill.pk,
|
||||||
|
public.pk,
|
||||||
|
secret.pk,
|
||||||
|
}
|
||||||
|
# The same exclusion in conjunctive position is hoisted, and does
|
||||||
|
# constrain the CJK clause.
|
||||||
|
assert _matched_ids(backend, "東京 AND NOT secret") == {public.pk}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
from whoosh_compat.fields import ResolvedField
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry() -> FieldRegistry:
|
||||||
|
return get_field_registry(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(registry: FieldRegistry, name: str) -> ResolvedField:
|
||||||
|
ref = registry.make_ref(name)
|
||||||
|
assert ref is not None, f"{name} is not a valid field ref"
|
||||||
|
resolved = registry.resolve(ref)
|
||||||
|
assert resolved is not None, f"{name} did not resolve"
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_forms(result: str | Sequence[str]) -> tuple[str, ...]:
|
||||||
|
"""The forms a term may match, in order, the way whoosh-compat's emitter
|
||||||
|
reads a pattern_normalizer's answer: a bare str is one form, a sequence is
|
||||||
|
several, deduplicated."""
|
||||||
|
if isinstance(result, str):
|
||||||
|
return (result,)
|
||||||
|
return tuple(dict.fromkeys(result))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldRegistry:
|
||||||
|
def test_internal_id_fields_are_not_registered(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
for name in (
|
||||||
|
"tag_id",
|
||||||
|
"owner_id",
|
||||||
|
"viewer_id",
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
"viewer_group_id",
|
||||||
|
):
|
||||||
|
assert name not in registry
|
||||||
|
|
||||||
|
def test_no_queryable_field_name_ends_in_id(self) -> None:
|
||||||
|
# The list above names the seven that were dropped; this catches the
|
||||||
|
# eighth. Internal *_id columns are written for permission filtering
|
||||||
|
# and joins, and whoosh only exposed them as query fields by accident,
|
||||||
|
# so a new one reaching the query surface is a leak rather than a
|
||||||
|
# feature. Checked against PUBLIC_FIELDS rather than the registry so
|
||||||
|
# an internal field is caught where it is declared.
|
||||||
|
leaked = [f.name for f in PUBLIC_FIELDS if f.name.endswith("_id")]
|
||||||
|
assert not leaked, f"internal id fields reached the query surface: {leaked}"
|
||||||
|
|
||||||
|
def test_type_alias_resolves_to_document_type(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
assert _resolve(registry, "type").spec.name == "document_type"
|
||||||
|
|
||||||
|
def test_path_alias_resolves_to_storage_path(self, registry: FieldRegistry) -> None:
|
||||||
|
assert _resolve(registry, "path").spec.name == "storage_path"
|
||||||
|
|
||||||
|
def test_notes_json_subpaths_resolve(self, registry: FieldRegistry) -> None:
|
||||||
|
resolved = _resolve(registry, "notes.user")
|
||||||
|
assert resolved.spec.name == "notes"
|
||||||
|
assert resolved.json_path == "user"
|
||||||
|
assert resolved.is_subpath is True
|
||||||
|
|
||||||
|
def test_custom_fields_json_subpaths_resolve(self, registry: FieldRegistry) -> None:
|
||||||
|
for raw in ("custom_fields.name", "custom_fields.value"):
|
||||||
|
_resolve(registry, raw)
|
||||||
|
|
||||||
|
def test_unregistered_json_subpath_does_not_resolve(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# An unregistered subpath is not even a valid FieldRef: make_ref
|
||||||
|
# returns None for a dotted name whose subpath isn't registered
|
||||||
|
# (it doesn't produce a ref for resolve() to then reject).
|
||||||
|
assert registry.make_ref("notes.bogus") is None
|
||||||
|
|
||||||
|
def test_tag_is_comma_values(self, registry: FieldRegistry) -> None:
|
||||||
|
assert _resolve(registry, "tag").spec.comma_values is True
|
||||||
|
|
||||||
|
def test_correspondent_is_not_comma_values(self, registry: FieldRegistry) -> None:
|
||||||
|
# "tag" is the only field that opts in. This is only observable here:
|
||||||
|
# end to end the two readings of "correspondent:foo,bar" agree,
|
||||||
|
# because the analyzer splits the literal value on the comma anyway,
|
||||||
|
# so a result-level test cannot tell a value list from literal text.
|
||||||
|
assert _resolve(registry, "correspondent").spec.comma_values is False
|
||||||
|
|
||||||
|
def test_created_is_date_kind(self, registry: FieldRegistry) -> None:
|
||||||
|
resolved = _resolve(registry, "created")
|
||||||
|
assert resolved.spec.kind is FieldKind.DATE
|
||||||
|
assert resolved.spec.date_only is True
|
||||||
|
|
||||||
|
def test_analyzer_lowercases_and_ascii_folds(self, registry: FieldRegistry) -> None:
|
||||||
|
# title uses the paperless_text analyzer: simple -> remove_long ->
|
||||||
|
# lowercase -> ascii_fold [-> stemmer]. With no language configured
|
||||||
|
# (None), no stemmer runs, so "Café" folds to the single token "cafe".
|
||||||
|
resolved = _resolve(registry, "title")
|
||||||
|
assert resolved.spec.analyzer is not None
|
||||||
|
assert resolved.spec.analyzer("Café") == ["cafe"]
|
||||||
|
|
||||||
|
def test_checksum_analyzer_is_identity_single_token(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# checksum uses the raw tokenizer at index time (no splitting).
|
||||||
|
resolved = _resolve(registry, "checksum")
|
||||||
|
assert resolved.spec.analyzer is not None
|
||||||
|
assert resolved.spec.analyzer("ABC-123") == ["ABC-123"]
|
||||||
|
|
||||||
|
def test_pattern_normalizer_follows_the_registry_language(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# Index terms are stemmed, so patterns offer their stem too, using the
|
||||||
|
# registry's own language: "Running" has to reach the indexed "run".
|
||||||
|
# Without a language the index holds surface forms, so there is no
|
||||||
|
# second form and the run is only case/accent-folded.
|
||||||
|
resolved = _resolve(registry, "title")
|
||||||
|
assert resolved.spec.pattern_normalizer is not None
|
||||||
|
assert _distinct_forms(resolved.spec.pattern_normalizer("Running")) == (
|
||||||
|
"running",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_en = _resolve(get_field_registry("en"), "title")
|
||||||
|
assert resolved_en.spec.pattern_normalizer is not None
|
||||||
|
assert _distinct_forms(resolved_en.spec.pattern_normalizer("Running")) == (
|
||||||
|
"running",
|
||||||
|
"run",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_is_cached_per_language(self) -> None:
|
||||||
|
a = get_field_registry("en")
|
||||||
|
b = get_field_registry("en")
|
||||||
|
assert a is b
|
||||||
|
|
||||||
|
def test_registry_rebuilds_on_language_change(self) -> None:
|
||||||
|
a = get_field_registry("en")
|
||||||
|
b = get_field_registry("de")
|
||||||
|
assert a is not b
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
from documents.search._schema import SCHEMA_VERSION
|
from documents.search._schema import SCHEMA_VERSION
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import field_descriptors
|
||||||
from documents.search._schema import needs_rebuild
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import schema_fingerprint
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -29,7 +37,13 @@ class TestNeedsRebuild:
|
|||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = "en"
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"language": "en",
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is False
|
assert needs_rebuild(index_dir) is False
|
||||||
|
|
||||||
@@ -76,3 +90,72 @@ class TestNeedsRebuild:
|
|||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
|
||||||
|
"""{name: field-state} for every field declared on a tantivy Schema.
|
||||||
|
|
||||||
|
tantivy-py 0.26 exposes no public introspection API on Schema (no
|
||||||
|
__iter__, get_field, to_json, etc.) -- __reduce__() (used internally for
|
||||||
|
pickling) is the only way to recover the field list, so we lean on it
|
||||||
|
here for test assertions only.
|
||||||
|
"""
|
||||||
|
state = schema.__reduce__()[1][0]
|
||||||
|
return {field["name"]: field for field in state["inner"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaMatchesPublicFields:
|
||||||
|
def test_every_public_field_is_in_the_schema(self) -> None:
|
||||||
|
schema = build_schema()
|
||||||
|
schema_field_names = set(_schema_fields(schema))
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
assert field.name in schema_field_names, (
|
||||||
|
f"{field.name} is in PUBLIC_FIELDS but missing from build_schema()"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_asn_page_count_num_notes_are_fast_unsigned_fields(self) -> None:
|
||||||
|
# Spot-check kind-derived construction for the U64 fields.
|
||||||
|
schema = build_schema()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_unsigned("id", 1)
|
||||||
|
doc.add_text("checksum", "x")
|
||||||
|
doc.add_unsigned("asn", 42)
|
||||||
|
doc.add_unsigned("page_count", 3)
|
||||||
|
doc.add_unsigned("num_notes", 0)
|
||||||
|
doc.add_date("created", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
doc.add_date("modified", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
doc.add_date("added", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
index = tantivy.Index(schema)
|
||||||
|
register_tokenizers(index, None)
|
||||||
|
writer = index.writer()
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
index.reload()
|
||||||
|
searcher = index.searcher()
|
||||||
|
results = searcher.search(tantivy.Query.term_query(schema, "asn", 42), limit=1)
|
||||||
|
assert len(results.hits) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFastFlagAgreement:
|
||||||
|
def test_every_public_field_fast_flag_matches_the_built_schema(self) -> None:
|
||||||
|
# whoosh-compat's registry trusts PUBLIC_FIELDS' fast flag when resolving
|
||||||
|
# field:* existence checks (its FAST_FIELD strategy); a fast=True
|
||||||
|
# entry whose actual tantivy column is not fast would make those
|
||||||
|
# searches silently match nothing at search time. Only the U64 and
|
||||||
|
# DATE descriptors can carry the flag today, so this
|
||||||
|
# pins the agreement for EVERY kind: a future fast=True
|
||||||
|
# TEXT/KEYWORD/JSON entry the builder silently ignores fails here
|
||||||
|
# instead of at a user's query.
|
||||||
|
#
|
||||||
|
# field_descriptors() (not tantivy-py's __reduce__() pickling
|
||||||
|
# internals) is used as the probe here: it is exactly the input
|
||||||
|
# build_schema()'s SchemaBuilder consumes for the `fast` kwarg on
|
||||||
|
# every field kind, so it pins the same agreement without depending
|
||||||
|
# on a private pickled representation surviving a tantivy-py
|
||||||
|
# upgrade.
|
||||||
|
descriptor_fast = {d.name: d.fast for d in field_descriptors()}
|
||||||
|
for public_field in PUBLIC_FIELDS:
|
||||||
|
assert descriptor_fast[public_field.name] == public_field.fast, (
|
||||||
|
f"{public_field.name}: PUBLIC_FIELDS says fast={public_field.fast} but"
|
||||||
|
f" field_descriptors() says fast={descriptor_fast[public_field.name]}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""The schema fingerprint stamped into .index_settings.json.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list, and `tantivy.Index(schema,
|
||||||
|
path=...)` (what every write path does) raises on any difference. SCHEMA_VERSION
|
||||||
|
is the manual guard against that, but build_schema() is edited for *parser*
|
||||||
|
reasons - adding an alias, flipping fast=True, adding a subpath - by people not
|
||||||
|
thinking about the on-disk index, and forgetting the bump is exactly how this
|
||||||
|
branch's bug happened.
|
||||||
|
|
||||||
|
The fingerprint is the automatic guard: it hashes the field descriptor list that
|
||||||
|
build_schema() itself iterates, so any change to a field's name, kind, options
|
||||||
|
or *position* forces a rebuild on its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
from documents.search import _schema
|
||||||
|
from documents.search._schema import SCHEMA_VERSION
|
||||||
|
from documents.search._schema import FieldDescriptor
|
||||||
|
from documents.search._schema import _write_sentinels
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import field_descriptors
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import schema_fingerprint
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an
|
||||||
|
# index-format change: it must come with a rebuild, which the fingerprint now
|
||||||
|
# forces automatically. Reproduced from build_schema()'s output as it stood
|
||||||
|
# before the descriptor refactor, so it also pins that the refactor changed
|
||||||
|
# nothing.
|
||||||
|
PINNED_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||||
|
FieldDescriptor("id", "u64", stored=True, indexed=True, fast=True, tokenizer=None),
|
||||||
|
FieldDescriptor(
|
||||||
|
"title",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"content",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"correspondent",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"document_type",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"storage_path",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"original_filename",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"tag",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"checksum",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
FieldDescriptor("asn", "u64", stored=True, indexed=True, fast=True, tokenizer=None),
|
||||||
|
FieldDescriptor(
|
||||||
|
"page_count",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"num_notes",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"created",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"modified",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"added",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes",
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes_text",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"custom_fields",
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"title_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"correspondent_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"type_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_content",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_title",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_correspondent",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_document_type",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_tag",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"simple_title",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"simple_content",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"autocomplete_word",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"owner_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"viewer_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"viewer_group_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_fields(schema: tantivy.Schema) -> list[dict]:
|
||||||
|
"""The tantivy-level field list, in declaration order.
|
||||||
|
|
||||||
|
tantivy-py 0.26 exposes no public introspection API on Schema, so
|
||||||
|
__reduce__() (its pickling hook) is the only way to recover the field list.
|
||||||
|
It is used here, in a test, precisely because it is the representation the
|
||||||
|
persisted fingerprint must NOT depend on.
|
||||||
|
"""
|
||||||
|
return schema.__reduce__()[1][0]["inner"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sentinels(index_dir: Path, **overrides: object) -> None:
|
||||||
|
data = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"language": None,
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
(index_dir / ".index_settings.json").write_text(json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescriptorsDescribeTheBuiltSchema:
|
||||||
|
def test_descriptors_match_the_pinned_field_layout(self) -> None:
|
||||||
|
assert tuple(field_descriptors()) == PINNED_DESCRIPTORS
|
||||||
|
|
||||||
|
def test_built_schema_matches_the_descriptors(self) -> None:
|
||||||
|
"""The descriptors are not a parallel description - they are the input.
|
||||||
|
|
||||||
|
Reading the built schema back proves the loop honours every option, so
|
||||||
|
a descriptor edit cannot claim a shape the SchemaBuilder did not build.
|
||||||
|
"""
|
||||||
|
kinds = {"text": "text", "json": "json_object", "u64": "u64", "date": "date"}
|
||||||
|
built = [
|
||||||
|
(
|
||||||
|
field["name"],
|
||||||
|
field["type"],
|
||||||
|
field["options"]["stored"],
|
||||||
|
bool(field["options"].get("fast")),
|
||||||
|
(field["options"].get("indexing") or {}).get("tokenizer"),
|
||||||
|
)
|
||||||
|
for field in _schema_fields(build_schema())
|
||||||
|
]
|
||||||
|
expected = [
|
||||||
|
(
|
||||||
|
descriptor.name,
|
||||||
|
kinds[descriptor.kind],
|
||||||
|
descriptor.stored,
|
||||||
|
descriptor.fast,
|
||||||
|
descriptor.tokenizer,
|
||||||
|
)
|
||||||
|
for descriptor in field_descriptors()
|
||||||
|
]
|
||||||
|
assert built == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintSensitivity:
|
||||||
|
def test_a_field_option_change_moves_the_fingerprint(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
before = schema_fingerprint()
|
||||||
|
changed = field_descriptors()
|
||||||
|
changed[1] = changed[1]._replace(fast=True)
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: changed)
|
||||||
|
|
||||||
|
assert schema_fingerprint() != before
|
||||||
|
|
||||||
|
def test_reordering_alone_moves_the_fingerprint(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The original bug: same fields, different declaration order.
|
||||||
|
|
||||||
|
A set- or dict-based fingerprint would be blind to this, and tantivy
|
||||||
|
would reject every write against the existing index.
|
||||||
|
"""
|
||||||
|
before = schema_fingerprint()
|
||||||
|
swapped = field_descriptors()
|
||||||
|
swapped[1], swapped[2] = swapped[2], swapped[1]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: swapped)
|
||||||
|
|
||||||
|
assert schema_fingerprint() != before
|
||||||
|
|
||||||
|
def test_repeated_calls_agree(self) -> None:
|
||||||
|
assert schema_fingerprint() == schema_fingerprint()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintIsIndependentOfTantivy:
|
||||||
|
def test_a_tantivy_option_key_addition_would_not_move_it(self) -> None:
|
||||||
|
"""A tantivy-py upgrade must not force a global reindex.
|
||||||
|
|
||||||
|
Hashing schema.__reduce__() would do exactly that: the simulated new
|
||||||
|
option key below changes that payload for every user with no schema
|
||||||
|
change at all.
|
||||||
|
"""
|
||||||
|
fields = _schema_fields(build_schema())
|
||||||
|
upgraded = [
|
||||||
|
{**field, "options": {**field["options"], "coerce": True}}
|
||||||
|
for field in fields
|
||||||
|
]
|
||||||
|
assert _hash(upgraded) != _hash(fields)
|
||||||
|
assert schema_fingerprint() == _fingerprint_of(field_descriptors())
|
||||||
|
|
||||||
|
def test_fingerprint_never_touches_the_schema_builder(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
before = schema_fingerprint()
|
||||||
|
|
||||||
|
class _RemovedSchemaBuilder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
raise AssertionError("tantivy.SchemaBuilder was consulted")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tantivy, "SchemaBuilder", _RemovedSchemaBuilder)
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
build_schema()
|
||||||
|
|
||||||
|
assert schema_fingerprint() == before
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(payload: object) -> str:
|
||||||
|
return hashlib.blake2b(json.dumps(payload).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint_of(descriptors: list[FieldDescriptor]) -> str:
|
||||||
|
return _hash([list(descriptor) for descriptor in descriptors])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsRebuildOnFingerprint:
|
||||||
|
def test_matching_fingerprint_does_not_rebuild(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is False
|
||||||
|
|
||||||
|
def test_stale_fingerprint_rebuilds_despite_a_matching_version(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The failure this task exists to prevent: schema edited, version not
|
||||||
|
bumped. Without the fingerprint check, `reindex --if-needed` reports the
|
||||||
|
index up to date and every write then raises."""
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
extended = [
|
||||||
|
*field_descriptors(),
|
||||||
|
FieldDescriptor(
|
||||||
|
"new_field",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_reordered_schema_rebuilds(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
reordered = field_descriptors()
|
||||||
|
reordered[1], reordered[2] = reordered[2], reordered[1]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_missing_fingerprint_rebuilds(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
"""No seeding: an index whose schema shape nobody recorded is rebuilt
|
||||||
|
rather than trusted."""
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
(index_dir / ".index_settings.json").write_text(
|
||||||
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_written_sentinels_satisfy_the_check(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
|
_write_sentinels(index_dir)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is False
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""SCHEMA_VERSION must change whenever build_schema()'s field list or order does.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list. ``Index.open()`` loads the
|
||||||
|
schema from the index's own ``meta.json``, so reads against an index built by an
|
||||||
|
older release keep working after a field reorder. Writes do not:
|
||||||
|
``WriteBatch.__enter__`` calls ``tantivy.Index(build_schema(), path=...)``, an
|
||||||
|
open-or-create that raises ``ValueError`` on any schema difference. Nothing
|
||||||
|
catches that ValueError, so consumption, index_document and bulk edit all
|
||||||
|
hard-fail while ``/api/status/`` still reports the index healthy.
|
||||||
|
|
||||||
|
The only thing that saves such an install is ``needs_rebuild()`` noticing the
|
||||||
|
version stamped in ``.index_settings.json`` is stale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import open_or_rebuild_index
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search]
|
||||||
|
|
||||||
|
RELEASED_V1_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _build_released_v1_schema() -> tantivy.Schema:
|
||||||
|
"""Frozen copy of build_schema() as shipped in v3.0.x (schema version 1).
|
||||||
|
|
||||||
|
Deliberately duplicated rather than imported: it must keep describing the
|
||||||
|
on-disk layout of already-deployed indexes even as build_schema() evolves.
|
||||||
|
"""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
|
||||||
|
sb.add_unsigned_field("id", stored=True, indexed=True, fast=True)
|
||||||
|
sb.add_text_field("checksum", stored=True, tokenizer_name="raw")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"title",
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"storage_path",
|
||||||
|
"original_filename",
|
||||||
|
"content",
|
||||||
|
):
|
||||||
|
sb.add_text_field(field, stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
for field in ("title_sort", "correspondent_sort", "type_sort"):
|
||||||
|
sb.add_text_field(
|
||||||
|
field,
|
||||||
|
stored=False,
|
||||||
|
tokenizer_name="simple_analyzer",
|
||||||
|
fast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"bigram_content",
|
||||||
|
"bigram_title",
|
||||||
|
"bigram_correspondent",
|
||||||
|
"bigram_document_type",
|
||||||
|
"bigram_tag",
|
||||||
|
):
|
||||||
|
sb.add_text_field(field, stored=False, tokenizer_name="bigram_analyzer")
|
||||||
|
|
||||||
|
for field in ("simple_title", "simple_content"):
|
||||||
|
sb.add_text_field(field, stored=False, tokenizer_name="simple_search_analyzer")
|
||||||
|
|
||||||
|
sb.add_text_field("autocomplete_word", stored=False, tokenizer_name="raw")
|
||||||
|
sb.add_text_field("tag", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
|
||||||
|
sb.add_text_field("notes_text", stored=True, tokenizer_name="paperless_text")
|
||||||
|
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
"tag_id",
|
||||||
|
"owner_id",
|
||||||
|
"viewer_id",
|
||||||
|
"viewer_group_id",
|
||||||
|
):
|
||||||
|
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("created", "modified", "added"):
|
||||||
|
sb.add_date_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("asn", "page_count", "num_notes"):
|
||||||
|
sb.add_unsigned_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
return sb.build()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def released_v1_index(tmp_path: Path) -> Path:
|
||||||
|
"""An index directory as a v3.0.x install would leave it on disk."""
|
||||||
|
index_dir = tmp_path / "index"
|
||||||
|
index_dir.mkdir()
|
||||||
|
tantivy.Index(_build_released_v1_schema(), path=str(index_dir))
|
||||||
|
(index_dir / ".index_settings.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": RELEASED_V1_SCHEMA_VERSION,
|
||||||
|
"language": django_settings.SEARCH_LANGUAGE,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return index_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpgradeFromReleasedV1Index:
|
||||||
|
def test_released_v1_index_is_flagged_for_rebuild(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The current schema differs from v1's, so the sentinel must be stale.
|
||||||
|
|
||||||
|
If this fails, `document_index reindex --if-needed` prints "Search index
|
||||||
|
is up to date" and skips, leaving the mismatched index in place.
|
||||||
|
"""
|
||||||
|
assert needs_rebuild(released_v1_index) is True
|
||||||
|
|
||||||
|
def test_v1_index_rejects_writes_against_the_current_schema(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The failure mode the version bump exists to prevent.
|
||||||
|
|
||||||
|
This is exactly what WriteBatch.__enter__ does on every index write.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError, match="schema does not match"):
|
||||||
|
tantivy.Index(build_schema(), path=str(released_v1_index))
|
||||||
|
|
||||||
|
def test_opening_a_v1_index_leaves_it_writable(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""End to end: open_or_rebuild_index must hand back an index that the
|
||||||
|
write path can reopen. Before the version bump, needs_rebuild() returned
|
||||||
|
False here, the stale directory survived untouched, and every subsequent
|
||||||
|
write raised the ValueError above."""
|
||||||
|
open_or_rebuild_index(released_v1_index)
|
||||||
|
|
||||||
|
tantivy.Index(build_schema(), path=str(released_v1_index))
|
||||||
|
|
||||||
|
def test_rebuilt_index_is_not_rebuilt_again(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The rebuild must stamp the version it actually wrote, otherwise every
|
||||||
|
startup wipes and reindexes the whole corpus."""
|
||||||
|
open_or_rebuild_index(released_v1_index)
|
||||||
|
|
||||||
|
assert needs_rebuild(released_v1_index) is False
|
||||||
@@ -7,8 +7,8 @@ import pytest
|
|||||||
import tantivy
|
import tantivy
|
||||||
|
|
||||||
from documents.search._tokenizer import _bigram_analyzer
|
from documents.search._tokenizer import _bigram_analyzer
|
||||||
from documents.search._tokenizer import _paperless_text
|
|
||||||
from documents.search._tokenizer import _simple_search_analyzer
|
from documents.search._tokenizer import _simple_search_analyzer
|
||||||
|
from documents.search._tokenizer import paperless_text_analyzer
|
||||||
from documents.search._tokenizer import register_tokenizers
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -25,7 +25,7 @@ class TestTokenizers:
|
|||||||
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
||||||
schema = sb.build()
|
schema = sb.build()
|
||||||
idx = tantivy.Index(schema, path=None)
|
idx = tantivy.Index(schema, path=None)
|
||||||
idx.register_tokenizer("paperless_text", _paperless_text(""))
|
idx.register_tokenizer("paperless_text", paperless_text_analyzer(""))
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,810 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
from documents.search._dates import _precision_bounds
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import tantivy
|
|
||||||
from documents.search._query import _FIELD_BOOSTS
|
|
||||||
from documents.search._query import DEFAULT_SEARCH_FIELDS
|
|
||||||
from documents.search._translate import OPEN_HI
|
|
||||||
from documents.search._translate import OPEN_LO
|
|
||||||
from documents.search._translate import Comma
|
|
||||||
from documents.search._translate import FieldRange
|
|
||||||
from documents.search._translate import FieldValue
|
|
||||||
from documents.search._translate import FieldValueList
|
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import Passthrough
|
|
||||||
from documents.search._translate import resolve_commas
|
|
||||||
from documents.search._translate import scan
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
from documents.search._translate import translate_range
|
|
||||||
from documents.search._translate import translate_scalar
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestPrecisionBounds:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("digits", "expected"),
|
|
||||||
[
|
|
||||||
("2020", ((2020, 1, 1), (2021, 1, 1))),
|
|
||||||
("202003", ((2020, 3, 1), (2020, 4, 1))),
|
|
||||||
("202012", ((2020, 12, 1), (2021, 1, 1))),
|
|
||||||
("20200115", ((2020, 1, 15), (2020, 1, 16))),
|
|
||||||
("20201231", ((2020, 12, 31), (2021, 1, 1))),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_valid(self, digits, expected):
|
|
||||||
lo, hi = _precision_bounds(digits)
|
|
||||||
assert (lo.year, lo.month, lo.day) == expected[0]
|
|
||||||
assert (hi.year, hi.month, hi.day) == expected[1]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("digits", ["202023", "20200230", "20201301", "20", "abcd"])
|
|
||||||
def test_invalid_returns_none(self, digits):
|
|
||||||
assert _precision_bounds(digits) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestScan:
|
|
||||||
def test_plain_words_are_passthrough(self):
|
|
||||||
assert scan("bank statement") == [Passthrough("bank statement")]
|
|
||||||
|
|
||||||
def test_field_value(self):
|
|
||||||
assert scan("created:2020") == [FieldValue("created", "2020")]
|
|
||||||
|
|
||||||
def test_field_value_in_boolean(self):
|
|
||||||
toks = scan("created:2020 OR foo")
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("created", "2020"),
|
|
||||||
Passthrough(" OR foo"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_field_value_in_parens(self):
|
|
||||||
toks = scan("(created:2020 OR foo)")
|
|
||||||
assert toks == [
|
|
||||||
Passthrough("("),
|
|
||||||
FieldValue("created", "2020"),
|
|
||||||
Passthrough(" OR foo)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_quoted_value(self):
|
|
||||||
assert scan('correspondent:"A B"') == [FieldValue("correspondent", '"A B"')]
|
|
||||||
|
|
||||||
def test_field_range(self):
|
|
||||||
assert scan("created:[2020 TO 2021]") == [
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 to]",
|
|
||||||
FieldRange("created", "[", "2020", "", "]"),
|
|
||||||
id="open_upper",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[to 2020]",
|
|
||||||
FieldRange("created", "[", "", "2020", "]"),
|
|
||||||
id="open_lower",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_open_range(self, query, expected):
|
|
||||||
assert scan(query) == [expected]
|
|
||||||
|
|
||||||
def test_comma_inside_range_not_split(self):
|
|
||||||
# No depth-0 comma here; the whole thing is one range token.
|
|
||||||
toks = scan("created:[2020 TO 2021]")
|
|
||||||
assert len(toks) == 1
|
|
||||||
|
|
||||||
# --- Edge-case / regression tests (scan must never raise) ---
|
|
||||||
|
|
||||||
def test_url_is_passthrough(self):
|
|
||||||
# "http" is not a known field; the whole URL must pass through verbatim.
|
|
||||||
assert scan("http://example.com") == [Passthrough("http://example.com")]
|
|
||||||
|
|
||||||
def test_unterminated_quote_is_passthrough(self):
|
|
||||||
# title is a known field but the quoted value has no closing quote;
|
|
||||||
# _consume_value returns None so the whole string falls into passthrough.
|
|
||||||
assert scan('title:"abc') == [Passthrough('title:"abc')]
|
|
||||||
|
|
||||||
def test_unterminated_bracket_is_passthrough(self):
|
|
||||||
# created is a known field but the range bracket is never closed;
|
|
||||||
# _consume_range returns None so the whole string falls into passthrough.
|
|
||||||
assert scan("created:[2020") == [Passthrough("created:[2020")]
|
|
||||||
|
|
||||||
def test_empty_value_at_end_is_passthrough(self):
|
|
||||||
# created is a known field but there is no value after the colon
|
|
||||||
# (_consume_value returns None for start >= n), so passthrough.
|
|
||||||
assert scan("created:") == [Passthrough("created:")]
|
|
||||||
|
|
||||||
def test_value_containing_colon(self):
|
|
||||||
# The bare-word value reader stops at whitespace/paren, not at colon,
|
|
||||||
# so "2020:30" is consumed as a single value token.
|
|
||||||
assert scan("created:2020:30") == [FieldValue("created", "2020:30")]
|
|
||||||
|
|
||||||
def test_comma_followed_by_unconsumable_value_stops(self):
|
|
||||||
# A comma followed by whitespace is neither a value-list continuation nor a
|
|
||||||
# clause separator: the value stops and the comma stays as passthrough.
|
|
||||||
assert scan("tag:foo, bar") == [
|
|
||||||
FieldValue("tag", "foo"),
|
|
||||||
Passthrough(", bar"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_bracket_without_to_is_open_upper_bound(self):
|
|
||||||
# A bracketed value with no TO falls back to (value, "") -> open upper bound.
|
|
||||||
assert scan("created:[2020]") == [
|
|
||||||
FieldRange("created", "[", "2020", "", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_known_field_name_midword_is_passthrough(self):
|
|
||||||
# A known field name embedded mid-word is not a field token (the
|
|
||||||
# word-boundary guard); the whole run stays passthrough.
|
|
||||||
assert scan("xtag:foo") == [Passthrough("xtag:foo")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestCommaResolution:
|
|
||||||
def test_value_list_multi_value_field(self):
|
|
||||||
toks = resolve_commas(scan("tag:foo,bar"))
|
|
||||||
assert toks == [FieldValueList("tag", ("foo", "bar"))]
|
|
||||||
|
|
||||||
def test_value_list_three(self):
|
|
||||||
toks = resolve_commas(scan("tag_id:1,2,3"))
|
|
||||||
assert toks == [FieldValueList("tag_id", ("1", "2", "3"))]
|
|
||||||
|
|
||||||
def test_text_field_comma_is_literal(self):
|
|
||||||
# correspondent is not multi-value: comma stays inside the value.
|
|
||||||
toks = resolve_commas(scan("correspondent:foo,bar"))
|
|
||||||
assert toks == [FieldValue("correspondent", "foo,bar")]
|
|
||||||
|
|
||||||
def test_clause_separator_before_known_field(self):
|
|
||||||
toks = resolve_commas(scan("tag:foo,type:bar"))
|
|
||||||
assert toks == [FieldValue("tag", "foo"), Comma(), FieldValue("type", "bar")]
|
|
||||||
|
|
||||||
def test_clause_separator_after_range(self):
|
|
||||||
toks = resolve_commas(scan("created:[2020 TO 2021],added:[2022 TO 2023]"))
|
|
||||||
assert toks == [
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("added", "[", "2022", "2023", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_clause_separator_after_quote(self):
|
|
||||||
toks = resolve_commas(scan('correspondent:"A B",created:[2020 TO 2021]'))
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", '"A B"'),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_url_comma_is_literal_passthrough(self):
|
|
||||||
toks = resolve_commas(scan("http://example.com/a,b"))
|
|
||||||
assert toks == [Passthrough("http://example.com/a,b")]
|
|
||||||
|
|
||||||
def test_non_multi_value_comma_is_literal(self):
|
|
||||||
# title is not in MULTI_VALUE_FIELDS: comma stays inside the value.
|
|
||||||
toks = resolve_commas(scan("title:10,20"))
|
|
||||||
assert toks == [FieldValue("title", "10,20")]
|
|
||||||
|
|
||||||
def test_clause_separator_before_known_date_field(self):
|
|
||||||
# The comma between a bare value and a known date field acts as a
|
|
||||||
# clause separator; both sides survive as distinct tokens.
|
|
||||||
toks = resolve_commas(scan("correspondent:foo,created:[2020 TO 2021]"))
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", "foo"),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateScalar:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "value", "expected"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"202003",
|
|
||||||
"created:[2020-03-01T00:00:00Z TO 2020-04-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"20200115",
|
|
||||||
"created:[2020-01-15T00:00:00Z TO 2020-01-16T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020-01-15",
|
|
||||||
"created:[2020-01-15T00:00:00Z TO 2020-01-16T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020-03",
|
|
||||||
"created:[2020-03-01T00:00:00Z TO 2020-04-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_partial_and_iso_dates(self, field: str, value: str, expected: str) -> None:
|
|
||||||
assert translate_scalar(field, value, UTC) == expected
|
|
||||||
|
|
||||||
def test_invalid_date_raises(self) -> None:
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "202023", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "202023"
|
|
||||||
|
|
||||||
def test_keyword_delegates(self) -> None:
|
|
||||||
# keyword path produces a half-open range; just assert it is a created range
|
|
||||||
out = translate_scalar("created", "today", UTC)
|
|
||||||
assert out.startswith("created:[") and out.endswith("}")
|
|
||||||
|
|
||||||
def test_14digit_compact_datetime(self) -> None:
|
|
||||||
out = translate_scalar("created", "20240115120000", UTC)
|
|
||||||
assert "20240115120000" not in out
|
|
||||||
assert out.startswith("created:")
|
|
||||||
assert out == "created:[2024-01-15T12:00:00Z TO 2024-01-15T12:00:00Z]"
|
|
||||||
|
|
||||||
def test_14digit_invalid_month_raises(self) -> None:
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "20231300120000", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "20231300120000"
|
|
||||||
|
|
||||||
def test_unrecognized_value_raises(self) -> None:
|
|
||||||
# A value that is not a keyword, digits, ISO date, or compact timestamp
|
|
||||||
# raises rather than producing invalid Tantivy syntax or silently matching
|
|
||||||
# nothing.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "garbage", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "garbage"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateRange:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("lo", "hi", "expected"),
|
|
||||||
[
|
|
||||||
("2005", "2009", "created:[2005-01-01T00:00:00Z TO 2010-01-01T00:00:00Z}"),
|
|
||||||
(
|
|
||||||
"202001",
|
|
||||||
"202006",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2020-07-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"20200101",
|
|
||||||
"20201231",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"2020-01-01",
|
|
||||||
"2020-12-31",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_absolute_ranges(self, lo, hi, expected):
|
|
||||||
assert translate_range("created", lo, hi, UTC) == expected
|
|
||||||
|
|
||||||
def test_reversed_swaps(self):
|
|
||||||
assert translate_range("created", "2009", "2005", UTC) == (
|
|
||||||
"created:[2005-01-01T00:00:00Z TO 2010-01-01T00:00:00Z}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_open_upper(self):
|
|
||||||
out = translate_range("created", "2020", "", UTC)
|
|
||||||
assert out == f"created:[2020-01-01T00:00:00Z TO {OPEN_HI}]"
|
|
||||||
|
|
||||||
def test_open_lower(self):
|
|
||||||
out = translate_range("created", "", "2020", UTC)
|
|
||||||
assert out == f"created:[{OPEN_LO} TO 2021-01-01T00:00:00Z}}"
|
|
||||||
|
|
||||||
def test_invalid_bound_raises(self):
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range("created", "202023", "2025", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "202023"
|
|
||||||
|
|
||||||
def test_invalid_high_bound_raises(self):
|
|
||||||
# Low bound parses, high bound does not -> raise on the high bound.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range("created", "2020", "garbage", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "garbage"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateQuery:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"created:2020",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
("tag:foo,bar", "tag:foo AND tag:bar"),
|
|
||||||
# 'type' is a user-facing alias rewritten to 'document_type' (the real schema field)
|
|
||||||
("tag:foo,type:bar", "tag:foo AND document_type:bar"),
|
|
||||||
(
|
|
||||||
"created:[2020 TO 2021],added:[2022 TO 2023]",
|
|
||||||
(
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
|
||||||
" AND "
|
|
||||||
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z}"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# correspondent is not multi-value: comma stays literal inside the value
|
|
||||||
("correspondent:foo,bar", "correspondent:foo,bar"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_golden(self, raw: str, expected: str) -> None:
|
|
||||||
assert translate_query(raw, UTC) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"created:2020",
|
|
||||||
"created:202003",
|
|
||||||
"created:[20200101 TO 20201231]",
|
|
||||||
"created:[2020-01-01 TO 2020-12-31]",
|
|
||||||
"created:[2020 to]",
|
|
||||||
"created:[to 2020]",
|
|
||||||
"title:x,created:[2020 TO 2021]",
|
|
||||||
"created:2020 OR foo",
|
|
||||||
"(created:2020 OR invoice)",
|
|
||||||
"tag:foo,type:bar",
|
|
||||||
"bank statement",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
# Must not raise:
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestFieldAliasing:
|
|
||||||
"""Whoosh->Tantivy field-name aliasing (type/path -> document_type/storage_path)."""
|
|
||||||
|
|
||||||
def test_type_alias(self) -> None:
|
|
||||||
assert translate_query("type:invoice", UTC) == "document_type:invoice"
|
|
||||||
|
|
||||||
def test_path_alias(self) -> None:
|
|
||||||
assert translate_query("path:/foo/bar", UTC) == "storage_path:/foo/bar"
|
|
||||||
|
|
||||||
def test_type_id_alias(self) -> None:
|
|
||||||
assert translate_query("type_id:5", UTC) == "document_type_id:5"
|
|
||||||
|
|
||||||
def test_path_id_alias(self) -> None:
|
|
||||||
assert translate_query("path_id:7", UTC) == "storage_path_id:7"
|
|
||||||
|
|
||||||
def test_clause_separator_plus_alias(self) -> None:
|
|
||||||
# Comma between known fields acts as AND separator; alias still applied.
|
|
||||||
assert (
|
|
||||||
translate_query("tag:foo,type:bar", UTC) == "tag:foo AND document_type:bar"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_type_range_alias(self) -> None:
|
|
||||||
# type is not a date field; range passes through verbatim with alias applied.
|
|
||||||
assert (
|
|
||||||
translate_query("type:[2020 TO 2021]", UTC)
|
|
||||||
== "document_type:[2020 TO 2021]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_parse_acceptance_type(self, index: tantivy.Index) -> None:
|
|
||||||
# Translated output must be accepted by the real Tantivy parser.
|
|
||||||
translated = translate_query("type:invoice", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_path(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("path:foo", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
# Freeze time so relative-date tests are deterministic.
|
|
||||||
_FROZEN_NOW = datetime(2026, 3, 28, 12, 0, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestRelativeRanges:
|
|
||||||
"""Relative date-range tokens resolved against a frozen clock."""
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_7_days_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-7 days to now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_week_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-1 week to now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_month_to_now(self) -> None:
|
|
||||||
assert translate_query("created:[-1 month to now]", UTC) == (
|
|
||||||
"created:[2026-02-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_year_to_now(self) -> None:
|
|
||||||
assert translate_query("modified:[-1 year to now]", UTC) == (
|
|
||||||
"modified:[2025-03-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_3_hours_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-3 hours to now]", UTC) == (
|
|
||||||
"added:[2026-03-28T09:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_uppercase_units(self) -> None:
|
|
||||||
assert translate_query("added:[-1 WEEK TO NOW]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_now_minus_7d_compact(self) -> None:
|
|
||||||
assert translate_query("added:[now-7d TO now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_reversed_range_swapped(self) -> None:
|
|
||||||
# now+1h TO now-1h is reversed; translate_range swaps -> lo=now-1h, hi=now+1h
|
|
||||||
assert translate_query("added:[now+1h TO now-1h]", UTC) == (
|
|
||||||
"added:[2026-03-28T11:00:00Z TO 2026-03-28T13:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"added:[-7 days to now]",
|
|
||||||
"added:[-1 week to now]",
|
|
||||||
"created:[-1 month to now]",
|
|
||||||
"modified:[-1 year to now]",
|
|
||||||
"added:[-3 hours to now]",
|
|
||||||
"added:[now-7d TO now]",
|
|
||||||
"added:[now+1h TO now-1h]",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestWhooshUnitAbbreviations:
|
|
||||||
"""
|
|
||||||
Whoosh's PlusMinus date grammar accepted abbreviated unit spellings
|
|
||||||
(e.g. "yrs", "mos", "wks", "hrs", "mins", "secs"); saved views/searches
|
|
||||||
created under the old Whoosh backend can contain those tokens (see
|
|
||||||
https://github.com/paperless-ngx/paperless-ngx/issues/13482), so the
|
|
||||||
Tantivy translator must still accept them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_999_yrs(self) -> None:
|
|
||||||
assert translate_query("created:[-999yrs to now]", UTC) == (
|
|
||||||
"created:[1027-03-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("token", "expected_lo"),
|
|
||||||
[
|
|
||||||
("-1y", "2025-03-28T12:00:00Z"),
|
|
||||||
("-1yr", "2025-03-28T12:00:00Z"),
|
|
||||||
("-3mos", "2025-12-28T12:00:00Z"),
|
|
||||||
("-3mo", "2025-12-28T12:00:00Z"),
|
|
||||||
("-2wks", "2026-03-14T12:00:00Z"),
|
|
||||||
("-2wk", "2026-03-14T12:00:00Z"),
|
|
||||||
("-5dys", "2026-03-23T12:00:00Z"),
|
|
||||||
("-5dy", "2026-03-23T12:00:00Z"),
|
|
||||||
("-1hrs", "2026-03-28T11:00:00Z"),
|
|
||||||
("-1hr", "2026-03-28T11:00:00Z"),
|
|
||||||
("-10mins", "2026-03-28T11:50:00Z"),
|
|
||||||
("-10min", "2026-03-28T11:50:00Z"),
|
|
||||||
("-30secs", "2026-03-28T11:59:30Z"),
|
|
||||||
("-30sec", "2026-03-28T11:59:30Z"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_abbreviated_units(self, token: str, expected_lo: str) -> None:
|
|
||||||
assert translate_query(f"added:[{token} to now]", UTC) == (
|
|
||||||
f"added:[{expected_lo} TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"created:[-999yrs to now]",
|
|
||||||
"added:[-1y to now]",
|
|
||||||
"created:[-3mos to now]",
|
|
||||||
"added:[-2wks to now]",
|
|
||||||
"added:[-5dys to now]",
|
|
||||||
"added:[-1hrs to now]",
|
|
||||||
"added:[-10mins to now]",
|
|
||||||
"added:[-30secs to now]",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestOperatorNormalization:
|
|
||||||
"""Post-render operator normalization in translate_query."""
|
|
||||||
|
|
||||||
def test_spaced_dash_removed(self) -> None:
|
|
||||||
assert (
|
|
||||||
translate_query("H52.1 - Kurzsichtigkeit", UTC) == "H52.1 Kurzsichtigkeit"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_spaced_dash_simple(self) -> None:
|
|
||||||
assert translate_query("bar - baz", UTC) == "bar baz"
|
|
||||||
|
|
||||||
def test_trailing_operator_stripped(self) -> None:
|
|
||||||
assert translate_query("foo -", UTC) == "foo"
|
|
||||||
|
|
||||||
def test_date_range_preserved(self) -> None:
|
|
||||||
out = translate_query("created:[2020 TO 2021]", UTC)
|
|
||||||
# Must not corrupt the ISO range
|
|
||||||
assert out == "created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
|
||||||
|
|
||||||
def test_date_scalar_with_or(self) -> None:
|
|
||||||
out = translate_query("created:2020 OR foo", UTC)
|
|
||||||
# The created scalar becomes a range; " OR foo" passes through verbatim.
|
|
||||||
assert out.startswith("created:[")
|
|
||||||
assert "OR foo" in out
|
|
||||||
|
|
||||||
def test_parse_acceptance_spaced_dash(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("H52.1 - Kurzsichtigkeit", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_trailing_op(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("foo -", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestMultiWordDateKeywords:
|
|
||||||
"""scan() must consume multi-word date keywords as a single value."""
|
|
||||||
|
|
||||||
def test_scan_previous_week_as_single_token(self) -> None:
|
|
||||||
# "created:previous week" must produce one FieldValue with value "previous week",
|
|
||||||
# not FieldValue("created","previous") + Passthrough(" week").
|
|
||||||
toks = scan("created:previous week")
|
|
||||||
assert toks == [FieldValue("created", "previous week")]
|
|
||||||
|
|
||||||
def test_scan_this_month_as_single_token(self) -> None:
|
|
||||||
toks = scan("added:this month")
|
|
||||||
assert toks == [FieldValue("added", "this month")]
|
|
||||||
|
|
||||||
def test_scan_previous_month_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous month")
|
|
||||||
assert toks == [FieldValue("created", "previous month")]
|
|
||||||
|
|
||||||
def test_scan_this_year_as_single_token(self) -> None:
|
|
||||||
toks = scan("added:this year")
|
|
||||||
assert toks == [FieldValue("added", "this year")]
|
|
||||||
|
|
||||||
def test_scan_previous_year_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous year")
|
|
||||||
assert toks == [FieldValue("created", "previous year")]
|
|
||||||
|
|
||||||
def test_scan_previous_quarter_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous quarter")
|
|
||||||
assert toks == [FieldValue("created", "previous quarter")]
|
|
||||||
|
|
||||||
def test_quoted_multi_word_keyword_still_works(self) -> None:
|
|
||||||
# The quoted form must continue to work as before.
|
|
||||||
toks = scan('created:"previous week"')
|
|
||||||
assert toks == [FieldValue("created", '"previous week"')]
|
|
||||||
|
|
||||||
def test_non_date_field_not_affected(self) -> None:
|
|
||||||
# "previous" stops at the space for non-date fields; " week" passes through.
|
|
||||||
toks = scan("correspondent:previous week")
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", "previous"),
|
|
||||||
Passthrough(" week"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestKeywordDateResolution:
|
|
||||||
"""Relative date keywords resolve to exact ISO ranges against a frozen clock.
|
|
||||||
|
|
||||||
Frozen at 2026-03-28 12:00 UTC (a Saturday in Q1) so the week, month,
|
|
||||||
quarter and year rollovers are all exercised by a single anchor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# created is a DateField: bounds are UTC midnight, no timezone offset.
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"today",
|
|
||||||
"created:[2026-03-28T00:00:00Z TO 2026-03-29T00:00:00Z}",
|
|
||||||
id="today",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"created:[2026-03-27T00:00:00Z TO 2026-03-28T00:00:00Z}",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"created:[2026-03-16T00:00:00Z TO 2026-03-23T00:00:00Z}",
|
|
||||||
id="previous-week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"created:[2026-03-01T00:00:00Z TO 2026-04-01T00:00:00Z}",
|
|
||||||
id="this-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"created:[2026-02-01T00:00:00Z TO 2026-03-01T00:00:00Z}",
|
|
||||||
id="previous-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2027-01-01T00:00:00Z}",
|
|
||||||
id="this-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"created:[2025-01-01T00:00:00Z TO 2026-01-01T00:00:00Z}",
|
|
||||||
id="previous-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous quarter",
|
|
||||||
"created:[2025-10-01T00:00:00Z TO 2026-01-01T00:00:00Z}",
|
|
||||||
id="previous-quarter",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_date_only_field_keyword_ranges(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
assert translate_query(f"created:{keyword}", UTC) == expected
|
|
||||||
|
|
||||||
# added is a DateTimeField: local-tz midnight converted to UTC. Tokyo
|
|
||||||
# (+09:00, no DST) shifts each midnight boundary back to 15:00Z the day
|
|
||||||
# before, so this also exercises the local-midnight offset path.
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"today",
|
|
||||||
"added:[2026-03-27T15:00:00Z TO 2026-03-28T15:00:00Z}",
|
|
||||||
id="today",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"added:[2026-03-26T15:00:00Z TO 2026-03-27T15:00:00Z}",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"added:[2026-03-15T15:00:00Z TO 2026-03-22T15:00:00Z}",
|
|
||||||
id="previous-week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"added:[2026-02-28T15:00:00Z TO 2026-03-31T15:00:00Z}",
|
|
||||||
id="this-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"added:[2026-01-31T15:00:00Z TO 2026-02-28T15:00:00Z}",
|
|
||||||
id="previous-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"added:[2025-12-31T15:00:00Z TO 2026-12-31T15:00:00Z}",
|
|
||||||
id="this-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"added:[2024-12-31T15:00:00Z TO 2025-12-31T15:00:00Z}",
|
|
||||||
id="previous-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous quarter",
|
|
||||||
"added:[2025-09-30T15:00:00Z TO 2025-12-31T15:00:00Z}",
|
|
||||||
id="previous-quarter",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_datetime_field_keyword_ranges_local_tz(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
assert translate_query(f"added:{keyword}", ZoneInfo("Asia/Tokyo")) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestISODatetimeBounds:
|
|
||||||
"""Full ISO datetime tokens in range bounds must be parsed directly."""
|
|
||||||
|
|
||||||
def test_translate_range_iso_bounds_passthrough(self) -> None:
|
|
||||||
# Already-ISO datetime bounds must pass through as-is (exact instant).
|
|
||||||
result = translate_range(
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
UTC,
|
|
||||||
)
|
|
||||||
assert result == "created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z]"
|
|
||||||
|
|
||||||
def test_translate_query_iso_range_preserved(self) -> None:
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
assert translate_query(q, UTC) == q
|
|
||||||
|
|
||||||
def test_translate_query_comma_separated_iso_ranges(self) -> None:
|
|
||||||
q = (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
result = translate_query(q, UTC)
|
|
||||||
assert result == (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
" AND "
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_translate_query_text_before_comma_separated_date_clause(self) -> None:
|
|
||||||
result = translate_query("schäfersee,created:previous year", UTC)
|
|
||||||
assert result == (
|
|
||||||
"schäfersee AND created:[2025-01-01T00:00:00Z TO 2026-01-01T00:00:00Z}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_iso_datetime_raises(self) -> None:
|
|
||||||
# A token with "T" that is not valid ISO datetime -> raise.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range(
|
|
||||||
"created",
|
|
||||||
"2020-01-01T99:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
UTC,
|
|
||||||
)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "2020-01-01T99:00:00Z"
|
|
||||||
|
|
||||||
def test_parse_acceptance_iso_bounds(self, index: tantivy.Index) -> None:
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
translated = translate_query(q, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_comma_iso_ranges(self, index: tantivy.Index) -> None:
|
|
||||||
q = (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
translated = translate_query(q, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
@@ -339,3 +339,21 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
self.assertEqual(response.content, b"Insufficient permissions")
|
||||||
|
|
||||||
|
def test_bad_search_query_returns_400(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "added:notadate"},
|
||||||
|
"content": "originals",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user-fixable query error must surface as a 400 naming the bad
|
||||||
|
# value, exactly like the search list endpoint, never a 500.
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"notadate", response.content)
|
||||||
|
|||||||
@@ -1976,3 +1976,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
||||||
|
|
||||||
|
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "added:notadate"},
|
||||||
|
"method": "set_storage_path",
|
||||||
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user-fixable query error must surface as a 400 naming the bad
|
||||||
|
# value, exactly like the search list endpoint, never a 500.
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"notadate", response.content)
|
||||||
|
|||||||
@@ -756,6 +756,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
tick=False,
|
tick=False,
|
||||||
):
|
):
|
||||||
response = self.client.get("/api/documents/?query=added:previous month")
|
response = self.client.get("/api/documents/?query=added:previous month")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"expected a successful search response, got {response.status_code}: "
|
||||||
|
f"{response.data!r}"
|
||||||
|
)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
|
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
@@ -788,6 +792,26 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("invalid-date", str(response.data["query"]))
|
self.assertIn("invalid-date", str(response.data["query"]))
|
||||||
|
|
||||||
|
def test_search_multiple_bad_fields_returns_all_messages(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- One document added
|
||||||
|
WHEN:
|
||||||
|
- Query with multiple bad fields (e.g. invalid date and invalid number)
|
||||||
|
THEN:
|
||||||
|
- 400 Bad Request with error messages for every bad field,
|
||||||
|
so the user can fix them all in one round-trip
|
||||||
|
"""
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
{"query": "created:notadate AND asn:notanumber"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
messages = response.data["query"]
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
self.assertTrue(any("created" in m for m in messages))
|
||||||
|
self.assertTrue(any("asn" in m for m in messages))
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="UTC",
|
TIME_ZONE="UTC",
|
||||||
)
|
)
|
||||||
@@ -831,6 +855,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual({r["id"] for r in results}, {1, 2})
|
self.assertEqual({r["id"] for r in results}, {1, 2})
|
||||||
|
|
||||||
|
@mock.patch("documents.search._backend.parse_user_query")
|
||||||
|
def test_search_parser_bug_surfaces_as_500_not_400(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- The query parser itself fails (a whoosh-compat bug, per
|
||||||
|
QueryParserError's own contract: not user-fixable input)
|
||||||
|
WHEN:
|
||||||
|
- Any search request runs
|
||||||
|
THEN:
|
||||||
|
- The error surfaces as a 500 monitoring can see, never a 400
|
||||||
|
blaming the user for a library defect
|
||||||
|
"""
|
||||||
|
from whoosh_compat.errors import QueryParserError
|
||||||
|
|
||||||
|
m.side_effect = QueryParserError("synthetic parser bug")
|
||||||
|
|
||||||
|
self.client.raise_request_exception = False
|
||||||
|
response = self.client.get("/api/documents/?query=anything")
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.search._backend.TantivyBackend.autocomplete")
|
@mock.patch("documents.search._backend.TantivyBackend.autocomplete")
|
||||||
def test_search_autocomplete_limits(self, m) -> None:
|
def test_search_autocomplete_limits(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -2005,3 +2052,45 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
response = self.client.get("/api/search/?query=no")
|
response = self.client.get("/api/search/?query=no")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def _assert_query_finds(self, doc: Document, query: str) -> None:
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
response = self.client.get("/api/documents/", {"query": query})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
ids = [r["id"] for r in response.data["results"]]
|
||||||
|
self.assertIn(doc.id, ids)
|
||||||
|
|
||||||
|
def test_search_by_asn(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Has ASN",
|
||||||
|
content="content",
|
||||||
|
checksum="asn-checksum",
|
||||||
|
archive_serial_number=555,
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "asn:555")
|
||||||
|
|
||||||
|
def test_search_by_page_count(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Multi-page",
|
||||||
|
content="content",
|
||||||
|
checksum="page-count-checksum",
|
||||||
|
page_count=42,
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "page_count:42")
|
||||||
|
|
||||||
|
def test_search_by_original_filename(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Named file",
|
||||||
|
content="content",
|
||||||
|
checksum="filename-checksum",
|
||||||
|
original_filename="quarterly-report.pdf",
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "original_filename:quarterly-report.pdf")
|
||||||
|
|
||||||
|
def test_search_by_checksum(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Checksum doc",
|
||||||
|
content="content",
|
||||||
|
checksum="deadbeef1234",
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "checksum:deadbeef1234")
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""The search list endpoint's exception handling: what becomes a 400 and
|
||||||
|
what a library defect surfaces as instead.
|
||||||
|
|
||||||
|
Companion to documents/tests/search/test_error_routing.py, which pins the
|
||||||
|
Cause -> SearchQueryError/QueryError routing inside documents/search/_query.py.
|
||||||
|
These tests pin the layer above it: DocumentViewSet.list's own except clauses,
|
||||||
|
which decide what an already-routed error becomes on the wire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
from whoosh_compat.errors import Cause
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
|
||||||
|
from documents.search import SearchQueryError
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchQueryErrorStillBecomesA400:
|
||||||
|
def test_search_query_error_becomes_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/?query=anything")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLibraryDefectsPropagate:
|
||||||
|
"""The exact regression this task exists to fix: an unexpected or
|
||||||
|
INTERNAL-cause library error must not be relabeled a 400."""
|
||||||
|
|
||||||
|
def test_unexpected_exception_is_not_converted_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to search grammar")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.get("/api/documents/?query=anything")
|
||||||
|
|
||||||
|
def test_internal_cause_query_error_is_not_converted_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
"""Forces the one library-internal failure mode reachable from a real
|
||||||
|
query: emit() reporting a defect in itself (Cause.INTERNAL) after a
|
||||||
|
real query string went through the real parse and routing pipeline.
|
||||||
|
|
||||||
|
``tantivy_emit`` (the whoosh-compat emitter) is monkeypatched rather
|
||||||
|
than ``parse_user_query`` itself, so everything upstream of it --
|
||||||
|
the pre-parse rewrites, ``wc.parse()``, and ``_map_emit_error``'s own
|
||||||
|
Cause routing in documents/search/_query.py -- runs for real; only
|
||||||
|
the final emit call is forced to report the defect.
|
||||||
|
"""
|
||||||
|
import documents.search._query as query_mod
|
||||||
|
|
||||||
|
def raise_internal(*args: object, **kwargs: object) -> object:
|
||||||
|
raise QueryError(
|
||||||
|
Diagnostic(
|
||||||
|
kind=DiagnosticKind.BACKEND_REJECTED,
|
||||||
|
cause=Cause.INTERNAL,
|
||||||
|
message="synthetic whoosh-compat emitter defect",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(query_mod, "tantivy_emit", raise_internal)
|
||||||
|
|
||||||
|
with pytest.raises(QueryError):
|
||||||
|
admin_client.get("/api/documents/?query=invoice")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectionPathsAgreeWithSearch:
|
||||||
|
"""DocumentSelectionMixin backs bulk edit, bulk download, and a
|
||||||
|
more_like_id selection filter. It catches only SearchQueryError -- the
|
||||||
|
same contract the search list endpoint enforces above -- so all three
|
||||||
|
must map SearchQueryError to a 400 and let anything else surface."""
|
||||||
|
|
||||||
|
def test_bulk_edit_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_bulk_edit_lets_an_unexpected_exception_surface(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to search grammar")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bulk_download_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_more_like_id_selection_filter_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("similar-document lookup is unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod.TantivyBackend,
|
||||||
|
"more_like_this_ids",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"more_like_id": indexed_document.pk},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_more_like_id_selection_filter_lets_an_unexpected_exception_surface(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to similarity lookup")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod.TantivyBackend,
|
||||||
|
"more_like_this_ids",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"more_like_id": indexed_document.pk},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""The query-length cap in ``_get_tantivy_query_and_mode`` (F3).
|
||||||
|
|
||||||
|
whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an
|
||||||
|
unbounded ``query`` (SearchMode.QUERY) string is a CPU-exhaustion vector
|
||||||
|
against a single request handler. The GET search endpoint is incidentally
|
||||||
|
bounded by the web server's header limit, but the POST selection-filter
|
||||||
|
path (bulk edit, bulk download) is not -- that is the real vector, so it
|
||||||
|
must be pinned here too, not just the GET path.
|
||||||
|
|
||||||
|
The cap is enforced once, in the shared helper both entry points call, so
|
||||||
|
these tests exercise the real endpoints rather than the helper directly:
|
||||||
|
a construct that looks right in isolation has repeatedly behaved
|
||||||
|
differently end to end on this branch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
from documents.views import _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSearchEndpointEnforcesTheCap:
|
||||||
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/", {"query": query})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/", {"query": query})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
def test_an_ordinary_query_is_unaffected(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get("/api/documents/", {"query": "invoice"})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostSelectionPathsEnforceTheCap:
|
||||||
|
"""The bulk-edit and bulk-download selection filters share the same
|
||||||
|
helper the GET search path uses. This is the path that actually
|
||||||
|
matters: it is not bounded by a web server's header-length limit the
|
||||||
|
way the GET path incidentally is."""
|
||||||
|
|
||||||
|
def test_bulk_edit_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||||
|
def test_bulk_edit_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
bulk_update_task_mock: mock.MagicMock,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
# The cap check must accept this query and let the request reach the
|
||||||
|
# real bulk-edit method; nothing here is testing that method itself,
|
||||||
|
# so the Celery dispatch it makes is mocked out, same as every other
|
||||||
|
# bulk-edit test (test_api_bulk_edit.py) does.
|
||||||
|
query = "a" * _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalSearchEnforcesTheCapToo:
|
||||||
|
"""GlobalSearchView calls the backend directly, not through the shared helper.
|
||||||
|
|
||||||
|
It hardcodes SearchMode.TEXT, which is linear rather than quadratic, so it
|
||||||
|
was never the CPU-exhaustion vector. It is capped anyway so that "every
|
||||||
|
user query string reaching the backend passes a length check" is an
|
||||||
|
invariant rather than a claim with an exception: the view already bounds
|
||||||
|
the query from below, and a later change letting it select a mode would
|
||||||
|
otherwise reopen the hole silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/",
|
||||||
|
{"query": "a" * (_MAX_QUERY_LENGTH + 1)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/",
|
||||||
|
{"query": "a" * _MAX_QUERY_LENGTH},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""An unterminated ``[`` date range bracket at the API level.
|
||||||
|
|
||||||
|
``created:[2020`` (with or without a dangling ``to <value>``) now raises
|
||||||
|
BAD_DATE and the search endpoint returns HTTP 400, where it used to parse
|
||||||
|
past the missing ``]`` and silently pass the malformed range through.
|
||||||
|
A 400 is correct: malformed input should fail loudly rather than silently
|
||||||
|
matching an unintended query. Pinned at the API level -- the layer a user
|
||||||
|
or client actually sees -- rather than only against the parser directly.
|
||||||
|
|
||||||
|
The properly closed decoy proves the bracket is what matters, not
|
||||||
|
whoosh-compat's date grammar generally: ``created:[2020 to 2021]`` parses
|
||||||
|
and searches cleanly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnterminatedBracketReturnsA400:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("created:[2020", id="missing_upper_bound_and_bracket"),
|
||||||
|
pytest.param("created:[2020 to 2021", id="missing_closing_bracket"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unterminated_bracket_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(f"/api/documents/?query={query}")
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "created" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_properly_closed_bracket_still_searches_cleanly(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/documents/?query=created:[2020 to 2021]",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
+50
-15
@@ -16,6 +16,7 @@ from time import mktime
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Final
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from unicodedata import normalize
|
from unicodedata import normalize
|
||||||
@@ -279,18 +280,41 @@ logger = logging.getLogger("paperless.api")
|
|||||||
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
||||||
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
||||||
|
|
||||||
|
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
||||||
|
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
||||||
|
# measured at ~0.96s/10k chars, ~3.67s/20k, ~14.4s/40k against the real field
|
||||||
|
# registry. Django's DATA_UPLOAD_MAX_MEMORY_SIZE default (2.5 MB) does not
|
||||||
|
# bound this on the POST-body selection-filter path, so an unbounded query
|
||||||
|
# is a single-request CPU exhaustion vector. 4096 chars caps the worst case
|
||||||
|
# at roughly 0.16s (quadratic extrapolation from the measurements above),
|
||||||
|
# far beyond any plausible hand-typed advanced query, while still being fast
|
||||||
|
# enough to absorb inside a request handler. Applied to all three modes at
|
||||||
|
# this shared choke point: TEXT and TITLE route through simple_search_tokens
|
||||||
|
# instead and measure linear even at 20k chars, so the cap is hygiene for
|
||||||
|
# them, not a fix, but a single limit here is simpler than one exemption.
|
||||||
|
# Not exposed as a PAPERLESS_* setting: this is a hard security boundary,
|
||||||
|
# not a tunable, and a raisable ceiling would let a misconfiguration
|
||||||
|
# reintroduce the exact hazard this exists to close.
|
||||||
|
_MAX_QUERY_LENGTH: Final[int] = 4096
|
||||||
|
|
||||||
|
|
||||||
def _get_tantivy_query_and_mode(params):
|
def _get_tantivy_query_and_mode(params):
|
||||||
|
from documents.search import QueryTooLongError
|
||||||
from documents.search import SearchMode
|
from documents.search import SearchMode
|
||||||
|
|
||||||
if "text" in params:
|
if "text" in params:
|
||||||
return str(params["text"]), SearchMode.TEXT
|
raw, mode = str(params["text"]), SearchMode.TEXT
|
||||||
if "title_search" in params:
|
elif "title_search" in params:
|
||||||
return str(params["title_search"]), SearchMode.TITLE
|
raw, mode = str(params["title_search"]), SearchMode.TITLE
|
||||||
if "query" in params:
|
elif "query" in params:
|
||||||
return str(params["query"]), SearchMode.QUERY
|
raw, mode = str(params["query"]), SearchMode.QUERY
|
||||||
|
else:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
|
if len(raw) > _MAX_QUERY_LENGTH:
|
||||||
|
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
||||||
|
return raw, mode
|
||||||
|
|
||||||
|
|
||||||
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
try:
|
try:
|
||||||
@@ -2421,6 +2445,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
from documents.search import TantivyBackend
|
from documents.search import TantivyBackend
|
||||||
from documents.search import TantivyRelevanceList
|
from documents.search import TantivyRelevanceList
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
from documents.search import search_query_error_messages
|
||||||
|
|
||||||
def parse_search_params() -> SearchParams:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
@@ -2611,15 +2636,10 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise
|
raise
|
||||||
except SearchQueryError as e:
|
except SearchQueryError as e:
|
||||||
# User-fixable query error (e.g. an unparsable date): surface the
|
# User-fixable query error(s) (e.g. unparsable dates/numbers):
|
||||||
# specific message so the user can correct it, rather than a generic
|
# surface every offending field's message, not just the first,
|
||||||
# 400 or silently empty results.
|
# so the user can fix them all in one round-trip.
|
||||||
raise ValidationError({"query": [str(e)]}) from e
|
raise ValidationError({"query": search_query_error_messages(e)}) from e
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"An error occurred listing search results: {e!s}")
|
|
||||||
return HttpResponseBadRequest(
|
|
||||||
"Error listing search results, check logs for more detail.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["GET"], name="Get Next ASN")
|
@action(detail=False, methods=["GET"], name="Get Next ASN")
|
||||||
def next_asn(self, request, *args, **kwargs):
|
def next_asn(self, request, *args, **kwargs):
|
||||||
@@ -2755,16 +2775,22 @@ class DocumentSelectionMixin:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from documents.search import SearchQueryError
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
from documents.search import search_query_error_messages
|
||||||
|
|
||||||
filter_name = search_filters[0]
|
filter_name = search_filters[0]
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
search_user = None if user.is_superuser else user
|
search_user = None if user.is_superuser else user
|
||||||
|
|
||||||
|
try:
|
||||||
if filter_name == "more_like_id":
|
if filter_name == "more_like_id":
|
||||||
more_like_doc_id = _get_more_like_id(filters, user)
|
more_like_doc_id = _get_more_like_id(filters, user)
|
||||||
|
|
||||||
search_ids = backend.more_like_this_ids(more_like_doc_id, user=search_user)
|
search_ids = backend.more_like_this_ids(
|
||||||
|
more_like_doc_id,
|
||||||
|
user=search_user,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
||||||
search_ids = backend.search_ids(
|
search_ids = backend.search_ids(
|
||||||
@@ -2772,6 +2798,11 @@ class DocumentSelectionMixin:
|
|||||||
user=search_user,
|
user=search_user,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
)
|
)
|
||||||
|
except SearchQueryError as e:
|
||||||
|
# Same user-fixable-query mapping as the search list endpoint:
|
||||||
|
# a bad date/number in a bulk selection filter is a 400 naming
|
||||||
|
# the value, never a 500.
|
||||||
|
raise ValidationError({"query": search_query_error_messages(e)}) from e
|
||||||
|
|
||||||
return search_ids
|
return search_ids
|
||||||
|
|
||||||
@@ -3608,6 +3639,10 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
return HttpResponseBadRequest("Query required")
|
return HttpResponseBadRequest("Query required")
|
||||||
if len(query) < 3:
|
if len(query) < 3:
|
||||||
return HttpResponseBadRequest("Query must be at least 3 characters")
|
return HttpResponseBadRequest("Query must be at least 3 characters")
|
||||||
|
if len(query) > _MAX_QUERY_LENGTH:
|
||||||
|
return HttpResponseBadRequest(
|
||||||
|
f"Query must be at most {_MAX_QUERY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
db_only = request.query_params.get("db_only", False)
|
db_only = request.query_params.get("db_only", False)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
|||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
@@ -2933,6 +2933,7 @@ dependencies = [
|
|||||||
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
|
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
|
{ name = "whoosh-compat", extra = ["tantivy"] },
|
||||||
{ name = "zxing-cpp" },
|
{ name = "zxing-cpp" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3091,6 +3092,7 @@ requires-dist = [
|
|||||||
{ name = "torch", specifier = "~=2.13.0", index = "https://download.pytorch.org/whl/cpu" },
|
{ name = "torch", specifier = "~=2.13.0", index = "https://download.pytorch.org/whl/cpu" },
|
||||||
{ name = "watchfiles", specifier = ">=1.2" },
|
{ name = "watchfiles", specifier = ">=1.2" },
|
||||||
{ name = "whitenoise", specifier = "~=6.11" },
|
{ name = "whitenoise", specifier = "~=6.11" },
|
||||||
|
{ name = "whoosh-compat", extras = ["tantivy"], specifier = "==0.1.0" },
|
||||||
{ name = "zxing-cpp", specifier = "~=3.1.0" },
|
{ name = "zxing-cpp", specifier = "~=3.1.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["mariadb", "postgres", "webserver"]
|
provides-extras = ["mariadb", "postgres", "webserver"]
|
||||||
@@ -5014,10 +5016,10 @@ version = "2.13.0+cpu"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
@@ -5652,6 +5654,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "whoosh-compat"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/f7/3e45f4a484afa174cd42e424ce2b8c514ae54f564830122656ad17b765e2/whoosh_compat-0.1.0.tar.gz", hash = "sha256:86935bdc159ed9b0a06a4661d17f1251d8280340b84e218cc73d915a2edaddf7", size = 577543, upload-time = "2026-08-25T15:22:42.316Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/51/9a8399d0f472814e136a2884bf910c6c59843a5a2c66afc4b5133eb531ea/whoosh_compat-0.1.0-py3-none-any.whl", hash = "sha256:3e7c5f519b4d397dbf4f8d7bbe4ca1eb6004da24c8892bc701844ed393bc97e7", size = 153875, upload-time = "2026-08-25T15:22:40.787Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
tantivy = [
|
||||||
|
{ name = "tantivy" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrapt"
|
name = "wrapt"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user