Quelltext des aktuellen Standes
<?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));
});
};