Compare commits

..
Author SHA1 Message Date
shamoonandCodex 8de2e2c5da Performance: replace django-treenode with adjacency-list tags
Co-Authored-By: Codex <267193182+codex@users.noreply.github.com>
2026-10-29 16:01:09 -07:00
23 changed files with 368 additions and 352 deletions

No files matched your search

@@ -39,7 +39,6 @@ declare -a scope=(
"${site_packages}/allauth" "${site_packages}/allauth"
"${site_packages}/drf_spectacular" "${site_packages}/drf_spectacular"
"${site_packages}/drf_spectacular_sidecar" "${site_packages}/drf_spectacular_sidecar"
"${site_packages}/treenode"
"${site_packages}/compression_middleware" "${site_packages}/compression_middleware"
) )
-1
View File
@@ -38,7 +38,6 @@ dependencies = [
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</context> <context context-type="linenumber">294,295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3420321797707163677" datatype="html"> <trans-unit id="2179847500064178686" datatype="html">
<source>Preview document</source> <source>Edit document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">328</context> <context context-type="linenumber">328</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2179847500064178686" datatype="html"> <trans-unit id="3420321797707163677" datatype="html">
<source>Edit document</source> <source>Preview document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">329</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4512084577073831437" datatype="html"> <trans-unit id="4512084577073831437" datatype="html">
@@ -1,3 +1,10 @@
.custom-fields-dropdown { .custom-fields-dropdown {
min-width: 300px; min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
} }
@@ -308,7 +308,7 @@
</tr> </tr>
<tr class="align-middle"> <tr class="align-middle">
<td i18n>Original SHA256 checksum</td> <td i18n>Original SHA256 checksum</td>
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr> </tr>
<tr> <tr>
<td i18n>Original file size</td> <td i18n>Original file size</td>
@@ -465,7 +465,7 @@
@switch (archiveContentRenderType) { @switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) { @case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container" tabindex="-1"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource()" [src]="pdfSource()"
[sourceRevision]="previewRevision()" [sourceRevision]="previewRevision()"
@@ -477,7 +477,7 @@
(loadError)="onError($event)" (loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </pngx-pdf-viewer>
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow"> <div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) { @if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap"> <div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
@@ -12,22 +12,6 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
&:focus {
outline: none;
}
.preview-controls {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
&:hover .preview-controls,
&:focus-within .preview-controls {
opacity: 1;
pointer-events: auto;
}
} }
.btn-group .dropdown-toggle-split { .btn-group .dropdown-toggle-split {
@@ -208,7 +208,7 @@
i18n>Correspondent</th> i18n>Correspondent</th>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer ps-5" <th class="cursor-pointer"
pngxSortable="title" pngxSortable="title"
title="Sort by title" i18n-title title="Sort by title" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
@@ -322,13 +322,13 @@
</td> </td>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)"> <td width="30%">
@if (activeDisplayFields.includes(DisplayField.TITLE)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()"> <div class="d-inline-block" (mouseleave)="popupPreview.close()">
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview> <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<i-bs name="eye"></i-bs> <i-bs name="eye"></i-bs>
</pngx-preview-popup> </pngx-preview-popup>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</div> </div>
} }
@if (activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TAGS)) {
@@ -87,10 +87,6 @@ $paperless-card-breakpoints: (
} }
} }
.table .ps-5 {
padding-left: 2.2rem !important;
}
a { a {
cursor: pointer; cursor: pointer;
} }
+1 -2
View File
@@ -1,7 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin from guardian.admin import GuardedModelAdmin
from treenode.admin import TreeNodeModelAdmin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -29,7 +28,7 @@ class CorrespondentAdmin(GuardedModelAdmin):
list_editable = ("match", "matching_algorithm") list_editable = ("match", "matching_algorithm")
class TagAdmin(GuardedModelAdmin, TreeNodeModelAdmin): class TagAdmin(GuardedModelAdmin):
list_display = ("name", "color", "match", "matching_algorithm") list_display = ("name", "color", "match", "matching_algorithm")
list_filter = ("matching_algorithm",) list_filter = ("matching_algorithm",)
list_editable = ("color", "match", "matching_algorithm") list_editable = ("color", "match", "matching_algorithm")
+7 -13
View File
@@ -255,19 +255,13 @@ def modify_tags(
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
DocumentTagRelationship = Document.tags.through DocumentTagRelationship = Document.tags.through
# add with all ancestors hierarchy = Tag.get_hierarchy()
expanded_add_tags: set[int] = set() expanded_add_tags = hierarchy.with_ancestors(
add_tag_objects = Tag.objects.filter(pk__in=add_tags) Tag.objects.filter(pk__in=add_tags).values_list("pk", flat=True),
for t in add_tag_objects: )
expanded_add_tags.add(int(t.id)) expanded_remove_tags = hierarchy.with_descendants(
expanded_add_tags.update(int(pk) for pk in t.get_ancestors_pks()) Tag.objects.filter(pk__in=remove_tags).values_list("pk", flat=True),
)
# remove with all descendants
expanded_remove_tags: set[int] = set()
remove_tag_objects = Tag.objects.filter(pk__in=remove_tags)
for t in remove_tag_objects:
expanded_remove_tags.add(int(t.id))
expanded_remove_tags.update(int(pk) for pk in t.get_descendants_pks())
with transaction.atomic(): with transaction.atomic():
if expanded_remove_tags: if expanded_remove_tags:
@@ -108,6 +108,24 @@ def _deserialize_record(
) from e ) from e
for field_name, field_value in record.get("fields", {}).items(): for field_name, field_value in record.get("fields", {}).items():
# Older exports include django-treenode's derived bookkeeping. The
# parent FK is authoritative; accept only these known removed fields.
if Model is Tag and field_name in {
"tn_ancestors_count",
"tn_ancestors_pks",
"tn_children_count",
"tn_children_pks",
"tn_depth",
"tn_descendants_count",
"tn_descendants_pks",
"tn_index",
"tn_level",
"tn_order",
"tn_priority",
"tn_siblings_count",
"tn_siblings_pks",
}:
continue
field = Model._meta.get_field(field_name) field = Model._meta.get_field(field_name)
remote = field.remote_field remote = field.remote_field
@@ -0,0 +1,64 @@
# Generated by Django 5.2.17 on 2026-10-28 22:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("documents", "0027_documentbarcode"),
]
operations = [
migrations.RemoveField(
model_name="tag",
name="tn_ancestors_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_ancestors_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_children_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_children_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_depth",
),
migrations.RemoveField(
model_name="tag",
name="tn_descendants_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_descendants_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_index",
),
migrations.RemoveField(
model_name="tag",
name="tn_level",
),
migrations.RemoveField(
model_name="tag",
name="tn_order",
),
migrations.RemoveField(
model_name="tag",
name="tn_priority",
),
migrations.RemoveField(
model_name="tag",
name="tn_siblings_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_siblings_pks",
),
]
+53 -23
View File
@@ -14,7 +14,6 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField from multiselectfield import MultiSelectField
from treenode.models import TreeNodeModel
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog from auditlog.registry import auditlog
@@ -100,7 +99,17 @@ class Correspondent(MatchingModel):
verbose_name_plural = _("correspondents") verbose_name_plural = _("correspondents")
class Tag(MatchingModel, TreeNodeModel): class Tag(MatchingModel):
# Keep the existing column and reverse relation during the transition.
tn_parent = models.ForeignKey(
"self",
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="tn_children",
verbose_name=_("Parent"),
)
color = models.CharField(_("color"), max_length=7, default="#a6cee3") color = models.CharField(_("color"), max_length=7, default="#a6cee3")
# Maximum allowed nesting depth for tags (root = 1, max depth = 5) # Maximum allowed nesting depth for tags (root = 1, max depth = 5)
MAX_NESTING_DEPTH: Final[int] = 5 MAX_NESTING_DEPTH: Final[int] = 5
@@ -114,27 +123,51 @@ class Tag(MatchingModel, TreeNodeModel):
), ),
) )
class Meta(MatchingModel.Meta, TreeNodeModel.Meta): class Meta(MatchingModel.Meta):
verbose_name = _("tag") verbose_name = _("tag")
verbose_name_plural = _("tags") verbose_name_plural = _("tags")
@classmethod
def get_hierarchy(cls):
from documents.tag_hierarchy import TagHierarchy
return TagHierarchy(cls.objects.values_list("pk", "tn_parent_id"))
@property
def parent_pk(self):
return self.tn_parent_id
def get_parent(self):
return self.tn_parent
def get_ancestors_pks(self):
return self.get_hierarchy().ancestors(self.tn_parent_id)
def get_ancestors(self):
ids = self.get_ancestors_pks()
tags = self.__class__.objects.in_bulk(ids)
return [tags[pk] for pk in ids]
def get_descendants_pks(self):
return self.get_hierarchy().descendants([self.pk])
def get_descendants(self):
return self.__class__.objects.filter(pk__in=self.get_descendants_pks())
def get_children_queryset(self):
return self.__class__.objects.filter(tn_parent_id=self.pk)
def clean(self) -> None: def clean(self) -> None:
# Prevent self-parenting and assigning a descendant as parent hierarchy = self.get_hierarchy()
parent = self.get_parent() if self.pk is not None and self.tn_parent_id == self.pk:
if parent == self: raise ValidationError({"tn_parent": _("Cannot set itself as parent.")})
raise ValidationError({"parent": _("Cannot set itself as parent.")}) ancestors = hierarchy.ancestors(self.tn_parent_id)
if parent and self.pk is not None and self.is_ancestor_of(parent): if self.pk is not None and self.pk in ancestors:
raise ValidationError({"parent": _("Cannot set parent to a descendant.")}) raise ValidationError(
{"tn_parent": _("Cannot set parent to a descendant.")},
# Enforce maximum nesting depth )
new_parent_depth = 0 if len(ancestors) + 1 + hierarchy.height(self.pk) > self.MAX_NESTING_DEPTH:
if parent: raise ValidationError({"tn_parent": _("Maximum nesting depth exceeded.")})
new_parent_depth = parent.get_ancestors_count() + 1
height = 0 if self.pk is None else self.get_depth()
deepest_new_depth = (new_parent_depth + 1) + height
if deepest_new_depth > self.MAX_NESTING_DEPTH:
raise ValidationError({"parent": _("Maximum nesting depth exceeded.")})
return super().clean() return super().clean()
@@ -539,10 +572,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
return self.created return self.created
def add_nested_tags(self, tags) -> None: def add_nested_tags(self, tags) -> None:
tag_ids = set() tag_ids = Tag.get_hierarchy().with_ancestors(tag.pk for tag in tags)
for tag in tags:
tag_ids.add(tag.id)
tag_ids.update(tag.get_ancestors_pks())
tags_to_add = self.tags.model.objects.filter(id__in=tag_ids) tags_to_add = self.tags.model.objects.filter(id__in=tag_ids)
self.tags.add(*tags_to_add) self.tags.add(*tags_to_add)
+28 -50
View File
@@ -589,7 +589,7 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
text_color = serializers.SerializerMethodField() text_color = serializers.SerializerMethodField()
# map to treenode's tn_parent # Preserve the public API parent field.
parent = serializers.PrimaryKeyRelatedField( parent = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
allow_null=True, allow_null=True,
@@ -692,7 +692,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
self.instance.clean() self.instance.clean()
except ValidationError as e: except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e) logger.debug("Tag parent validation failed: %s", e)
raise e raise serializers.ValidationError(
{"parent": e.message_dict["tn_parent"]},
) from e
finally: finally:
self.instance.tn_parent = original_parent self.instance.tn_parent = original_parent
else: else:
@@ -702,7 +704,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
temp.clean() temp.clean()
except ValidationError as e: except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e) logger.debug("Tag parent validation failed: %s", e)
raise e raise serializers.ValidationError(
{"parent": e.message_dict["tn_parent"]},
) from e
return super().validate(attrs) return super().validate(attrs)
@@ -1198,61 +1202,35 @@ class DocumentSerializer(
doc_id, doc_id,
) )
if "tags" in validated_data: if "tags" in validated_data:
# Respect tag hierarchy on updates: hierarchy = Tag.get_hierarchy()
# - Adding a child adds its ancestors previous_ids = set(instance.tags.values_list("pk", flat=True))
# - Removing a parent removes all its descendants requested_ids = {tag.pk for tag in validated_data["tags"]}
prev_tags = set(instance.tags.all()) required_ids = hierarchy.with_ancestors(requested_ids - previous_ids)
requested_tags = set(validated_data["tags"]) blocked_ids = hierarchy.with_descendants(previous_ids - requested_ids)
blocked_ids.difference_update(required_ids)
final_ids = hierarchy.with_ancestors(requested_ids) - blocked_ids
validated_data["tags"] = list(Tag.objects.filter(pk__in=final_ids))
# Tags newly added in this update and the ancestors they require
added_tags = requested_tags - prev_tags
required_by_add_tags = set(added_tags)
for t in added_tags:
required_by_add_tags.update(t.get_ancestors())
# Tags being removed in this update and all descendants, except
# those required by a tag that is being added in this same update
removed_tags = prev_tags - requested_tags
blocked_tags = set(removed_tags)
for t in removed_tags:
blocked_tags.update(t.get_descendants())
blocked_tags.difference_update(required_by_add_tags)
# Add all parent tags
final_tags = set(requested_tags)
for t in requested_tags:
final_tags.update(t.get_ancestors())
# Drop removed parents and their descendants
final_tags.difference_update(blocked_tags)
validated_data["tags"] = list(final_tags)
if validated_data.get("remove_inbox_tags"): if validated_data.get("remove_inbox_tags"):
current_tag_ids = {t.pk for t in instance.tags.all()} hierarchy = Tag.get_hierarchy()
tags = ( tags = (
validated_data["tags"] validated_data["tags"]
if "tags" in validated_data if "tags" in validated_data
else list(instance.tags.all()) else list(instance.tags.all())
) )
current_ids = set(instance.tags.values_list("pk", flat=True))
# Tags newly added in this update, plus their ancestors, are kept keep_ids = hierarchy.with_ancestors(
keep_ids: set[int] = set() tag.pk for tag in tags if tag.pk not in current_ids
for tag in tags: )
if tag.pk not in current_tag_ids: inbox_ids = (
keep_ids.add(tag.pk)
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
# Remove inbox tags and their descendants, except those being kept
remove_ids: set[int] = set()
for inbox_tag in (
Tag.objects.filter(is_inbox_tag=True) Tag.objects.filter(is_inbox_tag=True)
.exclude(pk__in=keep_ids) .exclude(
.only("pk", "tn_descendants_pks") pk__in=keep_ids,
): )
remove_ids.add(inbox_tag.pk) .values_list("pk", flat=True)
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks()) )
remove_ids = hierarchy.with_descendants(inbox_ids) - keep_ids
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids] validated_data["tags"] = [tag for tag in tags if tag.pk not in remove_ids]
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user): with set_actor(self.user):
+61
View File
@@ -0,0 +1,61 @@
"""An operation-local snapshot of tag edges, with no persistent derived fields.
Never cache this globally: tasks, bulk updates and other processes can change
parents. Callers traversing multiple tags should share one snapshot.
"""
from collections import defaultdict
from collections.abc import Iterable
class TagHierarchy:
def __init__(self, edges: Iterable[tuple[int, int | None]]) -> None:
self.parents = dict(edges)
self.children: dict[int | None, list[int]] = defaultdict(list)
for pk, parent in self.parents.items():
self.children[parent].append(pk)
def ancestors(self, parent_id: int | None) -> list[int]:
result = []
seen = set()
while parent_id is not None and parent_id not in seen:
seen.add(parent_id)
result.append(parent_id)
parent_id = self.parents.get(parent_id)
return result[::-1]
def with_ancestors(self, roots: Iterable[int]) -> set[int]:
result = set(roots)
for pk in list(result):
result.update(self.ancestors(self.parents.get(pk)))
return result
def with_descendants(self, roots: Iterable[int]) -> set[int]:
roots = set(roots)
return roots | self.descendants(roots)
def descendants(self, roots: Iterable[int]) -> set[int]:
roots = {pk for pk in roots if pk is not None}
seen = set(roots)
pending = list(roots)
while pending:
for child in self.children.get(pending.pop(), ()):
if child not in seen:
seen.add(child)
pending.append(child)
return seen - roots
def height(self, pk: int | None) -> int:
if pk is None:
return 0
seen = {pk}
pending = [(pk, 0)]
height = 0
while pending:
parent, depth = pending.pop()
height = max(height, depth)
for child in self.children.get(parent, ()):
if child not in seen:
seen.add(child)
pending.append((child, depth + 1))
return height
+104
View File
@@ -0,0 +1,104 @@
from django.core.exceptions import FieldDoesNotExist
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from documents.management.commands.document_importer import _deserialize_record
from documents.models import Tag
from documents.tag_hierarchy import TagHierarchy
from paperless_testing.migrations import TestMigrations
class TestTagAdjacency(TestCase):
def test_held_instance_uses_current_parent_edges(self):
first = Tag.objects.create(name="First")
second = Tag.objects.create(name="Second")
child = Tag.objects.create(name="Child", tn_parent=first)
Tag.objects.filter(pk=child.pk).update(tn_parent=second)
assert first.get_descendants_pks() == set()
assert second.get_descendants_pks() == {child.pk}
def test_queryset_delete_preserves_cascade(self):
root = Tag.objects.create(name="Root")
child = Tag.objects.create(name="Child", tn_parent=root)
Tag.objects.create(name="Grandchild", tn_parent=child)
survivor = Tag.objects.create(name="Other root")
Tag.objects.filter(pk=root.pk).delete()
assert list(Tag.objects.values_list("pk", flat=True)) == [survivor.pk]
def test_flat_writes_do_not_update_other_tags(self):
Tag.objects.bulk_create([Tag(name=f"Tag {i}") for i in range(100)])
with CaptureQueriesContext(connection) as queries:
new = Tag.objects.create(name="New")
assert not any(q["sql"].startswith("UPDATE") for q in queries)
with CaptureQueriesContext(connection) as queries:
new.name = "Renamed"
new.save()
tag_updates = [
q["sql"] for q in queries if q["sql"].startswith('UPDATE "documents_tag"')
]
assert len(tag_updates) == 1
assert 'WHERE "documents_tag"."id" =' in tag_updates[0]
def test_batch_snapshot_is_one_query_and_includes_overlapping_roots(self):
root = Tag.objects.create(name="Root")
child = Tag.objects.create(name="Child", tn_parent=root)
leaf = Tag.objects.create(name="Leaf", tn_parent=child)
with self.assertNumQueries(1):
hierarchy = Tag.get_hierarchy()
assert hierarchy.with_ancestors([leaf.pk]) == {root.pk, child.pk, leaf.pk}
assert hierarchy.with_descendants([root.pk, child.pk]) == {
root.pk,
child.pk,
leaf.pk,
}
assert hierarchy.height(None) == 0
def test_legacy_export_fields_are_ignored_but_parent_is_preserved(self):
model, tag, m2m = _deserialize_record(
{
"model": "documents.tag",
"pk": 2,
"fields": {
"name": "Child",
"tn_parent": 1,
"tn_order": 3,
"tn_siblings_pks": "4,5",
"tn_descendants_pks": "6",
},
},
)
assert model is Tag
assert tag.tn_parent_id == 1
assert tag.name == "Child"
assert not m2m
with self.assertRaises(FieldDoesNotExist):
_deserialize_record(
{"model": "documents.tag", "pk": 3, "fields": {"tn_typo": "bad"}},
)
def test_malformed_cycles_terminate(self):
hierarchy = TagHierarchy([(1, 2), (2, 1)])
assert hierarchy.with_descendants([1]) == {1, 2}
assert set(hierarchy.ancestors(2)) == {1, 2}
class TestTagAdjacencyMigration(TestMigrations):
migrate_from = "0027_documentbarcode"
migrate_to = "0028_replace_tag_tree_fields"
def setUpBeforeMigration(self, apps):
tag = apps.get_model("documents", "Tag")
root = tag.objects.create(name="Root")
child = tag.objects.create(name="Child", tn_parent=root)
self.edges = [(root.pk, None), (child.pk, root.pk)]
def test_parent_edges_survive(self):
tag = self.apps.get_model("documents", "Tag")
assert (
list(tag.objects.order_by("pk").values_list("pk", "tn_parent_id"))
== self.edges
)
assert {
field.name for field in tag._meta.fields if field.name.startswith("tn_")
} == {"tn_parent"}
+2 -7
View File
@@ -627,7 +627,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
queryset = queryset.order_by(*ordering) queryset = queryset.order_by(*ordering)
all_tags = list(queryset) all_tags = list(queryset)
descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} descendant_pks = Tag.get_hierarchy().descendants(t.pk for t in all_tags)
if descendant_pks: if descendant_pks:
user = getattr(getattr(self, "request", None), "user", None) user = getattr(getattr(self, "request", None), "user", None)
@@ -5060,12 +5060,7 @@ class BulkEditObjectsView(PassUserMixin):
if object_type == "tags": if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True)) editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True)) all_ids = set(objs.values_list("pk", flat=True))
for tag in objs: all_ids.update(Tag.get_hierarchy().descendants(all_ids) & editable_ids)
all_ids.update(
descendant.pk
for descendant in tag.get_descendants()
if descendant.pk in editable_ids
)
objs = object_class.objects.filter(pk__in=all_ids) objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner") objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True)) object_ids = list(objs.values_list("pk", flat=True))
+10 -12
View File
@@ -6,6 +6,7 @@ from guardian.shortcuts import remove_perm
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import Tag
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.permissions import PermissionsSpec from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
@@ -134,10 +135,9 @@ def apply_assignment_to_overrides(
if action.has_assign_tags: if action.has_assign_tags:
if overrides.tag_ids is None: if overrides.tag_ids is None:
overrides.tag_ids = [] overrides.tag_ids = []
tag_ids_to_add: set[int] = set() tag_ids_to_add = Tag.get_hierarchy().with_ancestors(
for tag in action.assign_tags.all(): tag.pk for tag in action.assign_tags.all()
tag_ids_to_add.add(tag.pk) )
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add) overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
@@ -216,10 +216,9 @@ def apply_removal_to_document(
if action.remove_all_tags: if action.remove_all_tags:
Document.objects.get(pk=document.pk).tags.clear() Document.objects.get(pk=document.pk).tags.clear()
else: else:
tag_ids_to_remove: set[int] = set() tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
for tag in action.remove_tags.all(): tag.pk for tag in action.remove_tags.all()
tag_ids_to_remove.add(tag.pk) )
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
if tag_ids_to_remove: if tag_ids_to_remove:
Document.objects.get(pk=document.pk).tags.remove(*tag_ids_to_remove) Document.objects.get(pk=document.pk).tags.remove(*tag_ids_to_remove)
@@ -297,10 +296,9 @@ def apply_removal_to_overrides(
if action.remove_all_tags: if action.remove_all_tags:
overrides.tag_ids = None overrides.tag_ids = None
elif overrides.tag_ids: elif overrides.tag_ids:
tag_ids_to_remove: set[int] = set() tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
for tag in action.remove_tags.all(): tag.pk for tag in action.remove_tags.all()
tag_ids_to_remove.add(tag.pk) )
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove] overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove]
-1
View File
@@ -157,7 +157,6 @@ INSTALLED_APPS = [
"allauth.headless", "allauth.headless",
"drf_spectacular", "drf_spectacular",
"drf_spectacular_sidecar", "drf_spectacular_sidecar",
"treenode",
*env_apps, *env_apps,
] ]
@@ -1,93 +0,0 @@
import sqlite3
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
import pytest
from llama_index.core.schema import TextNode
from paperless_ai.tables import DocumentChunksTable
from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
from paperless_ai.vector_store import VEC0_CHUNK_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
DIM = 16
def make_node(node_id: str, document_id: int) -> TextNode:
node = TextNode(
id_=node_id,
text="some text",
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
)
node.relationships = {}
node.embedding = [document_id + i / 100 for i in range(DIM)]
return node
def vec_table_sql(db_path: Path) -> str:
conn = sqlite3.connect(db_path)
try:
return conn.execute(
"SELECT sql FROM sqlite_master WHERE name = ?",
(DEFAULT_TABLE_NAME,),
).fetchone()[0]
finally:
conn.close()
def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
"""The table definition from before chunk_size was set explicitly."""
conn.execute(
f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
"id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
f"embedding float[{dim}] distance_metric=cosine)",
)
@pytest.fixture
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
yield store
class TestVecChunkSize:
def test_new_table_uses_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN an empty store
WHEN the first document is indexed and the vec0 table is created
THEN the table definition carries the configured chunk size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
def test_compact_moves_legacy_table_to_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN an index whose vec0 table was created with the default chunk size
WHEN the index is compacted
THEN the rebuilt table uses the configured chunk size
AND no indexed chunks are lost
"""
with patch.object(
PaperlessSqliteVecVectorStore,
"_create_vec_table",
staticmethod(create_legacy_vec_table),
):
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
store.upsert_document(2, [make_node("2-a", 2)])
assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
store.compact(force=True)
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
assert DocumentChunksTable.count(store.client) == 3
@@ -1,89 +0,0 @@
import sqlite3
from collections.abc import Generator
from pathlib import Path
import pytest
from llama_index.core.schema import TextNode
from paperless_ai.tables import DocumentChunksTable
from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
DIM = 16
LEGACY_PAGE_SIZE = 4096
def make_node(node_id: str, document_id: int) -> TextNode:
node = TextNode(
id_=node_id,
text="some text",
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
)
node.relationships = {}
node.embedding = [document_id + i / 100 for i in range(DIM)]
return node
def page_size_of(db_path: Path) -> int:
conn = sqlite3.connect(db_path)
try:
return conn.execute("PRAGMA page_size").fetchone()[0]
finally:
conn.close()
@pytest.fixture
def legacy_page_size_db(tmp_path: Path) -> Path:
"""An existing index file created before the page size was set."""
db_path = tmp_path / DB_FILENAME
conn = sqlite3.connect(db_path)
conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
conn.execute("CREATE TABLE _seed(x)")
conn.execute("DROP TABLE _seed")
conn.close()
assert page_size_of(db_path) == LEGACY_PAGE_SIZE
return db_path
@pytest.fixture
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
yield store
class TestIndexPageSize:
def test_new_index_file_uses_configured_page_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN no existing index file
WHEN a store is opened and a document is indexed
THEN the database file uses the configured page size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
def test_compact_moves_legacy_file_to_configured_page_size(
self,
legacy_page_size_db: Path,
) -> None:
"""
GIVEN an existing index file created with a different page size
WHEN the index is compacted
THEN the rebuilt file uses the configured page size
AND no indexed chunks are lost
"""
with PaperlessSqliteVecVectorStore(
uri=str(legacy_page_size_db.parent),
) as store:
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
store.compact(force=True)
assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
assert DocumentChunksTable.count(store.client) == 2
+1 -17
View File
@@ -47,20 +47,6 @@ SCHEMA_VERSION = 2
# a rebuild copies the live rows into a fresh table. # a rebuild copies the live rows into a fresh table.
COMPACT_BLOAT_RATIO = 2.0 COMPACT_BLOAT_RATIO = 2.0
# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
# of times faster with small chunks, at no cost in query speed or file size.
# Only applies to tables created from here on; an existing index picks it up
# the next time it is rebuilt (compact or rebuild).
VEC0_CHUNK_SIZE = 32
# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
# on a newly created file, so like the chunk size it reaches an existing index
# when it is rebuilt.
DB_PAGE_SIZE = 65536
# Number of rows fetched/copied per batch whenever this module streams rows # Number of rows fetched/copied per batch whenever this module streams rows
# instead of materializing them all at once, keeping memory bounded regardless # instead of materializing them all at once, keeping memory bounded regardless
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration # of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
@@ -236,7 +222,6 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
conn.enable_load_extension(True) # noqa: FBT003 conn.enable_load_extension(True) # noqa: FBT003
sqlite_vec.load(conn) sqlite_vec.load(conn)
conn.enable_load_extension(False) # noqa: FBT003 conn.enable_load_extension(False) # noqa: FBT003
conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA synchronous=NORMAL")
IndexMetaTable.create(conn) IndexMetaTable.create(conn)
@@ -344,8 +329,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
+ " +node_content TEXT," + " +node_content TEXT,"
+ " embedding float[" + " embedding float["
+ str(int(dim)) + str(int(dim))
+ "] distance_metric=cosine, chunk_size=" + "] distance_metric=cosine"
+ str(VEC0_CHUNK_SIZE)
+ ")", + ")",
) )
Generated
-11
View File
@@ -1210,15 +1210,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" }, { url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" },
] ]
[[package]]
name = "django-treenode"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2b/09f04b44b3583a911ae4f5d0e3360a23d529704c9e9115fce2520b7d4420/django_treenode-0.25.0.tar.gz", hash = "sha256:d14d0eb3571c227ce11147210d62a8f87b568bbb30fa76a5be9b9e6d514157c3", size = 29951, upload-time = "2026-08-26T00:20:13.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/ac/2140e8c20e2a47457ec23302273527d3430bf5c09edb8940d74c0725c138/django_treenode-0.25.0-py3-none-any.whl", hash = "sha256:f47caab5440420870b6287602b3ee401ca354416d44b24ab97579be5ea658927", size = 22343, upload-time = "2026-08-26T00:20:12.078Z" },
]
[[package]] [[package]]
name = "djangorestframework" name = "djangorestframework"
version = "3.18.3" version = "3.18.3"
@@ -3107,7 +3098,6 @@ dependencies = [
{ name = "django-multiselectfield" }, { name = "django-multiselectfield" },
{ name = "django-rich" }, { name = "django-rich" },
{ name = "django-soft-delete" }, { name = "django-soft-delete" },
{ name = "django-treenode" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "drf-spectacular" }, { name = "drf-spectacular" },
{ name = "drf-spectacular-sidecar" }, { name = "drf-spectacular-sidecar" },
@@ -3258,7 +3248,6 @@ requires-dist = [
{ name = "django-multiselectfield", specifier = "~=1.0.1" }, { name = "django-multiselectfield", specifier = "~=1.0.1" },
{ name = "django-rich", specifier = "~=2.2.0" }, { name = "django-rich", specifier = "~=2.2.0" },
{ name = "django-soft-delete", specifier = "~=1.0.18" }, { name = "django-soft-delete", specifier = "~=1.0.18" },
{ name = "django-treenode", specifier = ">=0.24" },
{ name = "djangorestframework", specifier = "~=3.16" }, { name = "djangorestframework", specifier = "~=3.16" },
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" }, { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },