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
+3 -1
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/prefixed-pdo.php';
function app_db_pdo(): PDO
{
@@ -23,11 +24,12 @@ function app_db_pdo(): PDO
}
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
$pdo = new PDO($dsn, $user, $password ?? '', [
$pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
]);
$pdo->setTablePrefix(app_db_table_prefix());
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* Vollständiger Katalog aller Tabellen, die zur Kaffeeliste gehören. Die
* zentrale SQL-Umschreibung beschränkt sich bewusst auf diese feste Liste;
* ein vom Request gelieferter Bezeichner kann dadurch nie in SQL gelangen.
*
* @return list<string>
*/
function app_database_table_names(): array
{
return [
'schema_migrations',
'CoffeeSurveyVotedEmails',
'CoffeeSurveyResponses',
'dev_baseline_metadata',
'kl_Kaffeeverbrauch',
'kl_Einzahlungen',
'kl_Mitarbeiter',
'kl_hinweise',
'kl_config',
'payment_import_batches',
'payment_import_rows',
'stripe_webhook_events',
'rate_limit_attempts',
'tenant_memberships',
'legal_acceptances',
'outbound_emails',
'user_auth_tokens',
'tenant_settings',
'platform_admins',
'tenant_features',
'paypal_payments',
'ledger_entries',
'legal_requests',
'tenant_domains',
'tenant_billing',
'faq_entries',
'participants',
'audit_log',
'notices',
'tenants',
'users',
];
}
function app_validate_db_table_prefix(string $prefix): string
{
$prefix = trim($prefix);
if ($prefix === '') {
return '';
}
if (strlen($prefix) > 16 || preg_match('/^[a-z][a-z0-9_]*_$/', $prefix) !== 1) {
throw new RuntimeException(
'DB_TABLE_PREFIX muss leer sein oder aus höchstens 16 Kleinbuchstaben, Ziffern und Unterstrichen bestehen und mit Unterstrich enden.'
);
}
return $prefix;
}
function app_db_table_prefix(): string
{
$prefix = app_validate_db_table_prefix((string)app_env('DB_TABLE_PREFIX', ''));
if ($prefix === '' && !app_is_dev()) {
throw new RuntimeException('DB_TABLE_PREFIX darf außerhalb der Entwicklungsumgebung nicht leer sein.');
}
return $prefix;
}
function app_db_table_name(string $baseName, ?string $prefix = null): string
{
if (!in_array($baseName, app_database_table_names(), true)) {
throw new InvalidArgumentException('Unbekannte Anwendungstabelle: ' . $baseName);
}
return app_validate_db_table_prefix($prefix ?? app_db_table_prefix()) . $baseName;
}
/**
* Schreibt ausschließlich bekannte Tabellen- und FK-Namen um. Das gilt auch
* innerhalb der dynamischen DDL-Strings in den Migrationen 0003/0008 und für
* deren INFORMATION_SCHEMA-Vergleiche.
*/
function app_prefix_database_sql(string $sql, ?string $prefix = null): string
{
$prefix = app_validate_db_table_prefix($prefix ?? app_db_table_prefix());
if ($prefix === '') {
return $sql;
}
static $tablePattern = null;
if ($tablePattern === null) {
$tables = app_database_table_names();
usort($tables, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
$tablePattern = '/(?<![A-Za-z0-9_])('
. implode('|', array_map(static fn(string $table): string => preg_quote($table, '/'), $tables))
. ')(?![A-Za-z0-9_])/i';
}
$sql = preg_replace_callback(
$tablePattern,
static fn(array $match): string => $prefix . $match[0],
$sql
) ?? $sql;
// MySQL verlangt innerhalb einer Datenbank eindeutige Namen für
// Fremdschlüssel-Constraints. Die Migrationen verwenden durchgängig
// sprechende fk_*-Namen; sie brauchen daher ebenfalls den Umgebungs-Prefix.
$sql = preg_replace_callback(
'/(?<![A-Za-z0-9_])(fk_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/i',
static fn(array $match): string => $prefix . $match[1],
$sql
) ?? $sql;
return $sql;
}
final class AppPrefixedPDO extends PDO
{
private string $tablePrefix = '';
public function setTablePrefix(string $prefix): void
{
$this->tablePrefix = app_validate_db_table_prefix($prefix);
}
public function tablePrefix(): string
{
return $this->tablePrefix;
}
public function prepare(string $query, array $options = []): PDOStatement|false
{
return parent::prepare(app_prefix_database_sql($query, $this->tablePrefix), $options);
}
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
{
$query = app_prefix_database_sql($query, $this->tablePrefix);
if ($fetchMode === null) {
return parent::query($query);
}
return parent::query($query, $fetchMode, ...$fetchModeArgs);
}
public function exec(string $statement): int|false
{
return parent::exec(app_prefix_database_sql($statement, $this->tablePrefix));
}
}