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:
@@ -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 1–3 tags at depth ≤ 4, so 3–5 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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user