diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java index 993480c4..84058e2f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java @@ -52,6 +52,13 @@ public class Person { private Integer birthYear; private Integer deathYear; + // Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest). + // Nullable for persons outside the curated family graph. Drives the + // Stammbaum strict-rank seed (see #689) and re-import preserves human + // edits via PersonService.preferHuman (ADR-025). + @Column(name = "generation") + private Integer generation; + @Column(name = "family_member", nullable = false) @Builder.Default @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/resources/db/migration/V70__add_generation_to_persons.sql b/backend/src/main/resources/db/migration/V70__add_generation_to_persons.sql new file mode 100644 index 00000000..acb39b30 --- /dev/null +++ b/backend/src/main/resources/db/migration/V70__add_generation_to_persons.sql @@ -0,0 +1,24 @@ +-- #689: persist the hand-curated "G 0…G 5" generation index from +-- canonical-persons.xlsx so the Stammbaum layout can use it as a strict +-- rank anchor (replacing the current iterative longest-path heuristic that +-- silently misplaces loose spouses with their own parents in the graph). +-- +-- Nullable: pre-import rows and persons outside the curated family graph +-- legitimately have no generation. The canonical importer back-fills via +-- preferHuman on the next run; a human-edited value is never overwritten +-- (see ADR-025). + +ALTER TABLE persons ADD COLUMN generation SMALLINT; + +-- Allowlist of valid generation indices. The upper bound is intentionally +-- loose — current data tops out at G 5, but a future G 6 → G 10 widening +-- needs no migration. A G −1 ancestor would require a separate one-shot +-- shift migration (out of scope here; the layout's normalise step already +-- handles negative seeds at render time). +ALTER TABLE persons ADD CONSTRAINT chk_generation_range + CHECK (generation IS NULL OR generation BETWEEN 0 AND 10); + +-- Partial index: only the curated rows (≈ 163 of 1,105) ever get a value, +-- and the layout only ever queries for non-null rows. +CREATE INDEX idx_persons_generation ON persons (generation) + WHERE generation IS NOT NULL; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 910e701e..add05ed7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -672,4 +672,39 @@ class PersonRepositoryTest { assertThat(slice.get(0).getDocumentCount()).isEqualTo(1); } + + // ─── generation column (#689) ────────────────────────────────────────────── + + @Test + void save_persistsGeneration_andFindByIdReturnsSameGeneration() { + Person person = Person.builder() + .firstName("Walter") + .lastName("Raddatz") + .generation(3) + .build(); + + Person saved = personRepository.save(person); + entityManager.flush(); + entityManager.clear(); + + Optional found = personRepository.findById(saved.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getGeneration()).isEqualTo(3); + } + + @Test + void save_allowsNullGeneration_existingRowsRemainNull() { + Person person = Person.builder() + .firstName("Anonym") + .lastName("Person") + .build(); + + Person saved = personRepository.save(person); + entityManager.flush(); + entityManager.clear(); + + Optional found = personRepository.findById(saved.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getGeneration()).isNull(); + } }