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 java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
@@ -31,7 +32,7 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@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));
|
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.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
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
|
// Exact first+last name match, used for filename-based sender lookup
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
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 ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -22,11 +23,11 @@ public class PersonService {
|
|||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
|
|
||||||
public List<Person> findAll(String q) {
|
public List<PersonSummaryDTO> findAll(String q) {
|
||||||
if (q != null && !q.isBlank()) {
|
if (q != null && !q.isBlank()) {
|
||||||
return personRepository.searchByName(q);
|
return personRepository.searchWithDocumentCount(q);
|
||||||
}
|
}
|
||||||
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
return personRepository.findAllWithDocumentCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Person getById(UUID id) {
|
public Person getById(UUID id) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import java.util.Collections;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -57,14 +59,27 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(person));
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
.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} ────────────────────────────────────────────────
|
// ─── GET /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@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.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import jakarta.persistence.PersistenceContext;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -288,6 +290,76 @@ class PersonRepositoryTest {
|
|||||||
assertThat(targetCount).isEqualTo(1); // no duplicate
|
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 ─────────────────────────────────────────────
|
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
@@ -52,32 +53,32 @@ class PersonServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_returnsAll_whenQueryIsNull() {
|
void findAll_returnsAll_whenQueryIsNull() {
|
||||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build());
|
List<PersonSummaryDTO> expected = List.of();
|
||||||
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
||||||
|
|
||||||
assertThat(personService.findAll(null)).isEqualTo(expected);
|
assertThat(personService.findAll(null)).isEqualTo(expected);
|
||||||
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
verify(personRepository).findAllWithDocumentCount();
|
||||||
verify(personRepository, never()).searchByName(any());
|
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_returnsAll_whenQueryIsBlank() {
|
void findAll_returnsAll_whenQueryIsBlank() {
|
||||||
List<Person> expected = List.of();
|
List<PersonSummaryDTO> expected = List.of();
|
||||||
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
||||||
|
|
||||||
assertThat(personService.findAll(" ")).isEqualTo(expected);
|
assertThat(personService.findAll(" ")).isEqualTo(expected);
|
||||||
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
verify(personRepository).findAllWithDocumentCount();
|
||||||
verify(personRepository, never()).searchByName(any());
|
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_searchesByName_whenQueryIsNonBlank() {
|
void findAll_searchesByName_whenQueryIsNonBlank() {
|
||||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build());
|
List<PersonSummaryDTO> expected = List.of();
|
||||||
when(personRepository.searchByName("Anna")).thenReturn(expected);
|
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
|
||||||
|
|
||||||
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
||||||
verify(personRepository).searchByName("Anna");
|
verify(personRepository).searchWithDocumentCount("Anna");
|
||||||
verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc();
|
verify(personRepository, never()).findAllWithDocumentCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── createPerson ─────────────────────────────────────────────────────────
|
// ─── createPerson ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user