Compare commits

..
Author SHA1 Message Date
stumpylog 8e65ba0efa fix: address review feedback on effective_content annotation skip
- _needs_effective_content_annotation() now checks for a non-blank,
  stripped param value rather than mere key presence, matching how
  SearchFilter/TitleContentFilter/EffectiveContentFilter themselves
  no-op on a blank value. An empty ?search= or a saved view with a
  cleared text filter no longer re-triggers the annotation.

- The "versions" prefetch on DocumentViewSet no longer carries content
  for every historical version of every document -- that's unused
  bloat for version-heavy documents. Added
  latest_version_content_prefetch() (versioning.py), a separate,
  windowed prefetch scoped to just the newest version's content per
  root, and taught Document.get_effective_content() to check it first.

- DocumentSerializer.to_representation() no longer unconditionally
  calls get_effective_content(). Added has_prefetched_effective_content()
  (versioning.py) as a cheap upfront check: only resolve version-aware
  content when an SQL annotation or a versions prefetch is already on
  the instance. TrashView and GlobalSearchView build their own
  querysets independently of DocumentViewSet and never display
  document content at all (checked both frontend components), so they
  now keep showing the document's own, unresolved content with zero
  extra queries -- the same behavior as before effective_content
  resolution existed, just generalized past the narrow hasattr() check
  it replaced.
2026-08-25 14:18:50 -07:00
stumpylog 3221c3a3e8 perf: skip effective_content annotation on document list unless filtered on
DocumentViewSet.get_queryset() always attached a correlated subquery
resolving each document's latest version content, even though it's only
needed for the deprecated search/title_content/content__* filter params.
Evaluated for every candidate row before pagination's LIMIT, this is
pathological on MariaDB: its default cardinality estimate for the mostly-
NULL root_document_id self-join drives it to a near-full-table scan per
row instead of using the FK index, turning a normal filtered list request
into a multi-second query (root cause of paperless-ngx#13778's report).

Only attach the annotation when a request actually filters on it. The
common case now relies on Document.get_effective_content()'s existing
prefetch-based fallback instead (extended the "versions" prefetch to
include content), which DocumentSerializer.to_representation() now calls
directly instead of checking for the annotation via hasattr().
2026-08-25 13:18:49 -07:00
8 changed files with 410 additions and 152 deletions
+17 -7
View File
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
from typing import Any
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
@@ -50,7 +51,6 @@ from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_object_ids
from documents.versioning import ensure_effective_content
if TYPE_CHECKING:
from collections.abc import Callable
@@ -180,9 +180,14 @@ class TitleContentFilter(Filter):
logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
)
return ensure_effective_content(qs).filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
try:
return qs.filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
except FieldError:
return qs.filter(
Q(title__icontains=value) | Q(content__icontains=value),
)
else:
return qs
@@ -193,9 +198,14 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value
if not value:
return qs
return ensure_effective_content(qs).filter(
**{f"effective_content__{self.lookup_expr}": value},
)
try:
return qs.filter(
**{f"effective_content__{self.lookup_expr}": value},
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField)
+14
View File
@@ -373,6 +373,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
If the queryset already annotated ``effective_content``, that value is used.
"""
# Here to avoid circular import
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
from documents.versioning import sort_versions_newest_first
from documents.versioning import versions_newest_first
@@ -382,6 +383,19 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
if self.root_document_id is not None or self.pk is None:
return self.content
latest_version_prefetch = getattr(
self,
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
None,
)
if latest_version_prefetch is not None:
# Empty list means prefetch ran and found no versions — use own content.
return (
latest_version_prefetch[0].content
if latest_version_prefetch
else self.content
)
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
+9 -2
View File
@@ -88,6 +88,7 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator
from documents.validators import url_validator
from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first
if TYPE_CHECKING:
@@ -1146,8 +1147,14 @@ class DocumentSerializer(
def to_representation(self, instance):
doc = super().to_representation(instance)
if "content" in self.fields and hasattr(instance, "effective_content"):
doc["content"] = getattr(instance, "effective_content") or ""
if "content" in self.fields and has_prefetched_effective_content(instance):
# Only resolve version-aware content when it's cheap: an SQL
# annotation or a versions prefetch is already on the instance.
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
# which build their own querysets) gets the document's own,
# unresolved content instead of paying for an extra per-instance
# query -- same as before effective_content resolution existed.
doc["content"] = instance.get_effective_content() or ""
if self.truncate_content and "content" in self.fields:
doc["content"] = doc.get("content")[0:550]
return doc
@@ -2,14 +2,15 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock
from auditlog.models import LogEntry # type: ignore[import-untyped]
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -20,8 +21,6 @@ from documents.filters import TitleContentFilter
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin
if TYPE_CHECKING:
from pathlib import Path
@@ -891,102 +890,31 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
class TestVersionAwareFilters(TestCase):
"""
The filters annotate effective_content themselves rather than relying on
the caller's queryset carrying it, so they stay version-aware on a plain
Document queryset (e.g. the bulk-edit "select all matching" path).
"""
def test_title_content_filter_falls_back_to_content(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
def setUp(self) -> None:
super().setUp()
self.root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
content="superseded-content",
)
Document.objects.create(
title="version",
checksum="version",
mime_type="application/pdf",
root_document=self.root,
version_index=1,
content="latest-content",
)
self.unversioned = Document.objects.create(
title="unversioned",
checksum="unversioned",
mime_type="application/pdf",
content="latest-content",
)
result = TitleContentFilter().filter(queryset, " latest ")
def test_title_content_filter_matches_latest_version_content(self) -> None:
result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
self.assertIs(result, fallback_queryset)
self.assertEqual(queryset.filter.call_count, 2)
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
result = EffectiveContentFilter(lookup_expr="icontains").filter(
queryset,
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_matches_latest_version_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_ignores_superseded_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
"superseded",
)
self.assertEqual(list(result), [])
def test_filters_reuse_an_existing_annotation(self) -> None:
"""
Annotating twice under the same alias is an error, so an already
annotated queryset (the search path) has to be left alone.
"""
annotated = annotate_effective_content(
Document.objects.filter(root_document__isnull=True),
)
result = EffectiveContentFilter(lookup_expr="icontains").filter(
annotated,
"latest",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
"""
Bulk edit's "select all matching" builds its own queryset, so before
the filters annotated for themselves it matched the root document's
superseded content -- selecting documents the list view, filtered by
the same term, does not show.
"""
user = User.objects.create_superuser(username="bulk_selection")
selected = DocumentSelectionMixin()._resolve_document_ids(
user=user,
validated_data={
"all": True,
"filters": {"content__icontains": "superseded"},
},
)
self.assertEqual(selected, [])
self.assertIs(result, fallback_queryset)
first_kwargs = queryset.filter.call_args_list[0].kwargs
second_kwargs = queryset.filter.call_args_list[1].kwargs
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock()
-23
View File
@@ -1917,29 +1917,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data["documents"]), 1)
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
def test_global_search_returns_latest_version_content(self) -> None:
root = Document.objects.create(
title="bank statement",
content="superseded content",
checksum="GSV1",
pk=23,
)
Document.objects.create(
title="bank statement v2",
content="latest content",
checksum="GSV2",
pk=24,
root_document=root,
version_index=1,
)
self.client.force_authenticate(self.user)
response = self.client.get("/api/search/?query=bank&db_only=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
self.assertEqual(returned.get(root.id), "latest content")
def test_global_search_filters_owned_mail_objects(self) -> None:
user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user")
@@ -0,0 +1,239 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from rest_framework import status
from documents.models import Document
from documents.tests.factories import DocumentFactory
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
from documents.versioning import has_prefetched_effective_content
from documents.versioning import latest_version_content_prefetch
from documents.views import DocumentViewSet
if TYPE_CHECKING:
from rest_framework.test import APIClient
class TestNeedsEffectiveContentAnnotation:
"""
DocumentViewSet._needs_effective_content_annotation() decides whether
the effective_content correlated subquery is worth attaching to the
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
for why. This only checks that decision's own logic (a plain query-param
membership test), not that Django/DRF's filtering machinery works.
"""
@pytest.mark.parametrize(
("params", "expected"),
[
({}, False),
({"ordering": "-added"}, False),
({"tags__id__in": "1,2"}, False),
({"search": ""}, False),
({"search": " "}, False),
({"content__icontains": ""}, False),
({"search": "foo"}, True),
({"title_content": "foo"}, True),
({"content__istartswith": "foo"}, True),
({"content__iendswith": "foo"}, True),
({"content__icontains": "foo"}, True),
({"content__iexact": "foo"}, True),
],
)
def test_detects_content_filter_params(
self,
params: dict[str, str],
expected: bool, # noqa: FBT001
) -> None:
# GIVEN a view bound to a request carrying the given query params
view = DocumentViewSet()
view.request = SimpleNamespace(query_params=params)
# WHEN checking whether the effective_content annotation is needed
# THEN it's needed only for requests that actually filter on it
assert view._needs_effective_content_annotation() is expected
@pytest.mark.django_db
class TestDocumentListEffectiveContentAnnotation:
"""
DocumentViewSet.get_queryset() only attaches the effective_content
correlated subquery when a request actually filters on it. Attaching it
unconditionally re-executes it once per candidate row before the page's
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
MariaDB's default cardinality estimation for the root_document_id
self-join once candidate counts get large (see the root_document_id /
effective_content perf investigation).
"""
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
self,
admin_client: APIClient,
) -> None:
# GIVEN a root document whose latest version has different content
root = DocumentFactory(content="old-root-content")
DocumentFactory(
root_document=root,
version_index=1,
content="new-version-content",
)
# WHEN listing documents with no search/content-filter param
with CaptureQueriesContext(connection) as ctx:
response = admin_client.get("/api/documents/?fields=id,content")
# THEN the response still reflects the latest version's content...
assert response.status_code == status.HTTP_200_OK
assert response.data["results"] == [
{"id": root.id, "content": "new-version-content"},
]
# ...without the database ever evaluating effective_content per row
assert not any(
"effective_content" in query["sql"] for query in ctx.captured_queries
)
def test_latest_version_content_prefetch_carries_only_the_newest_version(
self,
) -> None:
# GIVEN a root document with two versions
root = DocumentFactory(content="root-content")
DocumentFactory(
root_document=root,
version_index=1,
content="older-version-content",
)
DocumentFactory(
root_document=root,
version_index=2,
content="newest-version-content",
)
# WHEN fetching the root through latest_version_content_prefetch()
fetched_root = (
Document.objects.filter(pk=root.pk)
.prefetch_related(
latest_version_content_prefetch(),
)
.get()
)
# THEN the prefetch carries only the single newest version, not
# every historical version's content (the whole point of not
# reusing the metadata-only "versions" prefetch for this)
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
assert [v.content for v in latest] == ["newest-version-content"]
class TestHasPrefetchedEffectiveContent:
"""
DocumentSerializer.to_representation() only calls get_effective_content()
when has_prefetched_effective_content() says it's cheap -- otherwise a
caller that never set up an annotation or prefetch (TrashView,
GlobalSearchView, which build their own querysets and don't display
content at all) would pay for a per-instance query nobody asked for.
"""
def test_false_with_no_annotation_or_prefetch(self) -> None:
document = Document()
assert has_prefetched_effective_content(document) is False
def test_true_with_effective_content_annotation(self) -> None:
document = Document()
document.effective_content = "resolved"
assert has_prefetched_effective_content(document) is True
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
document = Document()
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
assert has_prefetched_effective_content(document) is True
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
document = Document()
document._prefetched_objects_cache = {"versions": []}
assert has_prefetched_effective_content(document) is True
def _get_effective_content_fallback_queries(
ctx: CaptureQueriesContext,
) -> list[dict[str, str]]:
"""
Document.get_effective_content()'s per-instance fallback (no annotation,
no prefetch) is a `.values_list("content", flat=True).first()` query --
a SELECT of just the content column. Distinct from get_versions()'s own,
unrelated per-instance metadata query (id/checksum/added/etc, no
content) run to build the "versions" response field, which isn't part
of what this test file covers.
"""
return [
q
for q in ctx.captured_queries
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
]
@pytest.mark.django_db
class TestTrashAndGlobalSearchDoNotResolveEffectiveContent:
"""
TrashView and GlobalSearchView serialize Document instances with
DocumentSerializer too, but build their querysets independently of
DocumentViewSet.get_queryset() -- and neither actually displays
document content. They should keep showing the document's own,
unresolved content with no extra query, exactly as before
effective_content resolution existed.
"""
def test_trash_list_shows_unresolved_content_with_no_extra_query(
self,
admin_client: APIClient,
) -> None:
# GIVEN a trashed root document whose own content differs from what
# a (also trashed, since deletion cascades) version would have had
root = DocumentFactory(content="own-content")
DocumentFactory(
root_document=root,
version_index=1,
content="version-content",
)
root.delete()
# WHEN listing trash
with CaptureQueriesContext(connection) as ctx:
response = admin_client.get("/api/trash/")
# THEN the response shows the document's own content...
assert response.status_code == status.HTTP_200_OK
[result] = [r for r in response.data["results"] if r["id"] == root.id]
assert result["content"] == "own-content"
# ...without ever querying for versions to resolve it
assert _get_effective_content_fallback_queries(ctx) == []
def test_global_search_db_only_shows_unresolved_content_with_no_extra_query(
self,
admin_client: APIClient,
) -> None:
# GIVEN a root document, findable by title, whose own content
# differs from its latest version's
root = DocumentFactory(title="findme", content="own-content")
DocumentFactory(
root_document=root,
version_index=1,
content="version-content",
)
# WHEN using the global search endpoint's db_only mode
with CaptureQueriesContext(connection) as ctx:
response = admin_client.get(
"/api/search/?query=findme&db_only=true",
)
# THEN the response shows the document's own content...
assert response.status_code == status.HTTP_200_OK
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
assert result["content"] == "own-content"
# ...without ever querying for versions to resolve it
assert _get_effective_content_fallback_queries(ctx) == []
+61 -11
View File
@@ -7,9 +7,12 @@ from typing import Any
from django.db.models import F
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models import QuerySet
from django.db.models import Subquery
from django.db.models import Window
from django.db.models.functions import Coalesce
from django.db.models.functions import RowNumber
from documents.models import Document
@@ -43,19 +46,66 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
)
def ensure_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Annotates effective_content unless the queryset already carries it.
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
Lets a filter depend on effective_content without having to assume its
caller annotated one -- annotating twice under the same alias is an error,
and silently matching on the root document's own content instead is worse,
because the same filter then selects different documents depending on which
queryset it was handed.
def latest_version_content_prefetch() -> Prefetch:
"""
if "effective_content" in documents.query.annotations:
return documents
return annotate_effective_content(documents)
A Prefetch for Document.versions scoped to just the newest version's
content, for get_effective_content()'s fallback when no SQL annotation
is present.
Deliberately not merged into a metadata-only "versions" prefetch (the one
used for the serialized versions list): that one fetches every historical
version of every document, and pulling full OCR content for versions
nobody will read wastes DB transfer/memory at scale. This one is windowed
down to a single row per root, then bounded by Prefetch's own IN-list to
whatever page/result set it's attached to -- one cheap bulk query total,
not one per document and not one per version.
"""
return Prefetch(
"versions",
queryset=(
Document.objects.filter(
root_document_id__isnull=False,
deleted_at__isnull=True,
)
.annotate(
rn=Window(
RowNumber(),
partition_by=F("root_document_id"),
order_by=[
F("version_index").desc(nulls_last=True),
F("id").desc(),
],
),
)
.filter(rn=1)
.only("id", "root_document_id", "content")
),
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
)
def has_prefetched_effective_content(document: Document) -> bool:
"""
True if document.get_effective_content() can answer without an extra
per-instance query -- an SQL ``effective_content`` annotation, the lean
latest_version_content_prefetch(), or the metadata-only "versions"
prefetch is already present on the instance.
Callers that haven't set any of those up (e.g. views that build their
own querysets independently of DocumentViewSet.get_queryset(), like
TrashView or GlobalSearchView) intentionally don't pay for version-aware
content resolution -- see DocumentSerializer.to_representation(), which
uses this to decide whether to call get_effective_content() at all.
"""
if hasattr(document, "effective_content"):
return True
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
return True
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
+48 -15
View File
@@ -230,10 +230,10 @@ from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags
from documents.utils import get_boolean
from documents.versioning import VersionResolutionError
from documents.versioning import annotate_effective_content
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_request_version_param
from documents.versioning import get_root_document
from documents.versioning import latest_version_content_prefetch
from documents.versioning import resolve_requested_version_for_root
from documents.versioning import versions_newest_first
from paperless import version
@@ -1086,12 +1086,40 @@ class DocumentViewSet(
],
}
def get_queryset(self):
latest_version_content = Subquery(
versions_newest_first(
Document.objects.filter(root_document=OuterRef("pk")),
).values("content")[:1],
# Query params whose filtering needs effective_content evaluated in SQL
# against every candidate row -- see _needs_effective_content_annotation().
_CONTENT_FILTER_PARAMS = (
"search", # DRF SearchFilter's search_fields includes effective_content
"title_content",
"content__istartswith",
"content__iendswith",
"content__icontains",
"content__iexact",
)
def _needs_effective_content_annotation(self) -> bool:
# effective_content is a per-row correlated subquery resolving each
# document's latest version. Cheap when evaluated only for the page
# that survives filtering/sorting/pagination (the common case, via
# the "versions" prefetch + Document.get_effective_content()'s
# fallback), but if anything filters *on* it, the database has to
# evaluate it for every candidate row before the LIMIT is reached --
# pathological on MariaDB specifically for the root_document_id
# self-join once real candidate counts get large. Everything on this
# list is deprecated in favor of the Tantivy-backed search endpoint
# (see filters.py's TitleContentFilter/EffectiveContentFilter docs),
# so keep paying that cost only when one is actually used. Checked as
# a stripped, non-blank value (not just key presence) to match how
# DRF's SearchFilter and TitleContentFilter/EffectiveContentFilter
# themselves no-op on a blank value -- otherwise an empty `?search=`
# or a saved view with a cleared text filter would still pay for the
# annotation despite applying no actual predicate.
params = self.request.query_params
return any(
params.get(param, "").strip() for param in self._CONTENT_FILTER_PARAMS
)
def get_queryset(self):
# A correlated subquery avoids the LEFT JOIN + Count() this used to
# be, which forced a GROUP BY aggregate over every matching document
# before the query could even be sorted or limited.
@@ -1111,10 +1139,9 @@ class DocumentViewSet(
# ObjectFilter.filter(). A blanket .distinct() here forces the
# database to fully sort and dedupe every visible document before
# it can apply LIMIT, which is disastrous at scale.
return (
queryset = (
Document.objects.filter(root_document__isnull=True)
.order_by("-created", "-id")
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
.annotate(num_notes=Coalesce(note_count, 0))
.select_related("correspondent", "storage_path", "document_type", "owner")
.prefetch_related(
@@ -1129,6 +1156,7 @@ class DocumentViewSet(
"version_index",
),
),
latest_version_content_prefetch(),
"tags",
Prefetch(
"custom_fields",
@@ -1137,6 +1165,16 @@ class DocumentViewSet(
"notes",
)
)
if self._needs_effective_content_annotation():
latest_version_content = Subquery(
versions_newest_first(
Document.objects.filter(root_document=OuterRef("pk")),
).values("content")[:1],
)
queryset = queryset.annotate(
effective_content=Coalesce(latest_version_content, F("content")),
)
return queryset
def get_serializer(self, *args, **kwargs):
fields_param = self.request.query_params.get("fields", None)
@@ -3617,13 +3655,8 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
# Never more than OBJECT_LIMIT rows come back here, so annotating
# is cheap -- and without it these results show the root
# document's superseded content.
all_docs = annotate_effective_content(
Document.objects.filter(
id__in=permitted_document_ids(request.user),
),
all_docs = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]