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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
TagRepository.TagCount::getTagId,
|
return buildTree(all, counts, subtreeCounts);
|
||||||
TagRepository.TagCount::getCount
|
}
|
||||||
));
|
|
||||||
return buildTree(all, counts);
|
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||||
|
return counts.stream().collect(Collectors.toMap(
|
||||||
|
TagRepository.TagCount::getTagId,
|
||||||
|
TagRepository.TagCount::getCount
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user