OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

MigrationIdempotencyTest.php

7 KB
<?php

declare(strict_types=1);

/**
 * Wacht über die Wiederholbarkeit der Migrationen.
 *
 * Jede Migration außer der Baseline `001` wird auf aufholenden Instanzen erneut
 * ausgeführt: Sie kommt als Teil eines Checkpoints wieder, sobald eine Instanz
 * sie noch nicht in `_migrations` stehen hat. Ein Statement, das beim zweiten
 * Lauf Daten verdoppelt oder überschreibt, richtet dort Schaden an – deshalb
 * prüft dieser Test die Regeln maschinell, statt sich auf Durchsicht zu
 * verlassen.
 */
return static function (): void {

    $verzeichnis = dirname(__DIR__) . '/database/migrations';

    // Zerlegung mit dem Splitter des Migrationsläufers: Nur so entsprechen die
    // geprüften Statements exakt denen, die später ausgeführt werden – ein
    // naives Trennen an `;` zerschnitte Statements mit Semikolon im Text.
    $appRef   = new ReflectionClass(\OpenNIT\Core\Application::class);
    $appLeer  = $appRef->newInstanceWithoutConstructor();
    $splitter = $appRef->getMethod('splitSqlStatements');
    $splitter->setAccessible(true);

    /** Statements einer Datei, so wie der Migrationsläufer sie sieht. */
    $statements = static function (string $datei) use ($splitter, $appLeer): array {
        return array_values(array_filter(
            array_map('trim', $splitter->invoke($appLeer, (string)file_get_contents($datei))),
            static fn(string $s) => $s !== ''
        ));
    };

    /** Alle Inkremente (alles außer der Baseline 001). */
    $inkremente = static function () use ($verzeichnis): array {
        $dateien = glob($verzeichnis . '/*.sql') ?: [];
        sort($dateien);
        return array_values(array_filter(
            $dateien,
            static fn(string $p) => !str_starts_with(basename($p), '001_')
        ));
    };

    /** Kurzfassung eines Statements für die Fehlermeldung. */
    $kurz = static fn(string $s): string => trim(preg_replace('/\s+/', ' ', substr($s, 0, 90)) ?? '');

    test('Es gibt genau eine Baseline und mindestens ein Inkrement', function () use ($verzeichnis, $inkremente) {
        $baseline = glob($verzeichnis . '/001_*.sql') ?: [];
        assertSame(1, count($baseline), 'Genau eine 001-Baseline erwartet');
        assertTrue(count($inkremente()) > 0, 'Mindestens ein Inkrement erwartet');
    });

    test('Kein Inkrement fügt Daten ohne Duplikatschutz ein', function () use ($inkremente, $statements, $kurz) {
        // Zulässig ist jeder der drei Wege: INSERT IGNORE, ON DUPLICATE KEY
        // UPDATE oder ein SELECT mit NOT EXISTS als Quelle.
        $verstoesse = [];
        foreach ($inkremente() as $datei) {
            foreach ($statements($datei) as $statement) {
                if (!preg_match('/^INSERT\s/i', $statement)) {
                    continue;
                }
                $geschuetzt = preg_match('/^INSERT\s+IGNORE\b/i', $statement)
                    || preg_match('/ON\s+DUPLICATE\s+KEY\s+UPDATE/i', $statement)
                    || preg_match('/\bSELECT\b.*\bNOT\s+EXISTS\s*\(/is', $statement);
                if (!$geschuetzt) {
                    $verstoesse[] = basename($datei) . ': ' . $kurz($statement);
                }
            }
        }
        assertSame([], $verstoesse, "INSERT ohne Duplikatschutz:\n" . implode("\n", $verstoesse));
    });

    test('Kein Inkrement verwendet zerstörende Anweisungen', function () use ($inkremente, $statements, $kurz) {
        $verstoesse = [];
        foreach ($inkremente() as $datei) {
            foreach ($statements($datei) as $statement) {
                $name = basename($datei);
                if (preg_match('/^TRUNCATE\b/i', $statement)) {
                    $verstoesse[] = $name . ': TRUNCATE – ' . $kurz($statement);
                }
                if (preg_match('/\bAUTO_INCREMENT\s*=\s*\d+/i', $statement)
                    && preg_match('/^ALTER\s/i', $statement)) {
                    $verstoesse[] = $name . ': AUTO_INCREMENT-Reset – ' . $kurz($statement);
                }
                // Relative Updates (SET spalte = spalte + 1) sind beim zweiten Lauf nicht wertgleich.
                if (preg_match('/^UPDATE\s/i', $statement)
                    && preg_match('/\bSET\s+`?(\w+)`?\s*=\s*`?\1`?\s*[+\-]/i', $statement)) {
                    $verstoesse[] = $name . ': relatives UPDATE – ' . $kurz($statement);
                }
            }
        }
        assertSame([], $verstoesse, "Zerstörende Anweisungen:\n" . implode("\n", $verstoesse));
    });

    test('Kein Inkrement ändert Daten ohne Bedingung', function () use ($inkremente, $statements, $kurz) {
        // Ein UPDATE über eine ganze Tabelle trifft beim erneuten Lauf auch die
        // Zeilen, die der Betreiber seither gepflegt hat. Eine Bedingung grenzt
        // die Anweisung auf den Zustand ein, für den sie gedacht ist.
        $verstoesse = [];
        foreach ($inkremente() as $datei) {
            foreach ($statements($datei) as $statement) {
                if (!preg_match('/^UPDATE\s/i', $statement)) {
                    continue;
                }
                if (!preg_match('/\bWHERE\b/i', $statement)) {
                    $verstoesse[] = basename($datei) . ': ' . $kurz($statement);
                }
            }
        }
        assertSame([], $verstoesse, "UPDATE ohne WHERE:\n" . implode("\n", $verstoesse));
    });

    test('Kein Inkrement legt Tabellen ohne IF NOT EXISTS an', function () use ($inkremente, $statements) {
        $verstoesse = [];
        foreach ($inkremente() as $datei) {
            foreach ($statements($datei) as $statement) {
                if (preg_match('/^CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)`?(\w+)/i', $statement, $treffer)) {
                    $verstoesse[] = basename($datei) . ': ' . $treffer[1];
                }
            }
        }
        assertSame([], $verstoesse, "CREATE TABLE ohne IF NOT EXISTS:\n" . implode("\n", $verstoesse));
    });

    test('Kein Inkrement nutzt DELIMITER oder mehrzeilige Rümpfe', function () use ($inkremente) {
        // Diese Prüfung braucht den Rohtext: Ein mehrzeiliger Rumpf würde vom
        // Splitter gerade zerlegt und wäre in den Einzelstatements nicht mehr
        // als solcher erkennbar.
        $verstoesse = [];
        foreach ($inkremente() as $datei) {
            $roh = (string)file_get_contents($datei);
            if (preg_match('/^\s*DELIMITER\b/im', $roh)) {
                $verstoesse[] = basename($datei) . ': DELIMITER';
            }
            if (preg_match('/\bCREATE\s+(TRIGGER|PROCEDURE|FUNCTION)\b/i', $roh)
                && preg_match('/\bBEGIN\b[^;]*\n[^;]*\bEND\b/i', $roh)) {
                $verstoesse[] = basename($datei) . ': mehrzeiliger Rumpf';
            }
        }
        assertSame([], $verstoesse, "Splitter-unverträgliche Konstrukte:\n" . implode("\n", $verstoesse));
    });
};