From c90b42d04558985e36b4b375c018f834efb06e00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:47:59 +0200 Subject: [PATCH] feat(documents): add density and date-range repository queries (#385) Native SQL aggregations backing GET /api/documents/density: - findDensityByMonth groups documents by truncated meta_date with optional from/to bounds (frontend fills zero-count gaps). - findMinMaxDocumentDate returns the earliest/latest meta_date via projection, null on empty archive. Covered by DocumentDensityIntegrationTest (Testcontainers PostgreSQL): empty archive, single+multi-month grouping, from/to bounds, null meta_date exclusion, min/max edge cases. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentDateRangeProjection.java | 13 +++ .../document/DocumentRepository.java | 24 +++++ .../DocumentDensityIntegrationTest.java | 101 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java new file mode 100644 index 00000000..ce52f9a0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java @@ -0,0 +1,13 @@ +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(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index a110d22c..875a6977 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -240,4 +240,28 @@ public interface DocumentRepository extends JpaRepository, 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 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(); + } \ No newline at end of file diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java new file mode 100644 index 00000000..946d72c3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java @@ -0,0 +1,101 @@ +package org.raddatz.familienarchiv.document; + +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.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class DocumentDensityIntegrationTest { + + @Autowired + private DocumentRepository documentRepository; + + @Test + void findDensityByMonth_returnsEmptyList_whenNoDocumentsExist() { + List rows = documentRepository.findDensityByMonth(null, null); + + assertThat(rows).isEmpty(); + } + + @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)); + + List rows = documentRepository.findDensityByMonth(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); + } + + @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)); + + List rows = documentRepository.findDensityByMonth( + LocalDate.of(1915, 1, 1), + LocalDate.of(1916, 12, 31)); + + assertThat(rows).extracting(r -> r[0].toString()) + .containsExactly("1915-08", "1916-01"); + } + + @Test + void findDensityByMonth_excludesDocumentsWithoutDate() { + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(null); + + List rows = documentRepository.findDensityByMonth(null, null); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0)[0].toString()).isEqualTo("1915-08"); + } + + @Test + void findMinMaxDocumentDate_returnsNullValues_whenNoDocumentsExist() { + DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + + assertThat(result.getMinDate()).isNull(); + assertThat(result.getMaxDate()).isNull(); + } + + @Test + void findMinMaxDocumentDate_returnsEarliestAndLatestDates() { + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(LocalDate.of(1899, 1, 15)); + saveDocumentOn(LocalDate.of(1950, 12, 31)); + + DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + + assertThat(result.getMinDate()).isEqualTo(LocalDate.of(1899, 1, 15)); + assertThat(result.getMaxDate()).isEqualTo(LocalDate.of(1950, 12, 31)); + } + + private void saveDocumentOn(LocalDate date) { + documentRepository.save(Document.builder() + .title("Doc " + date) + .originalFilename("doc-" + (date == null ? "nodate-" + System.nanoTime() : date.toString()) + ".pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(date) + .build()); + } +}