← Code-Übersicht

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'))"); }
}