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