feat(geschichten): filter by multiple persons with AND semantics
GET /api/geschichten now accepts repeated personId query params and returns only stories that mention every person supplied. Refactors the list path to a JPA Specification chain (one EXISTS subquery per id, mirroring DocumentSpecifications.hasTags) and embeds the COALESCE(publishedAt, updatedAt) DESC ordering inside the spec so a single repository.findAll covers all filter combinations.
This commit is contained in:
@@ -32,10 +32,14 @@ public class GeschichteController {
|
||||
@GetMapping
|
||||
public List<Geschichte> list(
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@RequestParam(required = false) UUID personId,
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||
return geschichteService.list(status, personId, documentId, limit);
|
||||
return geschichteService.list(
|
||||
status,
|
||||
personIds == null ? List.of() : personIds,
|
||||
documentId,
|
||||
limit);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Geschichte;
|
||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
@Query("""
|
||||
SELECT g FROM Geschichte g
|
||||
WHERE (:status IS NULL OR g.status = :status)
|
||||
AND (:personId IS NULL OR :personId IN (SELECT p.id FROM g.persons p))
|
||||
AND (:documentId IS NULL OR :documentId IN (SELECT d.id FROM g.documents d))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<Geschichte> search(
|
||||
@Param("status") GeschichteStatus status,
|
||||
@Param("personId") UUID personId,
|
||||
@Param("documentId") UUID documentId,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import jakarta.persistence.criteria.Subquery;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Geschichte;
|
||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class GeschichteSpecifications {
|
||||
|
||||
private GeschichteSpecifications() {}
|
||||
|
||||
public static Specification<Geschichte> hasStatus(GeschichteStatus status) {
|
||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@code ORDER BY COALESCE(publishedAt, updatedAt) DESC} to the query without contributing
|
||||
* a predicate. Combined into the spec chain via {@code .and(...)}; the {@code conjunction}
|
||||
* acts as a no-op WHERE clause.
|
||||
*/
|
||||
public static Specification<Geschichte> orderByDisplayDateDesc() {
|
||||
return (root, query, cb) -> {
|
||||
// Skip ordering on count queries — JPA forbids orderBy on COUNT projections.
|
||||
if (query != null
|
||||
&& Long.class != query.getResultType()
|
||||
&& long.class != query.getResultType()) {
|
||||
query.orderBy(cb.desc(cb.coalesce(root.get("publishedAt"), root.get("updatedAt"))));
|
||||
}
|
||||
return cb.conjunction();
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||
*
|
||||
* <p>Implemented as one EXISTS subquery per id (canonical Criteria-API idiom for AND across a
|
||||
* many-to-many join). Mirrors {@link DocumentSpecifications#hasTags} which uses the same shape.
|
||||
* Empty / null input returns {@code null} (i.e. no constraint added).
|
||||
*/
|
||||
public static Specification<Geschichte> hasAllPersons(Collection<UUID> personIds) {
|
||||
return (root, query, cb) -> {
|
||||
if (personIds == null || personIds.isEmpty()) return null;
|
||||
List<Predicate> predicates = new ArrayList<>(personIds.size());
|
||||
for (UUID id : personIds) {
|
||||
predicates.add(cb.exists(personSubquery(root, query, cb, id)));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private static Subquery<UUID> personSubquery(
|
||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID personId) {
|
||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||
Join<Geschichte, Person> persons = subRoot.join("persons");
|
||||
sub.select(subRoot.get("id"))
|
||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||
cb.equal(persons.get("id"), personId));
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static Subquery<UUID> documentSubquery(
|
||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
||||
sub.select(subRoot.get("id"))
|
||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||
cb.equal(documents.get("id"), documentId));
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import org.raddatz.familienarchiv.model.Geschichte;
|
||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.repository.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -64,11 +65,25 @@ public class GeschichteService {
|
||||
return g;
|
||||
}
|
||||
|
||||
public List<Geschichte> list(GeschichteStatus status, UUID personId, UUID documentId, int limit) {
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||
*/
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
Pageable pageable = PageRequest.of(0, safeLimit);
|
||||
return geschichteRepository.search(effective, personId, documentId, pageable);
|
||||
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
);
|
||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user