feat(search): extend hasText to match sender/receiver/tag names, add hasTagPartial
- hasText now JOINs sender (LEFT JOIN) and uses EXISTS subqueries for receivers and tags to avoid duplicate rows - hasTagPartial added for live debounced tag text filter (ILIKE partial match) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Document> 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<Document, Person> senderJoin = root.join("sender", 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);
|
||||
Join<Document, Person> 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<Long> tagSub = query.subquery(Long.class);
|
||||
Root<Document> tagRoot = tagSub.from(Document.class);
|
||||
Join<Document, Tag> 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<Document> 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<Document> hasTags(List<String> 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<Long> subquery = query.subquery(Long.class);
|
||||
Root<Document> subRoot = subquery.from(Document.class);
|
||||
Join<Document, Tag> 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]));
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche
|
||||
public static Specification<Document> hasTagPartial(String tagQ) {
|
||||
return (root, query, cb) -> {
|
||||
if (!StringUtils.hasText(tagQ))
|
||||
return null;
|
||||
String likePattern = "%" + tagQ.toLowerCase() + "%";
|
||||
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<Document> subRoot = subquery.from(Document.class);
|
||||
Join<Document, Tag> 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);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -250,6 +250,62 @@ class DocumentSpecificationsTest {
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasText_findsByPartialSenderLastName() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasText("üller")));
|
||||
assertThat(result).extracting(Document::getTitle)
|
||||
.containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasText_findsByPartialReceiverLastName() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasText("schmid")));
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasText_findsByPartialTagName() {
|
||||
List<Document> 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<Document> result = documentRepository.findAll(Specification.where(hasText("schmid")));
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── hasTagPartial ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void hasTagPartial_returnsAllDocuments_whenTextIsNull() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial(null)));
|
||||
assertThat(result).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasTagPartial_findsByPartialTagName() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("amili")));
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasTagPartial_isCaseInsensitive() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("URLAUB")));
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasTagPartial_returnsEmpty_whenNoTagMatches() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("xyz")));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── hasStatus ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user