refactor(person): extract shared DatePrecision coherence/normalize validator

PersonService and RelationshipService each carried a verbatim copy of the
date⇔precision coherence check and the null→UNKNOWN precision normalizer.
Hoist both into a single document.DatePrecisionValidation util so the rule
that the V76/V78 CHECK constraints mirror has one source of truth. Each
service keeps its own order check (BIRTH_AFTER_DEATH vs
INVALID_RELATIONSHIP_DATES), which is the only genuinely domain-specific part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 20:15:12 +02:00
parent d9028da941
commit 73a01b1cad
3 changed files with 61 additions and 43 deletions

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import java.time.LocalDate;
/**
* Cross-field validation and normalization shared by every domain that stores a
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
* and must carry no behaviour.
*/
public final class DatePrecisionValidation {
private DatePrecisionValidation() {}
/**
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
* requires a date. Validated in-service so the caller gets a structured 400 instead of
* the database constraint's raw 500.
*
* @param side human-readable field label woven into the error message ("birth", "from", …)
*/
public static void requireCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
public static DatePrecision normalize(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
}

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -448,41 +449,28 @@ public class PersonService {
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
// is shared with the relationship domain (DatePrecisionValidation); only the order check
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate);
}
}
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
@@ -499,9 +487,9 @@ public class PersonService {
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.person.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -120,9 +121,9 @@ public class RelationshipService {
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromDate(dto.fromDate())
.fromDatePrecision(normalizePrecision(dto.fromDatePrecision()))
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
.toDate(dto.toDate())
.toDatePrecision(normalizePrecision(dto.toDatePrecision()))
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
.notes(blankToNull(dto.notes()))
.build();
@@ -178,9 +179,9 @@ public class RelationshipService {
rel.setRelatedPerson(newObject);
rel.setRelationType(dto.relationType());
rel.setFromDate(dto.fromDate());
rel.setFromDatePrecision(normalizePrecision(dto.fromDatePrecision()));
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
rel.setToDate(dto.toDate());
rel.setToDatePrecision(normalizePrecision(dto.toDatePrecision()));
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
rel.setNotes(blankToNull(dto.notes()));
PersonRelationship saved;
@@ -240,31 +241,18 @@ public class RelationshipService {
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
LocalDate toDate, DatePrecision toPrecision) {
requireDatePrecisionCoherence(fromDate, fromPrecision, "from");
requireDatePrecisionCoherence(toDate, toPrecision, "to");
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
"toDate " + toDate + " is before fromDate " + fromDate);
}
}
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
private static RelationshipDTO toDTO(PersonRelationship r) {
Person p = r.getPerson();
Person rp = r.getRelatedPerson();