As a reader browsing topics I want each theme box to count documents across its whole sub-topic tree so I can judge a topic's depth at a glance #698
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context / problem
On
/themen, each theme box shows a count of documents tagged with that exact tag (rows indocument_tags,GROUP BY tag_id— never recursive). A parent tag whose documents mostly live on its children therefore reads as near-empty, understating how much material a topic actually holds. The reader surfaces should instead show the whole subtree.Strongest rationale: the document search at
/documents?tag=Xalready expands the tag to all its descendants (DocumentService:531→TagRepository.findDescendantIdsByName). So today a theme box shows "Reisen — 2" but clicking it lands the user on a page with 7+ results — the count and its own destination disagree. A subtree rollup makes the box number equal what the user actually finds. Count↔destination parity is the core win.User story
Decisions (resolved with product owner)
subtreeDocumentCounttoTagTreeNodeDTO. The reader surfaces read the rollup; the admin surfaces keep the existingdocumentCount(direct count) unchanged.subtreeDocumentCount):/themenpage (themen/+page.svelte) and the dashboardThemenWidget.svelte.documentCount, direct):admin/tagssidebar (TagTreeNode.svelte), and the two operation previewsTagMergeZone.svelteandTagDeleteGuard.svelte.documentCountglobally (Option A):documentCountis read as logic input byTagMergeZone(line 87, "N documents will move") andTagDeleteGuard(line 54, delete-impact warning). Both describe direct-document operations (merge re-tags only direct docs; single-tag delete removes only directdocument_tags). A rollup there would misstate a destructive action's effect. Option B keeps those previews truthful and requires no admin rework./themenmay change as a result of the new counts — accepted.Behaviour rules (EARS)
subtreeDocumentCount= the count of distinct documents tagged with that tag or any of its descendant tags.subtreeDocumentCountshall count that document exactly once.documentCountwith the existing direct per-tag count, unchanged. Both fields are returned byGET /api/tags/tree./themenpage andThemenWidget) shall displaysubtreeDocumentCount; the admin surfaces (sidebar tree, merge preview, delete-impact guard) shall continue to displaydocumentCount.subtreeDocumentCount == 0), the system shall omit that tag from the/themenpage (current empty-tag hiding behaviour is preserved, now keyed on the rollup).Acceptance criteria (Given-When-Then)
/themen, then the Reisen box header shows 7 (subtreeDocumentCount).subtreeDocumentCountfor Reisen is computed, then that document is counted once (if the 2 and 5 overlap by one document, the box shows 6, not 7). Make-or-break case — distinguishesCOUNT(DISTINCT)from a naive sum-of-children.subtreeDocumentCountequals itsdocumentCount(rollup of a leaf = its direct count)./themen, then its box shows a non-zero total (today it shows no number)./themenbox count for T against the number of results on/documents?tag=T, then the two are equal (count↔destination parity — both expand to descendants distinct). Fixture must use a unique name; name-based search vs ID-based rollup only align when names don't collide.TagMergeZone/TagDeleteGuardfor a tag, when the preview/impact number is shown, then it equals the tag's directdocumentCount(admin previews are unchanged by this feature).Non-functional requirements
/thementree, including all rolled-up distinct counts, shall be produced in a constant number of aggregate queries (no N+1 / no per-tag counting) — the existing direct-count aggregate plus the new subtree-rollup query. Assert this structurally in tests; do not encode the ≤300 ms wall-clock as a JUnit assertion (flaky on a loaded CI runner). Validate the 300 ms target via the Actuator → Prometheus → Grafanahttp_server_requestspanel for/api/tags/tree.TagControllerTest:95asserts 401 for anonymous), and there is no per-document ACL, so a subtree count leaks nothing aREAD_ALLuser can't already enumerate.Implementation notes (from review)
(ancestor_id, descendant_id)pairs (recursive, depth guard ≤50, reusing the existingTagRepositoryCTE idiom), join todocument_tagsondescendant_id, thenGROUP BY ancestor_idwithCOUNT(DISTINCT document_id). One query, all tags.bigint → intvia the existingTagCount.getCount().intValue()path.findDocumentCountsPerTag(GROUP BY tag_id) fordocumentCount— direct count is unchanged.buildTree()maps both counts onto each node.documentCountints in Java — overlapping documents make that wrong (AC #2). Distinct-count belongs in the DB.@Schema(requiredMode = REQUIRED) int subtreeDocumentCounttoTagTreeNodeDTO, thennpm run generate:apiinfrontend/./themen+page.svelteandThemenWidget.svelte→ displaysubtreeDocumentCount; their visibility filter keys onsubtreeDocumentCount > 0.hasAnyDocuments($lib/shared/utils/tagUtils.ts) currently checksdocumentCountrecursively. For the reader surfaces switch the check tosubtreeDocumentCount > 0(a single field read — the recursion becomes unnecessary). Leave admin consumers untouched.TagTreeNode,TagMergeZone,TagDeleteGuard) → no change, continue readingdocumentCount.postgres:16-alpine— not H2; recursive CTE +COUNT(DISTINCT)needs real PG):themenBoxCount(T) === documentSearchResultCount(?tag=T).admin_tag_merge_preview_docsandadmin_tag_delete_impactto the directdocumentCount— none exist today, so add them so a future change can't silently desync a destructive-action preview.TagServiceTest.getTagTree_populatesDocumentCount_fromAggregateQuerystays green (direct count unchanged);getTagTree_callsFindDocumentCountsPerTag_exactlyOnceneeds updating —getTagTreenow also issues the rollup query.vitest-browser-svelte): parent withdocumentCount: 0butsubtreeDocumentCount > 0→/themenheader shows the rollup (AC #5).TagService.getTagTree()(lines 177-178) — it claims the tree is "only used for the admin sidebar," which is false (themen + dashboard widget + admin). Updatebackend/.../tag/README.md./documents?tag={tag.name}); rollup is per tag ID. If two distinct tags share a name, name-based search and per-ID rollup diverge together.Scope / docs
generate:api. Doc touch-ups: thegetTagTree()JavaDoc +tag/README.md. No ADR required.Out of scope / parked (Release +1, "Could")
subtreeDocumentCounton the admin sidebar too (e.g. "12 direct · 200 in subtree") if admins later want subtree context alongside the direct count.Implemented ✅
Built on a worktree branch (
worktree-feat+issue-698-themen-subtree-count) following Option B — a newsubtreeDocumentCountfield, reader surfaces read the rollup, admin surfaces keep the directdocumentCount.Backend
335ef18afeat(tag)— addedsubtreeDocumentCounttoTagTreeNodeDTO;getTagTree()now maps both counts onto each node from two aggregate queries (no N+1, NFR-PERF-THEMEN-01 ✅). NewTagRepository.findSubtreeDocumentCountsPerTag()is one recursive-CTE tag closure (depth guard ≤50) ⋈document_tags,GROUP BY ancestor_idwithCOUNT(DISTINCT document_id)— REQ-THEMEN-01/02/03/06. StalegetTagTree()JavaDoc corrected.08a7f892test(tag)— Testcontainerspostgres:16-alpineintegration tests: leaf = direct (AC#4), shared doc counted once (AC#1+#2), full grandchild depth (AC#3), empty subtree absent (REQ-THEMEN-05), cycle terminates via the guard (REQ-THEMEN-06), and rollup == distinct documents from the realfindDescendantIdsByName+DocumentSpecifications.hasTagssearch expansion (AC#7 parity).4e8962a0docs(tag)—tag/README.mdnow documents the two counts and which surfaces read which.Frontend
e8377b57feat(themen)— regeneratedTagTreeNodeDTOtype;hasAnyDocumentsnow keys onsubtreeDocumentCount > 0(single field read, recursion dropped). Note: the generated type was hand-edited to matchgenerate:apioutput since codegen needs a live dev backend — re-runningnpm run generate:apiis byte-equivalent.1fd8c2b2feat(themen)—/themenpage (header, child rows, aria-labels) and dashboardThemenWidgetdisplaysubtreeDocumentCount. A parent with 0 direct docs but populated children now shows a non-zero total (AC#5, REQ-THEMEN-04).aa9d4aaftest(tag)— admin sidebar fixtures gain the now-required field (no behaviour change).98be732btest(admin-tags)— characterization tests pinning the merge preview and delete-impact warning to the directdocumentCount; each passes a straysubtreeDocumentCountand asserts it does not leak (AC#8).Validation
./mvnw clean package -DskipTests→ BUILD SUCCESS.TagServiceTest(43),TagControllerTest(18),TagRollupRepositoryIntegrationTest(6).npm run lintclean;npm run checkadds zero new type errors (browser-mode component tests run on CI per project convention).Notes / deferred
Next: open a PR from this branch.