From 609d242f5d307abd4f8b90c37696daea051bd110 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 22:24:50 +0200 Subject: [PATCH] feat(#248): enrich TagTreeNodeDTO with parentId and populate documentCount via single aggregate query Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/dto/TagTreeNodeDTO.java | 2 +- .../familienarchiv/service/TagService.java | 27 +++++++----- .../controller/TagControllerTest.java | 4 +- .../service/TagServiceTest.java | 41 +++++++++++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java index 14e59da9..0cec7260 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java @@ -3,4 +3,4 @@ package org.raddatz.familienarchiv.dto; import java.util.List; import java.util.UUID; -public record TagTreeNodeDTO(UUID id, String name, String color, int documentCount, List children) {} +public record TagTreeNodeDTO(UUID id, String name, String color, int documentCount, List children, UUID parentId) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java index c06e241b..4fe3112c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -141,12 +141,17 @@ public class TagService { } /** - * Returns all tags assembled into a tree. Document counts are not included here - * (they are populated by the controller layer if needed, or set to 0). + * Returns all tags assembled into a tree with document counts per node. + * Uses a single aggregate query to avoid N+1 behaviour. */ public List getTagTree() { List all = tagRepository.findAll(); - return buildTree(all); + Map counts = tagRepository.findDocumentCountsPerTag().stream() + .collect(Collectors.toMap( + row -> (UUID) row[0], + row -> (Long) row[1] + )); + return buildTree(all, counts); } // ─── private helpers ───────────────────────────────────────────────────── @@ -195,7 +200,7 @@ public class TagService { } } - private List buildTree(List tags) { + private List buildTree(List tags, Map counts) { Map> childrenByParent = new HashMap<>(); for (Tag tag : tags) { @@ -206,14 +211,14 @@ public class TagService { } for (Tag tag : tags) { + int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); TagTreeNodeDTO node = new TagTreeNodeDTO( - tag.getId(), tag.getName(), tag.getColor(), 0, - childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()) + tag.getId(), tag.getName(), tag.getColor(), documentCount, + childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()), + tag.getParentId() ); if (tag.getParentId() != null) { childrenByParent.get(tag.getParentId()).add(node); - } else { - childrenByParent.get(tag.getId()); // ensure root is tracked } } @@ -221,9 +226,11 @@ public class TagService { List roots = new ArrayList<>(); for (Tag tag : tags) { if (tag.getParentId() == null) { + int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); roots.add(new TagTreeNodeDTO( - tag.getId(), tag.getName(), tag.getColor(), 0, - childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()) + tag.getId(), tag.getName(), tag.getColor(), documentCount, + childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()), + null )); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java index b61adea6..0ce9f9ab 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java @@ -96,8 +96,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()); - TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child)); + TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId); + TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 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/service/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java index 6377a873..14540abc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -197,6 +197,7 @@ class TagServiceTest { @Test void getTagTree_returnsEmptyList_whenNoTags() { when(tagRepository.findAll()).thenReturn(List.of()); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); assertThat(tagService.getTagTree()).isEmpty(); } @@ -210,6 +211,7 @@ class TagServiceTest { Tag.builder().id(idB).name("Beta").build() ); when(tagRepository.findAll()).thenReturn(tags); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); var tree = tagService.getTagTree(); @@ -224,6 +226,7 @@ class TagServiceTest { Tag parent = Tag.builder().id(parentId).name("Parent").build(); Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build(); when(tagRepository.findAll()).thenReturn(List.of(parent, child)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); var tree = tagService.getTagTree(); @@ -233,6 +236,44 @@ class TagServiceTest { assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId); } + // ─── getTagTree (enriched) ──────────────────────────────────────────────── + + @Test + void getTagTree_populatesParentId_onChildNode() { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build(); + when(tagRepository.findAll()).thenReturn(List.of(parent, child)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + var tree = tagService.getTagTree(); + + assertThat(tree.get(0).children().get(0).parentId()).isEqualTo(parentId); + } + + @Test + void getTagTree_populatesDocumentCount_fromAggregateQuery() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + when(tagRepository.findAll()).thenReturn(List.of(tag)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(new Object[]{tagId, 5L})); + + var tree = tagService.getTagTree(); + + assertThat(tree.get(0).documentCount()).isEqualTo(5); + } + + @Test + void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() { + when(tagRepository.findAll()).thenReturn(List.of()); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + tagService.getTagTree(); + + verify(tagRepository, times(1)).findDocumentCountsPerTag(); + } + // ─── resolveEffectiveColors ─────────────────────────────────────────────── @Test