feat(documents): make density endpoint filter-reactive (#385)

Density bars now recompute when other filters change so the chart always
matches the list it sits above. Selectable filters: q, senderId, receiverId,
tag (multi), tagQ, status, tagOp. Date bounds (from/to) are deliberately
omitted — the chart is the surface for picking those, so it must always
span the broader space the user is selecting within.

Architectural shift: drop the native SQL GROUP BY in favour of in-memory
grouping over the existing Specification-driven findAll. This composes for
free with all the search predicates (FTS-rank-then-filter, sender/receiver,
tag-with-descendants, tagQ partial match, status, tagOp) and keeps the
density implementation a thin layer on top of searchDocuments. At the
current archive size (~5k docs) this stays well under the p95 200ms target;
Cache-Control: max-age=300 absorbs repeated browse loads.

- Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection.
- Replaces DocumentService.getDensity(LocalDate, LocalDate) with the
  filter-aware overload.
- Endpoint accepts the same query params as /api/documents/search minus
  paging+sort+from+to.
- DocumentDensityIntegrationTest rewritten as @SpringBootTest covering
  no-filter / sender / tag / status / sender+tag combos via real PostgreSQL.
- DocumentServiceTest unit tests updated to the new signature.
- DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:06:47 +02:00
parent 59a2faa145
commit e92e9e452e
7 changed files with 232 additions and 136 deletions

View File

@@ -392,9 +392,16 @@ public class DocumentController {
@GetMapping("/density")
public ResponseEntity<DocumentDensityResult> density(
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to) {
DocumentDensityResult result = documentService.getDensity(from, to);
@RequestParam(required = false) String q,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
DocumentDensityResult result = documentService.getDensity(
q, senderId, receiverId, tags, tagQ, status, operator);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
.body(result);

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.document;
import java.time.LocalDate;
/**
* Spring Data projection for the document_date min/max query backing the timeline
* density widget (issue #385). Column aliases in the native SQL must match these
* getter names exactly. Both values are {@code null} when the documents table is empty.
*/
public interface DocumentDateRangeProjection {
LocalDate getMinDate();
LocalDate getMaxDate();
}

View File

@@ -240,28 +240,4 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
""")
TranscriptionWeeklyStatsProjection findWeeklyStats();
/**
* Document count grouped by calendar month for the timeline density widget (issue #385).
* Each row is {@code Object[]{ String yearMonth, long count }}.
* Months without documents are not present — the frontend fills gaps from minDate/maxDate.
*/
@Query(nativeQuery = true, value = """
SELECT TO_CHAR(DATE_TRUNC('month', meta_date), 'YYYY-MM') AS month,
COUNT(*) AS doc_count
FROM documents
WHERE meta_date IS NOT NULL
AND (CAST(:from AS date) IS NULL OR meta_date >= :from)
AND (CAST(:to AS date) IS NULL OR meta_date <= :to)
GROUP BY 1
ORDER BY 1
""")
List<Object[]> findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to);
/**
* Earliest and latest {@code meta_date} across all documents. Both getters return
* {@code null} when the table holds no documents.
*/
@Query(nativeQuery = true, value = "SELECT MIN(meta_date) AS minDate, MAX(meta_date) AS maxDate FROM documents")
DocumentDateRangeProjection findMinMaxDocumentDate();
}

View File

@@ -127,15 +127,54 @@ public class DocumentService {
/**
* Per-month document counts for the timeline density widget (issue #385).
* Returns only months that have at least one document — the frontend fills
* gaps using the {@code minDate}/{@code maxDate} bounds.
*
* <p>Filter-reactive: the chart recomputes when other filters (sender,
* receiver, tag, q, status) change so it always matches the list it sits
* above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
* the surface for picking those, so it must always span the broader space
* the user is selecting within.
*
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
* because the existing {@link Specification} predicates compose easily
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
* controller layer absorbs repeated browse loads.
*/
public DocumentDensityResult getDensity(LocalDate from, LocalDate to) {
List<MonthBucket> buckets = documentRepository.findDensityByMonth(from, to).stream()
.map(row -> new MonthBucket((String) row[0], ((Number) row[1]).intValue()))
public DocumentDensityResult getDensity(
String text, UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) {
return new DocumentDensityResult(List.of(), null, null);
}
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, null, null,
sender, receiver, tags, tagQ, status, tagOperator);
List<LocalDate> dates = documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate)
.filter(Objects::nonNull)
.toList();
DocumentDateRangeProjection range = documentRepository.findMinMaxDocumentDate();
return new DocumentDensityResult(buckets, range.getMinDate(), range.getMaxDate());
if (dates.isEmpty()) {
return new DocumentDensityResult(List.of(), null, null);
}
Map<String, Integer> counts = new java.util.TreeMap<>();
for (LocalDate d : dates) {
String month = String.format("%04d-%02d", d.getYear(), d.getMonthValue());
counts.merge(month, 1, Integer::sum);
}
List<MonthBucket> buckets = counts.entrySet().stream()
.map(e -> new MonthBucket(e.getKey(), e.getValue()))
.toList();
LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
return new DocumentDensityResult(buckets, minDate, maxDate);
}
/**

View File

@@ -1252,7 +1252,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void density_returns200_withResultBody_whenAuthenticated() throws Exception {
when(documentService.getDensity(any(), any())).thenReturn(
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn(
new DocumentDensityResult(
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
java.time.LocalDate.of(1915, 8, 3),
@@ -1270,7 +1270,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void density_emitsPrivateCacheControlHeader() throws Exception {
when(documentService.getDensity(any(), any())).thenReturn(
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density"))
@@ -1283,17 +1283,46 @@ class DocumentControllerTest {
@Test
@WithMockUser
void density_passesFromAndToParametersToService() throws Exception {
when(documentService.getDensity(any(), any())).thenReturn(
void density_forwardsSenderAndTagFilters() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
UUID senderId = UUID.randomUUID();
mockMvc.perform(get("/api/documents/density")
.param("from", "1914-01-01")
.param("to", "1918-12-31"))
.param("senderId", senderId.toString())
.param("tag", "Familie")
.param("tag", "Urlaub")
.param("tagOp", "OR"))
.andExpect(status().isOk());
verify(documentService).getDensity(
java.time.LocalDate.of(1914, 1, 1),
java.time.LocalDate.of(1918, 12, 31));
org.mockito.ArgumentMatchers.isNull(),
eq(senderId),
org.mockito.ArgumentMatchers.isNull(),
eq(List.of("Familie", "Urlaub")),
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
eq(org.raddatz.familienarchiv.tag.TagOperator.OR));
}
@Test
@WithMockUser
void density_forwardsStatusAndQueryText() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density")
.param("q", "Brief")
.param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).getDensity(
eq("Brief"),
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
eq(DocumentStatus.REVIEWED),
eq(org.raddatz.familienarchiv.tag.TagOperator.AND));
}
}

View File

@@ -1,101 +1,158 @@
package org.raddatz.familienarchiv.document;
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.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
/**
* End-to-end test for the filter-reactive density aggregation.
* Density bars must recompute as the user changes other filters (sender, tag,
* status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart
* is the surface for picking those, so it must always span the broader space
* the user is selecting within.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class DocumentDensityIntegrationTest {
@Autowired
private DocumentRepository documentRepository;
@MockitoBean S3Client s3Client;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired TagRepository tagRepository;
@Test
void findDensityByMonth_returnsEmptyList_whenNoDocumentsExist() {
List<Object[]> rows = documentRepository.findDensityByMonth(null, null);
private Person hans;
private Person anna;
private Tag familieTag;
private Tag urlaubTag;
assertThat(rows).isEmpty();
@BeforeEach
void seed() {
hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build());
familieTag = tagRepository.save(Tag.builder().name("Familie").build());
urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build());
}
@Test
void findDensityByMonth_groupsDocumentsByMonth() {
saveDocumentOn(LocalDate.of(1915, 8, 3));
saveDocumentOn(LocalDate.of(1915, 8, 17));
saveDocumentOn(LocalDate.of(1915, 9, 1));
saveDocumentOn(LocalDate.of(1916, 1, 22));
void getDensity_returnsAllMonths_whenNoFiltersApplied() {
save("a", LocalDate.of(1915, 8, 3), null, Set.of());
save("b", LocalDate.of(1915, 8, 17), null, Set.of());
save("c", LocalDate.of(1915, 9, 1), null, Set.of());
List<Object[]> rows = documentRepository.findDensityByMonth(null, null);
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null);
assertThat(rows).hasSize(3);
assertThat(rows).extracting(r -> r[0].toString())
.containsExactly("1915-08", "1915-09", "1916-01");
assertThat(rows).extracting(r -> ((Number) r[1]).longValue())
.containsExactly(2L, 1L, 1L);
assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1915-09");
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
}
@Test
void findDensityByMonth_respectsFromAndToBounds() {
saveDocumentOn(LocalDate.of(1914, 6, 1));
saveDocumentOn(LocalDate.of(1915, 8, 3));
saveDocumentOn(LocalDate.of(1916, 1, 22));
saveDocumentOn(LocalDate.of(1920, 12, 31));
void getDensity_filtersBySender() {
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
save("b", LocalDate.of(1916, 1, 4), hans, Set.of());
save("c", LocalDate.of(1920, 5, 1), anna, Set.of());
List<Object[]> rows = documentRepository.findDensityByMonth(
LocalDate.of(1915, 1, 1),
LocalDate.of(1916, 12, 31));
DocumentDensityResult result = documentService.getDensity(
null, hans.getId(), null, null, null, null, null);
assertThat(rows).extracting(r -> r[0].toString())
assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1916-01");
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4));
}
@Test
void findDensityByMonth_excludesDocumentsWithoutDate() {
saveDocumentOn(LocalDate.of(1915, 8, 3));
saveDocumentOn(null);
void getDensity_filtersByTag() {
save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag));
save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag));
List<Object[]> rows = documentRepository.findDensityByMonth(null, null);
DocumentDensityResult result = documentService.getDensity(
null, null, null, List.of("Familie"), null, null, TagOperator.AND);
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[0].toString()).isEqualTo("1915-08");
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
}
@Test
void findMinMaxDocumentDate_returnsNullValues_whenNoDocumentsExist() {
DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate();
void getDensity_combinesSenderAndTag() {
save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag));
save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag));
save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag));
assertThat(result.getMinDate()).isNull();
assertThat(result.getMaxDate()).isNull();
DocumentDensityResult result = documentService.getDensity(
null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND);
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
}
@Test
void findMinMaxDocumentDate_returnsEarliestAndLatestDates() {
saveDocumentOn(LocalDate.of(1915, 8, 3));
saveDocumentOn(LocalDate.of(1899, 1, 15));
saveDocumentOn(LocalDate.of(1950, 12, 31));
void getDensity_filtersByStatus() {
save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED);
save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER);
DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate();
DocumentDensityResult result = documentService.getDensity(
null, null, null, null, null, DocumentStatus.UPLOADED, null);
assertThat(result.getMinDate()).isEqualTo(LocalDate.of(1899, 1, 15));
assertThat(result.getMaxDate()).isEqualTo(LocalDate.of(1950, 12, 31));
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
}
private void saveDocumentOn(LocalDate date) {
@Test
void getDensity_returnsEmpty_whenNoDocumentsMatch() {
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
DocumentDensityResult result = documentService.getDensity(
null, anna.getId(), null, null, null, null, null);
assertThat(result.buckets()).isEmpty();
assertThat(result.minDate()).isNull();
assertThat(result.maxDate()).isNull();
}
@Test
void getDensity_excludesDocumentsWithNullDate() {
save("dated", LocalDate.of(1915, 8, 3), null, Set.of());
save("undated", null, null, Set.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null);
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
}
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags) {
save(suffix, date, sender, tags, DocumentStatus.UPLOADED);
}
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags, DocumentStatus status) {
documentRepository.save(Document.builder()
.title("Doc " + date)
.originalFilename("doc-" + (date == null ? "nodate-" + System.nanoTime() : date.toString()) + ".pdf")
.status(DocumentStatus.UPLOADED)
.title("Doc " + suffix)
.originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf")
.status(status)
.documentDate(date)
.sender(sender)
.tags(new HashSet<>(tags))
.build());
}
}

View File

@@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
@@ -2325,14 +2326,10 @@ class DocumentServiceTest {
// ─── getDensity ────────────────────────────────────────────────────────────
@Test
void getDensity_returnsEmptyResult_whenArchiveIsEmpty() {
when(documentRepository.findDensityByMonth(null, null)).thenReturn(List.of());
DocumentDateRangeProjection emptyRange = mock(DocumentDateRangeProjection.class);
when(emptyRange.getMinDate()).thenReturn(null);
when(emptyRange.getMaxDate()).thenReturn(null);
when(documentRepository.findMinMaxDocumentDate()).thenReturn(emptyRange);
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(null, null);
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null);
assertThat(result.buckets()).isEmpty();
assertThat(result.minDate()).isNull();
@@ -2340,37 +2337,41 @@ class DocumentServiceTest {
}
@Test
void getDensity_mapsRepositoryRowsToMonthBuckets() {
List<Object[]> rows = List.of(
new Object[]{"1915-08", 2L},
new Object[]{"1915-09", 1L}
);
when(documentRepository.findDensityByMonth(null, null)).thenReturn(rows);
DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class);
when(range.getMinDate()).thenReturn(LocalDate.of(1915, 8, 3));
when(range.getMaxDate()).thenReturn(LocalDate.of(1915, 9, 1));
when(documentRepository.findMinMaxDocumentDate()).thenReturn(range);
void getDensity_groupsMatchingDocumentsByMonth() {
Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(null, null);
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null);
assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1915-09");
assertThat(result.buckets()).extracting(MonthBucket::count)
.containsExactly(2, 1);
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
}
@Test
void getDensity_passesFromAndToBoundsToRepository() {
when(documentRepository.findDensityByMonth(any(), any())).thenReturn(List.of());
DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class);
when(documentRepository.findMinMaxDocumentDate()).thenReturn(range);
void getDensity_excludesDocumentsWithNullDate() {
Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
Document undated = Document.builder().documentDate(null).build();
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
LocalDate from = LocalDate.of(1914, 1, 1);
LocalDate to = LocalDate.of(1918, 12, 31);
documentService.getDensity(from, to);
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null);
verify(documentRepository).findDensityByMonth(from, to);
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
}
@Test
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity("xyz", null, null, null, null, null, null);
assertThat(result.buckets()).isEmpty();
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
}
}