From a3c3f14aeab90f11424ac9a2596a60004b7f6186 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:42:32 +0200 Subject: [PATCH] feat(documents): return global undated count in search response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The undated bucket count was page-local — derived from the year-grouping of the current page's items, so it could never exceed the page size. The owner's decision is for it to reflect ALL undated documents matching the active filter across every page. Add an undatedCount field to DocumentSearchResult, computed once per search via a COUNT over the same filter spec with undatedOnly(true) forced — independent of the "Nur undatierte" toggle so it never collapses to the page slice or double-counts. A from/to range excludes undated rows by the collision rule, so the count is legitimately 0 inside a date range. Refs #668 Co-Authored-By: Claude Opus 4.7 --- .../document/DocumentSearchResult.java | 31 ++++++-- .../document/DocumentService.java | 43 +++++++++-- .../DocumentSearchPagedIntegrationTest.java | 77 +++++++++++++++++++ .../document/DocumentSearchResultTest.java | 28 +++++++ 4 files changed, 168 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java index b04f7fa2..0ce1758a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSearchResult.java @@ -15,24 +15,45 @@ public record DocumentSearchResult( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pageSize, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - int totalPages + int totalPages, + /** + * Total number of undated documents (meta_date IS NULL) matching the current + * filter context (q/tags/sender/receiver/status) across ALL pages — not the + * undated rows on the current page. Computed independently of the "Nur + * undatierte" toggle so it never collapses to the page slice (issue #668). + */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + long undatedCount ) { /** * Single-page convenience factory used by empty-result shortcuts and by tests that - * don't care about paging. Treats the whole list as page 0 of itself. + * don't care about paging. Treats the whole list as page 0 of itself. The undated + * count defaults to 0 — the service overlays the real global count via + * {@link #withUndatedCount(long)} before returning. */ public static DocumentSearchResult of(List items) { int size = items.size(); - return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1); + return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1, 0L); } /** * Paged factory used by the service when it has a real Pageable + full match count - * (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). + * (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). The undated + * count defaults to 0 — the service overlays the real global count via + * {@link #withUndatedCount(long)} before returning. */ public static DocumentSearchResult paged(List slice, Pageable pageable, long totalElements) { int pageSize = pageable.getPageSize(); int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize); - return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages); + return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages, 0L); + } + + /** + * Returns a copy with the global undated count overlaid, leaving every other + * field untouched. Lets the service compute the count once and attach it to + * whichever result shape the search path produced. + */ + public DocumentSearchResult withUndatedCount(long undatedCount) { + return new DocumentSearchResult(items, totalElements, pageNumber, pageSize, totalPages, undatedCount); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index fef6e618..b4762502 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -669,6 +669,43 @@ public class DocumentService { public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) { boolean hasText = StringUtils.hasText(text); + List rankedIds = null; + if (hasText) { + rankedIds = documentRepository.findAllMatchingIdsByFts(text); + // FTS matched nothing → no results and, by definition, no undated matches either. + if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); + } + + // Global undated count for the current filter (q/tags/sender/receiver/status), + // forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so + // it never collapses to the page slice and never double-counts (issue #668). + long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); + + return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable) + .withUndatedCount(undatedCount); + } + + /** + * Counts every undated document (meta_date IS NULL) matching the active filter, + * across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec} + * with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status. + * A {@code from}/{@code to} range excludes undated rows by the collision rule (#668), + * so the count is legitimately 0 inside a date range. + */ + private long countUndatedForFilter(boolean hasText, List ftsIds, + LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { + Specification undatedSpec = buildSearchSpec( + hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true); + return documentRepository.count(undatedSpec); + } + + /** The original search dispatch — produces the page slice + totals, sans undated count. */ + private DocumentSearchResult runSearch(String text, boolean hasText, List rankedIds, + LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, + DocumentSort sort, String dir, TagOperator tagOperator, + boolean undated, Pageable pageable) { // Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008). // An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the // undatedOnly predicate would be silently dropped. @@ -676,12 +713,6 @@ public class DocumentService { return relevanceSortedPageFromSql(text, pageable); } - List rankedIds = null; - if (hasText) { - rankedIds = documentRepository.findAllMatchingIdsByFts(text); - if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); - } - Specification spec = buildSearchSpec( hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java index 05c7e025..3d65cbac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java @@ -108,6 +108,83 @@ class DocumentSearchPagedIntegrationTest { assertThat(result.totalPages()).isEqualTo(3); } + @Test + void search_undatedCount_isGlobalFilteredTotal_notPageSlice() { + // Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page + // window the undated rows span multiple pages, so a page-local count could + // never exceed 50 — the global count must be the full 70 (issue #668). + int undatedTotal = 70; + for (int i = 0; i < undatedTotal; i++) { + documentRepository.save(Document.builder() + .title("Undatiert-" + String.format("%03d", i)) + .originalFilename("undatiert-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + + // Global undated count is the full undated total, independent of page size. + assertThat(result.undatedCount()).isEqualTo(undatedTotal); + // Total matches both dated + undated (no undated-only filter applied). + assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal); + // The first DATE-DESC page is all dated rows (nulls last), so a page-local + // tally would report 0 undated — proving the count is not page-derived. + assertThat(result.items()).allMatch(item -> item.documentDate() != null); + } + + @Test + void search_undatedCount_ignoresUndatedOnlyToggle() { + // The "Nur undatierte" toggle must not skew the count: whether undated=true or + // false, the global undated count for the same filter is identical (issue #668). + int undatedTotal = 12; + for (int i = 0; i < undatedTotal; i++) { + documentRepository.save(Document.builder() + .title("U-" + i) + .originalFilename("u-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult unfiltered = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + DocumentSearchResult undatedOnly = documentService.searchDocuments( + null, null, null, null, null, null, null, null, + DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50)); + + assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal); + assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal); + } + + @Test + void search_undatedCount_isZero_insideDateRange() { + // A from/to range excludes undated rows by the collision rule (#668), so the + // global undated count inside a range is legitimately 0 even when undated docs exist. + for (int i = 0; i < 5; i++) { + documentRepository.save(Document.builder() + .title("U-range-" + i) + .originalFilename("u-range-" + i + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(DatePrecision.UNKNOWN) + .documentDate(null) + .build()); + } + + DocumentSearchResult result = documentService.searchDocuments( + null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31), + null, null, null, null, null, + DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)); + + assertThat(result.undatedCount()).isZero(); + } + @Test void search_differentPagesReturnDisjointSlices() { DocumentSearchResult page0 = documentService.searchDocuments( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index ca4c77f5..b9b5d38d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -99,4 +99,32 @@ class DocumentSearchResultTest { assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); } } + + @Test + void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException { + Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class); + assertThat(schema).isNotNull(); + assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); + } + + @Test + void factories_default_undatedCount_to_zero() { + assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero(); + assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero(); + } + + @Test + void withUndatedCount_overlays_count_and_preserves_other_fields() { + DocumentSearchResult base = DocumentSearchResult.paged( + List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L); + + DocumentSearchResult withCount = base.withUndatedCount(7L); + + assertThat(withCount.undatedCount()).isEqualTo(7L); + assertThat(withCount.items()).isEqualTo(base.items()); + assertThat(withCount.totalElements()).isEqualTo(120L); + assertThat(withCount.pageNumber()).isEqualTo(1); + assertThat(withCount.pageSize()).isEqualTo(50); + assertThat(withCount.totalPages()).isEqualTo(3); + } }