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:
Marcel
2026-04-16 12:05:21 +02:00
parent ff1606f63d
commit 2e4d9a8375
5 changed files with 185 additions and 51 deletions

View File

@@ -179,7 +179,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
ORDER BY HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
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. */
@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)
LIMIT :limit
""")
List<Object[]> findTranscriptionQueue(@Param("limit") int limit);
List<TranscriptionQueueProjection> findTranscriptionQueue(@Param("limit") int limit);
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
@Query(nativeQuery = true, value = """
@@ -223,7 +223,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
) DESC
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. */
@Query(nativeQuery = true, value = """
@@ -237,6 +237,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
AND tb.reviewed = true) AS readyCount
""")
Object[] findWeeklyStats();
TranscriptionWeeklyStatsProjection findWeeklyStats();
}

View File

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

View File

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

View File

@@ -4,12 +4,10 @@ import lombok.RequiredArgsConstructor;
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.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
@@ -26,69 +24,41 @@ public class TranscriptionQueueService {
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.map(this::toDTO)
.toList();
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.map(this::toDTO)
.toList();
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.map(this::toDTO)
.toList();
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
Object[] row = documentRepository.findWeeklyStats();
var stats = documentRepository.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO(
toLong(row[0]),
toLong(row[1]),
toLong(row[2])
stats.getSegmentationCount(),
stats.getTranscriptionCount(),
stats.getReadyCount()
);
}
// --- mapping helpers ---
private TranscriptionQueueItemDTO mapRow(Object[] row) {
UUID id = toUUID(row[0]);
String title = (String) row[1];
LocalDate documentDate = toLocalDate(row[2]);
int annotationCount = toInt(row[3]);
int textedBlockCount = toInt(row[4]);
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());
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
return new TranscriptionQueueItemDTO(
p.getId(),
p.getTitle(),
p.getDocumentDate(),
p.getAnnotationCount(),
p.getTextedBlockCount(),
p.getReviewedBlockCount()
);
}
}