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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 21:47:59 +02:00
parent e61e3797d1
commit c90b42d045
3 changed files with 138 additions and 0 deletions

View File

@@ -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();
}

View File

@@ -240,4 +240,28 @@ 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

@@ -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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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());
}
}