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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user