Skip to content

Commit

Permalink
Revert by path and namespace (#228)
Browse files Browse the repository at this point in the history
* Add options `--namespace` and `--path` for `migrate:down` command

* Update limit

* Update tests

* Update tests

* Update tests

* Update tests

* Update tests

* Update tests

* Update tests

* Add test

* Fix test

* Apply Rector changes (CI)

* Optimizations, suggestions and tests

* Add test for filtering migrations without namespace

* Clear test

---------

Co-authored-by: Tigrov <Tigrov@users.noreply.github.com>
  • Loading branch information
Tigrov and Tigrov committed Nov 8, 2023
1 parent d227ecd commit 5f8b93d
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 14 deletions.
5 changes: 4 additions & 1 deletion composer.json
Expand Up @@ -48,7 +48,10 @@
},
"autoload-dev": {
"psr-4": {
"Yiisoft\\Db\\Migration\\Tests\\": "tests"
"Yiisoft\\Db\\Migration\\Tests\\": "tests",
"Yiisoft\\Db\\Migration\\Tests\\Support\\": "tests/Support",
"Yiisoft\\Db\\Migration\\Tests\\ForTest\\": "tests/Support",
"Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra\\": ["tests/Support/MigrationsExtra", "tests/Support/MigrationsExtra2"]
}
},
"extra": {
Expand Down
52 changes: 41 additions & 11 deletions src/Command/DownCommand.php
Expand Up @@ -18,6 +18,7 @@
use Yiisoft\Db\Migration\Service\MigrationService;

use function array_keys;
use function array_slice;
use function count;

/**
Expand All @@ -26,9 +27,15 @@
* For example:
*
* ```shell
* ./yii migrate:down # revert the last migration
* ./yii migrate:down --limit=3 # revert the last 3 migrations
* ./yii migrate:down --all # revert all migrations
* ./yii migrate:down # revert the last migration
* ./yii migrate:down --limit=3 # revert last 3 migrations
* ./yii migrate:down --all # revert all migrations
* ./yii migrate:down --path=@vendor/yiisoft/rbac-db/migrations # revert the last migration from the directory
* ./yii migrate:down --namespace=Yiisoft\\Rbac\\Db\\Migrations # revert the last migration from the namespace
*
* # revert migrations from multiple directories and namespaces
* ./yii migrate:down --path=@vendor/yiisoft/rbac-db/migrations --path=@vendor/yiisoft/cache-db/migrations
* ./yii migrate:down --namespace=Yiisoft\\Rbac\\Db\\Migrations --namespace=Yiisoft\\Cache\\Db\\Migrations
* ```
*/
#[AsCommand('migrate:down', 'Reverts the specified number of latest migrations.')]
Expand All @@ -46,7 +53,9 @@ protected function configure(): void
{
$this
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Number of migrations to revert.', 1)
->addOption('all', 'a', InputOption::VALUE_NONE, 'Revert all migrations.');
->addOption('all', 'a', InputOption::VALUE_NONE, 'Revert all migrations.')
->addOption('path', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path to migrations to revert.')
->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Namespace of migrations to revert.');
}

protected function execute(InputInterface $input, OutputInterface $output): int
Expand All @@ -69,16 +78,37 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return Command::INVALID;
}

$migrations = $this->migrator->getHistory($limit);
/** @psalm-var string[] $paths */
$paths = $input->getOption('path');
/** @psalm-var string[] $namespaces */
$namespaces = $input->getOption('namespace');

if (empty($migrations)) {
$output->writeln("<fg=yellow> >>> Apply at least one migration first.</>\n");
$io->warning('No migration has been done before.');
if (!empty($paths) || !empty($namespaces)) {
$migrations = $this->migrator->getHistory();
$migrations = array_keys($migrations);
$migrations = $this->migrationService->filterMigrations($migrations, $namespaces, $paths);

return Command::FAILURE;
}
if (empty($migrations)) {
$io->warning('No applied migrations found.');

return Command::FAILURE;
}

if ($limit !== null) {
$migrations = array_slice($migrations, 0, $limit);
}
} else {
$migrations = $this->migrator->getHistory($limit);

if (empty($migrations)) {
$output->writeln("<fg=yellow> >>> Apply at least one migration first.</>\n");
$io->warning('No migration has been done before.');

$migrations = array_keys($migrations);
return Command::FAILURE;
}

$migrations = array_keys($migrations);
}

$n = count($migrations);
$migrationWord = $n === 1 ? 'migration' : 'migrations';
Expand Down
110 changes: 108 additions & 2 deletions src/Service/MigrationService.php
Expand Up @@ -17,19 +17,29 @@
use Yiisoft\Db\Migration\RevertibleMigrationInterface;

use function array_map;
use function array_unique;
use function array_values;
use function closedir;
use function dirname;
use function gmdate;
use function in_array;
use function is_dir;
use function is_file;
use function krsort;
use function ksort;
use function opendir;
use function preg_match;
use function preg_replace;
use function readdir;
use function realpath;
use function reset;
use function str_contains;
use function str_replace;
use function str_starts_with;
use function strlen;
use function strrchr;
use function strrpos;
use function substr;
use function trim;
use function ucwords;

Expand Down Expand Up @@ -102,7 +112,7 @@ public function before(string $commandName): int
/**
* Returns the migrations that are not applied.
*
* @return array List of new migrations.
* @return string[] List of new migrations.
*
* @psalm-return list<class-string>
*/
Expand Down Expand Up @@ -353,6 +363,66 @@ public function findMigrationPath(): string
: $this->getNamespacePath($this->createNamespace);
}

/**
* Filters migrations by namespaces and paths.
*
* @param string[] $classes Migration classes to be filtered.
* @param string[] $namespaces Namespaces to filter by.
* @param string[] $paths Paths to filter by.
*
* @return string[] Filtered migration classes.
*
* @psalm-param list<class-string> $classes
*
* @psalm-return list<class-string>
*/
public function filterMigrations(array $classes, array $namespaces = [], array $paths = []): array
{
$result = [];
$pathNamespaces = [];

foreach ($paths as $path) {
$pathNamespaceList = $this->getNamespacesFromPath($path);

if (!empty($pathNamespaceList)) {
$pathNamespaces[$path] = $pathNamespaceList;
}
}

$namespaces = array_map(static fn ($namespace) => trim($namespace, '\\'), $namespaces);
$namespaces = array_unique($namespaces);

foreach ($classes as $class) {
$classNamespace = substr($class, 0, strrpos($class, '\\') ?: 0);

if ($classNamespace === '') {
continue;
}

if (in_array($classNamespace, $namespaces, true)) {
$result[] = $class;
continue;
}

foreach ($pathNamespaces as $path => $pathNamespaceList) {
/** @psalm-suppress RedundantCondition */
if (!in_array($classNamespace, $pathNamespaceList, true)) {
continue;
}

$className = substr(strrchr($class, '\\'), 1);
$file = $path . DIRECTORY_SEPARATOR . $className . '.php';

if (is_file($file)) {
$result[] = $class;
break;
}
}
}

return $result;
}

/**
* Returns the file path matching the give namespace.
*
Expand All @@ -374,7 +444,7 @@ private function getPathFromNamespace(string $path): string
/** @psalm-suppress UnresolvableInclude */
$map = require $this->getVendorDir() . '/composer/autoload_psr4.php';

/** @psalm-var array<string, array<int, string>> $map */
/** @psalm-var array<string, list<string>> $map */
foreach ($map as $namespace => $directories) {
foreach ($directories as $directory) {
$namespacesPath[str_replace('\\', '/', trim($namespace, '\\'))] = $directory;
Expand All @@ -384,6 +454,42 @@ private function getPathFromNamespace(string $path): string
return (new Aliases($namespacesPath))->get($path);
}

/**
* Returns the namespaces matching the give file path.
*
* @param string $path File path.
*
* @return string[] Namespaces.
*/
private function getNamespacesFromPath(string $path): array
{
$namespaces = [];
$path = realpath($this->aliases->get($path)) . DIRECTORY_SEPARATOR;
/** @psalm-suppress UnresolvableInclude */
$map = require $this->getVendorDir() . '/composer/autoload_psr4.php';

/** @psalm-var array<string, list<string>> $map */
foreach ($map as $namespace => $directories) {
foreach ($directories as $directory) {
$directory = realpath($directory) . DIRECTORY_SEPARATOR;

if (str_starts_with($path, $directory)) {
$length = strlen($directory);
$pathNamespace = $namespace . str_replace('/', '\\', substr($path, $length));
$namespaces[$length][$namespace] = rtrim($pathNamespace, '\\');
}
}
}

if (empty($namespaces)) {
return [];
}

krsort($namespaces);

return array_values(reset($namespaces));
}

private function getVendorDir(): string
{
$class = new ReflectionClass(ClassLoader::class);
Expand Down

0 comments on commit 5f8b93d

Please sign in to comment.