diff --git a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/Person.java b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/Person.java index 2a0081b..6510e6f 100644 --- a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/Person.java +++ b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/Person.java @@ -9,6 +9,7 @@ import lombok.*; import lombok.experimental.Accessors; import java.time.LocalDate; +import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -35,16 +36,26 @@ public class Person { private Integer partnerId; @NonNull - @Singular + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) private Set childIds = new HashSet<>(); @NonNull - @Singular + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) private Set parentIds = new HashSet<>(); public Person(int id) { this.id = id; } + + public Set relatedIds() { + val ids = new HashSet(); + if (partnerId() != null) { + ids.add(partnerId); + } + ids.addAll(parentIds); + ids.addAll(childIds); + return Collections.unmodifiableSet(ids); + } } diff --git a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonController.java b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonController.java index 7412e9c..2b46ac3 100644 --- a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonController.java +++ b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonController.java @@ -36,13 +36,6 @@ public class PersonController { return ResponseEntity.ok(result); } - @GetMapping("/{id}") - public ResponseEntity getPerson( - @PathVariable int id - ) { - throw new UnsupportedOperationException(); - } - @DeleteMapping public ResponseEntity deletePeople( @RequestBody List ids diff --git a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonService.java b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonService.java index adb560e..19cd74c 100644 --- a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonService.java +++ b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonService.java @@ -1,7 +1,7 @@ package com.spijkerman.ivo.threekidfamily.domain.person; -import com.google.common.collect.Lists; import com.google.common.collect.Sets; +import jakarta.annotation.PostConstruct; import jakarta.transaction.Transactional; import lombok.Locked; import lombok.RequiredArgsConstructor; @@ -9,14 +9,33 @@ import lombok.val; import org.apache.commons.lang3.tuple.Pair; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class PersonService { + public static final int MAX_RELATED_ASSUMPTION = 1000; private final PersonRepository personRepository; + // Cache all the valid parents, so we do not have to recalculate all on each upsert. + private final SortedSet parentsInValidFamiliesCache = new TreeSet<>(Comparator.comparingInt(Person::id)); + + @PostConstruct + private void populateCache() { + // Possible overflow risk, consider streaming/paging solution here + val persons = personRepository.findAll(); + val related = persons.stream().collect(Collectors.toMap(Person::id, Function.identity())); + for (val person : persons) { + if (isValid(person, related)) { + parentsInValidFamiliesCache.add(person); + } + } + } + /** * Upserts a person to storage, overwriting any existing or conflicting data. * @@ -45,21 +64,40 @@ public class PersonService { } if (!modifiedPersons.isEmpty()) { personRepository.saveAll(modifiedPersons); + + // Update valid parent cache + val validModifieds = filterValid(modifiedPersons); + parentsInValidFamiliesCache.addAll(validModifieds); + + val invalidModifieds = Sets.difference(modifiedPersons, validModifieds); + parentsInValidFamiliesCache.removeAll(invalidModifieds); } } private Pair> retrieveRelatedOf(Person newPerson) { - val personsToConsider = Lists.newArrayList(newPerson); - val oldPerson = personRepository.findById(newPerson.id()); - oldPerson.ifPresent(personsToConsider::add); + val oldPerson = personRepository.findById(newPerson.id()).orElse(null); + return oldPerson == null || newPerson.equals(oldPerson) ? + Pair.of( + oldPerson, + retrieveRelatedOf(Set.of(newPerson)) + ) : + Pair.of( + oldPerson, + retrieveRelatedOf(Set.of(newPerson, oldPerson)) + ); + } + private Map retrieveRelatedOf(Set persons) { val ids = new HashSet(); - for (val person : personsToConsider) { - if (person.partnerId() != null) { - ids.add(person.partnerId()); + for (val person : persons) { + ids.addAll(person.relatedIds()); + if (ids.size() > MAX_RELATED_ASSUMPTION) { + throw new AssertionError("Assumed that the amount of related IDs will never exceed %d. Need to rethink this program.".formatted(MAX_RELATED_ASSUMPTION)); } - ids.addAll(person.parentIds()); - ids.addAll(person.childIds()); + } + + if (ids.isEmpty()) { + return Map.of(); } val related = new HashMap(); @@ -72,11 +110,7 @@ public class PersonService { for (val missingId : missingIds) { related.put(missingId, new Person(missingId)); } - - return Pair.of( - oldPerson.orElse(null), - Collections.unmodifiableMap(related) - ); + return Collections.unmodifiableMap(related); } private Set modifiedPersonSetOf( @@ -156,18 +190,39 @@ public class PersonService { @Locked.Read public SortedSet getParentsInValidFamilies() { - throw new UnsupportedOperationException(); + return Collections.unmodifiableSortedSet(parentsInValidFamiliesCache); } - /** - * Returns all known data related to that specific user. - * - * @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(); + private Set filterValid(Set candidates) { + val related = retrieveRelatedOf(candidates); + return candidates.stream() + .filter(candidate -> isValid(candidate, related)) + .collect(Collectors.toSet()); + } + + private boolean isValid(Person person, Map related) { + // 1. Has a partner + if (person.partnerId() == null) { + return false; + } + // 2a. Has exactly 3 children + if (person.childIds().size() != 3) { + return false; + } + // 2b. All 3 children have that same partner listed as mother or father + val expectedParentIds = Set.of(person.id(), person.partnerId()); + val under18Date = LocalDate.now().minusYears(18); + var atLeastOneUnder18 = false; + for (val childId : person.childIds()) { + val child = related.get(childId); + if (!child.parentIds().containsAll(expectedParentIds)) { + return false; + } + atLeastOneUnder18 |= child.birthDate() != null + && (child.birthDate().isAfter(under18Date) || child.birthDate().isEqual(under18Date)); + } + // 3. At least one of those children is under 18 + return atLeastOneUnder18; } /** diff --git a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/dto/PersonUpsertRequest.java b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/dto/PersonUpsertRequest.java index 3610b0b..fd3378e 100644 --- a/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/dto/PersonUpsertRequest.java +++ b/src/main/java/com/spijkerman/ivo/threekidfamily/domain/person/dto/PersonUpsertRequest.java @@ -5,6 +5,8 @@ import jakarta.annotation.Nullable; import lombok.val; import java.time.LocalDate; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; public record PersonUpsertRequest( @@ -22,14 +24,25 @@ public record PersonUpsertRequest( var builder = Person.builder() .id(id) .name(name) - .parentId(idOf(parent1)) - .parentId(idOf(parent2)) .partnerId(idOf(partner)); + + val childIds = new HashSet(); if (children != null) { for (val child : children) { - builder = builder.childId(idOf(child)); + childIds.add(idOf(child)); } } + builder.childIds(childIds); + + val parentIds = new HashSet(); + if (parent1 != null) { + parentIds.add(idOf(parent1)); + } + if (parent2 != null) { + parentIds.add(idOf(parent2)); + } + builder.parentIds(parentIds); + return builder.build(); } diff --git a/src/test/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonServiceTest.java b/src/test/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonServiceTest.java index 44af357..4bd59f9 100644 --- a/src/test/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonServiceTest.java +++ b/src/test/java/com/spijkerman/ivo/threekidfamily/domain/person/PersonServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mockito; import java.time.LocalDate; import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class PersonServiceTest { @@ -23,8 +24,8 @@ class PersonServiceTest { .id(1) .name("Alice") .birthDate(LocalDate.now()) - .parentId(2) - .childId(10) + .parentIds(Set.of(2)) + .childIds(Set.of(10)) .partnerId(20) .build(); @@ -34,10 +35,11 @@ class PersonServiceTest { 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(2).childIds(Set.of(1)).build(), // Parent + Person.builder().id(10).parentIds(Set.of(1)).build(), // Child Person.builder().id(20).partnerId(1).build() // Partner )); + verify(repository).findAllById(Set.of(1, 2, 10, 20)); // For cache update verifyNoMoreInteractions(repository); } @@ -50,8 +52,8 @@ class PersonServiceTest { .id(1) .name("Alice") .birthDate(LocalDate.now()) - .parentId(2) - .childId(10) + .parentIds(Set.of(2)) + .childIds(Set.of(10)) .partnerId(20) .build(); @@ -96,6 +98,7 @@ class PersonServiceTest { verify(repository).findAllById(Set.of(1)); verify(repository).findAllById(Set.of(2)); verify(repository).saveAll(Set.of(alice, bob)); + verify(repository).findAllById(Set.of(1, 2)); // For cache update verifyNoMoreInteractions(repository); } @@ -129,4 +132,46 @@ class PersonServiceTest { return repository; } + + @Test + void getParentsInValidFamiliesCache() { + val repository = repository(); + val sut = new PersonService(repository); + + // Two parents + val mom = Person.builder() + .id(1) + .build(); + val dad = Person.builder() + .id(2) + .partnerId(mom.id()) + .build(); + val parentIds = Set.of(mom.id(), dad.id()); + // Three kids + val alice = Person.builder() + .id(11) + .birthDate(LocalDate.now().minusYears(14)) // one under 18 + .parentIds(parentIds) + .build(); + val bob = Person.builder() + .id(12) + .parentIds(parentIds) + .build(); + val charlie = Person.builder() + .id(13) + .parentIds(parentIds) + .build(); + + sut.upsertPerson(mom); + sut.upsertPerson(dad); + sut.upsertPerson(alice); + sut.upsertPerson(bob); + + // No valid persons yet + assertThat(sut.getParentsInValidFamilies()).isEmpty(); + sut.upsertPerson(charlie); + + // Both parents are valid now + assertThat(sut.getParentsInValidFamilies()).map(Person::id).containsExactly(1, 2); + } } \ No newline at end of file