MigrationRunner.php
Pfad: src/Infrastructure/Persistence/MigrationRunner.php
Ext: php
Größe: 6418 Bytes
Geändert: 2026-07-16T16:59:35+02:00
Frühere Version vom 2026-07-16T16:59:35+02:00 · zur aktuellen Fassung
<?php
declare(strict_types=1);
namespace Demo\Infrastructure\Persistence;
use PDO;
use RuntimeException;
use Throwable;
/**
* Fuehrt versionierte SQL-Migrationen checksum-basiert und exklusiv aus.
*/
final class MigrationRunner
{
/** @var list<int> Durch den vollstaendigen Systemseed ersetzte Migrationen. */
private const SUPERSEDED = [3, 6];
/** Bindet Runner an DB, Verzeichnis und Audit-Akteur. */
public function __construct(private readonly PDO $pdo, private readonly string $directory, private readonly string $actor)
{
}
/**
* Fuehrt offene Migrationen aus oder baselined einen belegten Bestand.
*
* @return list<array{version:int,file:string,status:string}>
*/
public function run(?int $baselineUntil = null): array
{
$this->ensureTrackingTable();
$this->lock();
try {
$files = $this->files();
$known = $this->known();
$this->validateBaseline($baselineUntil, $known);
$result = [];
foreach ($files as $version => $path) {
$checksum = hash_file('sha256', $path);
if ($checksum === false) {
throw new RuntimeException('Migration kann nicht gelesen werden: ' . $path);
}
if (isset($known[$version])) {
if ($known[$version] !== $checksum) {
throw new RuntimeException('Checksum-Abweichung bei Migration ' . $version);
}
continue;
}
$status = $this->status($version, $baselineUntil);
if ($status === 'APPLIED') {
$sql = file_get_contents($path);
if ($sql === false) {
throw new RuntimeException('Migration kann nicht geladen werden');
}
$this->apply($version, basename($path), $checksum, $sql, $status);
} else {
$this->record($version, basename($path), $checksum, $status);
}
$result[] = ['version' => $version, 'file' => basename($path), 'status' => $status];
}
return $result;
} finally {
$this->unlock();
}
}
/** Legt die Tracking-Tabelle idempotent vor der ersten Migration an. */
private function ensureTrackingTable(): void
{
$this->pdo->exec("CREATE TABLE IF NOT EXISTS public.schema_migration (version integer PRIMARY KEY, file_name text NOT NULL, checksum char(64) NOT NULL, status text NOT NULL CHECK (status IN ('APPLIED','BASELINED','SUPERSEDED')), applied_at timestamptz NOT NULL DEFAULT clock_timestamp(), actor text NOT NULL)");
}
/** @return array<int,string> Sortierte Version-Pfad-Zuordnung. */
private function files(): array
{
$paths = glob(rtrim($this->directory, '/') . '/*.sql') ?: [];
$files = [];
foreach ($paths as $path) {
if (preg_match('/\/(\d{4})_[^\/]+\.sql$/', $path, $match) !== 1) {
throw new RuntimeException('Ungueltiger Migrationsname: ' . $path);
}
$files[(int) $match[1]] = $path;
}
ksort($files);
return $files;
}
/** @return array<int,string> Bekannte Checksummen nach Version. */
private function known(): array
{
$known = [];
foreach ($this->pdo->query('SELECT version, checksum FROM public.schema_migration') as $row) {
$known[(int) $row['version']] = (string) $row['checksum'];
}
return $known;
}
/** Prueft, dass Baseline nur einmal und nur auf existierendem Bestand erfolgt. */
private function validateBaseline(?int $until, array $known): void
{
if ($until === null) {
return;
}
$exists = (bool) $this->pdo->query("SELECT to_regclass('public.dokumentation') IS NOT NULL")->fetchColumn();
if ($known !== [] || !$exists) {
throw new RuntimeException('Baseline ist nur fuer ungetrackten bestehenden DB-Stand erlaubt');
}
}
/** Ermittelt den auszufuehrenden Trackingstatus. */
private function status(int $version, ?int $baselineUntil): string
{
if ($baselineUntil !== null && $version <= $baselineUntil) {
return 'BASELINED';
}
return in_array($version, self::SUPERSEDED, true) ? 'SUPERSEDED' : 'APPLIED';
}
/** Fuehrt eine Migration samt Tracking atomar aus. */
private function apply(int $version, string $file, string $checksum, string $sql, string $status): void
{
$this->pdo->beginTransaction();
try {
$this->pdo->exec("SET LOCAL app.actor = " . $this->pdo->quote($this->actor));
$this->pdo->exec($sql);
$this->insert($version, $file, $checksum, $status);
$this->pdo->commit();
} catch (Throwable $error) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $error;
}
}
/** Schreibt einen nicht ausgefuehrten Baseline-/Superseded-Eintrag. */
private function record(int $version, string $file, string $checksum, string $status): void
{
$this->pdo->beginTransaction();
try {
$this->insert($version, $file, $checksum, $status);
$this->pdo->commit();
} catch (Throwable $error) {
$this->pdo->rollBack();
throw $error;
}
}
/** Persistiert eine abgeschlossene Version vorbereitet. */
private function insert(int $version, string $file, string $checksum, string $status): void
{
$statement = $this->pdo->prepare('INSERT INTO public.schema_migration(version,file_name,checksum,status,actor) VALUES(:version,:file,:checksum,:status,:actor)');
$statement->execute(['version' => $version, 'file' => $file, 'checksum' => $checksum, 'status' => $status, 'actor' => $this->actor]);
}
/** Setzt den sessionweiten Migrationslock. */
private function lock(): void { $this->pdo->query("SELECT pg_advisory_lock(hashtext('demo.karlkratz.com:migrations'))"); }
/** Gibt den sessionweiten Migrationslock frei. */
private function unlock(): void { $this->pdo->query("SELECT pg_advisory_unlock(hashtext('demo.karlkratz.com:migrations'))"); }
}