mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-28 13:37:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
013fe0baff | ||
|
|
02c547e856 | ||
|
|
9321d8772f |
+64
-33
@@ -305,46 +305,74 @@ def modify_custom_fields(
|
|||||||
else [(field, None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = CustomField.objects.filter(
|
custom_fields_by_id: dict[int, CustomField] = {
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
cf.id: cf
|
||||||
).distinct()
|
for cf in CustomField.objects.filter(
|
||||||
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
# Deferred, not `.only()`: signal receivers touch other Document fields,
|
||||||
|
# and `.only("pk")` would just turn that into a per-document reload.
|
||||||
|
# `content` is the one field both large and unused here. Skipped
|
||||||
|
# entirely for a remove-only call -- the removal pass below resolves
|
||||||
|
# its own documents.
|
||||||
|
docs_by_id: dict[int, Document] = (
|
||||||
|
{
|
||||||
|
doc.id: doc
|
||||||
|
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
|
||||||
|
}
|
||||||
|
if add_custom_fields
|
||||||
|
else {}
|
||||||
|
)
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
|
custom_field = custom_fields_by_id[field_id]
|
||||||
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
|
custom_field.data_type
|
||||||
|
]
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
defaults = {}
|
defaults = {value_field: value}
|
||||||
custom_field = custom_fields.get(id=field_id)
|
if (
|
||||||
if custom_field:
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
and value
|
||||||
custom_field.data_type
|
and doc_id in value
|
||||||
]
|
):
|
||||||
defaults[value_field] = value
|
# Prevent self-linking
|
||||||
if (
|
continue
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
# Not update_or_create(): it fetches an existing row via plain
|
||||||
and value
|
# `.get()` before calling .save(), so a signal receiver touching
|
||||||
and doc_id in value
|
# `.field`/`.document` (e.g. auditlog) on that save re-fetches
|
||||||
):
|
# per instance regardless of what's passed in as lookup kwargs.
|
||||||
# Prevent self-linking
|
# Assigning the cached objects ourselves before .save() avoids
|
||||||
continue
|
# that for both the create and update case.
|
||||||
CustomFieldInstance.objects.update_or_create(
|
try:
|
||||||
document_id=doc_id,
|
instance = CustomFieldInstance.objects.get(
|
||||||
field_id=field_id,
|
document=docs_by_id[doc_id],
|
||||||
defaults=defaults,
|
field=custom_field,
|
||||||
)
|
)
|
||||||
|
except CustomFieldInstance.DoesNotExist:
|
||||||
|
instance = CustomFieldInstance(
|
||||||
|
document=docs_by_id[doc_id],
|
||||||
|
field=custom_field,
|
||||||
|
)
|
||||||
|
instance.document = docs_by_id[doc_id]
|
||||||
|
instance.field = custom_field
|
||||||
|
for attr, val in defaults.items():
|
||||||
|
setattr(instance, attr, val)
|
||||||
|
instance.save()
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
doc = Document.objects.get(id=doc_id)
|
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
||||||
reflect_doclinks(doc, custom_field, value)
|
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links
|
# For doc link fields being removed, remove symmetrical links.
|
||||||
|
# select_related here avoids resolving every affected document up front.
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
document_id__in=affected_docs,
|
document_id__in=affected_docs,
|
||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
):
|
).select_related("field", "document"):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=Document.objects.get(
|
document=doclink_being_removed_instance.document,
|
||||||
id=doclink_being_removed_instance.document.id,
|
|
||||||
),
|
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -1177,10 +1205,13 @@ def remove_doclink(
|
|||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||||
"""
|
"""
|
||||||
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
# select_related: a signal receiver (auditlog) touches .document/.field
|
||||||
document_id=target_doc_id,
|
# on save() below -- without this, that's a per-call reload query.
|
||||||
field=field,
|
target_doc_field_instance = (
|
||||||
).first()
|
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
||||||
|
.select_related("document", "field")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
target_doc_field_instance is not None
|
target_doc_field_instance is not None
|
||||||
and document.id in target_doc_field_instance.value
|
and document.id in target_doc_field_instance.value
|
||||||
|
|||||||
@@ -373,7 +373,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
If the queryset already annotated ``effective_content``, that value is used.
|
If the queryset already annotated ``effective_content``, that value is used.
|
||||||
"""
|
"""
|
||||||
# Here to avoid circular import
|
# 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 sort_versions_newest_first
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
@@ -383,19 +382,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
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_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||||
prefetched_versions = (
|
prefetched_versions = (
|
||||||
prefetched_cache.get("versions")
|
prefetched_cache.get("versions")
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1147,14 +1146,8 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||||
# Only resolve version-aware content when it's cheap: an SQL
|
doc["content"] = getattr(instance, "effective_content") or ""
|
||||||
# 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:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from unittest import mock
|
|||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -344,6 +346,202 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
assert _cf_3 is not None
|
assert _cf_3 is not None
|
||||||
self.assertNotIn(self.doc3.id, _cf_3.value)
|
self.assertNotIn(self.doc3.id, _cf_3.value)
|
||||||
|
|
||||||
|
def test_modify_custom_fields_batches_field_lookup(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Several documents are being bulk-edited to add several custom
|
||||||
|
fields at once
|
||||||
|
WHEN:
|
||||||
|
- modify_custom_fields runs
|
||||||
|
THEN:
|
||||||
|
- Each CustomField is resolved with one batched query total, not
|
||||||
|
once per (field, document) pair
|
||||||
|
"""
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"batch-{i}", title=f"batch-{i}")
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
fields = [
|
||||||
|
CustomField.objects.create(
|
||||||
|
name=f"Batch Field {i}",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
for i in range(4)
|
||||||
|
]
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields=[field.id for field in fields],
|
||||||
|
remove_custom_fields=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
field_lookups = [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if 'FROM "documents_customfield"' in q["sql"]
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
len(field_lookups),
|
||||||
|
1,
|
||||||
|
"Expected a single batched query to resolve the custom fields, "
|
||||||
|
f"got {len(field_lookups)}: {field_lookups}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
self.assertEqual(doc.custom_fields.count(), len(fields))
|
||||||
|
|
||||||
|
def test_modify_custom_fields_batches_document_lookup_for_documentlink(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Several documents are being bulk-edited to add a DOCUMENTLINK
|
||||||
|
custom field at once
|
||||||
|
WHEN:
|
||||||
|
- modify_custom_fields runs
|
||||||
|
THEN:
|
||||||
|
- The Document rows needed to reflect the symmetrical links are
|
||||||
|
resolved with one batched query total, not once per document
|
||||||
|
"""
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"link-{i}", title=f"link-{i}")
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
target = Document.objects.create(checksum="link-target", title="link-target")
|
||||||
|
doclink_field = CustomField.objects.create(
|
||||||
|
name="Related",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields={doclink_field.id: [target.id]},
|
||||||
|
remove_custom_fields=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
single_document_lookups = [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if 'FROM "documents_document"' in q["sql"]
|
||||||
|
and '"documents_document"."id" = ' in q["sql"]
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
len(single_document_lookups),
|
||||||
|
0,
|
||||||
|
"Expected document rows to come from a batched query, not "
|
||||||
|
f"per-document lookups, got: {single_document_lookups}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
self.assertEqual(
|
||||||
|
doc.custom_fields.get(field=doclink_field).value,
|
||||||
|
[target.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_modify_custom_fields_update_caches_document_and_field(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Several documents already have an instance of a custom field
|
||||||
|
WHEN:
|
||||||
|
- modify_custom_fields runs again for the same field, updating
|
||||||
|
the existing instances rather than creating new ones
|
||||||
|
THEN:
|
||||||
|
- No per-instance `.document`/`.field` reload query is issued
|
||||||
|
(e.g. by auditlog's post_save receiver touching them)
|
||||||
|
"""
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"update-{i}", title=f"update-{i}")
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Update Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields=[field.id],
|
||||||
|
remove_custom_fields=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields={field.id: "updated value"},
|
||||||
|
remove_custom_fields=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
single_row_reloads = [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if ('FROM "documents_document"' in q["sql"] and '."id" = ' in q["sql"])
|
||||||
|
or ('FROM "documents_customfield"' in q["sql"] and '."id" = ' in q["sql"])
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
single_row_reloads,
|
||||||
|
[],
|
||||||
|
"Expected no per-instance document/field reload queries when "
|
||||||
|
f"updating existing custom field instances, got: {single_row_reloads}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
self.assertEqual(
|
||||||
|
doc.custom_fields.get(field=field).value,
|
||||||
|
"updated value",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_modify_custom_fields_removes_symmetrical_doclinks_batched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Several source documents link to a shared target via a doc
|
||||||
|
link field
|
||||||
|
WHEN:
|
||||||
|
- The field is removed from all of them in one call
|
||||||
|
THEN:
|
||||||
|
- The symmetrical links are removed from the target
|
||||||
|
- No per-document lookup query is issued, on either side
|
||||||
|
"""
|
||||||
|
target = Document.objects.create(checksum="rm-target", title="rm-target")
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"rm-{i}", title=f"rm-{i}")
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Related",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields={field.id: [target.id]},
|
||||||
|
remove_custom_fields=[],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
target.custom_fields.get(field=field).value,
|
||||||
|
[d.id for d in docs],
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.modify_custom_fields(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
add_custom_fields=[],
|
||||||
|
remove_custom_fields=[field.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
single_document_lookups = [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if 'FROM "documents_document"' in q["sql"]
|
||||||
|
and '"documents_document"."id" = ' in q["sql"]
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
single_document_lookups,
|
||||||
|
[],
|
||||||
|
"Expected batched document resolution, not per-document lookups, "
|
||||||
|
f"got: {single_document_lookups}",
|
||||||
|
)
|
||||||
|
self.assertEqual(target.custom_fields.get(field=field).value, [])
|
||||||
|
|
||||||
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
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) == []
|
|
||||||
@@ -7,12 +7,9 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.db.models import OuterRef
|
from django.db.models import OuterRef
|
||||||
from django.db.models import Prefetch
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models import Subquery
|
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 Coalesce
|
||||||
from django.db.models.functions import RowNumber
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -46,68 +43,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
|
||||||
|
|
||||||
|
|
||||||
def latest_version_content_prefetch() -> Prefetch:
|
|
||||||
"""
|
|
||||||
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]:
|
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||||
"""
|
"""
|
||||||
Same sorting as versions_newest_first()
|
Same sorting as versions_newest_first()
|
||||||
|
|||||||
+7
-46
@@ -233,7 +233,6 @@ from documents.versioning import VersionResolutionError
|
|||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_request_version_param
|
from documents.versioning import get_request_version_param
|
||||||
from documents.versioning import get_root_document
|
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 resolve_requested_version_for_root
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
from paperless import version
|
from paperless import version
|
||||||
@@ -1073,40 +1072,12 @@ class DocumentViewSet(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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):
|
def get_queryset(self):
|
||||||
|
latest_version_content = Subquery(
|
||||||
|
versions_newest_first(
|
||||||
|
Document.objects.filter(root_document=OuterRef("pk")),
|
||||||
|
).values("content")[:1],
|
||||||
|
)
|
||||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||||
# be, which forced a GROUP BY aggregate over every matching document
|
# be, which forced a GROUP BY aggregate over every matching document
|
||||||
# before the query could even be sorted or limited.
|
# before the query could even be sorted or limited.
|
||||||
@@ -1126,9 +1097,10 @@ class DocumentViewSet(
|
|||||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||||
# database to fully sort and dedupe every visible document before
|
# database to fully sort and dedupe every visible document before
|
||||||
# it can apply LIMIT, which is disastrous at scale.
|
# it can apply LIMIT, which is disastrous at scale.
|
||||||
queryset = (
|
return (
|
||||||
Document.objects.filter(root_document__isnull=True)
|
Document.objects.filter(root_document__isnull=True)
|
||||||
.order_by("-created", "-id")
|
.order_by("-created", "-id")
|
||||||
|
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||||
.annotate(num_notes=Coalesce(note_count, 0))
|
.annotate(num_notes=Coalesce(note_count, 0))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
@@ -1143,7 +1115,6 @@ class DocumentViewSet(
|
|||||||
"version_index",
|
"version_index",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
latest_version_content_prefetch(),
|
|
||||||
"tags",
|
"tags",
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"custom_fields",
|
"custom_fields",
|
||||||
@@ -1153,16 +1124,6 @@ class DocumentViewSet(
|
|||||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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):
|
def get_serializer(self, *args, **kwargs):
|
||||||
fields_param = self.request.query_params.get("fields", None)
|
fields_param = self.request.query_params.get("fields", None)
|
||||||
|
|||||||
Reference in New Issue
Block a user