Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Migrations middleware #774

Merged
merged 10 commits into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 set `app` config 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
------------------------

Expand Down
169 changes: 169 additions & 0 deletions src/Middleware/PendingMigrationsMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);

namespace Migrations\Middleware;

use Cake\Console\ConsoleIo;
use Cake\Core\Configure;
use Cake\Core\Exception\CakeException;
use Cake\Core\InstanceConfigTrait;
use Cake\Core\Plugin;
use Cake\Datasource\ConnectionManager;
use Cake\Utility\Hash;
use Migrations\Config\Config;
use Migrations\Migration\Manager;
use Migrations\Util\Util;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class PendingMigrationsMiddleware implements MiddlewareInterface
{
use InstanceConfigTrait;

protected const SKIP_QUERY_KEY = 'skip-middleware-check';

protected array $_defaultConfig = [
'paths' => [
'migrations' => ROOT . DS . 'config' . DS . 'Migrations' . DS,
],
'environment' => [
'connection' => 'default',
'migration_table' => 'phinxlog',
],
'app' => null,
'plugins' => null,
];

/**
* @param array<string, mixed> $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<string, string>
*/
protected function pendingMigrations(): array
{
$pending = [];
if (!$this->checkAppMigrations()) {
$pending['app'] = 'bin/cake migrations migrate';
}

/** @var array<string> $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);
}
}
13 changes: 4 additions & 9 deletions src/Migration/ManagerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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');

Expand Down
83 changes: 16 additions & 67 deletions src/Util/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*
Expand All @@ -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;
}
}
Loading
Loading