discover($module); $applied = $this->applied((string)$module['name']); $pending = []; $changed = []; foreach ($migrations as $migration) { $id = $migration['id']; if (!isset($applied[$id])) { $pending[] = $migration; continue; } if (($applied[$id]['checksum'] ?? '') !== $migration['checksum']) { $changed[] = $migration; } } return [ 'available' => count($migrations), 'applied' => count($applied), 'pending' => $pending, 'changed' => $changed, ]; } public function applyPending(array $module): array { $status = $this->status($module); $applied = []; $moduleName = (string)$module['name']; $context = new ModuleMigrationContext($this->basePdo, $this->modules, $module); foreach ($status['pending'] as $migration) { $this->basePdo->beginTransaction(); try { $runner = require $migration['path']; if (is_object($runner) && method_exists($runner, 'up')) { $runner->up($context); } elseif (is_callable($runner)) { $runner($context); } else { throw new \RuntimeException('Migration does not return a callable or object with up().'); } $this->record($moduleName, $migration); $this->basePdo->commit(); $applied[] = $migration; } catch (\Throwable $e) { if ($this->basePdo->inTransaction()) { $this->basePdo->rollBack(); } throw new \RuntimeException( 'Migration fehlgeschlagen: ' . $moduleName . '/' . $migration['id'] . ' - ' . $e->getMessage(), 0, $e ); } } if ($applied !== []) { $this->recordModuleVersion($moduleName, (string)($module['version'] ?? '')); } return $applied; } private function discover(array $module): array { $path = (string)($module['path'] ?? '') . '/migrations'; if (!is_dir($path)) { return []; } $items = []; foreach (glob($path . '/*.php') ?: [] as $file) { $id = basename($file, '.php'); $items[] = [ 'id' => $id, 'version' => $this->versionFromId($id), 'path' => $file, 'checksum' => hash_file('sha256', $file) ?: '', ]; } usort($items, static fn(array $a, array $b): int => strnatcmp($a['id'], $b['id'])); return $items; } private function applied(string $moduleName): array { $stmt = $this->basePdo->prepare( "SELECT migration, version, checksum, applied_at FROM nexus_module_migrations WHERE module_name = :module ORDER BY migration ASC" ); $stmt->execute(['module' => $moduleName]); $items = []; foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { $items[(string)$row['migration']] = $row; } return $items; } private function record(string $moduleName, array $migration): void { $driver = (string)$this->basePdo->getAttribute(\PDO::ATTR_DRIVER_NAME); if ($driver === 'pgsql' || $driver === 'sqlite') { $sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at) VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP) ON CONFLICT(module_name, migration) DO UPDATE SET version = excluded.version, checksum = excluded.checksum, applied_at = CURRENT_TIMESTAMP"; } else { $sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at) VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE version = VALUES(version), checksum = VALUES(checksum), applied_at = CURRENT_TIMESTAMP"; } $stmt = $this->basePdo->prepare($sql); $stmt->execute([ 'module' => $moduleName, 'migration' => $migration['id'], 'version' => $migration['version'], 'checksum' => $migration['checksum'], ]); } private function recordModuleVersion(string $moduleName, string $version): void { if ($version === '') { return; } $stmt = $this->basePdo->prepare( "UPDATE nexus_modules SET version = :version, updated_at = CURRENT_TIMESTAMP WHERE name = :module" ); $stmt->execute([ 'module' => $moduleName, 'version' => $version, ]); } private function versionFromId(string $id): string { if (preg_match('/(?:^|_)(\d+\.\d+\.\d+)(?:_|$)/', $id, $m)) { return $m[1]; } return ''; } }