refactor(documents): bundle density filters into a record (#385)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 11:42:38 +02:00
parent 00f35ab675
commit 86de118d63
6 changed files with 63 additions and 38 deletions

View File

@@ -0,0 +1,23 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.tag.TagOperator;
import java.util.List;
import java.util.UUID;
/**
* The non-date filters honoured by {@link DocumentService#getDensity(DensityFilters)}.
* Date bounds (from/to) are deliberately excluded — see the service Javadoc for why.
*
* Kept as a record so the seven values are passed as one named bundle instead of a
* positional argument list where two UUIDs (sender vs. receiver) can be swapped by
* accident at the call site.
*/
public record DensityFilters(
String text,
UUID sender,
UUID receiver,
List<String> tags,
String tagQ,
DocumentStatus status,
TagOperator tagOperator) {}

View File

@@ -401,7 +401,7 @@ public class DocumentController {
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
q, senderId, receiverId, tags, tagQ, status, operator); new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()) .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
.body(result); .body(result);

View File

@@ -146,10 +146,8 @@ public class DocumentService {
* 'YYYY-MM')) and accept that the criteria/specification surface needs a * 'YYYY-MM')) and accept that the criteria/specification surface needs a
* parallel native-query path. * parallel native-query path.
*/ */
public DocumentDensityResult getDensity( public DocumentDensityResult getDensity(DensityFilters filters) {
String text, UUID sender, UUID receiver, String text = filters.text();
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
@@ -160,7 +158,9 @@ public class DocumentService {
} }
Specification<Document> spec = buildSearchSpec( Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, null, null, hasText, rankedIds, null, null,
sender, receiver, tags, tagQ, status, tagOperator); filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(),
filters.status(), filters.tagOperator());
List<LocalDate> dates = documentRepository.findAll(spec).stream() List<LocalDate> dates = documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate) .map(Document::getDocumentDate)

View File

@@ -1253,7 +1253,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void density_returns200_withResultBody_whenAuthenticated() throws Exception { void density_returns200_withResultBody_whenAuthenticated() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult( new DocumentDensityResult(
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)), List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
java.time.LocalDate.of(1915, 8, 3), java.time.LocalDate.of(1915, 8, 3),
@@ -1278,7 +1278,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void density_declaresApplicationJsonContentType() throws Exception { void density_declaresApplicationJsonContentType() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null)); new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density") mockMvc.perform(get("/api/documents/density")
@@ -1289,7 +1289,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void density_emitsPrivateCacheControlHeader() throws Exception { void density_emitsPrivateCacheControlHeader() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null)); new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density")) mockMvc.perform(get("/api/documents/density"))
@@ -1303,7 +1303,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void density_forwardsSenderAndTagFilters() throws Exception { void density_forwardsSenderAndTagFilters() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null)); new DocumentDensityResult(List.of(), null, null));
UUID senderId = UUID.randomUUID(); UUID senderId = UUID.randomUUID();
@@ -1314,20 +1314,17 @@ class DocumentControllerTest {
.param("tagOp", "OR")) .param("tagOp", "OR"))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(documentService).getDensity( verify(documentService).getDensity(eq(new DensityFilters(
org.mockito.ArgumentMatchers.isNull(), null, senderId, null,
eq(senderId), List.of("Familie", "Urlaub"),
org.mockito.ArgumentMatchers.isNull(), null, null,
eq(List.of("Familie", "Urlaub")), org.raddatz.familienarchiv.tag.TagOperator.OR)));
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
eq(org.raddatz.familienarchiv.tag.TagOperator.OR));
} }
@Test @Test
@WithMockUser @WithMockUser
void density_forwardsStatusAndQueryText() throws Exception { void density_forwardsStatusAndQueryText() throws Exception {
when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null)); new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density") mockMvc.perform(get("/api/documents/density")
@@ -1335,13 +1332,9 @@ class DocumentControllerTest {
.param("status", "REVIEWED")) .param("status", "REVIEWED"))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(documentService).getDensity( verify(documentService).getDensity(eq(new DensityFilters(
eq("Brief"), "Brief", null, null, null, null,
org.mockito.ArgumentMatchers.isNull(), DocumentStatus.REVIEWED,
org.mockito.ArgumentMatchers.isNull(), org.raddatz.familienarchiv.tag.TagOperator.AND)));
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.isNull(),
eq(DocumentStatus.REVIEWED),
eq(org.raddatz.familienarchiv.tag.TagOperator.AND));
} }
} }

View File

@@ -56,13 +56,17 @@ class DocumentDensityIntegrationTest {
urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build()); urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build());
} }
private static DensityFilters noFilters() {
return new DensityFilters(null, null, null, null, null, null, null);
}
@Test @Test
void getDensity_returnsAllMonths_whenNoFiltersApplied() { void getDensity_returnsAllMonths_whenNoFiltersApplied() {
save("a", LocalDate.of(1915, 8, 3), null, Set.of()); save("a", LocalDate.of(1915, 8, 3), null, Set.of());
save("b", LocalDate.of(1915, 8, 17), null, Set.of()); save("b", LocalDate.of(1915, 8, 17), null, Set.of());
save("c", LocalDate.of(1915, 9, 1), null, Set.of()); save("c", LocalDate.of(1915, 9, 1), null, Set.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(noFilters());
assertThat(result.buckets()).extracting(MonthBucket::month) assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1915-09"); .containsExactly("1915-08", "1915-09");
@@ -78,7 +82,7 @@ class DocumentDensityIntegrationTest {
save("c", LocalDate.of(1920, 5, 1), anna, Set.of()); save("c", LocalDate.of(1920, 5, 1), anna, Set.of());
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
null, hans.getId(), null, null, null, null, null); new DensityFilters(null, hans.getId(), null, null, null, null, null));
assertThat(result.buckets()).extracting(MonthBucket::month) assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1916-01"); .containsExactly("1915-08", "1916-01");
@@ -91,7 +95,7 @@ class DocumentDensityIntegrationTest {
save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag)); save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag));
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
null, null, null, List.of("Familie"), null, null, TagOperator.AND); new DensityFilters(null, null, null, List.of("Familie"), null, null, TagOperator.AND));
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
} }
@@ -103,7 +107,7 @@ class DocumentDensityIntegrationTest {
save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag)); save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag));
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND); new DensityFilters(null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND));
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
} }
@@ -114,7 +118,7 @@ class DocumentDensityIntegrationTest {
save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER); save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER);
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
null, null, null, null, null, DocumentStatus.UPLOADED, null); new DensityFilters(null, null, null, null, null, DocumentStatus.UPLOADED, null));
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
} }
@@ -124,7 +128,7 @@ class DocumentDensityIntegrationTest {
save("a", LocalDate.of(1915, 8, 3), hans, Set.of()); save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
null, anna.getId(), null, null, null, null, null); new DensityFilters(null, anna.getId(), null, null, null, null, null));
assertThat(result.buckets()).isEmpty(); assertThat(result.buckets()).isEmpty();
assertThat(result.minDate()).isNull(); assertThat(result.minDate()).isNull();
@@ -136,7 +140,7 @@ class DocumentDensityIntegrationTest {
save("dated", LocalDate.of(1915, 8, 3), null, Set.of()); save("dated", LocalDate.of(1915, 8, 3), null, Set.of());
save("undated", null, null, Set.of()); save("undated", null, null, Set.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(noFilters());
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
} }

View File

@@ -2325,11 +2325,15 @@ class DocumentServiceTest {
// ─── getDensity ──────────────────────────────────────────────────────────── // ─── getDensity ────────────────────────────────────────────────────────────
private static DensityFilters anyFilters() {
return new DensityFilters(null, null, null, null, null, null, null);
}
@Test @Test
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() { void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of()); when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).isEmpty(); assertThat(result.buckets()).isEmpty();
assertThat(result.minDate()).isNull(); assertThat(result.minDate()).isNull();
@@ -2344,7 +2348,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c)); when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).extracting(MonthBucket::month) assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1915-09"); .containsExactly("1915-08", "1915-09");
@@ -2360,7 +2364,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated)); when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
} }
@@ -2369,7 +2373,8 @@ class DocumentServiceTest {
void getDensity_shortCircuits_whenFtsReturnsNoMatches() { void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity("xyz", null, null, null, null, null, null); DocumentDensityResult result = documentService.getDensity(
new DensityFilters("xyz", null, null, null, null, null, null));
assertThat(result.buckets()).isEmpty(); assertThat(result.buckets()).isEmpty();
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class)); verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));