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));
}
}
+8 -1
View File
@@ -36,6 +36,11 @@ Wichtige Punkte:
- `--single-transaction` sorgt für einen konsistenten Snapshot ohne
Tabellen zu sperren (InnoDB, wie hier durchgängig verwendet).
- Da `test_` und `prod_` in derselben physischen Datenbank liegen, enthält
der vollständige Dump beide logisch getrennten Schemas. Beim Restore darf
nicht nur ein einzelner Prefix zurückgespielt werden, ohne Abhängigkeiten
und den jeweiligen Stand von `test_schema_migrations` beziehungsweise
`prod_schema_migrations` zu prüfen.
- Der Backup-Ordner muss **außerhalb** des über HTTP erreichbaren Webroots
liegen, sonst wären Dumps öffentlich abrufbar.
- Zusätzlich zu lokalen Backups auf dem Webspace mindestens eine Kopie an
@@ -55,7 +60,9 @@ gunzip -c kaffeeliste_20260715_030000.sql.gz | mysql -h "$DB_HOST" -u "$DB_USER"
Danach `php scripts/migrate.php` laufen lassen, falls das Backup älter als
die zuletzt eingespielten Migrationen ist (das Skript ist idempotent und
wendet nur fehlende Migrationen an).
wendet nur fehlende Migrationen im konfigurierten `DB_TABLE_PREFIX` an).
Den Lauf daher mit der Konfiguration jeder wiederhergestellten Umgebung
separat ausführen.
**Restore-Test:** Mindestens einmal pro Quartal einen echten Restore gegen
eine separate Test-Datenbank durchführen und die App dagegen starten
+41 -14
View File
@@ -9,14 +9,22 @@ Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
## Umgebungen
| Umgebung | Host | Webroot | Zweck |
| --- | --- | --- | --- |
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | Echtbetrieb |
| Umgebung | Host | Webroot | Tabellen-Prefix | Zweck |
| --- | --- | --- | --- | --- |
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | `test_` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | `prod_` | Echtbetrieb |
Beide Umgebungen brauchen **eigene Datenbanken** und **eigene**
`env.local.php`. Die Staging-Umgebung läuft mit Stripe-Testkeys, die
Produktion mit Live-Keys.
Beide Umgebungen verwenden dieselbe physische Datenbank, aber zwingend
unterschiedliche Tabellen-Prefixe und jeweils eine eigene `env.local.php`.
Damit sind Daten, Migrationstabellen und Fremdschlüssel von Staging und
Produktion logisch getrennt. Die Staging-Umgebung läuft mit Stripe-Testkeys,
die Produktion mit Live-Keys.
Der Prefix schützt vor versehentlichem Zugriff der einen Umgebung auf die
Tabellen der anderen, ersetzt aber keine getrennte Datenbank als harte
Sicherheitsgrenze: Ein kompromittierter gemeinsamer Datenbankzugang kann
weiterhin alle Tabellen sehen. Deshalb Zugangsdaten besonders restriktiv
behandeln und immer die gesamte Datenbank sichern.
### Host-Split beachten
@@ -80,12 +88,13 @@ Die temporäre `phpinfo()`-Datei danach wieder löschen.
### 2. Datenbank anlegen
In Plesk eine leere MySQL-Datenbank samt eigenem Benutzer anlegen. Die
Zugangsdaten werden gleich in `env.local.php` eingetragen.
In Plesk eine MySQL-Datenbank samt Benutzer anlegen. Beide Umgebungen dürfen
dieselben Zugangsdaten verwenden; die Trennung erfolgt über
`DB_TABLE_PREFIX=test_` beziehungsweise `DB_TABLE_PREFIX=prod_`.
Die vorhandene Entwicklungsdatenbank **nicht** übernehmen: sie enthält
ausschließlich Testdaten inklusive des migrierten Default-Mandanten aus
der Legacy-App.
Unpräfixierte vorhandene Testdaten dürfen ausschließlich mit dem dafür
vorgesehenen Übernahmeskript nach `test_` kopiert werden. Sie dürfen niemals
nach `prod_` übernommen werden.
### 3. Dateien hochladen
@@ -121,12 +130,16 @@ putenv('APP_TIMEZONE=Europe/Berlin');
putenv('APP_DB_DRIVER=mysql');
putenv('DB_HOST=localhost');
putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…');
putenv('DB_TABLE_PREFIX=test_');
putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions');
putenv('APP_MAIL_TRANSPORT=mail');
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');
```
Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten).
In der produktiven `env.local.php` muss stattdessen
`DB_TABLE_PREFIX=prod_` stehen. Ein leerer Prefix ist im laufenden Betrieb
nicht zulässig.
### 5. Schreibrechte für `var/`
@@ -139,7 +152,21 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt.
Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
fehlende Migrationen an.
fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der
jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und
einmal in Produktion ausgeführt werden.
Für die einmalige Übernahme vorhandener, bisher unpräfixierter Testdaten:
```bash
php scripts/migrate-table-prefix.php --target-prefix=test_ --replace
```
Das Skript migriert zuerst das `test_`-Schema, ersetzt dessen Inhalt
transaktional durch eine exakte Kopie der unpräfixierten Daten und vergleicht
pro Tabelle die Zeilenzahl. Die unpräfixierten Quelltabellen bleiben als
Rückfallmöglichkeit unverändert. Vor dem Lauf trotzdem ein vollständiges
Datenbank-Backup erstellen.
Ohne SSH-Zugang läuft das über **Plesk → Geplante Aufgaben (Scheduled
Tasks)** als einmalig ausgeführter Task vom Typ *PHP-Skript ausführen*:
@@ -348,7 +375,7 @@ Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration.
ein Vorlauf mit `--dry-run` wurde kontrolliert
- [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist
eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert
- [ ] Frische Produktivdatenbank ohne Testdaten
- [ ] Frisches `prod_`-Schema ohne Testdaten; Staging verwendet ausschließlich `test_`
## Bewusst offen
+4 -1
View File
@@ -44,6 +44,7 @@ export DB_PORT="3306"
export DB_NAME="example_database"
export DB_USER="example_user"
export DB_PASS="example_password"
export DB_TABLE_PREFIX="test_"
export DEV_AUTH_EMAIL="admin@test.local"
export DEV_AUTH_NAME="Test Admin"
# Optional: Standard ist var/sessions im Repository.
@@ -95,7 +96,9 @@ der Port über die Ports-Ansicht weitergeleitet werden.
Nachrichten unter `var/mail` ab. Für Produktion kann zunächst
`APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt
werden; eine SMTP-/Provider-Anbindung bleibt ein späterer Betriebsausbau.
- Die Tabellen werden über `database/migrations/` angelegt.
- Die Tabellen werden über `database/migrations/` mit dem Prefix aus
`DB_TABLE_PREFIX` angelegt. Lokal und auf Staging ist das `test_`, in
Produktion ausschließlich `prod_`.
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
erhalten; der aktive Weg ist `scripts/migrate.php`.
- Session-Dateien liegen standardmäßig unter `var/sessions`; `var/` wird von
+2 -2
View File
@@ -111,8 +111,8 @@ LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu:$PWD/.local/php/usr/li
```
Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
`DB_USER` und `DB_PASS`. `scripts/init-mysql-dev.php` braucht zusätzlich
`DEV_AUTH_EMAIL`.
`DB_USER`, `DB_PASS` und `DB_TABLE_PREFIX`. `scripts/init-mysql-dev.php`
braucht zusätzlich `DEV_AUTH_EMAIL`.
## Aktueller Prüfstatus
+3
View File
@@ -36,6 +36,9 @@ putenv('DB_PORT=3306');
putenv('DB_NAME=');
putenv('DB_USER=');
putenv('DB_PASS=');
// Bei gemeinsamer physischer Datenbank zwingend pro Umgebung verschieden:
// Produktion `prod_`, Test/Staging `test_`. Der Prefix endet immer auf `_`.
putenv('DB_TABLE_PREFIX=prod_');
// Muss ausserhalb des oeffentlichen Webroots oder zumindest per .htaccess
// gesperrt sein - siehe Deployment-Hinweise.
+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";