feat(person): V76 migration — birth/death year to date + precision columns

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 17:47:29 +02:00
committed by marcel
parent f408f60631
commit ba7e8ca6f5
2 changed files with 286 additions and 0 deletions

View File

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

View File

@@ -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.
*
* <p>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());
}
}