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:
Marcel
2026-05-02 19:17:39 +02:00
parent 2ae830a3c8
commit 0802889ea9
7 changed files with 234 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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