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:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user