init
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
abstract class AppMigration {
|
||||
/** @var ContainerWrapper */
|
||||
protected $container;
|
||||
|
||||
/** @var EntityManager */
|
||||
protected $entityManager;
|
||||
|
||||
public function __construct(
|
||||
ContainerWrapper $container
|
||||
) {
|
||||
$this->container = $container;
|
||||
$this->entityManager = $container->get(EntityManager::class);
|
||||
}
|
||||
|
||||
abstract public function run(): void;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrations\App;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Migrator\AppMigration;
|
||||
|
||||
class AppMigrationTemplate extends AppMigration {
|
||||
public function run(): void {
|
||||
/*
|
||||
* TODO: Implement the migration logic here and remove this comment.
|
||||
*
|
||||
* App Level migration are intended for data migrations that use application level services.
|
||||
* The application level services require the DB structure to be up to date so they run after all DB migrations.
|
||||
*
|
||||
* Do not make changes in the DB structure in App Level migrations!
|
||||
*
|
||||
* You can use:
|
||||
* $this->entityManager For operations using Doctrine Entity Manager.
|
||||
* $this->container For accessing any needed service.
|
||||
*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use WP_CLI;
|
||||
|
||||
class Cli {
|
||||
/** @var Migrator */
|
||||
private $migrator;
|
||||
|
||||
/** @var Repository */
|
||||
private $repository;
|
||||
|
||||
/** @var Store */
|
||||
private $store;
|
||||
|
||||
public function __construct(
|
||||
Migrator $migrator,
|
||||
Repository $repository,
|
||||
Store $store
|
||||
) {
|
||||
$this->migrator = $migrator;
|
||||
$this->repository = $repository;
|
||||
$this->store = $store;
|
||||
}
|
||||
|
||||
public function initialize(): void {
|
||||
if (!class_exists(WP_CLI::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
WP_CLI::add_command('mailpoet:migrations:run', [$this, 'run'], [
|
||||
'shortdesc' => 'Runs MailPoet database migrations',
|
||||
]);
|
||||
|
||||
WP_CLI::add_command('mailpoet:migrations:status', [$this, 'status'], [
|
||||
'shortdesc' => 'Shows status of MailPoet database migrations',
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(): void {
|
||||
$this->printHeader();
|
||||
$this->migrator->run(new class($this) implements Logger {
|
||||
/** @var Cli */
|
||||
private $cli;
|
||||
|
||||
/** @var float */
|
||||
private $started;
|
||||
|
||||
/** @var float */
|
||||
private $migrationStarted;
|
||||
|
||||
/** @var int */
|
||||
private $migrationsCount = 0;
|
||||
|
||||
public function __construct(
|
||||
Cli $cli
|
||||
) {
|
||||
$this->cli = $cli;
|
||||
}
|
||||
|
||||
public function logBefore(array $status): void {
|
||||
WP_CLI::log("STATUS:\n");
|
||||
$this->cli->printStats($status);
|
||||
|
||||
$new = array_values(
|
||||
array_filter($status, function (array $migration): bool {
|
||||
return $migration['status'] === Migrator::MIGRATION_STATUS_NEW;
|
||||
})
|
||||
);
|
||||
|
||||
if (count($new) === 0) {
|
||||
WP_CLI::success('No new migrations to run.');
|
||||
} else {
|
||||
WP_CLI::log("RUNNING MIGRATIONS:\n");
|
||||
}
|
||||
$this->started = microtime(true);
|
||||
}
|
||||
|
||||
public function logMigrationStarted(array $migration): void {
|
||||
WP_CLI::out(sprintf(' %s... ', $migration['name']));
|
||||
$this->migrationStarted = microtime(true);
|
||||
}
|
||||
|
||||
public function logMigrationCompleted(array $migration): void {
|
||||
$this->migrationsCount += 1;
|
||||
$seconds = microtime(true) - $this->migrationStarted;
|
||||
WP_CLI::out(sprintf("completed in %.0Fs ✔\n", $seconds));
|
||||
}
|
||||
|
||||
public function logAfter(): void {
|
||||
if ($this->migrationsCount > 0) {
|
||||
$seconds = microtime(true) - $this->started;
|
||||
WP_CLI::log('');
|
||||
WP_CLI::success(sprintf("Completed %d new migrations in %.0Fs.", $this->migrationsCount, $seconds));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function status(): void {
|
||||
$this->printHeader();
|
||||
$status = $this->migrator->getStatus();
|
||||
if (!$status) {
|
||||
WP_CLI::warning("No migrations found.\n");
|
||||
} else {
|
||||
WP_CLI::log("STATUS:\n");
|
||||
$this->printStats($status);
|
||||
|
||||
WP_CLI::log("MIGRATIONS:\n");
|
||||
$table = array_map(function (array $data): array {
|
||||
$data['name'] .= $data['unknown'] ? ' (unknown)' : '';
|
||||
unset($data['unknown']);
|
||||
return array_map(function ($field) {
|
||||
return $field === null ? '' : $field;
|
||||
}, $data);
|
||||
}, $status);
|
||||
WP_CLI\Utils\format_items('table', $table, array_keys($table[0]));
|
||||
}
|
||||
}
|
||||
|
||||
public function printHeader(): void {
|
||||
WP_CLI::log('MAILPOET DATABASE MIGRATIONS');
|
||||
WP_CLI::log("============================\n");
|
||||
}
|
||||
|
||||
public function printStats(array $status): void {
|
||||
$stats = [
|
||||
Migrator::MIGRATION_STATUS_NEW => 0,
|
||||
Migrator::MIGRATION_STATUS_COMPLETED => 0,
|
||||
Migrator::MIGRATION_STATUS_STARTED => 0,
|
||||
Migrator::MIGRATION_STATUS_FAILED => 0,
|
||||
];
|
||||
foreach ($status as $migration) {
|
||||
$stats[$migration['status']] += 1;
|
||||
}
|
||||
|
||||
$defined = count($this->repository->loadAll());
|
||||
$processed = array_sum($stats) - $stats[Migrator::MIGRATION_STATUS_NEW];
|
||||
|
||||
WP_CLI::log(sprintf('Defined: %4d (in %s)', $defined, realpath($this->repository->getMigrationsDir())));
|
||||
WP_CLI::log(sprintf('Processed: %4d (in database table \'%s\')', $processed, $this->store->getMigrationsTable()));
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log(sprintf('New: %4d (not run yet)', $stats[Migrator::MIGRATION_STATUS_NEW]));
|
||||
WP_CLI::log(sprintf('Completed: %4d (successfully executed)', $stats[Migrator::MIGRATION_STATUS_COMPLETED]));
|
||||
WP_CLI::log(sprintf('Started: %4d (still running, or never completed)', $stats[Migrator::MIGRATION_STATUS_STARTED]));
|
||||
WP_CLI::log(sprintf('Failed: %4d (an error occurred)', $stats[Migrator::MIGRATION_STATUS_FAILED]));
|
||||
WP_CLI::log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoetVendor\Doctrine\DBAL\Connection;
|
||||
use MailPoetVendor\Doctrine\DBAL\Exception;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
abstract class DbMigration {
|
||||
/** @var Connection */
|
||||
protected $connection;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(
|
||||
ContainerWrapper $container
|
||||
) {
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->entityManager = $container->get(EntityManager::class);
|
||||
}
|
||||
|
||||
abstract public function run(): void;
|
||||
|
||||
/**
|
||||
* @param class-string<object> $entityClass
|
||||
*/
|
||||
protected function getTableName(string $entityClass): string {
|
||||
return $this->entityManager->getClassMetadata($entityClass)->getTableName();
|
||||
}
|
||||
|
||||
protected function createTable(string $tableName, array $attributes): void {
|
||||
$prefix = Env::$dbPrefix;
|
||||
$charsetCollate = Env::$dbCharsetCollate;
|
||||
$sql = implode(",\n", $attributes);
|
||||
$this->connection->executeStatement("
|
||||
CREATE TABLE IF NOT EXISTS {$prefix}{$tableName} (
|
||||
$sql
|
||||
) {$charsetCollate};
|
||||
");
|
||||
}
|
||||
|
||||
protected function columnExists(string $tableName, string $columnName): bool {
|
||||
global $wpdb;
|
||||
$suppressErrors = $wpdb->suppress_errors();
|
||||
try {
|
||||
$this->connection->executeStatement("SELECT $columnName FROM $tableName LIMIT 0");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
} finally {
|
||||
$wpdb->suppress_errors($suppressErrors);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tableExists(string $tableName): bool {
|
||||
global $wpdb;
|
||||
$suppressErrors = $wpdb->suppress_errors();
|
||||
try {
|
||||
$this->connection->executeStatement("SELECT 1 FROM $tableName LIMIT 0");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
} finally {
|
||||
$wpdb->suppress_errors($suppressErrors);
|
||||
}
|
||||
}
|
||||
|
||||
protected function indexExists(string $tableName, string $indexName): bool {
|
||||
global $wpdb;
|
||||
$suppressErrors = $wpdb->suppress_errors();
|
||||
try {
|
||||
$this->connection->executeStatement("ALTER TABLE $tableName ADD INDEX $indexName (__non__existent__column__name__)");
|
||||
} catch (Exception $e) {
|
||||
// Index creating index failed on not existing column we use a fallback. This can happen on MySQL 5.7 and lower and on some MariaDB versions.
|
||||
if ($e->getCode() === 1072) {
|
||||
$database = $wpdb->dbname;
|
||||
$result = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT count(*)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = COALESCE(DATABASE(), %s)
|
||||
AND table_name = %s
|
||||
AND index_name = %s",
|
||||
$database,
|
||||
$tableName,
|
||||
$indexName
|
||||
));
|
||||
|
||||
return $result > 0;
|
||||
}
|
||||
// Index exists when the error message contains its name. Otherwise, it's the non-existent column error.
|
||||
return strpos($e->getMessage(), $indexName) !== false;
|
||||
} finally {
|
||||
$wpdb->suppress_errors($suppressErrors);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrations\Db;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Migrator\DbMigration;
|
||||
|
||||
class DbMigrationTemplate extends DbMigration {
|
||||
public function run(): void {
|
||||
/*
|
||||
* TODO: Implement the migration logic here and remove this comment.
|
||||
*
|
||||
* DB Level Migrations are intended for DB structure changes and low level data migrations.
|
||||
* If you need more complex logic/services use App Level migrations.
|
||||
*
|
||||
* You can use:
|
||||
* $this->connection For SQL queries using Doctrine DBAL.
|
||||
* global $wpdb For SQL queries using WordPress $wpdb.
|
||||
*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
/**
|
||||
* @phpstan-import-type MigrationDefinition from Migrator
|
||||
*/
|
||||
interface Logger {
|
||||
/** @param MigrationDefinition[] $status */
|
||||
public function logBefore(array $status): void;
|
||||
|
||||
/** @param MigrationDefinition $migration */
|
||||
public function logMigrationStarted(array $migration): void;
|
||||
|
||||
/** @param MigrationDefinition $migration */
|
||||
public function logMigrationCompleted(array $migration): void;
|
||||
|
||||
public function logAfter(): void;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
/**
|
||||
* @phpstan-type MigrationDefinition array{name: string, level: string|null, status: string, started_at: string|null, completed_at: string|null, retries: int|null, error: string|null, unknown: bool}
|
||||
*/
|
||||
class Migrator {
|
||||
const MIGRATION_STATUS_NEW = 'new';
|
||||
const MIGRATION_STATUS_STARTED = 'started';
|
||||
const MIGRATION_STATUS_COMPLETED = 'completed';
|
||||
const MIGRATION_STATUS_FAILED = 'failed';
|
||||
|
||||
/** @var Repository */
|
||||
private $repository;
|
||||
|
||||
/** @var Runner */
|
||||
private $runner;
|
||||
|
||||
/** @var Store */
|
||||
private $store;
|
||||
|
||||
public function __construct(
|
||||
Repository $repository,
|
||||
Runner $runner,
|
||||
Store $store
|
||||
) {
|
||||
$this->repository = $repository;
|
||||
$this->runner = $runner;
|
||||
$this->store = $store;
|
||||
}
|
||||
|
||||
public function run(Logger $logger = null): void {
|
||||
$this->store->ensureMigrationsTable();
|
||||
$migrations = $this->getStatus();
|
||||
|
||||
if ($logger) {
|
||||
$logger->logBefore($migrations);
|
||||
}
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
if (!$migration['level'] || $migration['unknown'] || $migration['status'] === self::MIGRATION_STATUS_COMPLETED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($logger) {
|
||||
$logger->logMigrationStarted($migration);
|
||||
}
|
||||
|
||||
$this->runner->runMigration($migration['name'], $migration['level']);
|
||||
|
||||
if ($logger) {
|
||||
$logger->logMigrationCompleted($migration);
|
||||
}
|
||||
}
|
||||
|
||||
if ($logger) {
|
||||
$logger->logAfter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Array with of migration status data.
|
||||
* Ordering:
|
||||
* 1. Db migrations ordered by filename
|
||||
* 2. App migrations ordered by filename
|
||||
* 3. Unknown migrations (saved in store but not in repository e.g., renamed or deleted)
|
||||
* @return MigrationDefinition[]
|
||||
*/
|
||||
public function getStatus(): array {
|
||||
$defined = $this->repository->loadAll();
|
||||
$definedMap = array_combine(array_column($defined, 'name'), $defined) ?: [];
|
||||
$processed = $this->store->getAll();
|
||||
$processedMap = array_combine(array_column($processed, 'name'), $processed) ?: [];
|
||||
$all = array_unique(array_merge(array_keys($definedMap), array_keys($processedMap)));
|
||||
|
||||
$status = [];
|
||||
foreach ($all as $name) {
|
||||
$data = $processedMap[$name] ?? [];
|
||||
$status[] = [
|
||||
'name' => $name,
|
||||
'level' => $definedMap[$name]['level'] ?? null,
|
||||
'status' => $data ? $this->getMigrationStatus($data) : self::MIGRATION_STATUS_NEW,
|
||||
'started_at' => $data['started_at'] ?? null,
|
||||
'completed_at' => $data['completed_at'] ?? null,
|
||||
'retries' => isset($data['retries']) ? (int)$data['retries'] : null,
|
||||
'error' => $data && $data['error'] ? mb_strimwidth($data['error'], 0, 20, '…') : null,
|
||||
'unknown' => !isset($definedMap[$name]),
|
||||
];
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function getMigrationStatus(array $data): string {
|
||||
if (!isset($data['completed_at'])) {
|
||||
return self::MIGRATION_STATUS_STARTED;
|
||||
}
|
||||
return isset($data['error']) ? self::MIGRATION_STATUS_FAILED : self::MIGRATION_STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\InvalidStateException;
|
||||
use Throwable;
|
||||
|
||||
class MigratorException extends InvalidStateException {
|
||||
public static function templateFileReadFailed(string $path): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Could not read migration template file "%s".', $path)
|
||||
);
|
||||
}
|
||||
|
||||
public static function invalidMigrationLevel(string $level): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Migration level "%s" is not supported! Use "app" or "db".', $level)
|
||||
);
|
||||
}
|
||||
|
||||
public static function duplicateMigrationNames(array $names): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Duplicate migration names are not allowed. Duplicate names found: "%s".', join(', ', $names))
|
||||
);
|
||||
}
|
||||
|
||||
public static function migrationFileWriteFailed(string $path): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Could not write migration file "%s".', $path)
|
||||
);
|
||||
}
|
||||
|
||||
public static function migrationClassNotFound(string $className): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Migration class "%s" not found.', $className)
|
||||
);
|
||||
}
|
||||
|
||||
public static function migrationClassIsNotASubclassOf(string $className, string $parentClassName): self {
|
||||
return self::create()->withMessage(
|
||||
sprintf('Migration class "%1$s" is not a subclass of "%2$s".', $className, $parentClassName)
|
||||
);
|
||||
}
|
||||
|
||||
public static function migrationFailed(string $className, Throwable $previous): self {
|
||||
return self::create($previous)->withMessage(
|
||||
sprintf('Migration "%1$s" failed. Details: %2$s', $className, $previous->getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
|
||||
class Repository {
|
||||
const MIGRATIONS_LEVEL_APP = 'app';
|
||||
const MIGRATIONS_LEVEL_DB = 'db';
|
||||
|
||||
/** @var string */
|
||||
private $migrationsDir;
|
||||
|
||||
/** @var string */
|
||||
private $templateFile;
|
||||
|
||||
public function __construct() {
|
||||
$this->migrationsDir = __DIR__ . '/../Migrations';
|
||||
$this->templateFile = __DIR__ . '/{level}MigrationTemplate.php';
|
||||
}
|
||||
|
||||
public function getMigrationsDir(): string {
|
||||
return $this->migrationsDir;
|
||||
}
|
||||
|
||||
/** @return array{name: string, path: string} */
|
||||
public function create(string $level): array {
|
||||
if (!in_array($level, [self::MIGRATIONS_LEVEL_APP, self::MIGRATIONS_LEVEL_DB], true)) {
|
||||
throw MigratorException::invalidMigrationLevel($level);
|
||||
}
|
||||
$ucFirstLevel = ucfirst($level);
|
||||
$templateFile = str_replace('{level}', $ucFirstLevel, $this->templateFile);
|
||||
$template = @file_get_contents($templateFile);
|
||||
if (!$template) {
|
||||
throw MigratorException::templateFileReadFailed($templateFile);
|
||||
}
|
||||
$name = $this->generateName($level);
|
||||
$migration = str_replace('{level}', $ucFirstLevel, 'class {level}MigrationTemplate ');
|
||||
$migration = str_replace($migration, "class $name ", $template);
|
||||
$path = "$this->migrationsDir/$ucFirstLevel/$name.php";
|
||||
$result = @file_put_contents($path, $migration);
|
||||
if (!$result) {
|
||||
throw MigratorException::migrationFileWriteFailed($path);
|
||||
}
|
||||
return [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of migration filenames and types.
|
||||
* Db migrations are loaded first, then app migrations. This ensures that Db migrator is run before app migrations
|
||||
* @return array<array{level: string, name: string}>
|
||||
*/
|
||||
public function loadAll(): array {
|
||||
$migrations = array_merge(
|
||||
$this->loadForLevel(self::MIGRATIONS_LEVEL_DB),
|
||||
$this->loadForLevel(self::MIGRATIONS_LEVEL_APP)
|
||||
);
|
||||
$migrationNames = array_column($migrations, 'name');
|
||||
$duplicateNames = array_diff_assoc($migrationNames, array_unique($migrationNames));
|
||||
if (!empty($duplicateNames)) {
|
||||
throw MigratorException::duplicateMigrationNames($duplicateNames);
|
||||
}
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{level: string, name: string}>
|
||||
*/
|
||||
private function loadForLevel(string $level): array {
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($this->migrationsDir . '/' . ucfirst($level), RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$migrations = [];
|
||||
foreach ($files as $file) {
|
||||
if (!$file instanceof SplFileInfo || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (strtolower($file->getFilename()) === 'index.php') {
|
||||
continue;
|
||||
}
|
||||
if (strtolower($file->getExtension()) === 'php') {
|
||||
$migrations[] = $file->getBasename('.' . $file->getExtension());
|
||||
}
|
||||
}
|
||||
sort($migrations);
|
||||
return array_map(function ($migration) use ($level) {
|
||||
return [
|
||||
'level' => $level,
|
||||
'name' => $migration,
|
||||
];
|
||||
}, $migrations);
|
||||
}
|
||||
|
||||
private function generateName(string $level): string {
|
||||
return 'Migration_' . gmdate('Ymd_His') . '_' . ucfirst($level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoet\Migrations\App\AppMigrationTemplate;
|
||||
use MailPoet\Migrations\Db\DbMigrationTemplate;
|
||||
use Throwable;
|
||||
|
||||
class Runner {
|
||||
/** @var ContainerWrapper */
|
||||
private $container;
|
||||
|
||||
/** @var Store */
|
||||
private $store;
|
||||
|
||||
public function __construct(
|
||||
ContainerWrapper $container,
|
||||
Store $store
|
||||
) {
|
||||
$this->container = $container;
|
||||
$this->store = $store;
|
||||
}
|
||||
|
||||
public function runMigration(string $name, string $level): void {
|
||||
$className = $this->getClassName($name, $level);
|
||||
|
||||
try {
|
||||
/** @var DbMigration|AppMigration $migration */
|
||||
$migration = new $className($this->container);
|
||||
$this->store->startMigration($name);
|
||||
$migration->run();
|
||||
$this->store->completeMigration($name);
|
||||
} catch (Throwable $e) {
|
||||
$this->store->failMigration($name, (string)$e);
|
||||
throw MigratorException::migrationFailed($className, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function getClassName(string $name, string $level): string {
|
||||
$templateClass = $level === Repository::MIGRATIONS_LEVEL_DB ? DbMigrationTemplate::class : AppMigrationTemplate::class;
|
||||
$className = $this->getNamespace($templateClass) . '\\' . $name;
|
||||
if (!class_exists($className)) {
|
||||
throw MigratorException::migrationClassNotFound($className);
|
||||
}
|
||||
|
||||
$parentClass = $level === Repository::MIGRATIONS_LEVEL_DB ? DbMigration::class : AppMigration::class;
|
||||
if (!is_subclass_of($className, $parentClass)) {
|
||||
throw MigratorException::migrationClassIsNotASubclassOf($className, $parentClass);
|
||||
}
|
||||
return $className;
|
||||
}
|
||||
|
||||
private function getNamespace(string $className): string {
|
||||
$parts = explode('\\', $className);
|
||||
return implode('\\', array_slice($parts, 0, -1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Migrator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoetVendor\Doctrine\DBAL\Connection;
|
||||
|
||||
class Store {
|
||||
/** @var Connection */
|
||||
private $connection;
|
||||
|
||||
/** @var string */
|
||||
private $table;
|
||||
|
||||
public function __construct(
|
||||
Connection $connection
|
||||
) {
|
||||
$this->connection = $connection;
|
||||
$this->table = Env::$dbPrefix . 'migrations';
|
||||
}
|
||||
|
||||
public function getMigrationsTable(): string {
|
||||
return $this->table;
|
||||
}
|
||||
|
||||
public function startMigration(string $name): void {
|
||||
$this->connection->executeStatement("
|
||||
INSERT INTO {$this->table} (name, started_at)
|
||||
VALUES (?, current_timestamp())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
started_at = current_timestamp(),
|
||||
completed_at = NULL,
|
||||
retries = retries + 1,
|
||||
error = NULL
|
||||
", [$name]);
|
||||
}
|
||||
|
||||
public function completeMigration(string $name): void {
|
||||
$this->connection->executeStatement("
|
||||
UPDATE {$this->table}
|
||||
SET completed_at = current_timestamp()
|
||||
WHERE name = ?
|
||||
", [$name]);
|
||||
}
|
||||
|
||||
public function failMigration(string $name, string $error): void {
|
||||
$this->connection->executeStatement("
|
||||
UPDATE {$this->table}
|
||||
SET
|
||||
completed_at = current_timestamp(),
|
||||
error = ?
|
||||
WHERE name = ?
|
||||
", [$error ?: 'Unknown error', $name]);
|
||||
}
|
||||
|
||||
public function getAll(): array {
|
||||
// Some backup plugins may convert NULL values to empty strings,
|
||||
// in which case we need to cast the error column value to NULL.
|
||||
return $this->connection->fetchAllAssociative("
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
started_at,
|
||||
completed_at,
|
||||
retries,
|
||||
IF(error = '', NULL, error) AS error
|
||||
FROM {$this->table}
|
||||
ORDER BY id ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function ensureMigrationsTable(): void {
|
||||
$collate = Env::$dbCharsetCollate;
|
||||
$this->connection->executeStatement("
|
||||
CREATE TABLE IF NOT EXISTS {$this->table} (
|
||||
id int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
name varchar(191) NOT NULL,
|
||||
started_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at timestamp NULL,
|
||||
retries int(11) unsigned NOT NULL DEFAULT 0,
|
||||
error text NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (name)
|
||||
) {$collate};
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
Reference in New Issue
Block a user