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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 12:12:33 +02:00
committed by marcel
parent 944370dcfd
commit 138bf446e4
5 changed files with 101 additions and 14 deletions

View File

@@ -126,4 +126,31 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
*/ */
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true) @Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<TagCount> findDocumentCountsPerTag(); List<TagCount> findDocumentCountsPerTag();
/**
* Returns (tagId, count) pairs where count is the number of <b>distinct</b> documents tagged
* with that tag <b>or any of its descendants</b> (full subtree rollup).
* <p>
* 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<TagCount> findSubtreeDocumentCountsPerTag();
} }

View File

@@ -172,19 +172,27 @@ public class TagService {
} }
/** /**
* Returns all tags assembled into a tree with document counts per node. * Returns all tags assembled into a tree, each node carrying two counts:
* Uses a single aggregate query to avoid N+1 behaviour. * {@code documentCount} — documents tagged with that exact tag (direct) — and
* NOTE: document counts are global per tag, not scoped to any search filter. * {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
* The tree endpoint is only used for the admin sidebar, so this is intentional. * (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<TagTreeNodeDTO> getTagTree() { public List<TagTreeNodeDTO> getTagTree() {
List<Tag> all = tagRepository.findAll(); List<Tag> all = tagRepository.findAll();
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream() Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
.collect(Collectors.toMap( Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
return buildTree(all, counts, subtreeCounts);
}
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
return counts.stream().collect(Collectors.toMap(
TagRepository.TagCount::getTagId, TagRepository.TagCount::getTagId,
TagRepository.TagCount::getCount TagRepository.TagCount::getCount
)); ));
return buildTree(all, counts);
} }
// ─── private helpers ───────────────────────────────────────────────────── // ─── private helpers ─────────────────────────────────────────────────────
@@ -259,12 +267,14 @@ public class TagService {
} }
} }
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) { private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
Map<UUID, Long> subtreeCounts) {
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>(); Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
for (Tag tag : tags) { for (Tag tag : tags) {
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
nodeById.put(tag.getId(), new TagTreeNodeDTO( 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() new ArrayList<>(), tag.getParentId()
)); ));
} }

View File

@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
String color, String color,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount, @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<TagTreeNodeDTO> children, List<TagTreeNodeDTO> children,
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {} @Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}

View File

@@ -102,8 +102,8 @@ class TagControllerTest {
void getTagTree_returns200_withTreeStructure() throws Exception { void getTagTree_returns200_withTreeStructure() throws Exception {
UUID parentId = UUID.randomUUID(); UUID parentId = UUID.randomUUID();
UUID childId = UUID.randomUUID(); UUID childId = UUID.randomUUID();
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId); TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, 0, List.of(), parentId);
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null); TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, 0, List.of(child), null);
when(tagService.getTagTree()).thenReturn(List.of(parent)); when(tagService.getTagTree()).thenReturn(List.of(parent));
mockMvc.perform(get("/api/tags/tree")) mockMvc.perform(get("/api/tags/tree"))

View File

@@ -278,6 +278,53 @@ class TagServiceTest {
verify(tagRepository, times(1)).findDocumentCountsPerTag(); 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 ─────────────────────────────────────────────── // ─── resolveEffectiveColors ───────────────────────────────────────────────
@Test @Test