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:
@@ -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) {
|
||||
}
|
||||
@@ -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.
|
||||
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user