feat(importing): rebuild importer as modular canonical loaders (Phase 3, #669) #674
@@ -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