From 876db6d7448466207c3e929a650fc25a2ecc9514 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:36:26 -0700 Subject: [PATCH] feat(search): add shared PUBLIC_FIELDS table Create the shared field-definition table consumed by the schema builder (_schema.py) and the whoosh-compat field registry (_registry.py). This eliminates drift between what the index exposes and what queries can address. - Create PublicField frozen dataclass with field metadata - Define PUBLIC_FIELDS tuple with 16 searchable fields - Add comprehensive test suite covering field properties The whoosh-compat pyproject.toml dependency addition is added in a follow-up commit, with the correct [tantivy] extra, source comment, and a matching uv.lock update. Co-Authored-By: Claude Sonnet 5 --- src/documents/search/_fields.py | 45 +++++++++++++++++++++ src/documents/tests/search/test_fields.py | 49 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/documents/search/_fields.py create mode 100644 src/documents/tests/search/test_fields.py diff --git a/src/documents/search/_fields.py b/src/documents/search/_fields.py new file mode 100644 index 000000000..db9faaa5e --- /dev/null +++ b/src/documents/search/_fields.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from whoosh_compat import FieldKind + + +@dataclass(frozen=True, slots=True) +class PublicField: + """One query-syntax-addressable field, shared by the Tantivy schema + builder (_schema.py) and the whoosh-compat FieldRegistry (_registry.py). + + 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 stay + hardcoded in _schema.py's build_schema(). + """ + + name: str + kind: FieldKind + aliases: tuple[str, ...] = () + comma_values: bool = False + date_only: bool = False + fast: bool = False + subpaths: tuple[str, ...] = () # JSON kind only + + +PUBLIC_FIELDS: tuple[PublicField, ...] = ( + PublicField("title", FieldKind.TEXT), + PublicField("content", FieldKind.TEXT), + PublicField("correspondent", FieldKind.TEXT), + PublicField("document_type", FieldKind.TEXT, aliases=("type",)), + PublicField("storage_path", FieldKind.TEXT, aliases=("path",)), + PublicField("original_filename", FieldKind.TEXT), + PublicField("tag", FieldKind.TEXT, comma_values=True), + PublicField("checksum", FieldKind.KEYWORD), + PublicField("asn", FieldKind.U64, fast=True), + PublicField("page_count", FieldKind.U64, fast=True), + PublicField("num_notes", FieldKind.U64, fast=True), + PublicField("created", FieldKind.DATE, date_only=True, fast=True), + PublicField("modified", FieldKind.DATETIME, fast=True), + PublicField("added", FieldKind.DATETIME, fast=True), + PublicField("notes", FieldKind.JSON, subpaths=("user", "note")), + PublicField("custom_fields", FieldKind.JSON, subpaths=("name", "value")), +) diff --git a/src/documents/tests/search/test_fields.py b/src/documents/tests/search/test_fields.py new file mode 100644 index 000000000..af3e9d4c9 --- /dev/null +++ b/src/documents/tests/search/test_fields.py @@ -0,0 +1,49 @@ +from whoosh_compat import FieldKind + +from documents.search._fields import PUBLIC_FIELDS + + +class TestPublicFields: + def test_every_field_has_a_whoosh_compat_kind(self) -> None: + for field in PUBLIC_FIELDS: + assert isinstance(field.kind, FieldKind) + + def test_names_are_unique(self) -> None: + names = [f.name for f in PUBLIC_FIELDS] + assert len(names) == len(set(names)) + + 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" + + def test_non_json_fields_have_no_subpaths(self) -> None: + for field in PUBLIC_FIELDS: + if field.kind is not FieldKind.JSON: + assert field.subpaths == () + + def test_document_type_alias_is_type(self) -> None: + field = next(f for f in PUBLIC_FIELDS if f.name == "document_type") + assert field.aliases == ("type",) + + def test_storage_path_alias_is_path(self) -> None: + field = next(f for f in PUBLIC_FIELDS if f.name == "storage_path") + assert field.aliases == ("path",) + + def test_tag_allows_comma_values(self) -> None: + field = next(f for f in PUBLIC_FIELDS if f.name == "tag") + assert field.comma_values is True + + def test_notes_subpaths(self) -> None: + field = next(f for f in PUBLIC_FIELDS if f.name == "notes") + assert field.subpaths == ("user", "note") + + def test_custom_fields_subpaths(self) -> None: + field = next(f for f in PUBLIC_FIELDS if f.name == "custom_fields") + assert field.subpaths == ("name", "value") + + def test_no_internal_id_fields_present(self) -> None: + # tag_id/owner_id/viewer_id/etc. are permission-filter-only fields, + # never user-query-addressable (see design spec, "Field surface"). + names = {f.name for f in PUBLIC_FIELDS} + assert not any(name.endswith("_id") for name in names)