feat(importing): rebuild importer as modular canonical loaders (Phase 3, #669) #674

Merged
marcel merged 19 commits from feature/669-modular-importer into docs/import-migration 2026-05-27 11:34:29 +02:00
2 changed files with 267 additions and 0 deletions
Showing only changes of commit cbf1984430 - Show all commits

View File

@@ -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;
}
}

View File

@@ -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;
}
}