Produktiv- und Testtabellen per Prefix trennen
This commit is contained in:
@@ -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";
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user