feat(search): upgrade to PostgreSQL full-text search with German stemming #237

Merged
marcel merged 8 commits from feat/issue-222-fts-search into main 2026-04-15 12:40:21 +02:00
2 changed files with 102 additions and 2 deletions
Showing only changes of commit 947d8aeb6c - Show all commits

View File

@@ -319,8 +319,8 @@ public class DocumentService {
return sortBySender(results, dir);
}
// RELEVANCE: default when text present and no explicit non-relevance sort requested
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE || sort == DocumentSort.DATE);
// RELEVANCE: default when text present and no explicit sort given
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
if (useRankOrder) {
List<Document> results = documentRepository.findAll(spec);
final List<UUID> ids = rankedIds;

View File

@@ -0,0 +1,100 @@
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.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest {
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@Mock FileService fileService;
@Mock TagService tagService;
@Mock DocumentVersionService documentVersionService;
@Mock AnnotationService annotationService;
@InjectMocks DocumentService documentService;
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
@Test
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
Document older = Document.builder().id(id1)
.title("Brief").status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1940, 1, 1)).build();
Document newer = Document.builder().id(id2)
.title("Brief").status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1960, 1, 1)).build();
// FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, sort) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(newer, older));
List<Document> result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC");
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result).hasSize(2);
assertThat(result.get(0).getId()).isEqualTo(id2); // newer doc first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
@Test
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
UUID id1 = UUID.randomUUID(); // rank position 0
UUID id2 = UUID.randomUUID(); // rank position 1
Document doc1 = Document.builder().id(id1).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.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB
List<Document> result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
// Expect: rank order restored (id1 first)
assertThat(result.get(0).getId()).isEqualTo(id1);
}
@Test
void searchDocuments_with_null_sort_and_text_defaults_to_fts_rank_order() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document doc1 = Document.builder().id(id1).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.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1));
List<Document> result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null);
assertThat(result.get(0).getId()).isEqualTo(id1);
}
}