8889841cLogger.php000064400000001434150444174150006503 0ustar00migrator = $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(''); } } Store.php000064400000004401150444174150006355 0ustar00connection = $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, completed_at timestamp NULL, retries int(11) unsigned NOT NULL DEFAULT 0, error text NULL, PRIMARY KEY (id), UNIQUE KEY (name) ) {$collate}; "); } } Runner.php000064400000002622150444174150006535 0ustar00container = $container; $this->store = $store; $this->namespace = $this->getMigrationsNamespace(); } public function runMigration(string $name): void { $className = $this->namespace . '\\' . $name; if (!class_exists($className)) { throw MigratorException::migrationClassNotFound($className); } if (!is_subclass_of($className, Migration::class)) { throw MigratorException::migrationClassIsNotASubclassOf($className, Migration::class); } try { $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 getMigrationsNamespace(): string { $parts = explode('\\', MigrationTemplate::class); return implode('\\', array_slice($parts, 0, -1)); } } Repository.php000064400000003522150444174150007443 0ustar00migrationsDir = __DIR__ . '/../Migrations'; $this->templateFile = __DIR__ . '/MigrationTemplate.php'; } public function getMigrationsDir(): string { return $this->migrationsDir; } /** @return array{name: string, path: string} */ public function create(): array { $template = @file_get_contents($this->templateFile); if (!$template) { throw MigratorException::templateFileReadFailed($this->templateFile); } $name = $this->generateName(); $migration = str_replace('class MigrationTemplate ', "class $name ", $template); $path = $this->migrationsDir . "/$name.php"; $result = @file_put_contents($path, $migration); if (!$result) { throw MigratorException::migrationFileWriteFailed($path); } return [ 'name' => $name, 'path' => $path, ]; } public function loadAll(): array { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($this->migrationsDir, 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 $migrations; } private function generateName(): string { return 'Migration_' . gmdate('Ymd_His'); } } index.php000064400000000006150444174150006365 0ustar00repository = $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['unknown'] || $migration['status'] === self::MIGRATION_STATUS_COMPLETED) { continue; } if ($logger) { $logger->logMigrationStarted($migration); } $this->runner->runMigration($migration['name']); if ($logger) { $logger->logMigrationCompleted($migration); } } if ($logger) { $logger->logAfter(); } } /** @return array{name: string, status: string, started_at: string|null, completed_at: string|null, retries: int|null, error: string|null, unknown: bool}[] */ public function getStatus(): array { $defined = $this->repository->loadAll(); $processed = $this->store->getAll(); $processedMap = array_combine(array_column($processed, 'name'), $processed) ?: []; $all = array_unique(array_merge($defined, array_keys($processedMap))); sort($all); $status = []; foreach ($all as $name) { $data = $processedMap[$name] ?? []; $status[] = [ 'name' => $name, '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' => !in_array($name, $defined, true), ]; } 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; } } MigratorException.php000064400000002370150444174150010727 0ustar00withMessage( sprintf('Could not read migration template file "%s".', $path) ); } 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()) ); } } MigrationTemplate.php000064400000001037150444174150010710 0ustar00connection For SQL queries using Doctrine DBAL. * $this->entityManager For operations using Doctrine Entity Manager. * $this->container For accessing any needed service. */ } } Migration.php000064400000003337150444174150007221 0ustar00container = $container; $this->connection = $container->get(Connection::class); $this->entityManager = $container->get(EntityManager::class); } abstract public function run(): void; 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 { // We had a problem with the dbName value in ENV for some customers, because it doesn't match DB name in information schema. // So we decided to use the DATABASE() value instead. return $this->connection->executeQuery(" SELECT 1 FROM information_schema.columns WHERE table_schema = COALESCE(DATABASE(), ?) AND table_name = ? AND column_name = ? ", [Env::$dbName, $tableName, $columnName])->fetchOne() !== false; } }