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.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -448,41 +449,28 @@ public class PersonService {
|
|||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthDate(dto.getBirthDate())
|
.birthDate(dto.getBirthDate())
|
||||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
|
||||||
.deathDate(dto.getDeathDate())
|
.deathDate(dto.getDeathDate())
|
||||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
|
||||||
.generation(dto.getGeneration())
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
// 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,
|
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
|
||||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
|
||||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
"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
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
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.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthDate(dto.getBirthDate());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
|
||||||
person.setDeathDate(dto.getDeathDate());
|
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
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
// which routes through preferHuman, we write the DTO value verbatim.
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
person.setGeneration(dto.getGeneration());
|
person.setGeneration(dto.getGeneration());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -120,9 +121,9 @@ public class RelationshipService {
|
|||||||
.relatedPerson(relatedPerson)
|
.relatedPerson(relatedPerson)
|
||||||
.relationType(dto.relationType())
|
.relationType(dto.relationType())
|
||||||
.fromDate(dto.fromDate())
|
.fromDate(dto.fromDate())
|
||||||
.fromDatePrecision(normalizePrecision(dto.fromDatePrecision()))
|
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
|
||||||
.toDate(dto.toDate())
|
.toDate(dto.toDate())
|
||||||
.toDatePrecision(normalizePrecision(dto.toDatePrecision()))
|
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
|
||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -178,9 +179,9 @@ public class RelationshipService {
|
|||||||
rel.setRelatedPerson(newObject);
|
rel.setRelatedPerson(newObject);
|
||||||
rel.setRelationType(dto.relationType());
|
rel.setRelationType(dto.relationType());
|
||||||
rel.setFromDate(dto.fromDate());
|
rel.setFromDate(dto.fromDate());
|
||||||
rel.setFromDatePrecision(normalizePrecision(dto.fromDatePrecision()));
|
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
|
||||||
rel.setToDate(dto.toDate());
|
rel.setToDate(dto.toDate());
|
||||||
rel.setToDatePrecision(normalizePrecision(dto.toDatePrecision()));
|
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
|
||||||
rel.setNotes(blankToNull(dto.notes()));
|
rel.setNotes(blankToNull(dto.notes()));
|
||||||
|
|
||||||
PersonRelationship saved;
|
PersonRelationship saved;
|
||||||
@@ -240,31 +241,18 @@ public class RelationshipService {
|
|||||||
|
|
||||||
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
// 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.
|
// 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,
|
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
|
||||||
LocalDate toDate, DatePrecision toPrecision) {
|
LocalDate toDate, DatePrecision toPrecision) {
|
||||||
requireDatePrecisionCoherence(fromDate, fromPrecision, "from");
|
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
|
||||||
requireDatePrecisionCoherence(toDate, toPrecision, "to");
|
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
|
||||||
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
||||||
"toDate " + toDate + " is before fromDate " + fromDate);
|
"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) {
|
private static RelationshipDTO toDTO(PersonRelationship r) {
|
||||||
Person p = r.getPerson();
|
Person p = r.getPerson();
|
||||||
Person rp = r.getRelatedPerson();
|
Person rp = r.getRelatedPerson();
|
||||||
|
|||||||
Reference in New Issue
Block a user