diff --git a/docs/en/index.rst b/docs/en/index.rst index 1a92b87d..c7025475 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -1177,6 +1177,30 @@ for instance when deploying on your production environment, by using the bin/cake bake migration_snapshot MyMigration --no-lock +Alert of missing migrations +--------------------------- + +You can use the ``Migrations.PendingMigrations`` middleware in local development +to alert developers about new migrations that are not yet being applied:: + + use Migrations\Middleware\PendingMigrationsMiddleware; + + $config = [ + 'plugins' => [ + ... // Optionally include a list of plugins with migrations to check. + ], + ]; + + $middlewareQueue + ... // ErrorHandler middleware + ->add(new PendingMigrationsMiddleware($config)) + ... // rest + +You can add `'app'` config key set to `false` if you are only interested in plugin migrations to be checked. + +In case you run into the exception and need to skip it for a moment, you can temporarily disable +it using the query string `...?skip-migration-check=1`. + IDE autocomplete support ------------------------ diff --git a/src/Middleware/PendingMigrationsMiddleware.php b/src/Middleware/PendingMigrationsMiddleware.php new file mode 100644 index 00000000..abdcbc00 --- /dev/null +++ b/src/Middleware/PendingMigrationsMiddleware.php @@ -0,0 +1,169 @@ + [ + 'migrations' => ROOT . DS . 'config' . DS . 'Migrations' . DS, + ], + 'environment' => [ + 'connection' => 'default', + 'migration_table' => 'phinxlog', + ], + 'app' => null, + 'plugins' => null, + ]; + + /** + * @param array $config + */ + public function __construct(array $config = []) + { + if (!empty($config['plugins']) && $config['plugins'] === true) { + $config['plugins'] = Plugin::loaded(); + } + + $this->setConfig($config); + } + + /** + * Process method. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @throws \Cake\Core\Exception\CakeException + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!Configure::read('debug') || $this->isSkipped($request)) { + return $handler->handle($request); + } + + $pendingMigrations = $this->pendingMigrations(); + if (!$pendingMigrations) { + return $handler->handle($request); + } + + $message = sprintf('Pending migrations need to be run for %s:', implode(', ', array_keys($pendingMigrations))) . PHP_EOL; + $message .= '`' . implode('`,' . PHP_EOL . '`', $pendingMigrations) . '`'; + + throw new CakeException($message, 503); + } + + /** + * @return array + */ + protected function pendingMigrations(): array + { + $pending = []; + if (!$this->checkAppMigrations()) { + $pending['app'] = 'bin/cake migrations migrate'; + } + + /** @var array $plugins */ + $plugins = (array)$this->_config['plugins']; + foreach ($plugins as $plugin) { + if (!$this->checkPluginMigrations($plugin)) { + $pending[$plugin] = 'bin/cake migrations migrate -p ' . $plugin; + } + } + + return $pending; + } + + /** + * @return bool + */ + protected function checkAppMigrations(): bool + { + if ($this->_config['app'] === false) { + return true; + } + + $connection = ConnectionManager::get($this->_config['environment']['connection']); + $database = $connection->config()['database']; + $this->_config['environment']['database'] = $database; + + $config = new Config($this->_config); + $manager = new Manager($config, new ConsoleIo()); + + $migrations = $manager->getMigrations(); + foreach ($migrations as $migration) { + if (!$manager->isMigrated($migration->getVersion())) { + return false; + } + } + + return true; + } + + /** + * @param string $plugin + * @return bool + */ + protected function checkPluginMigrations(string $plugin): bool + { + $connection = ConnectionManager::get($this->_config['environment']['connection']); + $database = $connection->config()['database']; + $this->_config['environment']['database'] = $database; + + $pluginPath = Plugin::path($plugin); + if (!is_dir($pluginPath . 'config' . DS . 'Migrations' . DS)) { + return true; + } + + $config = [ + 'paths' => [ + 'migrations' => $pluginPath . 'config' . DS . 'Migrations' . DS, + ], + ] + $this->_config; + + $table = Util::tableName($plugin); + + $config['environment']['migration_table'] = $table; + + $managerConfig = new Config($config); + $manager = new Manager($managerConfig, new ConsoleIo()); + + $migrations = $manager->getMigrations(); + foreach ($migrations as $migration) { + if (!$manager->isMigrated($migration->getVersion())) { + return false; + } + } + + return true; + } + + /** + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return bool + */ + protected function isSkipped(ServerRequestInterface $request): bool + { + return (bool)Hash::get($request->getQueryParams(), static::SKIP_QUERY_KEY); + } +} diff --git a/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php index a86c1a2a..b5a42920 100644 --- a/src/Migration/ManagerFactory.php +++ b/src/Migration/ManagerFactory.php @@ -17,9 +17,9 @@ use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Datasource\ConnectionManager; -use Cake\Utility\Inflector; use Migrations\Config\Config; use Migrations\Config\ConfigInterface; +use Migrations\Util\Util; use RuntimeException; /** @@ -77,19 +77,14 @@ public function createConfig(): ConfigInterface if (defined('CONFIG')) { $dir = CONFIG . $folder; } - $plugin = $this->getOption('plugin'); - if ($plugin && is_string($plugin)) { + $plugin = (string)$this->getOption('plugin') ?: null; + if ($plugin) { $dir = Plugin::path($plugin) . 'config' . DS . $folder; } // Get the phinxlog table name. Plugins have separate migration history. // The names and separate table history is something we could change in the future. - $table = 'phinxlog'; - if ($plugin && is_string($plugin)) { - $prefix = Inflector::underscore($plugin) . '_'; - $prefix = str_replace(['\\', '/', '.'], '_', $prefix); - $table = $prefix . $table; - } + $table = Util::tableName($plugin); $templatePath = dirname(__DIR__) . DS . 'templates' . DS; $connectionName = (string)$this->getOption('connection'); diff --git a/src/Util/Util.php b/src/Util/Util.php index 32612b09..c8a48785 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -11,10 +11,7 @@ use Cake\Utility\Inflector; use DateTime; use DateTimeZone; -use Exception; use RuntimeException; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; /** * Temporary compatibility shim that can be refactored away. @@ -46,12 +43,6 @@ class Util */ protected const SEED_FILE_NAME_PATTERN = '/^([a-z][a-z\d]*)\.php$/i'; - /** - * @var string - * @psalm-var non-empty-string - */ - protected const CLASS_NAME_PATTERN = '/^(?:[A-Z][a-z\d]*)+$/'; - /** * Gets the current timestamp string, in UTC. * @@ -153,27 +144,6 @@ public static function mapFileNameToClassName(string $fileName): string return Inflector::camelize($fileName); } - /** - * Check if a migration class name is unique regardless of the - * timestamp. - * - * This method takes a class name and a path to a migrations directory. - * - * Migration class names must be in PascalCase format but consecutive - * capitals are allowed. - * e.g: AddIndexToPostsTable or CustomHTMLTitle. - * - * @param string $className Class Name - * @param string $path Path - * @return bool - */ - public static function isUniqueMigrationClassName(string $className, string $path): bool - { - $existingClassNames = static::getExistingMigrationClassNames($path); - - return !in_array($className, $existingClassNames, true); - } - /** * Check if a migration file name is valid. * @@ -230,43 +200,6 @@ public static function glob(string $path): array return []; } - /** - * Takes the path to a php file and attempts to include it if readable - * - * @param string $filename Filename - * @param \Symfony\Component\Console\Input\InputInterface|null $input Input - * @param \Symfony\Component\Console\Output\OutputInterface|null $output Output - * @param \Phinx\Console\Command\AbstractCommand|mixed|null $context Context - * @throws \Exception - * @return string - */ - public static function loadPhpFile(string $filename, ?InputInterface $input = null, ?OutputInterface $output = null, mixed $context = null): string - { - $filePath = realpath($filename); - if (!$filePath || !file_exists($filePath)) { - throw new Exception(sprintf("File does not exist: %s \n", $filename)); - } - - /** - * I lifed this from phpunits FileLoader class - * - * @see https://github.com/sebastianbergmann/phpunit/pull/2751 - */ - $isReadable = @fopen($filePath, 'r') !== false; - - if (!$isReadable) { - throw new Exception(sprintf("Cannot open file %s \n", $filename)); - } - - // TODO remove $input, $output, and $context from scope - // prevent this to be propagated to the included file - unset($isReadable); - - include_once $filePath; - - return $filePath; - } - /** * Given an array of paths, return all unique PHP files that are in them * @@ -286,4 +219,20 @@ public static function getFiles(string|array $paths): array return $files; } + + /** + * @param string|null $plugin + * @return string + */ + public static function tableName(?string $plugin): string + { + $table = 'phinxlog'; + if ($plugin) { + $prefix = Inflector::underscore($plugin) . '_'; + $prefix = str_replace(['\\', '/', '.'], '_', $prefix); + $table = $prefix . $table; + } + + return $table; + } } diff --git a/tests/TestCase/Middleware/PendingMigrationsMiddlewareTest.php b/tests/TestCase/Middleware/PendingMigrationsMiddlewareTest.php new file mode 100644 index 00000000..b5e67ee7 --- /dev/null +++ b/tests/TestCase/Middleware/PendingMigrationsMiddlewareTest.php @@ -0,0 +1,150 @@ +expectException(CakeException::class); + $this->expectExceptionCode(503); + $this->expectExceptionMessage('Pending migrations need to be run for app:'); + + $middleware->process($request, $handler); + } + + /** + * @doesNotPerformAssertions + * @return void + */ + public function testAppMigrationsSuccess(): void + { + $middleware = new PendingMigrationsMiddleware(); + + $config = [ + 'paths' => [ + 'migrations' => ROOT . DS . 'config' . DS . 'Migrations' . DS, + ], + 'environment' => [ + 'database' => 'cakephp_test', + 'connection' => 'default', + 'migration_table' => 'phinxlog', + ], + ]; + $config = new Config($config); + $manager = new Manager($config, new ConsoleIo()); + $manager->migrate(null, true); + + $request = new ServerRequest(); + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + $middleware->process($request, $handler); + } + + /** + * @return void + */ + public function testAppAndPluginsMigrationsFail(): void + { + $this->loadPlugins(['Migrator']); + + $middleware = new PendingMigrationsMiddleware([ + 'plugins' => true, + ]); + + $request = new ServerRequest(); + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $this->expectException(CakeException::class); + $this->expectExceptionCode(503); + $this->expectExceptionMessage('Pending migrations need to be run for Migrator:'); + + $middleware->process($request, $handler); + } + + /** + * @doesNotPerformAssertions + * @return void + */ + public function testAppAndPluginsMigrationsSuccess(): void + { + $this->loadPlugins(['Migrator']); + + $middleware = new PendingMigrationsMiddleware([ + 'plugins' => true, + ]); + + $config = [ + 'paths' => [ + 'migrations' => ROOT . DS . 'Plugin' . DS . 'Migrator' . DS . 'config' . DS . 'Migrations' . DS, + ], + 'environment' => [ + 'connection' => 'default', + 'database' => 'cakephp_test', + 'migration_table' => 'migrator_phinxlog', + ], + ]; + $config = new Config($config); + $manager = new Manager($config, new ConsoleIo()); + $manager->migrate(null, true); + + $request = new ServerRequest(); + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $middleware->process($request, $handler); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a6b64b40..93630647 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -104,19 +104,20 @@ } putenv('DB=' . $db); } + ConnectionManager::setConfig('test', [ 'cacheMetadata' => false, - 'url' => getenv('DB_URL'), + 'url' => getenv('DB_URL') ?: null, ]); ConnectionManager::setConfig('test_snapshot', [ 'cacheMetadata' => false, - 'url' => getenv('DB_URL_SNAPSHOT'), + 'url' => getenv('DB_URL_SNAPSHOT') ?: null, ]); if (getenv('DB_URL_COMPARE') !== false) { ConnectionManager::setConfig('test_comparisons', [ 'cacheMetadata' => false, - 'url' => getenv('DB_URL_COMPARE'), + 'url' => getenv('DB_URL_COMPARE') ?: null, ]); } diff --git a/tests/test_app/App/Http/TestRequestHandler.php b/tests/test_app/App/Http/TestRequestHandler.php new file mode 100644 index 00000000..2b376a4f --- /dev/null +++ b/tests/test_app/App/Http/TestRequestHandler.php @@ -0,0 +1,26 @@ +callable = $callable ?: function ($request) { + return new Response(); + }; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return ($this->callable)($request); + } +}