From 0be0a524b3bb63bce7468768728f00c722c328e9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 14:47:29 +0200 Subject: [PATCH] feat(tag): add a batched root-tag resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../raddatz/familienarchiv/tag/RootTag.java | 13 ++++ .../familienarchiv/tag/TagService.java | 48 +++++++++++++ .../tag/TagServiceIntegrationTest.java | 61 ++++++++++++++++ .../familienarchiv/tag/TagServiceTest.java | 69 +++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/tag/RootTag.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/RootTag.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/RootTag.java new file mode 100644 index 00000000..987f3cd1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/RootTag.java @@ -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) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java index 361d7d22..23bb2cb5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java @@ -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 resolveRootTags(Collection tags) { + if (tags == null || tags.isEmpty()) return Map.of(); + + Map distinct = new LinkedHashMap<>(); + for (Tag tag : tags) { + if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag); + } + + Map> ancestorIdsByTagId = new HashMap<>(); + Set idsToLoad = new HashSet<>(); + for (Tag tag : distinct.values()) { + if (tag.getParentId() == null) continue; + List ancestorIds = tagRepository.findAncestorIds(tag.getId()); + ancestorIdsByTagId.put(tag.getId(), ancestorIds); + idsToLoad.addAll(ancestorIds); + } + + Map ancestorsById = idsToLoad.isEmpty() ? Map.of() + : tagRepository.findAllById(idsToLoad).stream() + .collect(Collectors.toMap(Tag::getId, t -> t)); + + Map 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 ancestorIds, Map 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. * Used by DocumentService to expand selected filter tags before applying AND/OR logic. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceIntegrationTest.java new file mode 100644 index 00000000..b0742566 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceIntegrationTest.java @@ -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 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 result = tagService.resolveRootTags(List.of(root)); + + assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java index 3c097073..1851292d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.TagRepository; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -450,6 +451,74 @@ class TagServiceTest { 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 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 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 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 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 ──────────────────────────────────────────────────────────── @Test