feat(tag): add a batched root-tag resolver

TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).

This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 14:47:29 +02:00
parent 239565ea20
commit 0be0a524b3
4 changed files with 191 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.tag;
import java.util.UUID;
/**
* The root-ancestor view of a tag: its id, display name, and color token.
* Colors are stored only on root tags, so {@code color} is the authoritative token
* (one of {@link TagService#ALLOWED_TAG_COLORS}) or {@code null} when the root has none.
* Returned by {@link TagService#resolveRootTags} for read surfaces (the timeline chip,
* later the Thema buckets) that need a tag's theme without the entity graph.
*/
public record RootTag(UUID id, String name, String color) {
}

View File

@@ -175,6 +175,54 @@ public class TagService {
}); });
} }
/**
* Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
* token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
* ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
* non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
* letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
* from the resolved root's stored token (null when the root has none). Safe on detached tags.
*/
public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
if (tags == null || tags.isEmpty()) return Map.of();
Map<UUID, Tag> distinct = new LinkedHashMap<>();
for (Tag tag : tags) {
if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
}
Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
Set<UUID> idsToLoad = new HashSet<>();
for (Tag tag : distinct.values()) {
if (tag.getParentId() == null) continue;
List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
ancestorIdsByTagId.put(tag.getId(), ancestorIds);
idsToLoad.addAll(ancestorIds);
}
Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
: tagRepository.findAllById(idsToLoad).stream()
.collect(Collectors.toMap(Tag::getId, t -> t));
Map<UUID, RootTag> result = new HashMap<>();
for (Tag tag : distinct.values()) {
Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
}
return result;
}
private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
if (tag.getParentId() == null) return tag;
if (ancestorIds != null) {
for (UUID ancestorId : ancestorIds) {
Tag ancestor = ancestorsById.get(ancestorId);
if (ancestor != null && ancestor.getParentId() == null) return ancestor;
}
}
return tag;
}
/** /**
* For each tag name, returns the set of that tag's ID plus all descendant IDs. * For each tag name, returns the set of that tag's ID plus all descendant IDs.
* Used by DocumentService to expand selected filter tags before applying AND/OR logic. * Used by DocumentService to expand selected filter tags before applying AND/OR logic.

View File

@@ -0,0 +1,61 @@
package org.raddatz.familienarchiv.tag;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Real-Postgres proof that {@link TagService#resolveRootTags} walks a persisted tag chain to its
* true root through the recursive-CTE {@link TagRepository#findAncestorIds}. The CTE cannot run on
* H2, so this uses {@code postgres:16-alpine} via Testcontainers. Exhaustive case coverage lives in
* {@link TagServiceTest} (mocked); this pins the DB-dependent ancestry walk (issue #835, REQ-003/004).
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TagServiceIntegrationTest {
@Autowired private TagRepository tagRepository;
private TagService tagService;
@BeforeEach
void setUp() {
tagService = new TagService(tagRepository);
}
private Tag tag(String name, String color, UUID parentId) {
return tagRepository.save(Tag.builder().name(name).color(color).parentId(parentId).build());
}
@Test
void resolveRootTags_walksPersistedChainToRoot_withRootColor() {
// leaf → mid → root resolves to the root's (id, name, color) via the real recursive CTE.
Tag root = tag("Krieg", "sienna", null);
Tag mid = tag("Feldpost", null, root.getId());
Tag leaf = tag("Briefe von der Front", null, mid.getId());
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(leaf));
assertThat(result.get(leaf.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
}
@Test
void resolveRootTags_returnsRootItself_forPersistedRoot() {
Tag root = tag("Weihnachten", "amber", null);
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber"));
}
}

View File

@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository; import org.raddatz.familienarchiv.tag.TagRepository;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -450,6 +451,74 @@ class TagServiceTest {
assertThat(child2.getColor()).isEqualTo("sienna"); assertThat(child2.getColor()).isEqualTo("sienna");
} }
// ─── resolveRootTags ───────────────────────────────────────────────────────
@Test
void resolveRootTags_returnsTagItself_whenTagIsRoot() {
// REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
verify(tagRepository, never()).findAncestorIds(any());
verify(tagRepository, never()).findAllById(any());
}
@Test
void resolveRootTags_walksChildToRoot_withRootColor() {
// REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
UUID rootId = UUID.randomUUID();
UUID midId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
}
@Test
void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
// REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
UUID rootId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));
assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
verify(tagRepository, times(1)).findAncestorIds(childId);
verify(tagRepository, times(1)).findAllById(any());
}
@Test
void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
// REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
UUID rootId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
}
@Test
void resolveRootTags_returnsEmptyMap_forEmptyInput() {
assertThat(tagService.resolveRootTags(List.of())).isEmpty();
verify(tagRepository, never()).findAncestorIds(any());
}
// ─── mergeTags ──────────────────────────────────────────────────────────── // ─── mergeTags ────────────────────────────────────────────────────────────
@Test @Test