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 cf50675b..b936f16c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -8,24 +8,58 @@ 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.Tag; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; public class DocumentSpecifications { - // Filtert nach Text (in Titel, Dateiname oder Transkription) + // Filtert nach Text (in Titel, Dateiname, Transkription, Ort, Absender- und Empfängername, Tags) public static Specification hasText(String text) { return (root, query, cb) -> { if (!StringUtils.hasText(text)) return null; String likePattern = "%" + text.toLowerCase() + "%"; + // LEFT JOIN on sender (ManyToOne — no duplicate rows) + Join senderJoin = root.join("sender", 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); + Join receiverJoin = receiverRoot.join("receivers"); + receiverSub.select(cb.literal(1L)) + .where( + cb.equal(receiverRoot.get("id"), root.get("id")), + cb.or( + cb.like(cb.lower(receiverJoin.get("lastName")), likePattern), + cb.like(cb.lower(receiverJoin.get("firstName")), 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); + Join tagJoin = tagRoot.join("tags"); + tagSub.select(cb.literal(1L)) + .where( + cb.equal(tagRoot.get("id"), root.get("id")), + cb.like(cb.lower(tagJoin.get("name")), likePattern) + ); + + query.distinct(true); + return cb.or( cb.like(cb.lower(root.get("title")), likePattern), cb.like(cb.lower(root.get("originalFilename")), likePattern), cb.like(cb.lower(root.get("transcription")), likePattern), - cb.like(cb.lower(root.get("location")), likePattern)); + 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.exists(receiverSub), + cb.exists(tagSub) + ); }; } @@ -55,13 +89,13 @@ public class DocumentSpecifications { return cb.lessThanOrEqualTo(root.get("documentDate"), end); }; } - + // Filtert nach Status public static Specification hasStatus(DocumentStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } - // Filtert nach Schlagworten (UND-Verknüpfung) + // Filtert nach Schlagworten (UND-Verknüpfung, exakter Match) public static Specification hasTags(List tags) { return (root, query, cb) -> { if (tags == null || tags.isEmpty()) @@ -72,15 +106,13 @@ public class DocumentSpecifications { for (String tagName : tags) { if (!StringUtils.hasText(tagName)) continue; - // Subquery erstellen: "Gibt es für dieses Dokument (root.id) einen Tag mit dem Namen X?" - // Dies stellt sicher, dass ALLE Tags vorhanden sein müssen (AND Logik). Subquery subquery = query.subquery(Long.class); Root subRoot = subquery.from(Document.class); Join subTags = subRoot.join("tags"); subquery.select(subRoot.get("id")) .where( - cb.equal(subRoot.get("id"), root.get("id")), // Korrelation zum Haupt-Query + cb.equal(subRoot.get("id"), root.get("id")), cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase()) ); @@ -90,5 +122,26 @@ public class DocumentSpecifications { return cb.and(predicates.toArray(new Predicate[0])); }; } - -} \ No newline at end of file + + // Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche + public static Specification hasTagPartial(String tagQ) { + return (root, query, cb) -> { + if (!StringUtils.hasText(tagQ)) + return null; + String likePattern = "%" + tagQ.toLowerCase() + "%"; + + Subquery subquery = query.subquery(Long.class); + Root subRoot = subquery.from(Document.class); + Join tagJoin = subRoot.join("tags"); + + subquery.select(cb.literal(1L)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.like(cb.lower(tagJoin.get("name")), likePattern) + ); + + return cb.exists(subquery); + }; + } + +} 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 447f26e3..c2691213 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -250,6 +250,62 @@ class DocumentSpecificationsTest { assertThat(result).isEmpty(); } + @Test + void hasText_findsByPartialSenderLastName() { + List result = documentRepository.findAll(Specification.where(hasText("üller"))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasText_findsByPartialReceiverLastName() { + List result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasText_findsByPartialTagName() { + List result = documentRepository.findAll(Specification.where(hasText("amili"))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Familienfoto"); + } + + @Test + void hasText_doesNotProduceDuplicatesForDocumentWithMultipleReceivers() { + Person receiver2 = personRepository.save(Person.builder().firstName("Karl").lastName("Schmidt").build()); + briefEarly.setReceivers(new java.util.HashSet<>(Set.of(receiver, receiver2))); + documentRepository.save(briefEarly); + + List result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).hasSize(1); + } + + // ─── hasTagPartial ──────────────────────────────────────────────────────── + + @Test + void hasTagPartial_returnsAllDocuments_whenTextIsNull() { + List result = documentRepository.findAll(Specification.where(hasTagPartial(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasTagPartial_findsByPartialTagName() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("amili"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTagPartial_isCaseInsensitive() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("URLAUB"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief"); + } + + @Test + void hasTagPartial_returnsEmpty_whenNoTagMatches() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("xyz"))); + assertThat(result).isEmpty(); + } + // ─── hasStatus ──────────────────────────────────────────────────────────── @Test