This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -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