24 public function __construct(?
object $db =
null, ?
object $dd =
null)
26 $this->db = $db ??
dbx()->get_system_obj(
'dbxDB');
27 $this->dd = $dd ??
dbx()->get_system_obj(
'dbxDD');
28 if (!is_object($this->db) || !is_object($this->dd)) {
29 throw new RuntimeException(
'dbxDB/dbxDD stehen fuer Migrationen nicht zur Verfuegung.');
36 public function discover(
string $releaseRoot,
string $targetVersion =
''): array
38 $root = realpath($releaseRoot);
40 throw new RuntimeException(
'Releasewurzel fuer DB-Migrationen ist ungueltig.');
43 $migrations = array();
44 $pattern = rtrim(
$root,
'\\/') .
'/dbx/modules/*/migrations/*.migration.php';
45 foreach (glob($pattern) ?: array() as
$file) {
49 if (!is_array($record)) {
50 throw new RuntimeException(
'Migration liefert keine Definition: ' . basename(
$file));
53 $id = trim((
string)($record[
'id'] ??
''));
54 $version = trim((
string)($record[
'version'] ??
''));
56 || preg_match(
'/^[A-Za-z0-9._-]{1,128}$/', $id) !== 1
58 || preg_match(
'/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/',
$version) !== 1
60 throw new RuntimeException(
'Migration besitzt ID oder Version im ungueltigen Format: ' . basename(
$file));
62 if ($targetVersion !==
'' && version_compare(
$version, $targetVersion,
'>')) {
65 if (isset($migrations[$id])) {
66 throw new RuntimeException(
'Doppelte Migrations-ID: ' . $id);
69 $operations = is_array($record[
'operations'] ??
null)
70 ? $record[
'operations']
72 $affected = is_array($record[
'affected_dd'] ??
null)
73 ? $record[
'affected_dd']
75 foreach ($operations as $operation) {
76 if (is_array($operation)
77 && ($operation[
'type'] ??
'') ===
'sync_dd'
78 && trim((
string)($operation[
'dd'] ??
'')) !==
''
80 $affected[] = trim((
string)$operation[
'dd']);
86 $record[
'module'] = $this->moduleFromMigrationFile(
$file);
87 $record[
'file'] =
$file;
88 $record[
'checksum'] = hash_file(
'sha256',
$file);
89 $record[
'operations'] = $operations;
90 $record[
'affected_dd'] = array_values(array_unique(array_filter(
91 array_map(
'strval', $affected)
93 $migrations[$id] = $record;
96 uasort($migrations,
static function (array $left, array $right):
int {
97 return version_compare((
string)$left[
'version'], (
string)$right[
'version'])
98 ?: strcmp((
string)$left[
'id'], (
string)$right[
'id']);
103 private function moduleFromMigrationFile(
string $file): string
106 return preg_match(
'#/dbx/modules/([^/]+)/migrations/#',
$normalized, $match) === 1
111 private function ledgerExists(): bool
113 $server = (string)$this->db->get_dd_server(self::LEDGER_DD);
114 $table = (string)$this->db->get_dd_table(self::LEDGER_DD);
120 private function ledgerRecord(
string $id): array
122 if (!$this->ledgerExists()) {
125 $record = $this->db->select1(
127 array(
'migration_id' => $id),
131 return is_array($record) ? $record : array();
137 public function plan(
string $releaseRoot,
string $targetVersion): array
139 $migrations = $this->
discover($releaseRoot, $targetVersion);
144 $ledger = $this->ledgerRecord($id);
145 if ((
$ledger[
'status'] ??
'') ===
'finished') {
147 strtolower((
string)(
$ledger[
'checksum'] ??
'')),
150 throw new RuntimeException(
'Bereits ausgefuehrte Migration wurde veraendert: ' . $id);
159 'migrations' => $migrations,
160 'pending' => $pending,
172 string $targetVersion,
173 string $backupDirectory
175 $plan = $this->
plan($releaseRoot, $targetVersion);
176 $root = realpath($releaseRoot);
177 if (
$root ===
false) {
178 throw new RuntimeException(
'Releasewurzel ist nicht mehr vorhanden.');
180 if (
$plan[
'pending'] === array()) {
183 'release_root' =>
$root,
184 'target_version' => $targetVersion,
185 'pending' => array(),
186 'checksums' => array(),
187 'backups' => array(),
191 $dbBackupDir = rtrim($backupDirectory,
'\\/')
192 . DIRECTORY_SEPARATOR .
'database';
193 if (!is_dir($dbBackupDir)
194 && !mkdir($dbBackupDir, 0700,
true)
195 && !is_dir($dbBackupDir)
197 throw new RuntimeException(
'DB-Backupverzeichnis konnte nicht erstellt werden.');
200 $checksums = array();
202 foreach (
$plan[
'pending'] as $id) {
204 $checksums[$id] = (string)
$migration[
'checksum'];
205 foreach (
$migration[
'affected_dd'] as $ddRef) {
206 $affected[$ddRef] =
true;
210 $backupsByServer = array();
212 foreach (array_keys($affected) as $ddRef) {
213 $binding = $this->db->get_dd_server_binding_info($ddRef);
214 if ((
$binding[
'source'] ??
'') ===
'missing-dd') {
224 if (empty(
$binding[
'valid']) || (
$binding[
'resolved_server'] ??
'') ===
'') {
225 throw new RuntimeException(
'Ungueltige Serverbindung fuer Migration: ' . $ddRef);
228 $this->assertStagedServerCompatible(
$root, $ddRef,
$binding);
230 $table = (string)$this->db->get_dd_table($ddRef);
238 'indexes' => array(),
245 $entry[
'existed'] =
true;
246 $entry[
'fields'] = $this->dd->get_db_fields(
$server,
$table);
247 $entry[
'indexes'] = $this->dd->get_db_indexes(
$server,
$table);
248 $entry[
'file'] = $dbBackupDir . DIRECTORY_SEPARATOR
249 . preg_replace(
'/[^A-Za-z0-9._-]+/',
'-', $ddRef) .
'.ddb.zip';
251 $backupsByServer[
$server][] = count($backups) - 1;
254 foreach ($backupsByServer as
$indexes) {
255 $representative = $backups[
$indexes[0]][
'dd'];
256 if ($this->db->begin($representative) !== 1) {
257 throw new RuntimeException(
'DB-Backuptransaktion konnte nicht gestartet werden: ' . $representative);
261 $entry = $backups[
$index];
263 for ($step = 0; $step < 100000; $step++) {
264 $state = $this->dd->backup(
271 (
string)(
$state[
'status'] ??
''),
272 array(
'finished',
'error',
'cancelled'),
278 if ((
$state[
'status'] ??
'') !==
'finished') {
279 throw new RuntimeException(
'Tabellenbackup fehlgeschlagen: ' . $entry[
'dd']);
282 if ($this->db->commit($representative) !== 1) {
283 throw new RuntimeException(
'DB-Backuptransaktion konnte nicht abgeschlossen werden.');
285 }
catch (Throwable $exception) {
286 $this->db->rollback($representative);
293 'release_root' =>
$root,
294 'target_version' => $targetVersion,
295 'pending' =>
$plan[
'pending'],
296 'checksums' => $checksums,
297 'backups' => $backups,
305 private function assertStagedServerCompatible(
310 if ((
$binding[
'source'] ??
'') ===
'local-binding') {
313 $parts = explode(
'|', $ddRef, 2);
314 if (count($parts) !== 2) {
317 $file = rtrim($releaseRoot,
'\\/')
318 .
'/dbx/modules/' . $parts[0] .
'/dd/' . $parts[1] .
'.dd.php';
319 if (!is_file(
$file)) {
323 $stagedTable = (
static function (
string $ddFile): array {
330 $stagedServer = trim((
string)($stagedTable[
'server'] ??
''));
331 $declared = trim((
string)(
$binding[
'declared_server'] ??
''));
332 $module = trim((
string)$parts[0]);
333 if ($stagedServer !==
''
335 && $this->canonicalServerReference($stagedServer,
$module)
336 !== $this->canonicalServerReference($declared,
$module)
338 throw new RuntimeException(
339 'DD-Serverwechsel benoetigt eine explizite lokale Bindung: '
340 . $ddRef .
' (' . $declared .
' -> ' . $stagedServer .
')'
351 private function canonicalServerReference(
string $server,
string $module): string
357 if (strpos(
$server,
'|') !==
false) {
360 if (preg_match(
'/\.(?:db3|sqlite|sqlite3)$/i',
$server)) {
368 $releaseRoot = (string)(
$state[
'release_root'] ??
'');
369 $targetVersion = (string)(
$state[
'target_version'] ??
'');
370 $pending = is_array(
$state[
'pending'] ??
null) ?
$state[
'pending'] : array();
371 if ($pending === array()) {
372 return array(
'applied' => array());
375 $migrations = $this->
discover($releaseRoot, $targetVersion);
376 if (!$this->dd->create_db_tab(self::LEDGER_DD)) {
377 throw new RuntimeException(
'Migration-Ledger konnte nicht erstellt werden.');
381 foreach ($pending as $id) {
382 if (!isset($migrations[$id])) {
383 throw new RuntimeException(
'Vorbereitete Migration fehlt: ' . $id);
390 throw new RuntimeException(
'Migration wurde nach der Vorbereitung veraendert: ' . $id);
398 }
catch (Throwable $exception) {
400 throw new RuntimeException(
401 'DB-Migration fehlgeschlagen (' . $id .
'): ' . $exception->getMessage(),
408 return array(
'applied' =>
$applied);
419 private function activateReleaseDdDefinitions(array
$migration): array
423 if ($releaseRoot ===
'' || !is_dir($releaseRoot)) {
424 throw new RuntimeException(
'Releasewurzel fuer staged DDs fehlt.');
430 if (!isset(
$_SESSION[
'dbx'][
'cache'][
'dd'])
431 || !is_array(
$_SESSION[
'dbx'][
'cache'][
'dd'])
433 $_SESSION[
'dbx'][
'cache'][
'dd'] = array();
437 $activated = array();
438 foreach ((array)(
$migration[
'operations'] ?? array()) as $operation) {
439 if (!is_array($operation) || ($operation[
'type'] ??
'') !==
'sync_dd') {
442 $parts = explode(
'|', trim((
string)($operation[
'dd'] ??
'')), 2);
443 if (count($parts) !== 2) {
447 $cacheKey =
$module .
'|' . $ddName;
448 if (isset($activated[$cacheKey])) {
452 $ddFile = $releaseRoot .
'/dbx/modules/' .
$module .
'/dd/' . $ddName .
'.dd.php';
453 if (!is_file($ddFile)) {
455 $ddFile = $releaseRoot .
'/dbx/modules/dbx/dd/' . $ddName .
'.dd.php';
457 if (!is_file($ddFile)) {
458 throw new RuntimeException(
'Staged DD fehlt: ' . $cacheKey);
473 throw new RuntimeException(
'Staged DD ist ungueltig: ' . $cacheKey);
476 $languageDynamic = ((
$definition[
'table'][
'language'] ??
'') ===
'*');
477 if ($languageDynamic) {
478 $language = function_exists(
'dbx_lng_current')
480 : strtolower(trim((string)
dbx()->get_system_var(
'dbx_lng',
'de')));
481 if ($language ===
'' || preg_match(
'/^[a-z]{2,3}$/', $language) !== 1) {
484 $tableBase = trim((
string)(
$definition[
'table'][
'table'] ??
''));
485 $languageFile = $tableBase !==
''
486 ? dirname($ddFile) .
'/' . $tableBase .
'_' . $language .
'.dd.php'
488 if ($languageFile !==
'' && is_file($languageFile)) {
500 $ddFile = $languageFile;
501 } elseif ($tableBase !==
'') {
502 $definition[
'table'][
'table'] = $tableBase .
'_' . $language;
503 $definition[
'table'][
'datadic'] = $tableBase .
'_' . $language;
513 'existed' => $existed,
523 'language_dynamic' => $languageDynamic,
524 'declared_server' => (
string)(
$definition[
'table'][
'server'] ??
'default'),
526 $activated[$cacheKey] =
true;
531 private function restoreDdDefinitions(array $saved): void
533 foreach (array_reverse($saved) as $entry) {
534 $module = (string)($entry[
'module'] ??
'');
535 $ddName = (string)($entry[
'dd'] ??
'');
536 if (
$module ===
'' || $ddName ===
'') {
539 if (!empty($entry[
'existed'])) {
547 private function executeMigration(array
$migration): void
549 $savedDdDefinitions = $this->activateReleaseDdDefinitions(
$migration);
551 foreach (
$migration[
'operations'] as $operation) {
552 if (!is_array($operation)) {
553 throw new RuntimeException(
'Ungueltige Migrationsoperation.');
555 $type = (string)($operation[
'type'] ??
'');
556 if ($type ===
'sync_dd') {
557 $ddRef = trim((
string)($operation[
'dd'] ??
''));
558 $syncMode = strtolower(trim((
string)(
559 $operation[
'mode'] ??
'apply'
561 $parts = explode(
'|', $ddRef, 2);
562 if (count($parts) !== 2) {
563 throw new RuntimeException(
'sync_dd benoetigt modul|dd.');
565 if (!in_array($syncMode, array(
'apply',
'rebuild'),
true)) {
566 throw new RuntimeException(
567 'sync_dd erlaubt nur apply oder rebuild: ' . $ddRef
570 $this->dd->sync_dd_to_db($parts[0], $parts[1],
'reset');
572 for ($step = 0; $step < 1000; $step++) {
573 $sync = $this->dd->sync_dd_to_db(
579 (
string)($sync[
'status'] ??
''),
580 array(
'finished',
'error',
'cancelled'),
586 if (($sync[
'status'] ??
'') !==
'finished') {
587 throw new RuntimeException(
588 $ddRef .
': ' . (
string)($sync[
'message'] ??
'DD-Sync fehlgeschlagen')
593 if ($type ===
'seed_core') {
594 dbx()->get_include_obj(
'dbxInstallationService',
'dbxSetup');
600 throw new RuntimeException(
'Nicht erlaubter Migrationstyp: ' . $type);
605 throw new RuntimeException(
'Migration-up ist nicht aufrufbar.');
610 $this->restoreDdDefinitions($savedDdDefinitions);
614 private function writeLedger(
620 $existing = $this->ledgerRecord((string)
$migration[
'id']);
623 'release_version' => (
string)
$migration[
'version'],
628 'affected_servers' => json_encode(
629 array_values(array_unique(array_map(
630 static fn(array $entry):
string => (
string)($entry[
'server'] ??
''),
631 is_array(
$state[
'backups'] ??
null) ?
$state[
'backups'] : array()
633 JSON_UNESCAPED_SLASHES
635 'backup_reference' => json_encode(
636 array_values(array_filter(array_map(
637 static fn(array $entry):
string => (
string)($entry[
'file'] ??
''),
638 is_array(
$state[
'backups'] ??
null) ?
$state[
'backups'] : array()
640 JSON_UNESCAPED_SLASHES
642 'error_text' => $error,
644 if ($status ===
'running') {
645 $values[
'started_at'] = date(
'Y-m-d H:i:s.u');
646 $values[
'finished_at'] =
'';
648 $values[
'finished_at'] = date(
'Y-m-d H:i:s.u');
651 if ((
int)($existing[
'id'] ?? 0) > 0) {
652 $ok = $this->db->update(
655 (
int)$existing[
'id'],
662 $ok = $this->db->insert(
671 if ((
int)
$ok < 0 ||
$ok ===
false) {
672 throw new RuntimeException(
'Migration-Ledger konnte nicht geschrieben werden.');
681 $backups = is_array(
$state[
'backups'] ??
null) ?
$state[
'backups'] : array();
682 foreach (array_reverse($backups) as $entry) {
683 $ddRef = (string)($entry[
'dd'] ??
'');
684 $server = (string)($entry[
'server'] ??
'');
685 $table = (string)($entry[
'table'] ??
'');
689 $server = (string)$this->db->get_dd_server($ddRef);
690 $table = (string)$this->db->get_dd_table($ddRef);
697 if (empty($entry[
'existed'])) {
703 if (!$this->dd->create_db_tab_from_fields(
706 is_array($entry[
'fields'] ??
null) ? $entry[
'fields'] : array(),
707 is_array($entry[
'indexes'] ??
null) ? $entry[
'indexes'] : array()
709 throw new RuntimeException(
'Rollback-Schema konnte nicht erstellt werden: ' . $ddRef);
713 for ($step = 0; $step < 100000; $step++) {
714 $restore = $this->dd->restore(
717 (
string)$entry[
'file'],
722 (
string)($restore[
'status'] ??
''),
723 array(
'finished',
'error',
'cancelled'),
729 if (($restore[
'status'] ??
'') !==
'finished') {
730 throw new RuntimeException(
'Rollback-Daten konnten nicht wiederhergestellt werden: ' . $ddRef);
734 if ($this->ledgerExists()) {
735 foreach ((array)(
$state[
'pending'] ?? array()) as $id) {
736 $record = $this->ledgerRecord((
string)$id);
737 if ((
int)($record[
'id'] ?? 0) > 0) {
741 'status' =>
'rolled_back',
742 'finished_at' => date(
'Y-m-d H:i:s.u'),
754 return array(
'rolled_back' => array_values((array)(
$state[
'pending'] ?? array())));