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:
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user