From ba7e8ca6f5ac81c66b013b4771fa57fec37162c5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 17:47:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(person):=20V76=20migration=20=E2=80=94=20b?= =?UTF-8?q?irth/death=20year=20to=20date=20+=20precision=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-check aborts on corrupt year data, backfills YYYY-01-01/YEAR, adds five named CHECK constraints, drops birth_year/death_year. Staged-Flyway Testcontainers test covers pre-check aborts, backfill shapes, and post-migration schema. Co-Authored-By: Claude Fable 5 --- .../V76__person_birth_death_to_localdate.sql | 42 +++ .../person/PersonBirthDeathMigrationTest.java | 244 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java diff --git a/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql b/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql new file mode 100644 index 00000000..c8924d96 --- /dev/null +++ b/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql @@ -0,0 +1,42 @@ +-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date) +-- plus NOT NULL precision columns mirroring documents.meta_date_precision. +-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039). +-- One-way migration: rollback is a targeted pg_restore -t persons from the +-- pre-deploy backup (see docs/DEPLOYMENT.md). + +-- Pre-check (data quality gate — not a race guard): abort on corrupt year data +-- before any DDL runs. Single-writer family archive, so no race window matters. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year) + THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating', + (SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year); + END IF; + IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0) + THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating'; + END IF; +END $$; + +ALTER TABLE persons ADD COLUMN birth_date date; +ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; +ALTER TABLE persons ADD COLUMN death_date date; +ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; + +UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR' + WHERE birth_year IS NOT NULL; +UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR' + WHERE death_year IS NOT NULL; + +-- Named constraints: readable Postgres error messages when violated. +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death + CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date); +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence + CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values + CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence + CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values + CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); + +ALTER TABLE persons DROP COLUMN birth_year; +ALTER TABLE persons DROP COLUMN death_year; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java new file mode 100644 index 00000000..15a67a5c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java @@ -0,0 +1,244 @@ +package org.raddatz.familienarchiv.person; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies V76: persons.birth_year/death_year (integer) become + * birth_date/death_date (date) + *_date_precision columns, with backfill to + * YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality + * pre-check that aborts the migration on corrupt year data. + * + *

Runs Flyway programmatically (no Spring context): each test gets its own + * database so the staged migrate-to-V75 → seed → migrate-to-latest flow and + * the abort cases cannot interfere with each other. + */ +class PersonBirthDeathMigrationTest { + + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private String dbUrl; + + @BeforeAll + static void startContainer() { + POSTGRES.start(); + } + + @AfterAll + static void stopContainer() { + POSTGRES.stop(); + } + + @BeforeEach + void createFreshDatabase() throws SQLException { + String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet(); + try (Connection conn = DriverManager.getConnection( + baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword()); + Statement stmt = conn.createStatement()) { + stmt.execute("CREATE DATABASE " + dbName); + } + dbUrl = baseUrl(dbName); + } + + @Test + void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException { + migrateTo("75"); + seedPerson("Corrupt", 1950, 1940); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V76 aborted") + .hasMessageContaining("birth_year > death_year"); + } + + @Test + void precheck_abortsWhenYearZeroPresent() throws SQLException { + migrateTo("75"); + seedPerson("Zero", 0, null); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V76 aborted") + .hasMessageContaining("birth_year=0 or death_year=0"); + } + + @Test + void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException { + migrateTo("75"); + seedPerson("BirthOnly", 1901, null); + + migrateToLatest(); + + LifeDates row = lifeDates("BirthOnly"); + assertThat(row.birthDate()).hasToString("1901-01-01"); + assertThat(row.birthPrecision()).isEqualTo("YEAR"); + assertThat(row.deathDate()).isNull(); + assertThat(row.deathPrecision()).isEqualTo("UNKNOWN"); + } + + @Test + void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException { + migrateTo("75"); + seedPerson("DeathOnly", null, 1944); + + migrateToLatest(); + + LifeDates row = lifeDates("DeathOnly"); + assertThat(row.birthDate()).isNull(); + assertThat(row.birthPrecision()).isEqualTo("UNKNOWN"); + assertThat(row.deathDate()).hasToString("1944-01-01"); + assertThat(row.deathPrecision()).isEqualTo("YEAR"); + } + + @Test + void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException { + migrateTo("75"); + seedPerson("NoDates", null, null); + + migrateToLatest(); + + LifeDates row = lifeDates("NoDates"); + assertThat(row.birthDate()).isNull(); + assertThat(row.birthPrecision()).isEqualTo("UNKNOWN"); + assertThat(row.deathDate()).isNull(); + assertThat(row.deathPrecision()).isEqualTo("UNKNOWN"); + } + + @Test + void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException { + migrateTo("75"); + seedPerson("SameYear", 1901, 1901); + seedPerson("Ordered", 1899, 1972); + + migrateToLatest(); + + assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date")) + .isZero(); + } + + @Test + void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException { + migrateTo("75"); + seedPerson("Schema", 1901, 1944); + + migrateToLatest(); + + assertThat(columnExists("birth_year")).isFalse(); + assertThat(columnExists("death_year")).isFalse(); + assertThat(columnExists("birth_date")).isTrue(); + assertThat(columnExists("death_date")).isTrue(); + for (String constraint : new String[]{ + "chk_person_birth_before_death", + "chk_person_birth_date_precision_coherence", + "chk_person_birth_date_precision_values", + "chk_person_death_date_precision_coherence", + "chk_person_death_date_precision_values"}) { + assertThat(constraintExists(constraint)).as(constraint).isTrue(); + } + } + + // --- helpers --- + + private static String baseUrl(String dbName) { + return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName; + } + + private void migrateTo(String targetVersion) { + flywayBuilder().target(targetVersion).load().migrate(); + } + + private void migrateToLatest() { + flywayBuilder().load().migrate(); + } + + private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() { + return Flyway.configure() + .dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .placeholders(Map.of("grafanaDbPassword", "test-only")); + } + + private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) " + + "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) { + stmt.setString(1, lastName); + stmt.setObject(2, birthYear); + stmt.setObject(3, deathYear); + stmt.executeUpdate(); + } + } + + private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {} + + private LifeDates lifeDates(String lastName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT birth_date, birth_date_precision, death_date, death_date_precision " + + "FROM persons WHERE last_name = ?")) { + stmt.setString(1, lastName); + try (ResultSet rs = stmt.executeQuery()) { + assertThat(rs.next()).as("person %s exists", lastName).isTrue(); + return new LifeDates( + rs.getObject("birth_date"), + rs.getString("birth_date_precision"), + rs.getObject("death_date"), + rs.getString("death_date_precision")); + } + } + } + + private long countWhere(String condition) throws SQLException { + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) { + rs.next(); + return rs.getLong(1); + } + } + + private boolean columnExists(String columnName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) { + stmt.setString(1, columnName); + try (ResultSet rs = stmt.executeQuery()) { + rs.next(); + return rs.getInt(1) > 0; + } + } + } + + private boolean constraintExists(String constraintName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) { + stmt.setString(1, constraintName); + try (ResultSet rs = stmt.executeQuery()) { + rs.next(); + return rs.getInt(1) > 0; + } + } + } + + private Connection connect() throws SQLException { + return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword()); + } +}