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