implement valid family check

This commit is contained in:
2025-10-23 21:01:32 +02:00
parent ee9de0c895
commit 31949833f8
5 changed files with 159 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ import lombok.*;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
@@ -35,16 +36,26 @@ public class Person {
private Integer partnerId; private Integer partnerId;
@NonNull @NonNull
@Singular @Builder.Default
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
private Set<Integer> childIds = new HashSet<>(); private Set<Integer> childIds = new HashSet<>();
@NonNull @NonNull
@Singular @Builder.Default
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
private Set<Integer> parentIds = new HashSet<>(); private Set<Integer> parentIds = new HashSet<>();
public Person(int id) { public Person(int id) {
this.id = id; this.id = id;
} }
public Set<Integer> relatedIds() {
val ids = new HashSet<Integer>();
if (partnerId() != null) {
ids.add(partnerId);
}
ids.addAll(parentIds);
ids.addAll(childIds);
return Collections.unmodifiableSet(ids);
}
} }

View File

@@ -36,13 +36,6 @@ public class PersonController {
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@GetMapping("/{id}")
public ResponseEntity<?> getPerson(
@PathVariable int id
) {
throw new UnsupportedOperationException();
}
@DeleteMapping @DeleteMapping
public ResponseEntity<?> deletePeople( public ResponseEntity<?> deletePeople(
@RequestBody List<Integer> ids @RequestBody List<Integer> ids

View File

@@ -1,7 +1,7 @@
package com.spijkerman.ivo.threekidfamily.domain.person; package com.spijkerman.ivo.threekidfamily.domain.person;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets; import com.google.common.collect.Sets;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import lombok.Locked; import lombok.Locked;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -9,14 +9,33 @@ import lombok.val;
import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Pair;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.*; import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class PersonService { public class PersonService {
public static final int MAX_RELATED_ASSUMPTION = 1000;
private final PersonRepository personRepository; private final PersonRepository personRepository;
// Cache all the valid parents, so we do not have to recalculate all on each upsert.
private final SortedSet<Person> 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. * Upserts a person to storage, overwriting any existing or conflicting data.
* *
@@ -45,21 +64,40 @@ public class PersonService {
} }
if (!modifiedPersons.isEmpty()) { if (!modifiedPersons.isEmpty()) {
personRepository.saveAll(modifiedPersons); 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<Person, Map<Integer, Person>> retrieveRelatedOf(Person newPerson) { private Pair<Person, Map<Integer, Person>> retrieveRelatedOf(Person newPerson) {
val personsToConsider = Lists.newArrayList(newPerson); val oldPerson = personRepository.findById(newPerson.id()).orElse(null);
val oldPerson = personRepository.findById(newPerson.id()); return oldPerson == null || newPerson.equals(oldPerson) ?
oldPerson.ifPresent(personsToConsider::add); Pair.of(
oldPerson,
retrieveRelatedOf(Set.of(newPerson))
) :
Pair.of(
oldPerson,
retrieveRelatedOf(Set.of(newPerson, oldPerson))
);
}
private Map<Integer, Person> retrieveRelatedOf(Set<Person> persons) {
val ids = new HashSet<Integer>(); val ids = new HashSet<Integer>();
for (val person : personsToConsider) { for (val person : persons) {
if (person.partnerId() != null) { ids.addAll(person.relatedIds());
ids.add(person.partnerId()); 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<Integer, Person>(); val related = new HashMap<Integer, Person>();
@@ -72,11 +110,7 @@ public class PersonService {
for (val missingId : missingIds) { for (val missingId : missingIds) {
related.put(missingId, new Person(missingId)); related.put(missingId, new Person(missingId));
} }
return Collections.unmodifiableMap(related);
return Pair.of(
oldPerson.orElse(null),
Collections.unmodifiableMap(related)
);
} }
private Set<Person> modifiedPersonSetOf( private Set<Person> modifiedPersonSetOf(
@@ -156,18 +190,39 @@ public class PersonService {
@Locked.Read @Locked.Read
public SortedSet<Person> getParentsInValidFamilies() { public SortedSet<Person> getParentsInValidFamilies() {
throw new UnsupportedOperationException(); return Collections.unmodifiableSortedSet(parentsInValidFamiliesCache);
} }
/** private Set<Person> filterValid(Set<Person> candidates) {
* Returns all known data related to that specific user. val related = retrieveRelatedOf(candidates);
* return candidates.stream()
* @param id The ID of the person to be retrieved. .filter(candidate -> isValid(candidate, related))
* @return A Person object, may contain all information, or just the ID. .collect(Collectors.toSet());
*/ }
@Locked.Read
public Person getPersonById(int id) { private boolean isValid(Person person, Map<Integer, Person> related) {
throw new UnsupportedOperationException(); // 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;
} }
/** /**

View File

@@ -5,6 +5,8 @@ import jakarta.annotation.Nullable;
import lombok.val; import lombok.val;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
public record PersonUpsertRequest( public record PersonUpsertRequest(
@@ -22,14 +24,25 @@ public record PersonUpsertRequest(
var builder = Person.builder() var builder = Person.builder()
.id(id) .id(id)
.name(name) .name(name)
.parentId(idOf(parent1))
.parentId(idOf(parent2))
.partnerId(idOf(partner)); .partnerId(idOf(partner));
val childIds = new HashSet<Integer>();
if (children != null) { if (children != null) {
for (val child : children) { for (val child : children) {
builder = builder.childId(idOf(child)); childIds.add(idOf(child));
} }
} }
builder.childIds(childIds);
val parentIds = new HashSet<Integer>();
if (parent1 != null) {
parentIds.add(idOf(parent1));
}
if (parent2 != null) {
parentIds.add(idOf(parent2));
}
builder.parentIds(parentIds);
return builder.build(); return builder.build();
} }

View File

@@ -10,6 +10,7 @@ import org.mockito.Mockito;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class PersonServiceTest { class PersonServiceTest {
@@ -23,8 +24,8 @@ class PersonServiceTest {
.id(1) .id(1)
.name("Alice") .name("Alice")
.birthDate(LocalDate.now()) .birthDate(LocalDate.now())
.parentId(2) .parentIds(Set.of(2))
.childId(10) .childIds(Set.of(10))
.partnerId(20) .partnerId(20)
.build(); .build();
@@ -34,10 +35,11 @@ class PersonServiceTest {
verify(repository).findAllById(Set.of(2, 10, 20)); verify(repository).findAllById(Set.of(2, 10, 20));
verify(repository).saveAll(Set.of( verify(repository).saveAll(Set.of(
given, // Alice given, // Alice
Person.builder().id(2).childId(1).build(), // Parent Person.builder().id(2).childIds(Set.of(1)).build(), // Parent
Person.builder().id(10).parentId(1).build(), // Child Person.builder().id(10).parentIds(Set.of(1)).build(), // Child
Person.builder().id(20).partnerId(1).build() // Partner Person.builder().id(20).partnerId(1).build() // Partner
)); ));
verify(repository).findAllById(Set.of(1, 2, 10, 20)); // For cache update
verifyNoMoreInteractions(repository); verifyNoMoreInteractions(repository);
} }
@@ -50,8 +52,8 @@ class PersonServiceTest {
.id(1) .id(1)
.name("Alice") .name("Alice")
.birthDate(LocalDate.now()) .birthDate(LocalDate.now())
.parentId(2) .parentIds(Set.of(2))
.childId(10) .childIds(Set.of(10))
.partnerId(20) .partnerId(20)
.build(); .build();
@@ -96,6 +98,7 @@ class PersonServiceTest {
verify(repository).findAllById(Set.of(1)); verify(repository).findAllById(Set.of(1));
verify(repository).findAllById(Set.of(2)); verify(repository).findAllById(Set.of(2));
verify(repository).saveAll(Set.of(alice, bob)); verify(repository).saveAll(Set.of(alice, bob));
verify(repository).findAllById(Set.of(1, 2)); // For cache update
verifyNoMoreInteractions(repository); verifyNoMoreInteractions(repository);
} }
@@ -129,4 +132,46 @@ class PersonServiceTest {
return repository; 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);
}
} }