feat(person): add filter-aware paged repository queries

Add PersonSearchResult (mirrors DocumentSearchResult shape) and PersonFilter
records, plus paired findByFilter/countByFilter native queries sharing one
WHERE clause so the rendered page and totalElements can never drift. Filters
(type, familyOnly, hasDocuments, provisional, readerDefault, q) each disable
via a null/false param. Tested against real Postgres via Testcontainers.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 13:27:39 +02:00
parent 09b810afb6
commit a24764e58a
4 changed files with 279 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.person;
import lombok.Builder;
/**
* The reader/triage filter set for the persons directory, threaded as one value through
* {@code PersonController -> PersonService -> PersonRepository}. Each field is nullable:
* null means "do not constrain on this dimension".
*
* <ul>
* <li>{@code type} — restrict to a single {@link PersonType}.</li>
* <li>{@code familyOnly} — when true, only {@code familyMember} persons.</li>
* <li>{@code hasDocuments} — when true, only persons with documentCount &gt; 0.</li>
* <li>{@code provisional} — match the {@code Person.provisional} flag exactly.</li>
* <li>{@code readerDefault} — when true, restrict to {@code familyMember OR documentCount > 0}
* (the clean reader view). The explicit filters above AND with this restriction.</li>
* </ul>
*/
@Builder
public record PersonFilter(
PersonType type,
Boolean familyOnly,
Boolean hasDocuments,
Boolean provisional,
boolean readerDefault
) {
/** The unconstrained "show all" filter (transcriber view, no reader restriction). */
public static PersonFilter showAll() {
return PersonFilter.builder().readerDefault(false).build();
}
/** The clean reader default: familyMember OR documentCount &gt; 0, no other constraints. */
public static PersonFilter cleanDefault() {
return PersonFilter.builder().readerDefault(true).build();
}
}

View File

@@ -88,6 +88,61 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
nativeQuery = true)
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
// --- #667: filter-aware paged directory ---
//
// The slice query and the count query below MUST keep an IDENTICAL WHERE clause so the
// rendered page and totalElements can never drift. Every filter is nullable: a null param
// disables that predicate via the `:param IS NULL OR …` idiom. `readerDefault` (a plain
// boolean) restricts to "familyMember OR has documents"; the explicit filters AND on top.
// documentCount is recomputed inline (not via the SELECT alias) because WHERE cannot
// reference a computed alias. All params are named — no string concatenation, no injection.
String FILTER_WHERE = """
WHERE (CAST(:type AS text) IS NULL OR p.person_type = CAST(:type AS text))
AND (:familyOnly = FALSE OR :familyOnly IS NULL OR p.family_member = TRUE)
AND (:hasDocuments = FALSE OR :hasDocuments IS NULL OR (
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0)
AND (:provisional IS NULL OR p.provisional = :provisional)
AND (:readerDefault = FALSE OR (
p.family_member = TRUE OR (
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0))
AND (CAST(:query AS text) IS NULL OR
LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%')))
""";
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
""" + FILTER_WHERE + """
ORDER BY p.last_name ASC, p.first_name ASC
LIMIT :limit OFFSET :offset
""",
nativeQuery = true)
List<PersonSummaryDTO> findByFilter(@Param("type") String type,
@Param("familyOnly") Boolean familyOnly,
@Param("hasDocuments") Boolean hasDocuments,
@Param("provisional") Boolean provisional,
@Param("readerDefault") boolean readerDefault,
@Param("query") String query,
@Param("limit") int limit,
@Param("offset") int offset);
@Query(value = "SELECT COUNT(*) FROM persons p " + FILTER_WHERE, nativeQuery = true)
long countByFilter(@Param("type") String type,
@Param("familyOnly") Boolean familyOnly,
@Param("hasDocuments") Boolean hasDocuments,
@Param("provisional") Boolean provisional,
@Param("readerDefault") boolean readerDefault,
@Param("query") String query);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.person;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* Paged result for the /api/persons list endpoint.
*
* <p>Hand-written to mirror {@code document/DocumentSearchResult} field-for-field so the
* frontend sees one paged shape across the app. Deliberately NOT Spring {@code Page<T>}
* (unstable serialized shape across Spring versions, noisy in OpenAPI) and deliberately
* NOT a reuse of the document DTO (would couple two feature modules — duplication beats
* coupling here).
*/
public record PersonSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonSummaryDTO> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageNumber,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
) {
/**
* Paged factory: derives {@code totalPages} from the full match count and the page size.
* A zero count yields zero pages so the frontend hides the pagination control.
*/
public static PersonSearchResult paged(List<PersonSummaryDTO> slice, int pageNumber, int pageSize, long totalElements) {
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages);
}
}

View File

@@ -505,4 +505,156 @@ class PersonRepositoryTest {
.filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
assertThat(summary.isProvisional()).isTrue();
}
// ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ────────
// The slice query (findByFilter) and the count query (countByFilter) MUST share one
// WHERE clause so totalElements can never drift from the rendered page. These tests run
// against real Postgres because the slice ORDER BY uses a computed alias that fails on H2.
private void seedDirectoryFixture() {
// Register family member, no documents — visible by reader default (familyMember)
personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build());
// Person with one document — visible by reader default (documentCount > 0)
Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build());
documentRepository.save(Document.builder().title("B").originalFilename("b.pdf")
.status(DocumentStatus.UPLOADED).sender(hasDoc).build());
// Provisional, zero-document, non-family — hidden by reader default
personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build());
// An institution with no documents, non-family, non-provisional
personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build());
}
@Test
void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, true, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName)
.containsExactlyInAnyOrder("Register", "Person");
}
@Test
void countByFilter_readerDefault_matchesSliceSize() {
seedDirectoryFixture();
long count = personRepository.countByFilter(null, null, null, null, true, null);
assertThat(count).isEqualTo(2);
}
@Test
void findByFilter_showAll_returnsEveryone() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 0);
assertThat(slice).hasSize(4);
}
@Test
void findByFilter_typeInstitution_returnsOnlyInstitutions() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
"INSTITUTION", null, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_familyOnly_returnsOnlyFamilyMembers() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register");
}
@Test
void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person");
}
@Test
void findByFilter_provisionalTrue_returnsOnlyProvisional() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, true, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt");
}
@Test
void findByFilter_combinedFilters_andTogether() {
seedDirectoryFixture();
// family + has-documents → intersection is empty (Register has no docs, Doku is not family)
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, true, null, false, null, 50, 0);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_query_combinesWithFilters() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, "Verlag", 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_pageBeyondRange_returnsEmptySlice() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 999 * 50);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_respectsPageSize() {
seedDirectoryFixture();
List<PersonSummaryDTO> firstPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 0);
List<PersonSummaryDTO> secondPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 2);
assertThat(firstPage).hasSize(2);
assertThat(secondPage).hasSize(2);
assertThat(firstPage).extracting(PersonSummaryDTO::getId)
.doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList());
}
@Test
void countByFilter_typeInstitution_matchesSlice() {
seedDirectoryFixture();
long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null);
assertThat(count).isEqualTo(1);
}
@Test
void findByFilter_projectsDocumentCount() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
}
}