feat(#221): add AND/OR tag filtering with hierarchy expansion in document search
- Replace hasTags(List<String>) spec with hasTags(List<Set<UUID>>, useOr) - AND mode: one EXISTS subquery per expanded tag ID set; empty set = disjunction - OR mode: union of all expanded sets into a single EXISTS subquery - DocumentService calls tagService.expandTagNamesToDescendantIdSets() before building spec - DocumentController exposes ?tagOp=AND|OR query param (default AND) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -204,11 +204,12 @@ public class DocumentController {
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) {
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||
}
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, tagOp));
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
@@ -4,6 +4,7 @@ import jakarta.persistence.criteria.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -54,34 +55,64 @@ public class DocumentSpecifications {
|
||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||
}
|
||||
|
||||
// Filtert nach Schlagworten (UND-Verknüpfung, exakter Match)
|
||||
public static Specification<Document> hasTags(List<String> tags) {
|
||||
/**
|
||||
* Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik.
|
||||
*
|
||||
* <p>AND (useOr=false): Das Dokument muss mindestens einen Tag aus <em>jedem</em> Set besitzen.
|
||||
* <p>OR (useOr=true): Das Dokument muss mindestens einen Tag aus der Vereinigung aller Sets besitzen.
|
||||
*
|
||||
* <p>Jedes Set repräsentiert einen ausgewählten Tag inklusive aller seiner Nachkommen
|
||||
* (vorausgeweitet durch {@code TagRepository.findDescendantIdsByName}).
|
||||
*/
|
||||
public static Specification<Document> hasTags(List<Set<UUID>> tagIdSets, boolean useOr) {
|
||||
return (root, query, cb) -> {
|
||||
if (tags == null || tags.isEmpty())
|
||||
if (tagIdSets == null || tagIdSets.isEmpty())
|
||||
return null;
|
||||
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
for (String tagName : tags) {
|
||||
if (!StringUtils.hasText(tagName)) continue;
|
||||
|
||||
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")),
|
||||
cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase())
|
||||
);
|
||||
|
||||
predicates.add(cb.exists(subquery));
|
||||
if (!useOr) {
|
||||
// AND mode: an empty set means the tag resolved to no IDs (doesn't exist) —
|
||||
// no document can satisfy the condition, so return no results immediately.
|
||||
boolean hasEmptySet = tagIdSets.stream().anyMatch(s -> s == null || s.isEmpty());
|
||||
if (hasEmptySet) return cb.disjunction();
|
||||
}
|
||||
|
||||
List<Set<UUID>> nonEmpty = tagIdSets.stream()
|
||||
.filter(s -> s != null && !s.isEmpty())
|
||||
.toList();
|
||||
if (nonEmpty.isEmpty()) return null;
|
||||
|
||||
if (useOr) {
|
||||
Set<UUID> union = new java.util.HashSet<>();
|
||||
nonEmpty.forEach(union::addAll);
|
||||
return documentHasTagIn(root, query, cb, union);
|
||||
}
|
||||
|
||||
// AND: one EXISTS subquery per set
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
for (Set<UUID> ids : nonEmpty) {
|
||||
predicates.add(documentHasTagIn(root, query, cb, ids));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private static Predicate documentHasTagIn(
|
||||
Root<Document> root,
|
||||
jakarta.persistence.criteria.CriteriaQuery<?> query,
|
||||
jakarta.persistence.criteria.CriteriaBuilder cb,
|
||||
Set<UUID> tagIds) {
|
||||
Subquery<UUID> subquery = query.subquery(UUID.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")),
|
||||
subTags.get("id").in(tagIds)
|
||||
);
|
||||
return cb.exists(subquery);
|
||||
}
|
||||
|
||||
// Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche
|
||||
public static Specification<Document> hasTagPartial(String tagQ) {
|
||||
return (root, query, cb) -> {
|
||||
|
||||
@@ -293,7 +293,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, String tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
|
||||
@@ -302,12 +302,15 @@ public class DocumentService {
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = "OR".equalsIgnoreCase(tagOperator);
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
|
||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||
Specification<Document> spec = Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user