feat(importing): add PersonTreeImporter loader
Third canonical loader. Reads canonical-persons-tree.json, upserts tree persons via PersonService keyed on the shared personId slug (#670 now emits it into the tree, so the tree reconciles with the register rather than duplicating it). Relationships are resolved from local rowIds to the upserted person UUIDs and created via RelationshipService (never the repository). A duplicate/circular relationship on re-import is swallowed for idempotency; unresolved rowIds are skipped with a warning. Refs #669 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code canonical-persons-tree.json} into the person + relationship domains.
|
||||||
|
* Tree persons are upserted via {@link PersonService} keyed on the shared
|
||||||
|
* {@code personId} slug (which Phase 1 #670 now emits into the tree), so they reconcile
|
||||||
|
* with the register rather than duplicating it. Relationships reference persons by the
|
||||||
|
* tree's local {@code rowId}; each side is mapped to the upserted person's UUID and
|
||||||
|
* created through {@link RelationshipService} (never the relationship repository —
|
||||||
|
* layering rule). A duplicate relationship on re-import is swallowed for idempotency.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PersonTreeImporter {
|
||||||
|
|
||||||
|
private final PersonService personService;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public int load(File artifact) {
|
||||||
|
JsonNode root = readTree(artifact);
|
||||||
|
Map<String, UUID> idByRowId = upsertPersons(root.path("persons"));
|
||||||
|
int relationships = createRelationships(root.path("relationships"), idByRowId);
|
||||||
|
log.info("Imported {} tree persons and {} relationships from {}",
|
||||||
|
idByRowId.size(), relationships, artifact.getName());
|
||||||
|
return idByRowId.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readTree(File artifact) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(artifact);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||||
|
"Unreadable canonical artifact: " + artifact.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, UUID> upsertPersons(JsonNode persons) {
|
||||||
|
Map<String, UUID> idByRowId = new HashMap<>();
|
||||||
|
for (JsonNode node : persons) {
|
||||||
|
String personId = text(node, "personId");
|
||||||
|
if (personId.isBlank()) continue;
|
||||||
|
Person person = personService.upsertBySourceRef(toCommand(node, personId));
|
||||||
|
idByRowId.put(text(node, "rowId"), person.getId());
|
||||||
|
}
|
||||||
|
return idByRowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonUpsertCommand toCommand(JsonNode node, String personId) {
|
||||||
|
return PersonUpsertCommand.builder()
|
||||||
|
.sourceRef(personId)
|
||||||
|
.lastName(blankToNull(text(node, "lastName")))
|
||||||
|
.firstName(blankToNull(text(node, "firstName")))
|
||||||
|
.maidenName(blankToNull(text(node, "maidenName")))
|
||||||
|
.notes(blankToNull(text(node, "notes")))
|
||||||
|
.birthYear(intOrNull(node, "birthYear"))
|
||||||
|
.deathYear(intOrNull(node, "deathYear"))
|
||||||
|
.familyMember(node.path("familyMember").asBoolean(false))
|
||||||
|
.personType(PersonType.PERSON)
|
||||||
|
.provisional(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||||
|
int created = 0;
|
||||||
|
for (JsonNode node : relationships) {
|
||||||
|
UUID person = idByRowId.get(text(node, "personId"));
|
||||||
|
UUID related = idByRowId.get(text(node, "relatedPersonId"));
|
||||||
|
if (person == null || related == null) {
|
||||||
|
log.warn("Skipping tree relationship with unresolved rowId: {} -> {}",
|
||||||
|
text(node, "personId"), text(node, "relatedPersonId"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (addRelationshipIdempotently(person, related, text(node, "type"))) {
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
|
try {
|
||||||
|
relationshipService.addRelationship(person,
|
||||||
|
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
||||||
|
return true;
|
||||||
|
} catch (DomainException e) {
|
||||||
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|| e.getCode() == ErrorCode.CIRCULAR_RELATIONSHIP) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? "" : value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intOrNull(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PersonTreeImporterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_upsertsTreePersonBySourceRef_withFamilyMemberFlag(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_002","firstName":"Elsgard","lastName":"Allemeyer","maidenName":"Wöhler",
|
||||||
|
"notes":"Nichte","birthYear":1920,"deathYear":1999,"familyMember":true,"personId":"allemeyer-elsgard"}
|
||||||
|
],"relationships":[]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper())
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
PersonUpsertCommand cmd = captor.getValue();
|
||||||
|
assertThat(cmd.sourceRef()).isEqualTo("allemeyer-elsgard");
|
||||||
|
assertThat(cmd.familyMember()).isTrue();
|
||||||
|
assertThat(cmd.provisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_createsRelationship_resolvingRowIdsToUpsertedPersons(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
UUID idA = UUID.randomUUID();
|
||||||
|
UUID idB = UUID.randomUUID();
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> {
|
||||||
|
PersonUpsertCommand c = inv.getArgument(0);
|
||||||
|
return Person.builder().id(c.sourceRef().equals("a") ? idA : idB)
|
||||||
|
.sourceRef(c.sourceRef()).lastName(c.lastName()).build();
|
||||||
|
});
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"},
|
||||||
|
{"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper())
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
||||||
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_swallowsDuplicateRelationship_forIdempotentReimport(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any()))
|
||||||
|
.thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
doThrow(DomainException.conflict(ErrorCode.DUPLICATE_RELATIONSHIP, "exists"))
|
||||||
|
.when(relationshipService).addRelationship(any(), any());
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"},
|
||||||
|
{"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
PersonTreeImporter importer = new PersonTreeImporter(personService, relationshipService,
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
|
|
||||||
|
// Must not propagate the conflict — re-import is idempotent.
|
||||||
|
importer.load(json.toFile());
|
||||||
|
|
||||||
|
verify(relationshipService).addRelationship(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsRelationship_whenRowIdUnresolved(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_ghost","type":"SPOUSE_OF","source":"x"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService, new com.fasterxml.jackson.databind.ObjectMapper())
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path write(Path dir, String json) throws Exception {
|
||||||
|
Path file = dir.resolve("canonical-persons-tree.json");
|
||||||
|
Files.writeString(file, json);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user