refactor(fts): add FtsHit/FtsPage records; rename findRankedIdsByFts -> findAllMatchingIdsByFts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-09 14:06:24 +02:00
parent de1c55d18e
commit ea136a8724
7 changed files with 41 additions and 28 deletions

View File

@@ -100,7 +100,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
ORDER BY ts_rank(d.search_vector, q.pq) DESC, ORDER BY ts_rank(d.search_vector, q.pq) DESC,
d.meta_date DESC NULLS LAST d.meta_date DESC NULLS LAST
""") """)
List<UUID> findRankedIdsByFts(@Param("query") String query); // Unpaged path — use findFtsPageRaw for paginated search results
List<UUID> findAllMatchingIdsByFts(@Param("query") String query);
/** /**
* Returns match-enrichment data for a set of documents identified by their IDs. * Returns match-enrichment data for a set of documents identified by their IDs.

View File

@@ -162,7 +162,7 @@ public class DocumentService {
*/ */
private List<UUID> resolveFtsIds(String text) { private List<UUID> resolveFtsIds(String text) {
if (!StringUtils.hasText(text)) return null; if (!StringUtils.hasText(text)) return null;
return documentRepository.findRankedIdsByFts(text); return documentRepository.findAllMatchingIdsByFts(text);
} }
/** Loads matching documents and projects to non-null {@link LocalDate}s. */ /** Loads matching documents and projects to non-null {@link LocalDate}s. */
@@ -485,7 +485,7 @@ public class DocumentService {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text); rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return List.of(); if (rankedIds.isEmpty()) return List.of();
} }
@@ -648,7 +648,7 @@ public class DocumentService {
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
if (hasText) { if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text); rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
} }

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/** A single document hit from a paginated FTS query — id and its ts_rank score. */
record FtsHit(UUID id, double rank) {}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.List;
/** One page of FTS results — the ranked hit list for this page and the total match count. */
record FtsPage(List<FtsHit> hits, long total) {}

View File

@@ -69,7 +69,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief")); documentRepository.saveAndFlush(document("Alter Brief"));
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).hasSize(1); assertThat(ids).hasSize(1);
} }
@@ -79,7 +79,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief")); documentRepository.saveAndFlush(document("Alter Brief"));
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Briefe"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Briefe");
assertThat(ids).hasSize(1); assertThat(ids).hasSize(1);
} }
@@ -89,7 +89,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein furchtbarer Brief")); documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("furchtb");
assertThat(ids).hasSize(1); assertThat(ids).hasSize(1);
} }
@@ -99,7 +99,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Familienfoto")); documentRepository.saveAndFlush(document("Familienfoto"));
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).isEmpty(); assertThat(ids).isEmpty();
} }
@@ -115,7 +115,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("schreiben"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("schreiben");
assertThat(ids).contains(doc.getId()); assertThat(ids).contains(doc.getId());
} }
@@ -125,14 +125,14 @@ class DocumentFtsTest {
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument")); Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
em.clear(); em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).isEmpty(); assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).isEmpty();
UUID annotationId = annotation(doc.getId()); UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0)); blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
em.flush(); em.flush();
em.clear(); em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId()); assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
} }
@Test @Test
@@ -144,13 +144,13 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId()); assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
blockRepository.deleteById(block.getId()); blockRepository.deleteById(block.getId());
em.flush(); em.flush();
em.clear(); em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).doesNotContain(doc.getId()); assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).doesNotContain(doc.getId());
} }
// ─── Ranking ─────────────────────────────────────────────────────────────── // ─── Ranking ───────────────────────────────────────────────────────────────
@@ -166,7 +166,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Grundbuch"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Grundbuch");
assertThat(ids).hasSize(2); assertThat(ids).hasSize(2);
assertThat(ids.get(0)).isEqualTo(docA.getId()); assertThat(ids.get(0)).isEqualTo(docA.getId());
@@ -179,7 +179,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein Brief von der Oma")); documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("der die das und"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("der die das und");
assertThat(ids).isEmpty(); assertThat(ids).isEmpty();
} }
@@ -195,7 +195,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Wille");
assertThat(ids).contains(doc.getId()); assertThat(ids).contains(doc.getId());
} }
@@ -205,7 +205,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Brief")); documentRepository.saveAndFlush(document("Brief"));
em.clear(); em.clear();
assertThatNoException().isThrownBy(() -> documentRepository.findRankedIdsByFts("(((")); assertThatNoException().isThrownBy(() -> documentRepository.findAllMatchingIdsByFts("((("));
} }
// ─── Weight C: sender/receiver names ─────────────────────────────────────── // ─── Weight C: sender/receiver names ───────────────────────────────────────
@@ -223,7 +223,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Schmidt"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Schmidt");
assertThat(ids).contains(doc.getId()); assertThat(ids).contains(doc.getId());
} }
@@ -241,7 +241,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Raddatz"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Raddatz");
assertThat(ids).contains(doc.getId()); assertThat(ids).contains(doc.getId());
} }
@@ -260,7 +260,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Familiengeschichte"); List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Familiengeschichte");
assertThat(ids).hasSize(1); assertThat(ids).hasSize(1);
} }
@@ -278,7 +278,7 @@ class DocumentFtsTest {
em.flush(); em.flush();
em.clear(); em.clear();
List<UUID> rankedIds = documentRepository.findRankedIdsByFts("Grundbuch"); List<UUID> rankedIds = documentRepository.findAllMatchingIdsByFts("Grundbuch");
Specification<Document> spec = Specification.where(hasIds(rankedIds)) Specification<Document> spec = Specification.where(hasIds(rankedIds))
.and(hasStatus(DocumentStatus.UPLOADED)); .and(hasStatus(DocumentStatus.UPLOADED));

View File

@@ -58,7 +58,7 @@ class DocumentServiceSortTest {
.documentDate(LocalDate.of(1960, 1, 1)).build(); .documentDate(LocalDate.of(1960, 1, 1)).build();
// FTS returns id1 first (higher rank), id2 second // FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2)); when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, pageable) — the correct date path — returns date-DESC order // findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Pageable.class))) when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(newer, older))); .thenReturn(new PageImpl<>(List.of(newer, older)));
@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build(); Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build(); Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2)); when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class))) when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB .thenReturn(List.of(doc2, doc1)); // unordered from DB
@@ -100,7 +100,7 @@ class DocumentServiceSortTest {
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build(); Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build(); Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2)); when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class))) when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); .thenReturn(List.of(doc2, doc1));

View File

@@ -1620,7 +1620,7 @@ class DocumentServiceTest {
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term // chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null}); List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId)); when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc)); .thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
@@ -1654,7 +1654,7 @@ class DocumentServiceTest {
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin"; String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null}); List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId)); when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc)); .thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
@@ -2202,7 +2202,7 @@ class DocumentServiceTest {
@Test @Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() { void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter( List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null); "xyz", null, null, null, null, null, null, null, null);
@@ -2386,7 +2386,7 @@ class DocumentServiceTest {
@Test @Test
void getDensity_shortCircuits_whenFtsReturnsNoMatches() { void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity( DocumentDensityResult result = documentService.getDensity(
new DensityFilters("xyz", null, null, null, null, null, null)); new DensityFilters("xyz", null, null, null, null, null, null));