feat(search): extended search, sort options, live tag filter, result count #183

Merged
marcel merged 26 commits from feat/issue-180-extended-search-sort into main 2026-04-06 19:18:12 +02:00
2 changed files with 118 additions and 9 deletions
Showing only changes of commit beca2d463a - Show all commits

View File

@@ -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);
};
}
}

View File

@@ -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