feat(search): extend document text search to match alias last names

Adds sender alias LEFT JOIN and receiver alias EXISTS subquery to
DocumentSpecifications.hasText(). Uses entity-graph navigation via
Person.nameAliases (@OneToMany) to avoid a separate DB roundtrip
while respecting domain boundaries. 2 new integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-07 13:18:31 +02:00
parent db61d6b77f
commit 90c9ac9357
2 changed files with 46 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import java.util.UUID;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
@@ -25,6 +26,12 @@ public class DocumentSpecifications {
// LEFT JOIN on sender (ManyToOne — no duplicate rows)
Join<Document, Person> senderJoin = root.join("sender", JoinType.LEFT);
// LEFT JOIN sender → aliases (entity-graph navigation avoids a separate DB
// roundtrip while respecting domain boundaries — the alias table is part of
// the Person aggregate, navigated via @OneToMany, not via a cross-domain
// repository call from DocumentService)
Join<Person, PersonNameAlias> senderAliasJoin = senderJoin.join("nameAliases", JoinType.LEFT);
// EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs
Subquery<Long> receiverSub = query.subquery(Long.class);
Root<Document> receiverRoot = receiverSub.from(Document.class);
@@ -38,6 +45,17 @@ public class DocumentSpecifications {
)
);
// EXISTS subquery for receiver alias name
Subquery<Long> receiverAliasSub = query.subquery(Long.class);
Root<Document> receiverAliasRoot = receiverAliasSub.from(Document.class);
Join<Document, Person> recAliasPersonJoin = receiverAliasRoot.join("receivers");
Join<Person, PersonNameAlias> recAliasJoin = recAliasPersonJoin.join("nameAliases");
receiverAliasSub.select(cb.literal(1L))
.where(
cb.equal(receiverAliasRoot.get("id"), root.get("id")),
cb.like(cb.lower(recAliasJoin.get("lastName")), likePattern)
);
// EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs
Subquery<Long> tagSub = query.subquery(Long.class);
Root<Document> tagRoot = tagSub.from(Document.class);
@@ -57,7 +75,9 @@ public class DocumentSpecifications {
cb.like(cb.lower(root.get("location")), likePattern),
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
cb.exists(receiverSub),
cb.exists(receiverAliasSub),
cb.exists(tagSub)
);
};

View File

@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
@@ -28,6 +30,7 @@ class DocumentSpecificationsTest {
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired TagRepository tagRepository;
private Person sender;
@@ -325,4 +328,27 @@ class DocumentSpecificationsTest {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
assertThat(result).isEmpty();
}
// ─── hasText with aliases ────────────────────────────────────────────────
@Test
void hasText_findsDocumentBySenderAliasLastName() {
aliasRepository.save(PersonNameAlias.builder()
.person(sender).lastName("von Mueller").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Document> result = documentRepository.findAll(Specification.where(hasText("von Mueller")));
assertThat(result).isNotEmpty();
assertThat(result).extracting(Document::getTitle).contains("Alter Brief");
}
@Test
void hasText_findsDocumentByReceiverAliasLastName() {
aliasRepository.save(PersonNameAlias.builder()
.person(receiver).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Document> result = documentRepository.findAll(Specification.where(hasText("de Gruyter")));
assertThat(result).isNotEmpty();
}
}