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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-17 11:27:41 +02:00
parent 59b7f7cddf
commit d075bf390a
2 changed files with 93 additions and 1 deletions

View File

@@ -39,7 +39,9 @@ public class TagService {
private final TagRepository tagRepository;
public List<Tag> search(String query) {
return tagRepository.findByNameContainingIgnoreCase(query);
List<Tag> 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 13 tags at depth ≤ 4, so 35 queries total.
private List<Tag> enrichWithRelatives(List<Tag> matched) {
Set<UUID> matchedIds = matched.stream().map(Tag::getId).collect(Collectors.toSet());
Set<UUID> 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<Tag> 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,

View File

@@ -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<Tag> 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<Tag> 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<Tag> 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());
}
}