*/ 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', 'totp_recovery_codes', 'public_page_views', '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 = '/(? 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( '/(? $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)); } }