All pending database migrations of this hook */ protected $migrations; /** @var ?string The current version of this hook */ protected $version; /** * Get whether the specified table exists in the given database * * @param Connection $conn * @param string $table * * @return bool */ public static function tableExists(Connection $conn, string $table): bool { /** @var false|int $exists */ $exists = $conn->prepexec( 'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result', $table )->fetchColumn(); return (bool) $exists; } /** * Get whether the specified column exists in the provided table * * @param Connection $conn * @param string $table * @param string $column * * @return ?string */ public static function getColumnType(Connection $conn, string $table, string $column): ?string { $pdoStmt = $conn->prepexec( sprintf( 'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns' . ' WHERE table_name = ? AND column_name = ?', $conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type', $conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL' ), [$table, $column] ); /** @var false|stdClass $result */ $result = $pdoStmt->fetch(PDO::FETCH_OBJ); if ($result === false) { return null; } if ($result->column_length !== null) { $result->column_type .= '(' . $result->column_length . ')'; } return $result->column_type; } /** * Get the mysql collation name of the given column of the specified table * * @param Connection $conn * @param string $table * @param string $column * * @return ?string */ public static function getColumnCollation(Connection $conn, string $table, string $column): ?string { if ($conn->getAdapter() instanceof Pgsql) { return null; } /** @var false|string $collation */ $collation = $conn->prepexec( 'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?', [$table, $column] )->fetchColumn(); return ! $collation ? null : $collation; } /** * Get statically provided descriptions of the individual migrate scripts * * @return string[] */ abstract public function providedDescriptions(): array; /** * Get the full name of the component this hook is implemented by * * @return string */ abstract public function getName(): string; /** * Get the current schema version of this migration hook * * @return string */ abstract public function getVersion(): string; /** * Get a database connection * * @return Connection */ abstract public function getDb(): Connection; /** * Get all the pending migrations of this hook * * @return DbMigrationStep[] */ public function getMigrations(): array { if ($this->migrations === null) { $this->migrations = []; $this->load(); } return $this->migrations ?? []; } /** * Get the latest migrations limited by the given number * * @param int $limit * * @return DbMigrationStep[] */ public function getLatestMigrations(int $limit): array { $migrations = $this->getMigrations(); if ($limit > 0) { $migrations = array_slice($migrations, -$limit, null, true); } return array_reverse($migrations); } /** * Apply all pending migrations of this hook * * @param ?Connection $conn Use the provided database connection to apply the migrations. * Is only used to elevate database users with insufficient privileges. * * @return bool Whether the migration(s) have been successfully applied */ final public function run(Connection $conn = null): bool { if (! $conn) { $conn = $this->getDb(); } foreach ($this->getMigrations() as $migration) { try { $migration->apply($conn); $this->version = $migration->getVersion(); unset($this->migrations[$migration->getVersion()]); $data = [ 'name' => $this->getName(), 'version' => $migration->getVersion() ]; AuditHook::logActivity( 'migrations', 'Migrated database schema of {{name}} to version {{version}}', $data ); $this->storeState($migration->getVersion(), null); } catch (Exception $e) { Logger::error( "Failed to apply %s pending migration version %s \n%s", $this->getName(), $migration->getVersion(), $e ); Logger::debug($e->getTraceAsString()); static::insertFailedEntry( $conn, $migration->getVersion(), $e->getMessage() . PHP_EOL . $e->getTraceAsString() ); return false; } } return true; } /** * Get whether this hook is implemented by a module * * @return bool */ public function isModule(): bool { return ClassLoader::classBelongsToModule(static::class); } /** * Get the name of the module this hook is implemented by * * @return string */ public function getModuleName(): string { if (! $this->isModule()) { return static::DEFAULT_MODULE; } return ClassLoader::extractModuleName(static::class); } /** * Get the number of pending migrations of this hook * * @return int */ public function count(): int { return count($this->getMigrations()); } /** * Get a schema version query * * @return Query */ abstract protected function getSchemaQuery(): Query; protected function load(): void { $upgradeDir = static::MYSQL_UPGRADE_DIR; if ($this->getDb()->getAdapter() instanceof Pgsql) { $upgradeDir = static::PGSQL_UPGRADE_DIR; } if (! $this->isModule()) { $path = Icinga::app()->getBaseDir(); } else { $path = Module::get($this->getModuleName())->getBaseDir(); } $descriptions = $this->providedDescriptions(); $version = $this->getVersion(); /** @var SplFileInfo $file */ foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) { if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) { [$_, $_, $migrateVersion, $description] = array_pad($m, 4, null); /** @var string $migrateVersion */ if ($migrateVersion && version_compare($migrateVersion, $version, '>')) { $migration = new DbMigrationStep($migrateVersion, $file->getRealPath()); if (isset($descriptions[$migrateVersion])) { $migration->setDescription($descriptions[$migrateVersion]); } elseif ($description) { $migration->setDescription(str_replace('_', ' ', $description)); } $migration->setLastState($this->loadLastState($migrateVersion)); $this->migrations[$migrateVersion] = $migration; } } } if ($this->migrations) { // Sort all the migrations by their version numbers in ascending order. uksort($this->migrations, function ($a, $b) { return version_compare($a, $b); }); } } /** * Insert failed migration entry into the database or to the session * * @param Connection $conn * @param string $version * @param string $reason * * @return $this */ protected function insertFailedEntry(Connection $conn, string $version, string $reason): self { $schemaQuery = $this->getSchemaQuery() ->filter(Filter::equal('version', $version)); if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) { $this->storeState($version, $reason); } else { /** @var Schema $schema */ $schema = $schemaQuery->first(); if ($schema) { $conn->update($schema->getTableName(), [ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0, 'success' => 'n', 'reason' => $reason ], ['id = ?' => $schema->id]); } else { $conn->insert($schemaQuery->getModel()->getTableName(), [ 'version' => $version, 'timestamp' => (new DateTime())->getTimestamp() * 1000.0, 'success' => 'n', 'reason' => $reason ]); } } return $this; } /** * Store a failed state message in the session for the given version * * @param string $version * @param ?string $reason * * @return $this */ protected function storeState(string $version, ?string $reason): self { $session = Session::getSession()->getNamespace('migrations'); /** @var array $states */ $states = $session->get($this->getModuleName(), []); $states[$version] = $reason; $session->set($this->getModuleName(), $states); return $this; } /** * Load last failed state from database/session for the given version * * @param string $version * * @return ?string */ protected function loadLastState(string $version): ?string { $session = Session::getSession()->getNamespace('migrations'); /** @var array $states */ $states = $session->get($this->getModuleName(), []); if (! isset($states[$version])) { $schemaQuery = $this->getSchemaQuery() ->filter(Filter::equal('version', $version)) ->filter(Filter::all(Filter::equal('success', 'n'))); if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) { /** @var Schema $schema */ $schema = $schemaQuery->first(); if ($schema) { return $schema->reason; } } return null; } return $states[$version]; } }