From d075bf390ad40812711e855e630e04bb18a6eee3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 17 Apr 2026 11:27:41 +0200 Subject: [PATCH] feat(tag-search): expand children and surface ancestor path in search results Modifies TagService.search() to enrich name-matches with tree relatives: root matches expand descendants, child matches prepend ancestors. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/TagService.java | 27 +++++++- .../service/TagServiceTest.java | 67 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) 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()); + } }