diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b19b80..2517eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,11 @@ where applicable. ## Unreleased -- Initial SymPress Migration package documentation. +### Changed + +- Split WP-CLI migration command execution and reporting into focused collaborators. +- Adopt shared SymPress QA tooling for package scripts and development dependencies. + +### Fixed + +- Cover rollback behavior when a migration fails during reverse execution. diff --git a/composer.json b/composer.json index 98e90fe..ccc92b1 100644 --- a/composer.json +++ b/composer.json @@ -16,14 +16,11 @@ "symfony/console": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^10.5", - "mockery/mockery": "^1.6", - "phpstan/phpstan": "^2.1", - "friendsofphp/php-cs-fixer": "^3.94", - "sympress/coding-standards": "dev-main", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "brain/monkey": "^2", - "symfony/var-dumper": "^8.1" + "friendsofphp/php-cs-fixer": "^3.94", + "mockery/mockery": "^1.6", + "symfony/var-dumper": "^8.1", + "sympress/qa": "dev-main" }, "autoload": { "psr-4": { @@ -33,19 +30,22 @@ "scripts": { "cs": [ "Composer\\Config::disableProcessTimeout", - "phpcs --standard=phpcs.xml.dist" + "qa cs" ], "cs:fix": [ "Composer\\Config::disableProcessTimeout", - "phpcbf --standard=phpcs.xml.dist" + "qa cs:fix" ], "static-analysis": [ "Composer\\Config::disableProcessTimeout", - "phpstan analyse --memory-limit=1G --no-progress -c phpstan.neon.dist" + "qa static-analysis" ], "tests": [ "Composer\\Config::disableProcessTimeout", - "phpunit --configuration phpunit.xml.dist --no-coverage" + "qa tests" + ], + "test": [ + "@tests" ], "qa": [ "@cs", @@ -58,7 +58,8 @@ "optimize-autoloader": true, "preferred-install": "dist", "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true } }, "repositories": [ diff --git a/docs/example/your-plugin.php b/docs/example/your-plugin.php index 7f3a5ad..a943837 100644 --- a/docs/example/your-plugin.php +++ b/docs/example/your-plugin.php @@ -6,7 +6,7 @@ * Plugin Name: Your Plugin * Description: Example plugin using the migration system * Version: 1.0.0 - * Requires PHP: 8.2 + * Requires PHP: 8.5 */ declare(strict_types=1); diff --git a/src/Cli/MigrationCommand.php b/src/Cli/MigrationCommand.php index c1659b4..321e9b3 100644 --- a/src/Cli/MigrationCommand.php +++ b/src/Cli/MigrationCommand.php @@ -4,18 +4,21 @@ namespace SymPress\WordPress\Migration\Cli; -use SymPress\WordPress\Migration\Domain\MigrationManager; -use SymPress\WordPress\Migration\Infrastructure\MigrationTracker; use SymPress\WordPress\Migration\Registry\MigrationRegistry; -use SymPress\WordPress\Migration\Value\MigrationExecution; -use WP_CLI; final readonly class MigrationCommand { + private MigrationCommandExecutor $executor; + private MigrationCommandReporter $reporter; + public function __construct( - private ?MigrationRegistry $registry = null, - private ?SymfonyConsoleRunner $consoleRunner = null, + ?MigrationRegistry $registry = null, + ?SymfonyConsoleRunner $consoleRunner = null, ) { + + $context = new MigrationCommandContext($registry, $consoleRunner); + $this->executor = new MigrationCommandExecutor($context); + $this->reporter = new MigrationCommandReporter($context); } /** @@ -52,19 +55,7 @@ public function run(array $args): void */ public function migrate(array $args, array $assocArgs = []): void { - $pluginSlug = $args[0] ?? null; - $target = $args[1] ?? null; - - if (!is_string($pluginSlug) || $pluginSlug === '') { - if ($target !== null) { - WP_CLI::error('A target version requires a plugin slug.'); - } - - $this->migrateAllPlugins(); - return; - } - - $this->migrateSinglePlugin($pluginSlug, $this->normalizeOptionalString($target)); + $this->executor->migrate($args); } /** @@ -83,15 +74,7 @@ public function migrate(array $args, array $assocArgs = []): void */ public function rollback(array $args, array $assocArgs): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $migrationClass = $this->normalizeOptionalString($assocArgs['migration'] ?? null); - - if ($pluginSlug !== null) { - $this->rollbackSinglePlugin($pluginSlug, $migrationClass); - return; - } - - $this->rollbackAllPlugins(); + $this->executor->rollback($args, $assocArgs); } /** @@ -116,26 +99,7 @@ public function rollback(array $args, array $assocArgs): void */ public function execute(array $args, array $assocArgs): void { - $pluginSlug = $this->requirePluginSlug($args, 'execute'); - $migrationClass = $this->requireMigrationClass($args, 'execute'); - $direction = $this->resolveExecutionDirection($assocArgs); - $manager = $this->getManagerOrFail($pluginSlug); - - if (!$manager->executeMigration($migrationClass, $direction)) { - WP_CLI::error(sprintf( - 'Failed to execute migration "%s" with direction "%s" for "%s".', - $migrationClass, - $direction, - $pluginSlug, - )); - } - - WP_CLI::success(sprintf( - 'Executed "%s" for migration "%s" on "%s".', - $direction, - $migrationClass, - $pluginSlug, - )); + $this->executor->execute($args, $assocArgs); } /** @@ -160,24 +124,7 @@ public function execute(array $args, array $assocArgs): void */ public function version(array $args, array $assocArgs): void { - $pluginSlug = $this->requirePluginSlug($args, 'version'); - $migrationClass = $this->requireMigrationClass($args, 'version'); - $direction = $this->resolveVersionDirection($assocArgs); - $manager = $this->getManagerOrFail($pluginSlug); - - if (!$manager->markMigration($migrationClass, $direction)) { - WP_CLI::error(sprintf( - 'Failed to update metadata for migration "%s" on "%s".', - $migrationClass, - $pluginSlug, - )); - } - - WP_CLI::success(sprintf( - 'Metadata updated for migration "%s" on "%s".', - $migrationClass, - $pluginSlug, - )); + $this->executor->version($args, $assocArgs); } /** @@ -199,20 +146,7 @@ public function version(array $args, array $assocArgs): void */ public function status(array $args, array $assocArgs = []): void { - if ($this->runConsoleCommand('migration:status', $args, $assocArgs)) { - return; - } - - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $verbose = isset($assocArgs['verbose']); - $format = $this->outputFormat($assocArgs); - - if ($pluginSlug !== null) { - $this->statusSinglePlugin($pluginSlug, $verbose, $format); - return; - } - - $this->statusAllPlugins($format); + $this->reporter->status($args, $assocArgs); } /** @@ -230,36 +164,7 @@ public function status(array $args, array $assocArgs = []): void */ public function upToDate(array $args = [], array $assocArgs = []): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - - if ($pluginSlug !== null) { - $manager = $this->getManagerOrFail($pluginSlug); - - if ($manager->isUpToDate()) { - WP_CLI::success(sprintf('"%s" is up to date.', $pluginSlug)); - return; - } - - WP_CLI::warning(sprintf('"%s" has pending migrations.', $pluginSlug)); - return; - } - - $format = $this->outputFormat($assocArgs); - $rows = []; - - foreach ($this->registry()->all() as $slug => $manager) { - $rows[] = [ - 'plugin' => $slug, - 'up_to_date' => $manager->isUpToDate() ? 'yes' : 'no', - ]; - } - - if ($rows === []) { - WP_CLI::warning('No plugins with migrations registered.'); - return; - } - - \WP_CLI\Utils\format_items($format, $rows, ['plugin', 'up_to_date']); + $this->reporter->upToDate($args, $assocArgs); } /** @@ -278,48 +183,7 @@ public function upToDate(array $args = [], array $assocArgs = []): void */ public function current(array $args = [], array $assocArgs = []): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $format = $this->outputFormat($assocArgs); - - if ($pluginSlug !== null) { - $manager = $this->getManagerOrFail($pluginSlug); - $row = $this->normalizeCurrentRow($pluginSlug, $manager->getCurrentMigration()); - - if ($row === null) { - WP_CLI::warning(sprintf('No migrated version found for "%s".', $pluginSlug)); - return; - } - - \WP_CLI\Utils\format_items( - $format, - [$row], - ['plugin', 'class', 'name', 'version', 'migrated_at'], - ); - return; - } - - $rows = []; - - foreach ($this->registry()->all() as $slug => $manager) { - $row = $this->normalizeCurrentRow($slug, $manager->getCurrentMigration()); - - if ($row === null) { - continue; - } - - $rows[] = $row; - } - - if ($rows === []) { - WP_CLI::warning('No migrated versions found.'); - return; - } - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['plugin', 'class', 'name', 'version', 'migrated_at'], - ); + $this->reporter->current($args, $assocArgs); } /** @@ -338,40 +202,7 @@ public function current(array $args = [], array $assocArgs = []): void */ public function latest(array $args = [], array $assocArgs = []): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $format = $this->outputFormat($assocArgs); - - if ($pluginSlug !== null) { - $manager = $this->getManagerOrFail($pluginSlug); - $row = $this->normalizeLatestRow($pluginSlug, $manager->getLatestMigration()); - - if ($row === null) { - WP_CLI::warning(sprintf('No registered migrations found for "%s".', $pluginSlug)); - return; - } - - \WP_CLI\Utils\format_items($format, [$row], ['plugin', 'class', 'name', 'version']); - return; - } - - $rows = []; - - foreach ($this->registry()->all() as $slug => $manager) { - $row = $this->normalizeLatestRow($slug, $manager->getLatestMigration()); - - if ($row === null) { - continue; - } - - $rows[] = $row; - } - - if ($rows === []) { - WP_CLI::warning('No registered migrations found.'); - return; - } - - \WP_CLI\Utils\format_items($format, $rows, ['plugin', 'class', 'name', 'version']); + $this->reporter->latest($args, $assocArgs); } /** @@ -390,35 +221,7 @@ public function latest(array $args = [], array $assocArgs = []): void */ public function history(array $args = [], array $assocArgs = []): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $format = $this->outputFormat($assocArgs); - $tracker = $this->tracker(); - $records = $pluginSlug === null - ? $tracker->findAllHistory() - : $tracker->findHistoryForPlugin($pluginSlug); - - if ($records === []) { - WP_CLI::warning('No migration history found.'); - return; - } - - $rows = array_map( - fn (MigrationExecution $record): array => [ - 'plugin' => $record->plugin, - 'migration' => $record->migration, - 'name' => $this->extractClassName($record->migration), - 'version' => $record->version, - 'direction' => $record->direction, - 'executed_at' => $record->executedAt, - ], - $records, - ); - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], - ); + $this->reporter->history($args, $assocArgs); } /** @@ -437,19 +240,7 @@ public function history(array $args = [], array $assocArgs = []): void */ public function list(array $args = [], array $assocArgs = []): void { - if ($this->runConsoleCommand('migration:list', $args, $assocArgs)) { - return; - } - - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - $format = $this->outputFormat($assocArgs); - - if ($pluginSlug !== null) { - $this->listPluginMigrations($pluginSlug, $format); - return; - } - - $this->listRegisteredPlugins($format); + $this->reporter->list($args, $assocArgs); } /** @@ -468,50 +259,7 @@ public function list(array $args = [], array $assocArgs = []): void */ public function info(array $args, array $assocArgs = []): void { - $pluginSlug = $this->requirePluginSlug($args, 'info'); - $manager = $this->getManagerOrFail($pluginSlug); - $format = $this->outputFormat($assocArgs); - - $this->statusSinglePlugin($pluginSlug, true, $format); - - $migrated = $manager->getMigratedVersions(); - - if ($migrated !== []) { - WP_CLI::log("\nMigrated versions:"); - \WP_CLI\Utils\format_items($format, $migrated, ['migration', 'version', 'migrated_at']); - } - - $pending = $manager->getPendingMigrations(); - - if ($pending !== []) { - WP_CLI::log("\nPending migrations:"); - \WP_CLI\Utils\format_items($format, $pending, ['class', 'name', 'version']); - } - - $history = $this->tracker()->findHistoryForPlugin($pluginSlug); - - if ($history === []) { - return; - } - - WP_CLI::log("\nExecution history:"); - $rows = array_map( - fn (MigrationExecution $record): array => [ - 'plugin' => $record->plugin, - 'migration' => $record->migration, - 'name' => $this->extractClassName($record->migration), - 'version' => $record->version, - 'direction' => $record->direction, - 'executed_at' => $record->executedAt, - ], - $history, - ); - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], - ); + $this->reporter->info($args, $assocArgs); } /** @@ -529,553 +277,6 @@ public function info(array $args, array $assocArgs = []): void */ public function syncMetadataStorage(array $args = [], array $assocArgs = []): void { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - - if ($pluginSlug !== null) { - $manager = $this->getManagerOrFail($pluginSlug); - - if (!$manager->syncMetadataStorage()) { - WP_CLI::error(sprintf('Failed to sync metadata storage for "%s".', $pluginSlug)); - } - - WP_CLI::success(sprintf('Metadata storage synced for "%s".', $pluginSlug)); - return; - } - - if (!$this->tracker()->ensureTableExists()) { - WP_CLI::error('Failed to sync metadata storage.'); - } - - WP_CLI::success('Metadata storage synced.'); - } - - private function migrateSinglePlugin(string $pluginSlug, ?string $target): void - { - $manager = $this->getManagerOrFail($pluginSlug); - - if ($target === null && !$manager->hasPendingMigrations()) { - WP_CLI::success(sprintf('No pending migrations for "%s".', $pluginSlug)); - return; - } - - if ($target === null) { - WP_CLI::log(sprintf('Running pending migrations for "%s"...', $pluginSlug)); - } - - if ($target !== null) { - WP_CLI::log(sprintf('Migrating "%s" to "%s"...', $pluginSlug, $target)); - } - - if (!$manager->migrateTo($target)) { - WP_CLI::error(sprintf('Migration failed for "%s".', $pluginSlug)); - } - - if ($target === null) { - WP_CLI::success(sprintf('All migrations completed for "%s".', $pluginSlug)); - return; - } - - WP_CLI::success(sprintf('Migration target "%s" reached for "%s".', $target, $pluginSlug)); - } - - private function migrateAllPlugins(): void - { - $managers = $this->registry()->all(); - - if ($managers === []) { - WP_CLI::warning('No plugins with migrations registered.'); - return; - } - - $processed = 0; - - foreach ($managers as $slug => $manager) { - if (!$manager->hasPendingMigrations()) { - continue; - } - - $processed++; - WP_CLI::log(sprintf('Running pending migrations for "%s"...', $slug)); - - if ($manager->runMigrations()) { - WP_CLI::success(sprintf('Completed migrations for "%s".', $slug)); - continue; - } - - WP_CLI::error(sprintf('Migration failed for "%s".', $slug)); - } - - if ($processed === 0) { - WP_CLI::success('No pending migrations found.'); - return; - } - - WP_CLI::success(sprintf('All migrations completed for %d plugin(s).', $processed)); - } - - private function rollbackSinglePlugin(string $pluginSlug, ?string $migrationClass): void - { - $manager = $this->getManagerOrFail($pluginSlug); - - if ($migrationClass !== null) { - $this->rollbackSpecificMigration($pluginSlug, $manager, $migrationClass); - return; - } - - WP_CLI::log(sprintf('Rolling back all migrations for "%s"...', $pluginSlug)); - - if (!$manager->rollbackMigrations()) { - WP_CLI::error(sprintf('Rollback failed for "%s".', $pluginSlug)); - } - - WP_CLI::success(sprintf('All migrations rolled back for "%s".', $pluginSlug)); - } - - private function rollbackSpecificMigration( - string $pluginSlug, - MigrationManager $manager, - string $migrationClass, - ): void { - - WP_CLI::log(sprintf( - 'Rolling back migration "%s" for "%s"...', - $migrationClass, - $pluginSlug, - )); - - if (!$manager->rollbackMigration($migrationClass)) { - WP_CLI::error(sprintf( - 'Failed to rollback migration "%s" for "%s".', - $migrationClass, - $pluginSlug, - )); - } - - WP_CLI::success(sprintf( - 'Migration "%s" rolled back for "%s".', - $migrationClass, - $pluginSlug, - )); - } - - private function rollbackAllPlugins(): void - { - $managers = $this->registry()->all(); - - if ($managers === []) { - WP_CLI::warning('No plugins with migrations registered.'); - return; - } - - $processed = 0; - - foreach ($managers as $slug => $manager) { - $processed++; - WP_CLI::log(sprintf('Rolling back migrations for "%s"...', $slug)); - - if ($manager->rollbackMigrations()) { - WP_CLI::success(sprintf('Rollback completed for "%s".', $slug)); - continue; - } - - WP_CLI::error(sprintf('Rollback failed for "%s".', $slug)); - } - - WP_CLI::success(sprintf('All rollbacks completed for %d plugin(s).', $processed)); - } - - private function statusSinglePlugin(string $pluginSlug, bool $verbose, string $format): void - { - $manager = $this->getManagerOrFail($pluginSlug); - $current = $manager->getCurrentMigration(); - $latest = $manager->getLatestMigration(); - $migrated = $manager->getMigratedVersions(); - $pending = $manager->getPendingMigrations(); - $history = $manager->getMigrationHistory(); - - WP_CLI::log(sprintf("\nPlugin: %s", $pluginSlug)); - WP_CLI::log(sprintf('Status: %s', $manager->isUpToDate() ? 'Up to Date' : 'Pending')); - WP_CLI::log(sprintf('Current: %s', $current['version'] ?? 'none')); - WP_CLI::log(sprintf('Latest: %s', $latest['version'] ?? 'none')); - WP_CLI::log(sprintf('Migrated: %d', count($migrated))); - WP_CLI::log(sprintf('Pending: %d', count($pending))); - WP_CLI::log(sprintf('Executions: %d', count($history))); - - if ($verbose && $pending !== []) { - WP_CLI::log("\nPending migrations:"); - \WP_CLI\Utils\format_items($format, $pending, ['class', 'name', 'version']); - } - - if ($verbose && $history !== []) { - WP_CLI::log("\nRecent execution history:"); - \WP_CLI\Utils\format_items( - $format, - array_slice($this->historyRowsFromManager($manager), 0, 10), - ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], - ); - } - - if ($manager->isUpToDate()) { - WP_CLI::success(sprintf("\nPlugin \"%s\" is up to date.", $pluginSlug)); - return; - } - - WP_CLI::warning(sprintf("\nPlugin \"%s\" has pending migrations.", $pluginSlug)); - } - - private function statusAllPlugins(string $format): void - { - $rows = []; - - foreach ($this->registry()->all() as $slug => $manager) { - $rows[] = $this->pluginOverview($slug, $manager); - } - - if ($rows === []) { - WP_CLI::warning('No plugins with migrations registered.'); - return; - } - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['plugin', 'current', 'latest', 'migrated', 'pending', 'executions', 'status'], - ); - } - - private function listRegisteredPlugins(string $format): void - { - $rows = []; - - foreach ($this->registry()->all() as $slug => $manager) { - $rows[] = $this->pluginOverview($slug, $manager); - } - - if ($rows === []) { - WP_CLI::warning('No plugins with migrations registered.'); - return; - } - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['plugin', 'current', 'latest', 'migrated', 'pending', 'executions', 'status'], - ); - } - - private function listPluginMigrations(string $pluginSlug, string $format): void - { - $manager = $this->getManagerOrFail($pluginSlug); - $migratedByClass = []; - - foreach ($manager->getMigratedVersions() as $record) { - $migratedByClass[$record['migration']] = $record; - } - - $rows = []; - - foreach ($manager->all() as $class => $migration) { - $record = $migratedByClass[$class] ?? null; - $rows[] = [ - 'class' => $class, - 'name' => $this->extractClassName($class), - 'version' => $migration->getVersion(), - 'status' => is_array($record) ? 'Migrated' : 'Pending', - 'migrated_at' => is_array($record) ? $record['migrated_at'] : '', - ]; - } - - if ($rows === []) { - WP_CLI::warning(sprintf('No registered migrations found for "%s".', $pluginSlug)); - return; - } - - \WP_CLI\Utils\format_items( - $format, - $rows, - ['class', 'name', 'version', 'status', 'migrated_at'], - ); - } - - /** - * @return array{ - * plugin: string, - * current: string, - * latest: string, - * migrated: int, - * pending: int, - * executions: int, - * status: string - * } - */ - private function pluginOverview(string $pluginSlug, MigrationManager $manager): array - { - $current = $manager->getCurrentMigration(); - $latest = $manager->getLatestMigration(); - - return [ - 'plugin' => $pluginSlug, - 'current' => $current['version'] ?? 'none', - 'latest' => $latest['version'] ?? 'none', - 'migrated' => count($manager->getMigratedVersions()), - 'pending' => count($manager->getPendingMigrations()), - 'executions' => count($manager->getMigrationHistory()), - 'status' => $manager->isUpToDate() ? 'Up to Date' : 'Pending', - ]; - } - - /** - * @param array{ - * class: string, - * name: string, - * version: string, - * migrated_at: string - * }|null $current - * @return array{ - * plugin: string, - * class: string, - * name: string, - * version: string, - * migrated_at: string - * }|null - */ - private function normalizeCurrentRow(string $pluginSlug, ?array $current): ?array - { - if ($current === null) { - return null; - } - - return [ - 'plugin' => $pluginSlug, - 'class' => $current['class'], - 'name' => $current['name'], - 'version' => $current['version'], - 'migrated_at' => $current['migrated_at'], - ]; - } - - /** - * @param array{class: string, name: string, version: string}|null $latest - * @return array{plugin: string, class: string, name: string, version: string}|null - */ - private function normalizeLatestRow(string $pluginSlug, ?array $latest): ?array - { - if ($latest === null) { - return null; - } - - return [ - 'plugin' => $pluginSlug, - 'class' => $latest['class'], - 'name' => $latest['name'], - 'version' => $latest['version'], - ]; - } - - /** - * @return list - */ - private function historyRowsFromManager(MigrationManager $manager): array - { - return array_map( - fn (array $row): array => [ - 'plugin' => $row['plugin'], - 'migration' => $row['migration'], - 'name' => $this->extractClassName($row['migration']), - 'version' => $row['version'], - 'direction' => $row['direction'], - 'executed_at' => $row['executed_at'], - ], - $manager->getMigrationHistory(), - ); - } - - /** - * @param array $assocArgs - */ - private function resolveExecutionDirection(array $assocArgs): string - { - $up = isset($assocArgs['up']); - $down = isset($assocArgs['down']); - - if ($up && $down) { - WP_CLI::error('Use either --up or --down, not both.'); - } - - if (!$up && !$down) { - WP_CLI::error('Please provide either --up or --down.'); - } - - if ($up) { - return 'up'; - } - - return 'down'; - } - - /** - * @param array $assocArgs - */ - private function resolveVersionDirection(array $assocArgs): string - { - $add = isset($assocArgs['add']); - $delete = isset($assocArgs['delete']); - - if ($add && $delete) { - WP_CLI::error('Use either --add or --delete, not both.'); - } - - if (!$add && !$delete) { - WP_CLI::error('Please provide either --add or --delete.'); - } - - if ($add) { - return 'up'; - } - - return 'down'; - } - - /** - * @param list $args - */ - private function requirePluginSlug(array $args, string $command): string - { - $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); - - if ($pluginSlug !== null) { - return $pluginSlug; - } - - WP_CLI::error(sprintf( - 'Please provide a plugin slug. Example: wp migration %s my-plugin', - $command, - )); - } - - /** - * @param list $args - */ - private function requireMigrationClass(array $args, string $command): string - { - $migrationClass = $this->normalizeOptionalString($args[1] ?? null); - - if ($migrationClass !== null) { - return $migrationClass; - } - - WP_CLI::error(sprintf( - 'Please provide a migration class. Example: ' - . 'wp migration %s my-plugin Vendor\\\\MyMigration --up', - $command, - )); - } - - private function getManagerOrFail(string $pluginSlug): MigrationManager - { - $manager = $this->registry()->get($pluginSlug); - - if ($manager instanceof MigrationManager) { - return $manager; - } - - $message = sprintf( - 'Plugin "%s" not found. Use "wp migration list" to see registered plugins.', - $pluginSlug, - ); - - WP_CLI::error($message); - } - - private function registry(): MigrationRegistry - { - return $this->registry ?? MigrationRegistry::getInstance(); - } - - private function tracker(): MigrationTracker - { - return new MigrationTracker($this->database()); - } - - private function database(): \wpdb - { - $database = $GLOBALS['wpdb'] ?? null; - - if ($database instanceof \wpdb) { - return $database; - } - - WP_CLI::error('Global $wpdb is not available.'); - } - - /** - * @param array $assocArgs - */ - private function outputFormat(array $assocArgs): string - { - $format = $this->normalizeOptionalString($assocArgs['format'] ?? null); - - if ($format !== null) { - return $format; - } - - return 'table'; - } - - /** - * @param list $args - * @param array $assocArgs - */ - private function runConsoleCommand(string $commandName, array $args, array $assocArgs): bool - { - if (!$this->consoleRunner instanceof SymfonyConsoleRunner || !$this->consoleRunner->has($commandName)) { - return false; - } - - $status = $this->consoleRunner->run($commandName, $args, $assocArgs); - - if ($status !== 0) { - if (method_exists(WP_CLI::class, 'halt')) { - WP_CLI::halt($status); - } - - WP_CLI::error(sprintf('Symfony command "%s" failed with status %d.', $commandName, $status)); - } - - return true; - } - - private function normalizeOptionalString(mixed $value): ?string - { - if (!is_scalar($value) && !$value instanceof \Stringable) { - return null; - } - - $stringValue = trim((string) $value); - - if ($stringValue === '') { - return null; - } - - return $stringValue; - } - - private function extractClassName(string $fullClassName): string - { - $parts = explode('\\', $fullClassName); - $className = array_pop($parts); - - if ($className === '') { - return $fullClassName; - } - - return $className; + $this->executor->syncMetadataStorage($args); } } diff --git a/src/Cli/MigrationCommandContext.php b/src/Cli/MigrationCommandContext.php new file mode 100644 index 0000000..b6f7c30 --- /dev/null +++ b/src/Cli/MigrationCommandContext.php @@ -0,0 +1,153 @@ +registry ?? MigrationRegistry::getInstance(); + } + + public function tracker(): MigrationTracker + { + return new MigrationTracker($this->database()); + } + + public function database(): \wpdb + { + $database = $GLOBALS['wpdb'] ?? null; + + if ($database instanceof \wpdb) { + return $database; + } + + WP_CLI::error('Global $wpdb is not available.'); + } + + public function managerOrFail(string $pluginSlug): MigrationManager + { + $manager = $this->registry()->get($pluginSlug); + + if ($manager instanceof MigrationManager) { + return $manager; + } + + WP_CLI::error(sprintf( + 'Plugin "%s" not found. Use "wp migration list" to see registered plugins.', + $pluginSlug, + )); + } + + /** + * @param list $args + */ + public function requirePluginSlug(array $args, string $command): string + { + $pluginSlug = $this->normalizeOptionalString($args[0] ?? null); + + if ($pluginSlug !== null) { + return $pluginSlug; + } + + WP_CLI::error(sprintf( + 'Please provide a plugin slug. Example: wp migration %s my-plugin', + $command, + )); + } + + /** + * @param list $args + */ + public function requireMigrationClass(array $args, string $command): string + { + $migrationClass = $this->normalizeOptionalString($args[1] ?? null); + + if ($migrationClass !== null) { + return $migrationClass; + } + + WP_CLI::error(sprintf( + 'Please provide a migration class. Example: ' + . 'wp migration %s my-plugin Vendor\\\\MyMigration --up', + $command, + )); + } + + /** + * @param array $assocArgs + */ + public function outputFormat(array $assocArgs): string + { + $format = $this->normalizeOptionalString($assocArgs['format'] ?? null); + + if ($format !== null) { + return $format; + } + + return 'table'; + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function runConsoleCommand(string $commandName, array $args, array $assocArgs): bool + { + if (!$this->consoleRunner instanceof SymfonyConsoleRunner || !$this->consoleRunner->has($commandName)) { + return false; + } + + $status = $this->consoleRunner->run($commandName, $args, $assocArgs); + + if ($status !== 0) { + if (method_exists(WP_CLI::class, 'halt')) { + WP_CLI::halt($status); + } + + WP_CLI::error(sprintf('Symfony command "%s" failed with status %d.', $commandName, $status)); + } + + return true; + } + + public function normalizeOptionalString(mixed $value): ?string + { + if (!is_scalar($value) && !$value instanceof \Stringable) { + return null; + } + + $stringValue = trim((string) $value); + + if ($stringValue === '') { + return null; + } + + return $stringValue; + } + + public function extractClassName(string $fullClassName): string + { + $parts = explode('\\', $fullClassName); + $className = array_pop($parts); + + if ($className === '') { + return $fullClassName; + } + + return $className; + } +} diff --git a/src/Cli/MigrationCommandExecutor.php b/src/Cli/MigrationCommandExecutor.php new file mode 100644 index 0000000..461ffa8 --- /dev/null +++ b/src/Cli/MigrationCommandExecutor.php @@ -0,0 +1,309 @@ + $args */ + public function migrate(array $args): void + { + $pluginSlug = $args[0] ?? null; + $target = $args[1] ?? null; + + if (!is_string($pluginSlug) || $pluginSlug === '') { + if ($target !== null) { + WP_CLI::error('A target version requires a plugin slug.'); + } + + $this->migrateAllPlugins(); + return; + } + + $this->migrateSinglePlugin($pluginSlug, $this->context->normalizeOptionalString($target)); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function rollback(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $migrationClass = $this->context->normalizeOptionalString($assocArgs['migration'] ?? null); + + if ($pluginSlug !== null) { + $this->rollbackSinglePlugin($pluginSlug, $migrationClass); + return; + } + + $this->rollbackAllPlugins(); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function execute(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->requirePluginSlug($args, 'execute'); + $migrationClass = $this->context->requireMigrationClass($args, 'execute'); + $direction = $this->resolveExecutionDirection($assocArgs); + $manager = $this->context->managerOrFail($pluginSlug); + + if (!$manager->executeMigration($migrationClass, $direction)) { + WP_CLI::error(sprintf( + 'Failed to execute migration "%s" with direction "%s" for "%s".', + $migrationClass, + $direction, + $pluginSlug, + )); + } + + WP_CLI::success(sprintf( + 'Executed "%s" for migration "%s" on "%s".', + $direction, + $migrationClass, + $pluginSlug, + )); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function version(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->requirePluginSlug($args, 'version'); + $migrationClass = $this->context->requireMigrationClass($args, 'version'); + $direction = $this->resolveVersionDirection($assocArgs); + $manager = $this->context->managerOrFail($pluginSlug); + + if (!$manager->markMigration($migrationClass, $direction)) { + WP_CLI::error(sprintf( + 'Failed to update metadata for migration "%s" on "%s".', + $migrationClass, + $pluginSlug, + )); + } + + WP_CLI::success(sprintf( + 'Metadata updated for migration "%s" on "%s".', + $migrationClass, + $pluginSlug, + )); + } + + /** @param list $args */ + public function syncMetadataStorage(array $args): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + + if ($pluginSlug !== null) { + $manager = $this->context->managerOrFail($pluginSlug); + + if (!$manager->syncMetadataStorage()) { + WP_CLI::error(sprintf('Failed to sync metadata storage for "%s".', $pluginSlug)); + } + + WP_CLI::success(sprintf('Metadata storage synced for "%s".', $pluginSlug)); + return; + } + + if (!$this->context->tracker()->ensureTableExists()) { + WP_CLI::error('Failed to sync metadata storage.'); + } + + WP_CLI::success('Metadata storage synced.'); + } + + private function migrateSinglePlugin(string $pluginSlug, ?string $target): void + { + $manager = $this->context->managerOrFail($pluginSlug); + + if ($target === null && !$manager->hasPendingMigrations()) { + WP_CLI::success(sprintf('No pending migrations for "%s".', $pluginSlug)); + return; + } + + if ($target === null) { + WP_CLI::log(sprintf('Running pending migrations for "%s"...', $pluginSlug)); + } + + if ($target !== null) { + WP_CLI::log(sprintf('Migrating "%s" to "%s"...', $pluginSlug, $target)); + } + + if (!$manager->migrateTo($target)) { + WP_CLI::error(sprintf('Migration failed for "%s".', $pluginSlug)); + } + + if ($target === null) { + WP_CLI::success(sprintf('All migrations completed for "%s".', $pluginSlug)); + return; + } + + WP_CLI::success(sprintf('Migration target "%s" reached for "%s".', $target, $pluginSlug)); + } + + private function migrateAllPlugins(): void + { + $managers = $this->context->registry()->all(); + + if ($managers === []) { + WP_CLI::warning('No plugins with migrations registered.'); + return; + } + + $processed = 0; + + foreach ($managers as $slug => $manager) { + if (!$manager->hasPendingMigrations()) { + continue; + } + + $processed++; + WP_CLI::log(sprintf('Running pending migrations for "%s"...', $slug)); + + if ($manager->runMigrations()) { + WP_CLI::success(sprintf('Completed migrations for "%s".', $slug)); + continue; + } + + WP_CLI::error(sprintf('Migration failed for "%s".', $slug)); + } + + if ($processed === 0) { + WP_CLI::success('No pending migrations found.'); + return; + } + + WP_CLI::success(sprintf('All migrations completed for %d plugin(s).', $processed)); + } + + private function rollbackSinglePlugin(string $pluginSlug, ?string $migrationClass): void + { + $manager = $this->context->managerOrFail($pluginSlug); + + if ($migrationClass !== null) { + $this->rollbackSpecificMigration($pluginSlug, $manager, $migrationClass); + return; + } + + WP_CLI::log(sprintf('Rolling back all migrations for "%s"...', $pluginSlug)); + + if (!$manager->rollbackMigrations()) { + WP_CLI::error(sprintf('Rollback failed for "%s".', $pluginSlug)); + } + + WP_CLI::success(sprintf('All migrations rolled back for "%s".', $pluginSlug)); + } + + private function rollbackSpecificMigration( + string $pluginSlug, + MigrationManager $manager, + string $migrationClass, + ): void { + + WP_CLI::log(sprintf( + 'Rolling back migration "%s" for "%s"...', + $migrationClass, + $pluginSlug, + )); + + if (!$manager->rollbackMigration($migrationClass)) { + WP_CLI::error(sprintf( + 'Failed to rollback migration "%s" for "%s".', + $migrationClass, + $pluginSlug, + )); + } + + WP_CLI::success(sprintf( + 'Migration "%s" rolled back for "%s".', + $migrationClass, + $pluginSlug, + )); + } + + private function rollbackAllPlugins(): void + { + $managers = $this->context->registry()->all(); + + if ($managers === []) { + WP_CLI::warning('No plugins with migrations registered.'); + return; + } + + $processed = 0; + + foreach ($managers as $slug => $manager) { + $processed++; + WP_CLI::log(sprintf('Rolling back migrations for "%s"...', $slug)); + + if ($manager->rollbackMigrations()) { + WP_CLI::success(sprintf('Rollback completed for "%s".', $slug)); + continue; + } + + WP_CLI::error(sprintf('Rollback failed for "%s".', $slug)); + } + + WP_CLI::success(sprintf('All rollbacks completed for %d plugin(s).', $processed)); + } + + /** + * @param array $assocArgs + */ + private function resolveExecutionDirection(array $assocArgs): string + { + $up = isset($assocArgs['up']); + $down = isset($assocArgs['down']); + + if ($up && $down) { + WP_CLI::error('Use either --up or --down, not both.'); + } + + if (!$up && !$down) { + WP_CLI::error('Please provide either --up or --down.'); + } + + if ($up) { + return 'up'; + } + + return 'down'; + } + + /** + * @param array $assocArgs + */ + private function resolveVersionDirection(array $assocArgs): string + { + $add = isset($assocArgs['add']); + $delete = isset($assocArgs['delete']); + + if ($add && $delete) { + WP_CLI::error('Use either --add or --delete, not both.'); + } + + if (!$add && !$delete) { + WP_CLI::error('Please provide either --add or --delete.'); + } + + if ($add) { + return 'up'; + } + + return 'down'; + } +} diff --git a/src/Cli/MigrationCommandReporter.php b/src/Cli/MigrationCommandReporter.php new file mode 100644 index 0000000..06fbc8c --- /dev/null +++ b/src/Cli/MigrationCommandReporter.php @@ -0,0 +1,429 @@ + $args + * @param array $assocArgs + */ + public function status(array $args, array $assocArgs): void + { + if ($this->context->runConsoleCommand('migration:status', $args, $assocArgs)) { + return; + } + + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $verbose = isset($assocArgs['verbose']); + $format = $this->context->outputFormat($assocArgs); + + if ($pluginSlug !== null) { + $this->statusSinglePlugin($pluginSlug, $verbose, $format); + return; + } + + $this->statusAllPlugins($format); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function upToDate(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + + if ($pluginSlug !== null) { + $manager = $this->context->managerOrFail($pluginSlug); + + if ($manager->isUpToDate()) { + WP_CLI::success(sprintf('"%s" is up to date.', $pluginSlug)); + return; + } + + WP_CLI::warning(sprintf('"%s" has pending migrations.', $pluginSlug)); + return; + } + + $format = $this->context->outputFormat($assocArgs); + $rows = []; + + foreach ($this->context->registry()->all() as $slug => $manager) { + $rows[] = [ + 'plugin' => $slug, + 'up_to_date' => $manager->isUpToDate() ? 'yes' : 'no', + ]; + } + + if ($rows === []) { + WP_CLI::warning('No plugins with migrations registered.'); + return; + } + + \WP_CLI\Utils\format_items($format, $rows, ['plugin', 'up_to_date']); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function current(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $format = $this->context->outputFormat($assocArgs); + + if ($pluginSlug !== null) { + $manager = $this->context->managerOrFail($pluginSlug); + $row = $this->normalizeCurrentRow($pluginSlug, $manager->getCurrentMigration()); + + if ($row === null) { + WP_CLI::warning(sprintf('No migrated version found for "%s".', $pluginSlug)); + return; + } + + \WP_CLI\Utils\format_items( + $format, + [$row], + ['plugin', 'class', 'name', 'version', 'migrated_at'], + ); + return; + } + + $rows = []; + + foreach ($this->context->registry()->all() as $slug => $manager) { + $row = $this->normalizeCurrentRow($slug, $manager->getCurrentMigration()); + + if ($row === null) { + continue; + } + + $rows[] = $row; + } + + if ($rows === []) { + WP_CLI::warning('No migrated versions found.'); + return; + } + + \WP_CLI\Utils\format_items( + $format, + $rows, + ['plugin', 'class', 'name', 'version', 'migrated_at'], + ); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function latest(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $format = $this->context->outputFormat($assocArgs); + + if ($pluginSlug !== null) { + $manager = $this->context->managerOrFail($pluginSlug); + $row = $this->normalizeLatestRow($pluginSlug, $manager->getLatestMigration()); + + if ($row === null) { + WP_CLI::warning(sprintf('No registered migrations found for "%s".', $pluginSlug)); + return; + } + + \WP_CLI\Utils\format_items($format, [$row], ['plugin', 'class', 'name', 'version']); + return; + } + + $rows = []; + + foreach ($this->context->registry()->all() as $slug => $manager) { + $row = $this->normalizeLatestRow($slug, $manager->getLatestMigration()); + + if ($row === null) { + continue; + } + + $rows[] = $row; + } + + if ($rows === []) { + WP_CLI::warning('No registered migrations found.'); + return; + } + + \WP_CLI\Utils\format_items($format, $rows, ['plugin', 'class', 'name', 'version']); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function history(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $format = $this->context->outputFormat($assocArgs); + $tracker = $this->context->tracker(); + $records = $pluginSlug === null + ? $tracker->findAllHistory() + : $tracker->findHistoryForPlugin($pluginSlug); + + if ($records === []) { + WP_CLI::warning('No migration history found.'); + return; + } + + \WP_CLI\Utils\format_items( + $format, + $this->executionRows($records), + ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], + ); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function list(array $args, array $assocArgs): void + { + if ($this->context->runConsoleCommand('migration:list', $args, $assocArgs)) { + return; + } + + $pluginSlug = $this->context->normalizeOptionalString($args[0] ?? null); + $format = $this->context->outputFormat($assocArgs); + + if ($pluginSlug !== null) { + $this->listPluginMigrations($pluginSlug, $format); + return; + } + + $this->listRegisteredPlugins($format); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function info(array $args, array $assocArgs): void + { + $pluginSlug = $this->context->requirePluginSlug($args, 'info'); + $manager = $this->context->managerOrFail($pluginSlug); + $format = $this->context->outputFormat($assocArgs); + + $this->statusSinglePlugin($pluginSlug, true, $format); + + $migrated = $manager->getMigratedVersions(); + + if ($migrated !== []) { + WP_CLI::log("\nMigrated versions:"); + \WP_CLI\Utils\format_items($format, $migrated, ['migration', 'version', 'migrated_at']); + } + + $pending = $manager->getPendingMigrations(); + + if ($pending !== []) { + WP_CLI::log("\nPending migrations:"); + \WP_CLI\Utils\format_items($format, $pending, ['class', 'name', 'version']); + } + + $history = $this->context->tracker()->findHistoryForPlugin($pluginSlug); + + if ($history === []) { + return; + } + + WP_CLI::log("\nExecution history:"); + \WP_CLI\Utils\format_items( + $format, + $this->executionRows($history), + ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], + ); + } + + private function statusSinglePlugin(string $pluginSlug, bool $verbose, string $format): void + { + $report = $this->statusReporter()->plugin($pluginSlug); + + if ($report === null) { + $this->context->managerOrFail($pluginSlug); + return; + } + + $overview = $report['overview']; + WP_CLI::log(sprintf("\nPlugin: %s", $pluginSlug)); + WP_CLI::log(sprintf('Status: %s', $overview['status'])); + WP_CLI::log(sprintf('Current: %s', $overview['current'])); + WP_CLI::log(sprintf('Latest: %s', $overview['latest'])); + WP_CLI::log(sprintf('Migrated: %d', $overview['migrated'])); + WP_CLI::log(sprintf('Pending: %d', $overview['pending'])); + WP_CLI::log(sprintf('Executions: %d', $overview['executions'])); + + if ($verbose && $report['pending_migrations'] !== []) { + WP_CLI::log("\nPending migrations:"); + \WP_CLI\Utils\format_items($format, $report['pending_migrations'], ['class', 'name', 'version']); + } + + if ($verbose && $report['recent_history'] !== []) { + WP_CLI::log("\nRecent execution history:"); + \WP_CLI\Utils\format_items( + $format, + $report['recent_history'], + ['plugin', 'migration', 'name', 'version', 'direction', 'executed_at'], + ); + } + + if ($overview['status'] === 'Up to Date') { + WP_CLI::success(sprintf("\nPlugin \"%s\" is up to date.", $pluginSlug)); + return; + } + + WP_CLI::warning(sprintf("\nPlugin \"%s\" has pending migrations.", $pluginSlug)); + } + + private function statusAllPlugins(string $format): void + { + $rows = $this->statusReporter()->all(); + + if ($rows === []) { + WP_CLI::warning('No plugins with migrations registered.'); + return; + } + + \WP_CLI\Utils\format_items( + $format, + $rows, + ['plugin', 'current', 'latest', 'migrated', 'pending', 'executions', 'status'], + ); + } + + private function listRegisteredPlugins(string $format): void + { + $this->statusAllPlugins($format); + } + + private function listPluginMigrations(string $pluginSlug, string $format): void + { + $manager = $this->context->managerOrFail($pluginSlug); + $migratedByClass = []; + + foreach ($manager->getMigratedVersions() as $record) { + $migratedByClass[$record['migration']] = $record; + } + + $rows = []; + + foreach ($manager->all() as $class => $migration) { + $record = $migratedByClass[$class] ?? null; + $rows[] = [ + 'class' => $class, + 'name' => $this->context->extractClassName($class), + 'version' => $migration->getVersion(), + 'status' => is_array($record) ? 'Migrated' : 'Pending', + 'migrated_at' => is_array($record) ? $record['migrated_at'] : '', + ]; + } + + if ($rows === []) { + WP_CLI::warning(sprintf('No registered migrations found for "%s".', $pluginSlug)); + return; + } + + \WP_CLI\Utils\format_items( + $format, + $rows, + ['class', 'name', 'version', 'status', 'migrated_at'], + ); + } + + private function statusReporter(): MigrationStatusReporter + { + return new MigrationStatusReporter($this->context->registry()); + } + + /** + * @param array{ + * class: string, + * name: string, + * version: string, + * migrated_at: string + * }|null $current + * @return array{ + * plugin: string, + * class: string, + * name: string, + * version: string, + * migrated_at: string + * }|null + */ + private function normalizeCurrentRow(string $pluginSlug, ?array $current): ?array + { + if ($current === null) { + return null; + } + + return [ + 'plugin' => $pluginSlug, + 'class' => $current['class'], + 'name' => $current['name'], + 'version' => $current['version'], + 'migrated_at' => $current['migrated_at'], + ]; + } + + /** + * @param array{class: string, name: string, version: string}|null $latest + * @return array{plugin: string, class: string, name: string, version: string}|null + */ + private function normalizeLatestRow(string $pluginSlug, ?array $latest): ?array + { + if ($latest === null) { + return null; + } + + return [ + 'plugin' => $pluginSlug, + 'class' => $latest['class'], + 'name' => $latest['name'], + 'version' => $latest['version'], + ]; + } + + /** + * @param list $records + * @return list + */ + private function executionRows(array $records): array + { + return array_map( + fn (MigrationExecution $record): array => [ + 'plugin' => $record->plugin, + 'migration' => $record->migration, + 'name' => $this->context->extractClassName($record->migration), + 'version' => $record->version, + 'direction' => $record->direction, + 'executed_at' => $record->executedAt, + ], + $records, + ); + } +} diff --git a/src/Infrastructure/MigrationTracker.php b/src/Infrastructure/MigrationTracker.php index 6d5e5b5..b7ec01a 100644 --- a/src/Infrastructure/MigrationTracker.php +++ b/src/Infrastructure/MigrationTracker.php @@ -168,9 +168,10 @@ public function findRecord(string $plugin, string $migrationName): ?MigrationRec } $query = $this->database->prepare( - "SELECT plugin, migration, version, migrated_at - FROM {$this->tableName} - WHERE plugin = %s AND migration = %s", + 'SELECT plugin, migration, version, migrated_at + FROM %i + WHERE plugin = %s AND migration = %s', + $this->tableName, $plugin, $migrationName, ); @@ -214,10 +215,11 @@ public function findRecordsForPlugin(string $plugin): array } $query = $this->database->prepare( - "SELECT plugin, migration, version, migrated_at - FROM {$this->tableName} + 'SELECT plugin, migration, version, migrated_at + FROM %i WHERE plugin = %s - ORDER BY id ASC", + ORDER BY id ASC', + $this->tableName, $plugin, ); @@ -269,7 +271,8 @@ public function hasMigrations(string $plugin): bool } $query = $this->database->prepare( - "SELECT COUNT(*) FROM {$this->tableName} WHERE plugin = %s", + 'SELECT COUNT(*) FROM %i WHERE plugin = %s', + $this->tableName, $plugin, ); @@ -327,10 +330,11 @@ public function findHistoryForPlugin(string $pluginSlug): array } $query = $this->database->prepare( - "SELECT plugin, migration, version, direction, executed_at - FROM {$this->historyTableName} + 'SELECT plugin, migration, version, direction, executed_at + FROM %i WHERE plugin = %s - ORDER BY id DESC", + ORDER BY id DESC', + $this->historyTableName, $pluginSlug, ); @@ -471,7 +475,7 @@ private function loadWordPressUpgradeLibrary(): void $absolutePath = constant('ABSPATH'); - if (!is_string($absolutePath) || $absolutePath === '') { + if ($absolutePath === '') { throw new \RuntimeException('ABSPATH must be a non-empty string.'); } diff --git a/src/Infrastructure/WordPressSqlExecutor.php b/src/Infrastructure/WordPressSqlExecutor.php index 55885d9..ec3b179 100644 --- a/src/Infrastructure/WordPressSqlExecutor.php +++ b/src/Infrastructure/WordPressSqlExecutor.php @@ -62,7 +62,7 @@ private function loadWordPressUpgradeLibrary(): void $absolutePath = constant('ABSPATH'); - if (!is_string($absolutePath) || $absolutePath === '') { + if ($absolutePath === '') { throw new \RuntimeException('ABSPATH must be a non-empty string.'); } diff --git a/tests/Support/TestEnvironment.php b/tests/Support/TestEnvironment.php index bf4bd27..bb62b99 100644 --- a/tests/Support/TestEnvironment.php +++ b/tests/Support/TestEnvironment.php @@ -92,10 +92,21 @@ public function get_charset_collate(): string public function prepare(string $query, mixed ...$args): string { - $query = str_replace(['%d', '%f'], '%s', $query); - $preparedArgs = array_map([$this, 'prepareValue'], $args); + $index = 0; - return vsprintf($query, $preparedArgs); + return (string) preg_replace_callback( + '/(?prepareIdentifier($value); + } + + return $this->prepareValue($value); + }, + $query, + ); } public function get_var(string $query): string|int|null @@ -104,7 +115,12 @@ public function get_var(string $query): string|int|null return $this->hasTable($matches[1]) ? $matches[1] : null; } - if (preg_match("/^SELECT COUNT\\(\\*\\) FROM ([a-zA-Z0-9_]+)(?: WHERE plugin = '([^']+)')?$/i", trim($query), $matches) !== 1) { + if (preg_match( + '/^SELECT COUNT\\(\\*\\) FROM `?([a-zA-Z0-9_]+)`?' + . "(?: WHERE plugin = '([^']+)')?$/i", + trim($query), + $matches, + ) !== 1) { return null; } @@ -130,7 +146,7 @@ public function get_var(string $query): string|int|null public function get_row(string $query, string|int $output = ARRAY_A): ?array { if (preg_match( - "/FROM ([a-zA-Z0-9_]+)\s+WHERE plugin = '([^']+)' AND migration = '([^']+)'/i", + "/FROM `?([a-zA-Z0-9_]+)`?\s+WHERE plugin = '([^']+)' AND migration = '([^']+)'/i", $query, $matches, ) !== 1) { @@ -149,7 +165,7 @@ public function get_row(string $query, string|int $output = ARRAY_A): ?array public function get_results(string $query, string|int $output = ARRAY_A): ?array { if (preg_match( - "/FROM ([a-zA-Z0-9_]+)\s+WHERE plugin = '([^']+)'\s+ORDER BY id ASC/i", + "/FROM `?([a-zA-Z0-9_]+)`?\s+WHERE plugin = '([^']+)'\s+ORDER BY id ASC/i", $query, $matches, ) === 1) { @@ -171,7 +187,7 @@ public function get_results(string $query, string|int $output = ARRAY_A): ?array } if (preg_match( - "/FROM ([a-zA-Z0-9_]+)\s+WHERE plugin = '([^']+)'\s+ORDER BY id DESC/i", + "/FROM `?([a-zA-Z0-9_]+)`?\s+WHERE plugin = '([^']+)'\s+ORDER BY id DESC/i", $query, $matches, ) === 1) { @@ -193,7 +209,7 @@ public function get_results(string $query, string|int $output = ARRAY_A): ?array } if (preg_match( - "/FROM ([a-zA-Z0-9_]+)\s+ORDER BY id DESC/i", + "/FROM `?([a-zA-Z0-9_]+)`?\s+ORDER BY id DESC/i", $query, $matches, ) === 1) { @@ -212,7 +228,7 @@ public function get_results(string $query, string|int $output = ARRAY_A): ?array } if (preg_match( - "/FROM ([a-zA-Z0-9_]+)\s+ORDER BY plugin ASC, id ASC/i", + "/FROM `?([a-zA-Z0-9_]+)`?\s+ORDER BY plugin ASC, id ASC/i", $query, $matches, ) !== 1) { @@ -403,6 +419,11 @@ private function prepareValue(mixed $value): string return sprintf("'%s'", str_replace("'", "\\'", (string) $value)); } + private function prepareIdentifier(mixed $value): string + { + return sprintf('`%s`', str_replace('`', '``', (string) $value)); + } + private function recordKey(string $plugin, string $migration): string { return sprintf('%s|%s', $plugin, $migration); diff --git a/tests/Unit/Migration/MigrationManagerTest.php b/tests/Unit/Migration/MigrationManagerTest.php index bb61cd8..83a129f 100644 --- a/tests/Unit/Migration/MigrationManagerTest.php +++ b/tests/Unit/Migration/MigrationManagerTest.php @@ -90,6 +90,25 @@ public function test_it_rolls_back_in_reverse_order_and_tracks_history(): void self::assertCount(4, $this->manager->getMigrationHistory()); } + public function test_it_stops_rollback_when_a_migration_fails(): void + { + $this->manager->runMigrations(); + $this->database->executedStatements = []; + $this->database->failedStatements[] = 'ALTER TABLE wp_customers DROP INDEX idx_email;'; + + self::assertFalse($this->manager->rollbackMigrations()); + self::assertSame( + ['ALTER TABLE wp_customers DROP INDEX idx_email;'], + $this->database->executedStatements, + ); + self::assertCount(2, $this->manager->getMigratedVersions()); + self::assertSame( + AddCustomersEmailIndexMigration::class, + $this->manager->getCurrentMigration()['class'], + ); + self::assertCount(2, $this->manager->getMigrationHistory()); + } + public function test_it_stops_when_a_migration_fails(): void { $this->database->failedStatements[] = 'ALTER TABLE wp_customers ADD INDEX idx_email (email);';