refactor(#240): replace Object[] positional mapping with Spring Data projections
Introduces TranscriptionQueueProjection and TranscriptionWeeklyStatsProjection interfaces so column reordering in native SQL can never silently produce wrong data. Removes the four type-coercion helpers (toUUID, toLocalDate, toInt, toLong) from TranscriptionQueueService. Covered by TranscriptionQueueServiceTest (6 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -179,7 +179,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
ORDER BY HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
ORDER BY HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
List<Object[]> findSegmentationQueue(@Param("limit") int limit);
|
List<TranscriptionQueueProjection> findSegmentationQueue(@Param("limit") int limit);
|
||||||
|
|
||||||
/** Documents with annotations but not yet fully reviewed — Transkription column. */
|
/** Documents with annotations but not yet fully reviewed — Transkription column. */
|
||||||
@Query(nativeQuery = true, value = """
|
@Query(nativeQuery = true, value = """
|
||||||
@@ -200,7 +200,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
List<Object[]> findTranscriptionQueue(@Param("limit") int limit);
|
List<TranscriptionQueueProjection> findTranscriptionQueue(@Param("limit") int limit);
|
||||||
|
|
||||||
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
|
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
|
||||||
@Query(nativeQuery = true, value = """
|
@Query(nativeQuery = true, value = """
|
||||||
@@ -223,7 +223,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
) DESC
|
) DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
List<Object[]> findReadyToReadQueue(@Param("limit") int limit);
|
List<TranscriptionQueueProjection> findReadyToReadQueue(@Param("limit") int limit);
|
||||||
|
|
||||||
/** Weekly pulse: distinct documents that received new work in each pipeline stage. */
|
/** Weekly pulse: distinct documents that received new work in each pipeline stage. */
|
||||||
@Query(nativeQuery = true, value = """
|
@Query(nativeQuery = true, value = """
|
||||||
@@ -237,6 +237,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
|
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
|
||||||
AND tb.reviewed = true) AS readyCount
|
AND tb.reviewed = true) AS readyCount
|
||||||
""")
|
""")
|
||||||
Object[] findWeeklyStats();
|
TranscriptionWeeklyStatsProjection findWeeklyStats();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data projection for a single row in one of the three Mission Control Strip queues.
|
||||||
|
* Column aliases in the native SQL queries must match these getter names exactly.
|
||||||
|
*/
|
||||||
|
public interface TranscriptionQueueProjection {
|
||||||
|
UUID getId();
|
||||||
|
String getTitle();
|
||||||
|
LocalDate getDocumentDate();
|
||||||
|
int getAnnotationCount();
|
||||||
|
int getTextedBlockCount();
|
||||||
|
int getReviewedBlockCount();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data projection for the weekly activity pulse stats.
|
||||||
|
* Column aliases in the native SQL query must match these getter names exactly.
|
||||||
|
*/
|
||||||
|
public interface TranscriptionWeeklyStatsProjection {
|
||||||
|
long getSegmentationCount();
|
||||||
|
long getTranscriptionCount();
|
||||||
|
long getReadyCount();
|
||||||
|
}
|
||||||
@@ -4,12 +4,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
|
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
|
||||||
@@ -26,69 +24,41 @@ public class TranscriptionQueueService {
|
|||||||
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
||||||
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
|
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::mapRow)
|
.map(this::toDTO)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
||||||
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
|
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::mapRow)
|
.map(this::toDTO)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
||||||
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
|
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::mapRow)
|
.map(this::toDTO)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
||||||
Object[] row = documentRepository.findWeeklyStats();
|
var stats = documentRepository.findWeeklyStats();
|
||||||
return new TranscriptionWeeklyStatsDTO(
|
return new TranscriptionWeeklyStatsDTO(
|
||||||
toLong(row[0]),
|
stats.getSegmentationCount(),
|
||||||
toLong(row[1]),
|
stats.getTranscriptionCount(),
|
||||||
toLong(row[2])
|
stats.getReadyCount()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- mapping helpers ---
|
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
|
||||||
|
return new TranscriptionQueueItemDTO(
|
||||||
private TranscriptionQueueItemDTO mapRow(Object[] row) {
|
p.getId(),
|
||||||
UUID id = toUUID(row[0]);
|
p.getTitle(),
|
||||||
String title = (String) row[1];
|
p.getDocumentDate(),
|
||||||
LocalDate documentDate = toLocalDate(row[2]);
|
p.getAnnotationCount(),
|
||||||
int annotationCount = toInt(row[3]);
|
p.getTextedBlockCount(),
|
||||||
int textedBlockCount = toInt(row[4]);
|
p.getReviewedBlockCount()
|
||||||
int reviewedBlockCount = toInt(row[5]);
|
);
|
||||||
return new TranscriptionQueueItemDTO(id, title, documentDate,
|
|
||||||
annotationCount, textedBlockCount, reviewedBlockCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID toUUID(Object o) {
|
|
||||||
if (o instanceof UUID u) return u;
|
|
||||||
return UUID.fromString(o.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDate toLocalDate(Object o) {
|
|
||||||
if (o == null) return null;
|
|
||||||
if (o instanceof LocalDate d) return d;
|
|
||||||
if (o instanceof java.sql.Date d) return d.toLocalDate();
|
|
||||||
return LocalDate.parse(o.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private int toInt(Object o) {
|
|
||||||
if (o == null) return 0;
|
|
||||||
if (o instanceof Number n) return n.intValue();
|
|
||||||
if (o instanceof BigDecimal bd) return bd.intValue();
|
|
||||||
return Integer.parseInt(o.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private long toLong(Object o) {
|
|
||||||
if (o == null) return 0L;
|
|
||||||
if (o instanceof Number n) return n.longValue();
|
|
||||||
if (o instanceof BigDecimal bd) return bd.longValue();
|
|
||||||
return Long.parseLong(o.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TranscriptionQueueServiceTest {
|
||||||
|
|
||||||
|
@Mock DocumentRepository documentRepository;
|
||||||
|
@InjectMocks TranscriptionQueueService service;
|
||||||
|
|
||||||
|
// ─── getSegmentationQueue ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
TranscriptionQueueProjection proj = mockQueueProjection(id, "Brief von 1920", null, 0, 0, 0);
|
||||||
|
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
||||||
|
|
||||||
|
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||||
|
|
||||||
|
verify(documentRepository).findSegmentationQueue(5);
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(id);
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("Brief von 1920");
|
||||||
|
assertThat(result.get(0).documentDate()).isNull();
|
||||||
|
assertThat(result.get(0).annotationCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
|
||||||
|
LocalDate date = LocalDate.of(1920, 6, 15);
|
||||||
|
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Brief", date, 0, 0, 0);
|
||||||
|
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
||||||
|
|
||||||
|
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
||||||
|
|
||||||
|
assertThat(result.get(0).documentDate()).isEqualTo(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getTranscriptionQueue ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
TranscriptionQueueProjection proj = mockQueueProjection(id, "Tagebuch", LocalDate.of(1943, 1, 1), 3, 1, 0);
|
||||||
|
when(documentRepository.findTranscriptionQueue(5)).thenReturn(List.of(proj));
|
||||||
|
|
||||||
|
List<TranscriptionQueueItemDTO> result = service.getTranscriptionQueue();
|
||||||
|
|
||||||
|
verify(documentRepository).findTranscriptionQueue(5);
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).annotationCount()).isEqualTo(3);
|
||||||
|
assertThat(result.get(0).textedBlockCount()).isEqualTo(1);
|
||||||
|
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getReadyToReadQueue ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() {
|
||||||
|
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Urkunde", null, 4, 4, 4);
|
||||||
|
when(documentRepository.findReadyToReadQueue(5)).thenReturn(List.of(proj));
|
||||||
|
|
||||||
|
List<TranscriptionQueueItemDTO> result = service.getReadyToReadQueue();
|
||||||
|
|
||||||
|
verify(documentRepository).findReadyToReadQueue(5);
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getWeeklyStats ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getWeeklyStats_mapsProjectionToDTO() {
|
||||||
|
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L, 2L);
|
||||||
|
when(documentRepository.findWeeklyStats()).thenReturn(proj);
|
||||||
|
|
||||||
|
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
|
||||||
|
|
||||||
|
assertThat(result.segmentationCount()).isEqualTo(3L);
|
||||||
|
assertThat(result.transcriptionCount()).isEqualTo(7L);
|
||||||
|
assertThat(result.readyCount()).isEqualTo(2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getWeeklyStats_returnsZeros_whenAllCountsAreZero() {
|
||||||
|
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L, 0L);
|
||||||
|
when(documentRepository.findWeeklyStats()).thenReturn(proj);
|
||||||
|
|
||||||
|
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
|
||||||
|
|
||||||
|
assertThat(result.segmentationCount()).isEqualTo(0L);
|
||||||
|
assertThat(result.transcriptionCount()).isEqualTo(0L);
|
||||||
|
assertThat(result.readyCount()).isEqualTo(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private TranscriptionQueueProjection mockQueueProjection(
|
||||||
|
UUID id, String title, LocalDate documentDate,
|
||||||
|
int annotationCount, int textedBlockCount, int reviewedBlockCount) {
|
||||||
|
TranscriptionQueueProjection proj = mock(TranscriptionQueueProjection.class);
|
||||||
|
when(proj.getId()).thenReturn(id);
|
||||||
|
when(proj.getTitle()).thenReturn(title);
|
||||||
|
when(proj.getDocumentDate()).thenReturn(documentDate);
|
||||||
|
when(proj.getAnnotationCount()).thenReturn(annotationCount);
|
||||||
|
when(proj.getTextedBlockCount()).thenReturn(textedBlockCount);
|
||||||
|
when(proj.getReviewedBlockCount()).thenReturn(reviewedBlockCount);
|
||||||
|
return proj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranscriptionWeeklyStatsProjection mockStatsProjection(
|
||||||
|
long segmentationCount, long transcriptionCount, long readyCount) {
|
||||||
|
TranscriptionWeeklyStatsProjection proj = mock(TranscriptionWeeklyStatsProjection.class);
|
||||||
|
when(proj.getSegmentationCount()).thenReturn(segmentationCount);
|
||||||
|
when(proj.getTranscriptionCount()).thenReturn(transcriptionCount);
|
||||||
|
when(proj.getReadyCount()).thenReturn(readyCount);
|
||||||
|
return proj;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user