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:
@@ -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 > 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 > 0, no other constraints. */
|
||||
public static PersonFilter cleanDefault() {
|
||||
return PersonFilter.builder().readerDefault(true).build();
|
||||
}
|
||||
}
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user