feat(persons): add PersonSummaryDTO with document count to GET /api/persons
Native queries compute sender + receiver document count in one SQL call, eliminating N+1. GET /api/persons now returns PersonSummaryDTO list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
@@ -31,7 +32,7 @@ public class PersonController {
|
||||
private final DocumentService documentService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Projection returned by the /api/persons list endpoint.
|
||||
* Includes document count to avoid N+1 queries in the UI.
|
||||
* Uses interface projection for compatibility with native queries.
|
||||
*/
|
||||
public interface PersonSummaryDTO {
|
||||
UUID getId();
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
String getAlias();
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
String getNotes();
|
||||
long getDocumentCount();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
@@ -31,6 +32,33 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
|
||||
// --- PersonSummaryDTO with document count ---
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
(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
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> findAllWithDocumentCount();
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
(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
|
||||
WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -22,11 +23,11 @@ public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
|
||||
public List<Person> findAll(String q) {
|
||||
public List<PersonSummaryDTO> findAll(String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.searchByName(q);
|
||||
return personRepository.searchWithDocumentCount(q);
|
||||
}
|
||||
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
||||
return personRepository.findAllWithDocumentCount();
|
||||
}
|
||||
|
||||
public Person getById(UUID id) {
|
||||
|
||||
@@ -21,6 +21,8 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -57,14 +59,27 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||
Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.findAll("Hans")).thenReturn(List.of(person));
|
||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||
}
|
||||
|
||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||
return new PersonSummaryDTO() {
|
||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||
public String getFirstName() { return firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public String getAlias() { return null; }
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public String getNotes() { return null; }
|
||||
public long getDocumentCount() { return 0; }
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GET /api/persons/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import java.util.List;
|
||||
@@ -288,6 +290,76 @@ class PersonRepositoryTest {
|
||||
assertThat(targetCount).isEqualTo(1); // no duplicate
|
||||
}
|
||||
|
||||
// ─── Phase 3.2: findAllWithDocumentCount ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() {
|
||||
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||
|
||||
// Walter sends 2 docs to Anna (Anna receives 2)
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief 1").originalFilename("b1.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(walter).receivers(Set.of(anna)).build());
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief 2").originalFilename("b2.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(walter).receivers(Set.of(anna)).build());
|
||||
// Anna also sends 1 doc to Walter
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief 3").originalFilename("b3.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(anna).receivers(Set.of(walter)).build());
|
||||
|
||||
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO walterSummary = result.stream()
|
||||
.filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow();
|
||||
PersonSummaryDTO annaSummary = result.stream()
|
||||
.filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow();
|
||||
|
||||
assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1
|
||||
assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() {
|
||||
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
|
||||
|
||||
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO soloSummary = result.stream()
|
||||
.filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow();
|
||||
assertThat(soloSummary.getDocumentCount()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_filtersAndIncludesCount() {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(hans).receivers(Set.of(anna)).build());
|
||||
|
||||
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Hans");
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getFirstName()).isEqualTo("Hans");
|
||||
assertThat(result.get(0).getDocumentCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_isCaseInsensitive() {
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("hans");
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
@@ -52,32 +53,32 @@ class PersonServiceTest {
|
||||
|
||||
@Test
|
||||
void findAll_returnsAll_whenQueryIsNull() {
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build());
|
||||
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
||||
List<PersonSummaryDTO> expected = List.of();
|
||||
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findAll(null)).isEqualTo(expected);
|
||||
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
verify(personRepository).findAllWithDocumentCount();
|
||||
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAll_returnsAll_whenQueryIsBlank() {
|
||||
List<Person> expected = List.of();
|
||||
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
||||
List<PersonSummaryDTO> expected = List.of();
|
||||
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findAll(" ")).isEqualTo(expected);
|
||||
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
verify(personRepository).findAllWithDocumentCount();
|
||||
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAll_searchesByName_whenQueryIsNonBlank() {
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build());
|
||||
when(personRepository.searchByName("Anna")).thenReturn(expected);
|
||||
List<PersonSummaryDTO> expected = List.of();
|
||||
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
||||
verify(personRepository).searchByName("Anna");
|
||||
verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc();
|
||||
verify(personRepository).searchWithDocumentCount("Anna");
|
||||
verify(personRepository, never()).findAllWithDocumentCount();
|
||||
}
|
||||
|
||||
// ─── createPerson ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user