From 90c9ac9357a848089081b5e39906438d6831988e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:18:31 +0200 Subject: [PATCH] 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 --- .../repository/DocumentSpecifications.java | 20 ++++++++++++++ .../DocumentSpecificationsTest.java | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index b936f16c..ee9550c1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -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 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 senderAliasJoin = senderJoin.join("nameAliases", JoinType.LEFT); + // EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs Subquery receiverSub = query.subquery(Long.class); Root receiverRoot = receiverSub.from(Document.class); @@ -38,6 +45,17 @@ public class DocumentSpecifications { ) ); + // EXISTS subquery for receiver alias name + Subquery receiverAliasSub = query.subquery(Long.class); + Root receiverAliasRoot = receiverAliasSub.from(Document.class); + Join recAliasPersonJoin = receiverAliasRoot.join("receivers"); + Join 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 tagSub = query.subquery(Long.class); Root 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) ); }; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index c2691213..b13b71fe 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -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 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 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 result = documentRepository.findAll(Specification.where(hasText("de Gruyter"))); + + assertThat(result).isNotEmpty(); + } }