implement service.upsert
This commit is contained in:
@@ -1,4 +1,50 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person;
|
||||
|
||||
public record Person() {
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import lombok.*;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Accessors(fluent = true)
|
||||
@Builder(toBuilder = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Person {
|
||||
|
||||
@Id
|
||||
@NonNull
|
||||
private Integer id;
|
||||
|
||||
@Nullable
|
||||
private String name;
|
||||
|
||||
@Nullable
|
||||
private LocalDate birthDate;
|
||||
|
||||
// Store only relation IDs instead of JPA mappings to reduce JPA/Hibernate tomfoolery
|
||||
@Nullable
|
||||
private Integer partnerId;
|
||||
|
||||
@NonNull
|
||||
@Singular
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
private Set<Integer> childIds = new HashSet<>();
|
||||
|
||||
@NonNull
|
||||
@Singular
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
private Set<Integer> parentIds = new HashSet<>();
|
||||
|
||||
public Person(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.spijkerman.ivo.threekidfamily.domain.person;
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.dto.PersonUpsertRequest;
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.dto.PersonUpsertResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.val;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -19,7 +21,19 @@ public class PersonController {
|
||||
public ResponseEntity<List<PersonUpsertResponse>> upsertPerson(
|
||||
@RequestBody PersonUpsertRequest request
|
||||
) {
|
||||
throw new UnsupportedOperationException();
|
||||
val person = request.toDomain();
|
||||
personService.upsertPerson(person);
|
||||
|
||||
val parentsInValidFamilies = personService.getParentsInValidFamilies();
|
||||
if (parentsInValidFamilies.isEmpty()) {
|
||||
// Specs state HTTP 444, but since this is a custom nginx status + not a client error, I cannot do this
|
||||
return ResponseEntity.noContent().build(); // 204 instead
|
||||
}
|
||||
val result = new ArrayList<PersonUpsertResponse>();
|
||||
for (val parent : parentsInValidFamilies) {
|
||||
result.add(PersonUpsertResponse.fromDomain(parent));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PersonEntity {
|
||||
|
||||
@Id
|
||||
@NonNull
|
||||
private Integer id;
|
||||
|
||||
@Nullable
|
||||
private String name;
|
||||
|
||||
@Nullable
|
||||
private LocalDate birthDate;
|
||||
|
||||
// Store only relation IDs instead of JPA mappings to reduce JPA/Hibernate tomfoolery
|
||||
@Nullable
|
||||
private Integer partnerId;
|
||||
|
||||
@NonNull
|
||||
@Singular
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
private Set<Integer> childIds = new HashSet<>();
|
||||
|
||||
@NonNull
|
||||
@Singular
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
private Set<Integer> parentIds = new HashSet<>();
|
||||
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface PersonRepository extends JpaRepository<PersonEntity, Integer> {
|
||||
public interface PersonRepository extends JpaRepository<Person, Integer> {
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Sets;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.Locked;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.val;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -16,10 +20,142 @@ public class PersonService {
|
||||
/**
|
||||
* Upserts a person to storage, overwriting any existing or conflicting data.
|
||||
*
|
||||
* @param person The Person data to be persisted
|
||||
* @return The IDs of the Persons that have been modified because of this operation.
|
||||
* @param newPerson The Person data to be persisted
|
||||
*/
|
||||
public @NonNull Set<Integer> upsertPerson(Person person) {
|
||||
@Locked.Write
|
||||
@Transactional
|
||||
public void upsertPerson(Person newPerson) {
|
||||
int id = newPerson.id();
|
||||
val relatedPersons = retrieveRelatedOf(newPerson);
|
||||
val oldPerson = relatedPersons.getLeft();
|
||||
val related = relatedPersons.getRight();
|
||||
|
||||
val modifiedPersons = oldPerson == null ? modifiedPersonSetOf(
|
||||
setPartner(id, related, newPerson.partnerId(), null),
|
||||
setParents(id, related, newPerson.parentIds(), Set.of()),
|
||||
setChildren(id, related, newPerson.childIds(), Set.of())
|
||||
) : modifiedPersonSetOf(
|
||||
setPartner(id, related, newPerson.partnerId(), oldPerson.partnerId()),
|
||||
setParents(id, related, newPerson.parentIds(), oldPerson.parentIds()),
|
||||
setChildren(id, related, newPerson.childIds(), oldPerson.childIds())
|
||||
);
|
||||
|
||||
if (!Objects.equals(newPerson, oldPerson)) {
|
||||
modifiedPersons.add(newPerson);
|
||||
}
|
||||
if (!modifiedPersons.isEmpty()) {
|
||||
personRepository.saveAll(modifiedPersons);
|
||||
}
|
||||
}
|
||||
|
||||
private Pair<Person, Map<Integer, Person>> retrieveRelatedOf(Person newPerson) {
|
||||
val personsToConsider = Lists.newArrayList(newPerson);
|
||||
val oldPerson = personRepository.findById(newPerson.id());
|
||||
oldPerson.ifPresent(personsToConsider::add);
|
||||
|
||||
val ids = new HashSet<Integer>();
|
||||
for (val person : personsToConsider) {
|
||||
if (person.partnerId() != null) {
|
||||
ids.add(person.partnerId());
|
||||
}
|
||||
ids.addAll(person.parentIds());
|
||||
ids.addAll(person.childIds());
|
||||
}
|
||||
|
||||
val related = new HashMap<Integer, Person>();
|
||||
for (val foundPerson : personRepository.findAllById(ids)) {
|
||||
related.put(foundPerson.id(), foundPerson);
|
||||
}
|
||||
|
||||
// Need to copy, because .difference returns an unmodifiable view
|
||||
val missingIds = Set.copyOf(Sets.difference(ids, related.keySet()));
|
||||
for (val missingId : missingIds) {
|
||||
related.put(missingId, new Person(missingId));
|
||||
}
|
||||
|
||||
return Pair.of(
|
||||
oldPerson.orElse(null),
|
||||
Collections.unmodifiableMap(related)
|
||||
);
|
||||
}
|
||||
|
||||
private Set<Person> modifiedPersonSetOf(
|
||||
Set<Person> modifiedPartners,
|
||||
Set<Person> modifiedParents,
|
||||
Set<Person> modifiedChildren
|
||||
) {
|
||||
val result = new HashSet<Person>();
|
||||
result.addAll(modifiedPartners);
|
||||
result.addAll(modifiedParents);
|
||||
result.addAll(modifiedChildren);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<Person> setPartner(
|
||||
int selfId,
|
||||
Map<Integer, Person> related,
|
||||
Integer newPartnerId,
|
||||
Integer oldPartnerId
|
||||
) {
|
||||
val modifiedPersons = new HashSet<Person>();
|
||||
if (Objects.equals(newPartnerId, oldPartnerId)) {
|
||||
return Set.of();
|
||||
}
|
||||
if (newPartnerId != null) {
|
||||
val newPartner = related.get(newPartnerId);
|
||||
newPartner.partnerId(selfId);
|
||||
modifiedPersons.add(newPartner);
|
||||
}
|
||||
if (oldPartnerId != null) {
|
||||
val oldPartner = related.get(oldPartnerId);
|
||||
oldPartner.partnerId(null);
|
||||
modifiedPersons.add(oldPartner);
|
||||
}
|
||||
return modifiedPersons;
|
||||
}
|
||||
|
||||
private Set<Person> setParents(
|
||||
int childId,
|
||||
Map<Integer, Person> related,
|
||||
Set<Integer> newParentIds,
|
||||
Set<Integer> oldParentIds
|
||||
) {
|
||||
val modifiedPersons = new HashSet<Person>();
|
||||
for (val removeFromId : Sets.difference(oldParentIds, newParentIds)) {
|
||||
val removeFrom = related.get(removeFromId);
|
||||
removeFrom.childIds().remove(childId);
|
||||
modifiedPersons.add(removeFrom);
|
||||
}
|
||||
for (val addToId : Sets.difference(newParentIds, oldParentIds)) {
|
||||
val addTo = related.get(addToId);
|
||||
addTo.childIds().add(childId);
|
||||
modifiedPersons.add(addTo);
|
||||
}
|
||||
return modifiedPersons;
|
||||
}
|
||||
|
||||
private Set<Person> setChildren(
|
||||
int parentId,
|
||||
Map<Integer, Person> related,
|
||||
Set<Integer> newChildIds,
|
||||
Set<Integer> oldChildIds
|
||||
) {
|
||||
val modifiedPersons = new HashSet<Person>();
|
||||
for (val removeFromId : Sets.difference(oldChildIds, newChildIds)) {
|
||||
val removeFrom = related.get(removeFromId);
|
||||
removeFrom.parentIds().remove(parentId);
|
||||
modifiedPersons.add(removeFrom);
|
||||
}
|
||||
for (val addToId : Sets.difference(newChildIds, oldChildIds)) {
|
||||
val addTo = related.get(addToId);
|
||||
addTo.parentIds().add(parentId);
|
||||
modifiedPersons.add(addTo);
|
||||
}
|
||||
return modifiedPersons;
|
||||
}
|
||||
|
||||
@Locked.Read
|
||||
public SortedSet<Person> getParentsInValidFamilies() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@@ -29,14 +165,17 @@ public class PersonService {
|
||||
* @param id The ID of the person to be retrieved.
|
||||
* @return A Person object, may contain all information, or just the ID.
|
||||
*/
|
||||
@Locked.Read
|
||||
public Person getPersonById(int id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all data related to the specified user, and blacklists that ID from later use.
|
||||
*
|
||||
* @param id The ID of the person to be deleted and blacklisted.
|
||||
*/
|
||||
@Locked.Write
|
||||
public void deletePersonById(int id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,42 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person.dto;
|
||||
|
||||
public record PersonUpsertRequest() {
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.Person;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.val;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record PersonUpsertRequest(
|
||||
int id,
|
||||
@Nullable String name,
|
||||
@Nullable LocalDate birthDate,
|
||||
@Nullable PersonReference parent1,
|
||||
@Nullable PersonReference parent2,
|
||||
@Nullable PersonReference partner,
|
||||
@Nullable List<PersonReference> children
|
||||
) {
|
||||
public record PersonReference(int id){}
|
||||
|
||||
public Person toDomain() {
|
||||
var builder = Person.builder()
|
||||
.id(id)
|
||||
.name(name)
|
||||
.parentId(idOf(parent1))
|
||||
.parentId(idOf(parent2))
|
||||
.partnerId(idOf(partner));
|
||||
if (children != null) {
|
||||
for (val child : children) {
|
||||
builder = builder.childId(idOf(child));
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static @Nullable Integer idOf(@Nullable PersonReference personReference) {
|
||||
if (personReference == null) {
|
||||
return null;
|
||||
}
|
||||
return personReference.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person.dto;
|
||||
|
||||
public record PersonUpsertResponse() {
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.Person;
|
||||
import jakarta.annotation.Nullable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record PersonUpsertResponse(
|
||||
int id,
|
||||
@Nullable String name,
|
||||
@Nullable LocalDate birthDate
|
||||
// Not sure if relations should be returned as well, decided to not
|
||||
) {
|
||||
public static PersonUpsertResponse fromDomain(Person domain) {
|
||||
return new PersonUpsertResponse(domain.id(), domain.name(), domain.birthDate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.spijkerman.ivo.threekidfamily;
|
||||
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.Person;
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.PersonEntity;
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.PersonRepository;
|
||||
import com.spijkerman.ivo.threekidfamily.domain.person.PersonService;
|
||||
import lombok.val;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -41,7 +39,7 @@ class ThreeKidFamilyIT {
|
||||
|
||||
@Test
|
||||
void testDbRoundTrip() {
|
||||
val given = PersonEntity.builder().id(12).build();
|
||||
val given = Person.builder().id(12).build();
|
||||
val saved = personRepository.save(given);
|
||||
|
||||
assertThat(saved).isEqualTo(given);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.spijkerman.ivo.threekidfamily.domain.person;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Maps;
|
||||
import lombok.val;
|
||||
import org.assertj.core.util.Sets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PersonServiceTest {
|
||||
|
||||
@Test
|
||||
void testUpsertPerson_forFullPerson_verifyFamilySaved() {
|
||||
val repository = repository();
|
||||
val sut = new PersonService(repository);
|
||||
|
||||
val given = Person.builder()
|
||||
.id(1)
|
||||
.name("Alice")
|
||||
.birthDate(LocalDate.now())
|
||||
.parentId(2)
|
||||
.childId(10)
|
||||
.partnerId(20)
|
||||
.build();
|
||||
|
||||
sut.upsertPerson(given);
|
||||
|
||||
verify(repository).findById(1);
|
||||
verify(repository).findAllById(Set.of(2, 10, 20));
|
||||
verify(repository).saveAll(Set.of(
|
||||
given, // Alice
|
||||
Person.builder().id(2).childId(1).build(), // Parent
|
||||
Person.builder().id(10).parentId(1).build(), // Child
|
||||
Person.builder().id(20).partnerId(1).build() // Partner
|
||||
));
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpsertPerson_forFullThenSlimPerson_expectFamilyStripped() {
|
||||
val repository = repository();
|
||||
val sut = new PersonService(repository);
|
||||
|
||||
val fullGiven = Person.builder()
|
||||
.id(1)
|
||||
.name("Alice")
|
||||
.birthDate(LocalDate.now())
|
||||
.parentId(2)
|
||||
.childId(10)
|
||||
.partnerId(20)
|
||||
.build();
|
||||
|
||||
val slimGiven = Person.builder()
|
||||
.id(1)
|
||||
.build();
|
||||
|
||||
sut.upsertPerson(fullGiven);
|
||||
clearInvocations(repository); // So we only have to verify the invocations of inserting slim alice
|
||||
sut.upsertPerson(slimGiven);
|
||||
|
||||
verify(repository).findById(1);
|
||||
verify(repository).findAllById(Set.of(2, 10, 20));
|
||||
verify(repository).saveAll(Set.of(
|
||||
slimGiven,
|
||||
Person.builder().id(2).build(), // Parent
|
||||
Person.builder().id(10).build(), // Child
|
||||
Person.builder().id(20).build() // Partner
|
||||
));
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpsertPerson_forTwoPartners_expectOneSave() {
|
||||
val repository = repository();
|
||||
val sut = new PersonService(repository);
|
||||
|
||||
val alice = Person.builder()
|
||||
.id(1)
|
||||
.partnerId(2)
|
||||
.build();
|
||||
val bob = Person.builder()
|
||||
.id(2)
|
||||
.partnerId(1)
|
||||
.build();
|
||||
|
||||
sut.upsertPerson(alice);
|
||||
sut.upsertPerson(bob);
|
||||
|
||||
verify(repository).findById(1);
|
||||
verify(repository).findById(2);
|
||||
verify(repository).findAllById(Set.of(1));
|
||||
verify(repository).findAllById(Set.of(2));
|
||||
verify(repository).saveAll(Set.of(alice, bob));
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
private static PersonRepository repository() {
|
||||
val mockDb = new HashMap<Integer, Person>();
|
||||
val repository = mock(PersonRepository.class);
|
||||
|
||||
when(repository.save(any())).then(inv -> {
|
||||
Person person = inv.getArgument(0);
|
||||
mockDb.put(person.id(), person);
|
||||
return person;
|
||||
});
|
||||
|
||||
when(repository.saveAll(any())).then(inv -> {
|
||||
Iterable<Person> persons = inv.getArgument(0);
|
||||
for (val person : persons) {
|
||||
mockDb.put(person.id(), person);
|
||||
}
|
||||
return Lists.newArrayList(persons);
|
||||
});
|
||||
|
||||
when(repository.findById(any())).then(inv -> {
|
||||
int id = inv.getArgument(0);
|
||||
return Optional.ofNullable(mockDb.get(id));
|
||||
});
|
||||
|
||||
when(repository.findAllById(any())).then(inv -> {
|
||||
val ids = Sets.<Integer>newHashSet(inv.getArgument(0));
|
||||
return List.copyOf(Maps.filterKeys(mockDb, ids::contains).values());
|
||||
});
|
||||
|
||||
return repository;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user