Produktiv- und Testtabellen per Prefix trennen

This commit is contained in:
2026-08-22 15:29:52 +02:00
parent a31a235422
commit 213dd10dbd
11 changed files with 490 additions and 24 deletions
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../app/prefixed-pdo.php';
$assertSame = static function (string $expected, string $actual, string $message): void {
if ($expected !== $actual) {
fwrite(STDERR, "FEHLER: {$message}\nErwartet: {$expected}\nTatsächlich: {$actual}\n");
exit(1);
}
};
$assertSame('', app_validate_db_table_prefix(''), 'Ein leerer Prefix muss für die einmalige Datenübernahme erlaubt sein.');
$assertSame('test_', app_validate_db_table_prefix('test_'), 'Der Test-Prefix muss gültig sein.');
$assertSame('prod_', app_validate_db_table_prefix('prod_'), 'Der Produktions-Prefix muss gültig sein.');
foreach (['Test_', '1test_', 'test', 'test-'] as $invalidPrefix) {
try {
app_validate_db_table_prefix($invalidPrefix);
fwrite(STDERR, "FEHLER: Ungültiger Prefix {$invalidPrefix} wurde akzeptiert.\n");
exit(1);
} catch (RuntimeException) {
// Erwartet.
}
}
$previousAppEnv = getenv('APP_ENV');
$previousDbPrefix = getenv('DB_TABLE_PREFIX');
putenv('APP_ENV=prod');
putenv('DB_TABLE_PREFIX');
try {
app_db_table_prefix();
fwrite(STDERR, "FEHLER: Produktion wurde ohne DB_TABLE_PREFIX akzeptiert.\n");
exit(1);
} catch (RuntimeException) {
// Erwartet: Außerhalb von Dev gilt fail-closed.
} finally {
$previousAppEnv === false ? putenv('APP_ENV') : putenv('APP_ENV=' . $previousAppEnv);
$previousDbPrefix === false ? putenv('DB_TABLE_PREFIX') : putenv('DB_TABLE_PREFIX=' . $previousDbPrefix);
}
$assertSame(
'SELECT * FROM test_users JOIN test_tenants ON test_tenants.id = test_users.tenant_id',
app_prefix_database_sql('SELECT * FROM users JOIN tenants ON tenants.id = users.tenant_id', 'test_'),
'Normale Laufzeit-Abfragen müssen vollständig umgeschrieben werden.'
);
$assertSame(
'SELECT * FROM test_users',
app_prefix_database_sql('SELECT * FROM test_users', 'test_'),
'Bereits umgeschriebene Tabellennamen dürfen keinen doppelten Prefix erhalten.'
);
$assertSame(
"ALTER TABLE test_users ADD CONSTRAINT test_fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES test_tenants(id)",
app_prefix_database_sql(
'ALTER TABLE users ADD CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)',
'test_'
),
'Tabellen und Fremdschlüssel müssen gemeinsam umgeschrieben werden.'
);
$assertSame(
"WHERE TABLE_NAME = 'test_users' AND CONSTRAINT_NAME = 'test_fk_users_tenant'",
app_prefix_database_sql("WHERE TABLE_NAME = 'users' AND CONSTRAINT_NAME = 'fk_users_tenant'", 'test_'),
'Dynamische Migrationen müssen auch INFORMATION_SCHEMA-Vergleiche umschreiben.'
);
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
if (count($migrationFiles) !== 30) {
fwrite(STDERR, 'FEHLER: Erwartet wurden 30 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
exit(1);
}
$allMigrationSql = '';
foreach ($migrationFiles as $migrationFile) {
$contents = file_get_contents($migrationFile);
if ($contents === false) {
fwrite(STDERR, "FEHLER: Migration {$migrationFile} konnte nicht gelesen werden.\n");
exit(1);
}
$allMigrationSql .= "\n" . $contents;
}
$prefixedMigrationSql = app_prefix_database_sql($allMigrationSql, 'test_');
foreach (app_database_table_names() as $table) {
$rawPattern = '/(?<![A-Za-z0-9_])' . preg_quote($table, '/') . '(?![A-Za-z0-9_])/i';
if (preg_match($rawPattern, $allMigrationSql) === 1 && preg_match($rawPattern, $prefixedMigrationSql) === 1) {
fwrite(STDERR, "FEHLER: Tabelle {$table} blieb in den umgeschriebenen Migrationen ohne Prefix.\n");
exit(1);
}
}
if (preg_match('/(?<![A-Za-z0-9_])fk_[A-Za-z0-9_]+(?![A-Za-z0-9_])/i', $prefixedMigrationSql) === 1) {
fwrite(STDERR, "FEHLER: Mindestens ein Fremdschlüssel blieb in den Migrationen ohne Prefix.\n");
exit(1);
}
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 30 Migrationen werden getrennt umgeschrieben.\n";
+16 -3
View File
@@ -25,6 +25,16 @@ if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($bas
$errors[] = 'APP_BASE_URL muss eine HTTPS-URL auf APP_HOST sein.';
}
try {
$dbTablePrefix = app_db_table_prefix();
if ($dbTablePrefix === '') {
$errors[] = 'DB_TABLE_PREFIX ist leer; Produktion und Test wären in derselben Datenbank nicht sicher getrennt.';
}
} catch (Throwable $e) {
$dbTablePrefix = '';
$errors[] = 'DB_TABLE_PREFIX ist ungültig: ' . $e->getMessage();
}
$phone = app_legal_phone();
if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) {
$errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.';
@@ -66,10 +76,13 @@ if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCs
try {
$pdo = app_db_pdo();
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
$stmt = $pdo->prepare('SHOW TABLES LIKE ?');
$stmt->execute([$table]);
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$stmt->execute([app_db_table_name($table)]);
if ($stmt->fetchColumn() === false) {
$errors[] = "Datenbanktabelle {$table} fehlt; Migrationen ausführen.";
$errors[] = "Datenbanktabelle " . app_db_table_name($table) . ' fehlt; Migrationen ausführen.';
}
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
+11 -2
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
require_once __DIR__ . '/../app/prefixed-pdo.php';
function dev_required_env(string $name): string
{
@@ -15,7 +16,7 @@ function dev_required_env(string $name): string
return $value;
}
function dev_pdo(): PDO
function dev_pdo_for_prefix(string $prefix): AppPrefixedPDO
{
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
@@ -24,11 +25,19 @@ function dev_pdo(): PDO
dev_required_env('DB_NAME')
);
return new PDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [
$pdo = new AppPrefixedPDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
]);
$pdo->setTablePrefix($prefix);
return $pdo;
}
function dev_pdo(): AppPrefixedPDO
{
return dev_pdo_for_prefix(app_db_table_prefix());
}
function dev_apply_schema(PDO $pdo): void
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$options = getopt('', ['source-prefix:', 'target-prefix:', 'replace']);
$sourcePrefix = app_validate_db_table_prefix((string)($options['source-prefix'] ?? ''));
$targetOption = $options['target-prefix'] ?? null;
if (!is_string($targetOption) || trim($targetOption) === '') {
fwrite(STDERR, "Aufruf: php scripts/migrate-table-prefix.php --target-prefix=test_ --replace [--source-prefix=alt_]\n");
exit(1);
}
$targetPrefix = app_validate_db_table_prefix($targetOption);
if ($sourcePrefix === $targetPrefix) {
fwrite(STDERR, "Quell- und Ziel-Prefix müssen verschieden sein.\n");
exit(1);
}
if (!array_key_exists('replace', $options)) {
fwrite(STDERR, "Abbruch: --replace ist erforderlich, weil vorhandene Ziel-Testdaten ersetzt werden.\n");
exit(1);
}
/** @return string */
$quoteIdentifier = static function (string $name): string {
return '`' . str_replace('`', '``', $name) . '`';
};
$rawPdo = dev_pdo_for_prefix('');
$targetPdo = dev_pdo_for_prefix($targetPrefix);
echo "Zielschema {$targetPrefix} wird auf den aktuellen Migrationsstand gebracht.\n";
$applied = dev_apply_migrations($targetPdo);
echo count($applied) . " neue Migration(en) für {$targetPrefix} angewendet.\n";
$tableExists = static function (PDO $pdo, string $table): bool {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$stmt->execute([$table]);
return $stmt->fetchColumn() !== false;
};
/** @return list<string> */
$tableColumns = static function (PDO $pdo, string $table): array {
$stmt = $pdo->prepare(
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION'
);
$stmt->execute([$table]);
return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
};
$targetsToClear = [];
$copies = [];
$sourceTableCount = 0;
foreach (app_database_table_names() as $baseName) {
if ($baseName === 'schema_migrations') {
continue;
}
$sourceTable = app_db_table_name($baseName, $sourcePrefix);
$targetTable = app_db_table_name($baseName, $targetPrefix);
$sourceExists = $tableExists($rawPdo, $sourceTable);
$targetExists = $tableExists($rawPdo, $targetTable);
if ($sourceExists) {
$sourceTableCount++;
}
if ($sourceExists && !$targetExists) {
throw new RuntimeException("Zieltabelle {$targetTable} fehlt nach den Migrationen.");
}
if (!$targetExists) {
continue;
}
$targetsToClear[] = $targetTable;
if (!$sourceExists) {
continue;
}
$sourceColumns = $tableColumns($rawPdo, $sourceTable);
$targetColumns = $tableColumns($rawPdo, $targetTable);
if ($sourceColumns !== $targetColumns) {
throw new RuntimeException("Spalten von {$sourceTable} und {$targetTable} stimmen nicht überein.");
}
$copies[] = [$sourceTable, $targetTable, $sourceColumns];
}
if ($sourceTableCount === 0) {
throw new RuntimeException('Im Quellschema wurden keine Anwendungstabellen gefunden.');
}
$copiedRows = 0;
$rawPdo->exec('SET FOREIGN_KEY_CHECKS = 0');
try {
$rawPdo->beginTransaction();
foreach ($targetsToClear as $targetTable) {
$rawPdo->exec('DELETE FROM ' . $quoteIdentifier($targetTable));
}
foreach ($copies as [$sourceTable, $targetTable, $columns]) {
$quotedColumns = implode(', ', array_map($quoteIdentifier, $columns));
$sourceRows = (int)$rawPdo
->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($sourceTable))
->fetchColumn();
if ($sourceRows > 0) {
$rawPdo->exec(
'INSERT INTO ' . $quoteIdentifier($targetTable)
. ' (' . $quotedColumns . ') SELECT ' . $quotedColumns
. ' FROM ' . $quoteIdentifier($sourceTable)
);
}
$targetRows = (int)$rawPdo
->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($targetTable))
->fetchColumn();
if ($sourceRows !== $targetRows) {
throw new RuntimeException(
"Zeilenzahl stimmt für {$sourceTable} -> {$targetTable} nicht: {$sourceRows} != {$targetRows}."
);
}
$copiedRows += $targetRows;
echo "{$sourceTable} -> {$targetTable}: {$targetRows} Zeile(n)\n";
}
$rawPdo->commit();
} catch (Throwable $e) {
if ($rawPdo->inTransaction()) {
$rawPdo->rollBack();
}
throw $e;
} finally {
$rawPdo->exec('SET FOREIGN_KEY_CHECKS = 1');
}
echo "Datenübernahme abgeschlossen: {$copiedRows} Zeile(n). Das Quellschema blieb unverändert.\n";