From 335ef18a8ea1a61b7e3a6a375b0a2a591dba3788 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:12:33 +0200 Subject: [PATCH] feat(tag): add subtree document-count rollup to tag tree (#698) Add subtreeDocumentCount to TagTreeNodeDTO, populated by a new recursive-CTE aggregate query that builds a tag closure and counts distinct documents per ancestor subtree. The direct documentCount is unchanged; getTagTree now maps both counts onto each node from two aggregate queries (no N+1). Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/tag/TagRepository.java | 27 +++++++++++ .../familienarchiv/tag/TagService.java | 34 +++++++++----- .../familienarchiv/tag/TagTreeNodeDTO.java | 3 ++ .../familienarchiv/tag/TagControllerTest.java | 4 +- .../familienarchiv/tag/TagServiceTest.java | 47 +++++++++++++++++++ 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagRepository.java index f1b3b7ab..108b4bc8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagRepository.java @@ -126,4 +126,31 @@ public interface TagRepository extends JpaRepository { */ @Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true) List findDocumentCountsPerTag(); + + /** + * Returns (tagId, count) pairs where count is the number of distinct documents tagged + * with that tag or any of its descendants (full subtree rollup). + *

+ * Builds a tag closure of (ancestor_id, descendant_id) pairs via a recursive CTE — each tag is + * its own ancestor at depth 0, then descends into children (depth guard of 50 levels prevents a + * cycle or pathological depth from running away) — joins it to {@code document_tags} on the + * descendant, and counts distinct documents per ancestor. A document tagged with several tags in + * the same subtree is therefore counted once. Tags whose entire subtree holds no documents do + * not appear in the result (they default to 0 in the tree). One aggregate query for all tags. + */ + @Query(value = """ + WITH RECURSIVE closure AS ( + SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth FROM tag + UNION ALL + SELECT c.ancestor_id, t.id AS descendant_id, c.depth + 1 + FROM tag t + JOIN closure c ON t.parent_id = c.descendant_id + WHERE c.depth < 50 + ) + SELECT c.ancestor_id AS tagId, COUNT(DISTINCT dt.document_id) AS count + FROM closure c + JOIN document_tags dt ON dt.tag_id = c.descendant_id + GROUP BY c.ancestor_id + """, nativeQuery = true) + List findSubtreeDocumentCountsPerTag(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java index 14e1e9fa..1dac8610 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java @@ -172,19 +172,27 @@ public class TagService { } /** - * Returns all tags assembled into a tree with document counts per node. - * Uses a single aggregate query to avoid N+1 behaviour. - * NOTE: document counts are global per tag, not scoped to any search filter. - * The tree endpoint is only used for the admin sidebar, so this is intentional. + * Returns all tags assembled into a tree, each node carrying two counts: + * {@code documentCount} — documents tagged with that exact tag (direct) — and + * {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant + * (subtree rollup). Each count comes from one aggregate query (no N+1). + * NOTE: counts are global per tag, not scoped to any search filter. + * Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the + * subtree rollup) as well as the admin sidebar and tag operation previews (which read the + * direct count). */ public List getTagTree() { List all = tagRepository.findAll(); - Map counts = tagRepository.findDocumentCountsPerTag().stream() - .collect(Collectors.toMap( - TagRepository.TagCount::getTagId, - TagRepository.TagCount::getCount - )); - return buildTree(all, counts); + Map counts = toCountMap(tagRepository.findDocumentCountsPerTag()); + Map subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag()); + return buildTree(all, counts, subtreeCounts); + } + + private static Map toCountMap(List counts) { + return counts.stream().collect(Collectors.toMap( + TagRepository.TagCount::getTagId, + TagRepository.TagCount::getCount + )); } // ─── private helpers ───────────────────────────────────────────────────── @@ -259,12 +267,14 @@ public class TagService { } } - private List buildTree(List tags, Map counts) { + private List buildTree(List tags, Map counts, + Map subtreeCounts) { Map nodeById = new LinkedHashMap<>(); for (Tag tag : tags) { int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); + int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue(); nodeById.put(tag.getId(), new TagTreeNodeDTO( - tag.getId(), tag.getName(), tag.getColor(), documentCount, + tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount, new ArrayList<>(), tag.getParentId() )); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagTreeNodeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagTreeNodeDTO.java index 2b8a60db..3399da04 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagTreeNodeDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagTreeNodeDTO.java @@ -10,5 +10,8 @@ public record TagTreeNodeDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name, String color, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + description = "Distinct documents tagged with this tag or any descendant tag (subtree rollup)") + int subtreeDocumentCount, List children, @Schema(description = "Parent tag ID, null for root tags") UUID parentId) {} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java index 1504f1fa..4d2ccae9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java @@ -102,8 +102,8 @@ class TagControllerTest { void getTagTree_returns200_withTreeStructure() throws Exception { UUID parentId = UUID.randomUUID(); UUID childId = UUID.randomUUID(); - TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId); - TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null); + TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, 0, List.of(), parentId); + TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, 0, List.of(child), null); when(tagService.getTagTree()).thenReturn(List.of(parent)); mockMvc.perform(get("/api/tags/tree")) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java index 486f4afe..e9e06a70 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java @@ -278,6 +278,53 @@ class TagServiceTest { verify(tagRepository, times(1)).findDocumentCountsPerTag(); } + @Test + void getTagTree_populatesSubtreeDocumentCount_fromRollupQuery() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Reisen").build(); + TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class); + when(subtreeEntry.getTagId()).thenReturn(tagId); + when(subtreeEntry.getCount()).thenReturn(7L); + when(tagRepository.findAll()).thenReturn(List.of(tag)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry)); + + var tree = tagService.getTagTree(); + + assertThat(tree.get(0).subtreeDocumentCount()).isEqualTo(7); + } + + @Test + void getTagTree_keepsDirectAndSubtreeCountsIndependent() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Reisen").build(); + TagRepository.TagCount directEntry = mock(TagRepository.TagCount.class); + when(directEntry.getTagId()).thenReturn(tagId); + when(directEntry.getCount()).thenReturn(2L); + TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class); + when(subtreeEntry.getTagId()).thenReturn(tagId); + when(subtreeEntry.getCount()).thenReturn(7L); + when(tagRepository.findAll()).thenReturn(List.of(tag)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(directEntry)); + when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry)); + + var node = tagService.getTagTree().get(0); + + assertThat(node.documentCount()).isEqualTo(2); + assertThat(node.subtreeDocumentCount()).isEqualTo(7); + } + + @Test + void getTagTree_callsFindSubtreeDocumentCountsPerTag_exactlyOnce() { + when(tagRepository.findAll()).thenReturn(List.of()); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of()); + + tagService.getTagTree(); + + verify(tagRepository, times(1)).findSubtreeDocumentCountsPerTag(); + } + // ─── resolveEffectiveColors ─────────────────────────────────────────────── @Test