Introduce base `MigrationHook` class & helpers
This commit is contained in:
parent
21bde13274
commit
babc59437f
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||
|
||||
namespace Icinga\Application\Hook\Common;
|
||||
|
||||
use ipl\Sql\Connection;
|
||||
use RuntimeException;
|
||||
|
||||
class DbMigration
|
||||
{
|
||||
/** @var string The sql string to be executed */
|
||||
protected $query;
|
||||
|
||||
/** @var string The sql script version the queries are loaded from */
|
||||
protected $version;
|
||||
|
||||
/** @var string */
|
||||
protected $scriptPath;
|
||||
|
||||
/** @var ?string */
|
||||
protected $description;
|
||||
|
||||
/** @var ?string */
|
||||
protected $lastState;
|
||||
|
||||
public function __construct(string $version, string $scriptPath)
|
||||
{
|
||||
$this->scriptPath = $scriptPath;
|
||||
|
||||
$this->setVersion($version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sql script version the queries are loaded from
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sql script version the queries are loaded from
|
||||
*
|
||||
* @param string $version
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setVersion(string $version): self
|
||||
{
|
||||
$this->version = $version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upgrade script relative path name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getScriptPath(): string
|
||||
{
|
||||
return $this->scriptPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description of this database migration if any
|
||||
*
|
||||
* @return ?string
|
||||
*/
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the description of this database migration
|
||||
*
|
||||
* @param ?string $description
|
||||
*
|
||||
* @return DbMigration
|
||||
*/
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last error message of this hook if any
|
||||
*
|
||||
* @return ?string
|
||||
*/
|
||||
public function getLastState(): ?string
|
||||
{
|
||||
return $this->lastState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last error message
|
||||
*
|
||||
* @param ?string $message
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setLastState(?string $message): self
|
||||
{
|
||||
$this->lastState = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the sql migration
|
||||
*
|
||||
* @param Connection $conn
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws RuntimeException Throws an error in case of any database errors or when there is nothing to migrate
|
||||
*/
|
||||
public function apply(Connection $conn): self
|
||||
{
|
||||
if (! $this->query) {
|
||||
$statements = @file_get_contents($this->getScriptPath());
|
||||
if (! $statements) {
|
||||
throw new RuntimeException(sprintf('Cannot load upgrade script %s', $this->getScriptPath()));
|
||||
}
|
||||
|
||||
if (preg_match('/\s*delimiter\s*(\S+)\s*$/im', $statements, $matches)) {
|
||||
/** @var string $statements */
|
||||
$statements = preg_replace('/\s*delimiter\s*(\S+)\s*$/im', '', $statements);
|
||||
/** @var string $statements */
|
||||
$statements = preg_replace('/' . preg_quote($matches[1], '/') . '$/m', ';', $statements);
|
||||
}
|
||||
|
||||
$this->query = $statements;
|
||||
}
|
||||
|
||||
if (empty($this->query)) {
|
||||
throw new RuntimeException('Nothing to migrate');
|
||||
}
|
||||
|
||||
$conn->exec($this->query);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,379 @@
|
|||
<?php
|
||||
|
||||
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||
|
||||
namespace Icinga\Application\Hook;
|
||||
|
||||
use Countable;
|
||||
use DateTime;
|
||||
use DirectoryIterator;
|
||||
use Exception;
|
||||
use Icinga\Application\ClassLoader;
|
||||
use Icinga\Application\Hook\Common\DbMigration;
|
||||
use Icinga\Application\Icinga;
|
||||
use Icinga\Application\Logger;
|
||||
use Icinga\Application\Modules\Module;
|
||||
use Icinga\Model\Schema;
|
||||
use Icinga\Web\Session;
|
||||
use ipl\I18n\Translation;
|
||||
use ipl\Orm\Query;
|
||||
use ipl\Sql\Adapter\Pgsql;
|
||||
use ipl\Sql\Connection;
|
||||
use ipl\Stdlib\Filter;
|
||||
use PDO;
|
||||
use SplFileInfo;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Allows you to automatically perform database migrations.
|
||||
*
|
||||
* The version numbers of the sql migrations are determined by extracting the respective migration script names.
|
||||
* It's required to place the sql migrate scripts below the respective following directories:
|
||||
*
|
||||
* `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
|
||||
*/
|
||||
abstract class MigrationHook implements Countable
|
||||
{
|
||||
use Translation;
|
||||
|
||||
public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';
|
||||
|
||||
public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';
|
||||
|
||||
/** @var string Fakes a module when this hook is implemented by the framework itself */
|
||||
public const DEFAULT_MODULE = 'icingaweb2';
|
||||
|
||||
/** @var string Migration hook param name */
|
||||
public const MIGRATION_PARAM = 'migration';
|
||||
|
||||
public const ALL_MIGRATIONS = 'all-migrations';
|
||||
|
||||
/** @var ?array<string, DbMigration> 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 stdClass $query */
|
||||
$query = $conn->prepexec(
|
||||
'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
|
||||
$table
|
||||
)->fetch(PDO::FETCH_OBJ);
|
||||
|
||||
return $query->result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 all the pending migrations of this hook
|
||||
*
|
||||
* @return DbMigration[]
|
||||
*/
|
||||
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 DbMigration[]
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @return bool Whether the migration(s) have been successfully applied
|
||||
*/
|
||||
public function run(): bool
|
||||
{
|
||||
$conn = $this->getDb();
|
||||
foreach ($this->getMigrations() as $migration) {
|
||||
try {
|
||||
$migration->apply($conn);
|
||||
|
||||
$this->version = $migration->getVersion();
|
||||
unset($this->migrations[$migration->getVersion()]);
|
||||
|
||||
Logger::error(
|
||||
"Applied %s pending migration version %s successfully",
|
||||
$this->getName(),
|
||||
$migration->getVersion()
|
||||
);
|
||||
|
||||
$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->getMessage()
|
||||
);
|
||||
Logger::debug($e->getTraceAsString());
|
||||
|
||||
static::insertFailedEntry(
|
||||
$conn,
|
||||
$this->getSchemaQueryFor($migration->getVersion()),
|
||||
$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 database connection
|
||||
*
|
||||
* @return Connection
|
||||
*/
|
||||
abstract protected function getDb(): Connection;
|
||||
|
||||
/**
|
||||
* Get a schema version query filtered by the given $version
|
||||
*
|
||||
* @param string $version
|
||||
*
|
||||
* @return Query
|
||||
*/
|
||||
abstract protected function getSchemaQueryFor(string $version): 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('/^(?:r|v)?((?:\d+\.){0,2}\d+)(?:_([\w+]+))?\.sql$/', $file->getFilename(), $m)) {
|
||||
if (version_compare($m[1], $version, '>')) {
|
||||
$migration = new DbMigration($m[1], $file->getRealPath());
|
||||
if (isset($descriptions[$migration->getVersion()])) {
|
||||
$migration->setDescription($descriptions[$migration->getVersion()]);
|
||||
} elseif (isset($m[2])) {
|
||||
$migration->setDescription(str_replace('_', ' ', $m[2]));
|
||||
}
|
||||
|
||||
$migration->setLastState($this->loadLastState($migration->getVersion()));
|
||||
|
||||
$this->migrations[$m[1]] = $migration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all the migrations by their version numbers in ascending order.
|
||||
uksort($this->migrations, 'version_compare');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert failed migration entry into the database or to the session
|
||||
*
|
||||
* @param Connection $conn
|
||||
* @param Query $schemaQuery
|
||||
* @param string $version
|
||||
* @param string $reason
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function insertFailedEntry(Connection $conn, Query $schemaQuery, string $version, string $reason): self
|
||||
{
|
||||
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<string, string> $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<string, string> $states */
|
||||
$states = $session->get($this->getModuleName(), []);
|
||||
if (! isset($states[$version])) {
|
||||
$schemaQuery = $this->getSchemaQueryFor($version);
|
||||
$schemaQuery->setFilter(Filter::all(Filter::equal('success', 'n')));
|
||||
if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
|
||||
/** @var Schema $schema */
|
||||
$schema = $schemaQuery->first();
|
||||
if ($schema && version_compare($schema->version, $version, '==')) {
|
||||
return $schema->reason;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $states[$version];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue