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 042b7943..9afc5e63 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -39,7 +39,9 @@ public class TagService { private final TagRepository tagRepository; public List search(String query) { - return tagRepository.findByNameContainingIgnoreCase(query); + List matched = tagRepository.findByNameContainingIgnoreCase(query); + if (matched.isEmpty()) return matched; + return enrichWithRelatives(matched); } public Tag getById(UUID id) { @@ -161,6 +163,29 @@ public class TagService { // ─── private helpers ───────────────────────────────────────────────────── + // Each matched tag issues 1 CTE query (findDescendantIds or findAncestorIds) + 1 batch + // fetch for extras. Typical queries match 1–3 tags at depth ≤ 4, so 3–5 queries total. + private List enrichWithRelatives(List matched) { + Set matchedIds = matched.stream().map(Tag::getId).collect(Collectors.toSet()); + Set extraIds = new HashSet<>(); + + for (Tag tag : matched) { + if (tag.getParentId() == null) { + extraIds.addAll(tagRepository.findDescendantIds(tag.getId())); + } else { + extraIds.addAll(tagRepository.findAncestorIds(tag.getId())); + } + } + extraIds.removeAll(matchedIds); + + List result = new ArrayList<>(matched); + if (!extraIds.isEmpty()) { + result.addAll(tagRepository.findAllById(extraIds)); + } + resolveEffectiveColors(result); + return result; + } + private void validateNotSelf(UUID sourceId, UUID targetId) { if (sourceId.equals(targetId)) { throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF, 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 f4c564c8..bc56d98d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -496,4 +496,71 @@ class TagServiceTest { .extracting(e -> ((DomainException) e).getCode()) .isEqualTo(ErrorCode.TAG_NOT_FOUND); } + + // ─── search ─────────────────────────────────────────────────────────────── + + @Test + void search_includes_children_when_root_matches() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + + List result = tagService.search("Brief"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + } + + @Test + void search_includes_ancestors_when_child_matches() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Hochzeit").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Hochzeit")).thenReturn(List.of(child)); + when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId)); + when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root)); + + List result = tagService.search("Hochzeit"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + } + + @Test + void search_deduplicates_when_root_also_in_descendant_result() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + // findDescendantIds includes the seed (rootId) — dedup must remove it from extras + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + + List result = tagService.search("Brief"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + assertThat(result).hasSize(2); + } + + @Test + void search_callsResolveEffectiveColors_onAllResults() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").color("sage").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + // resolveEffectiveColors will call findAllById for the child's parent color + when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root)); + + tagService.search("Brief"); + + // verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors + verify(tagRepository, atLeastOnce()).findAllById(any()); + } }