diff --git a/CHANGELOG.md b/CHANGELOG.md index 4939d98a..356dc34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ `migrate:create` commands: remove redundant messages, replace `>>>` with cleaner output, and move "Database connection" info to the top (@samdark, @vjik) - Enh #333: Use `newMigrationPath` and `newMigrationNamespace` as source (@Tigrov) +- Bug #341: Fix migration namespaces and paths (@Tigrov) ## 2.0.1 December 20, 2025 diff --git a/composer.json b/composer.json index 16c29a96..2aa34df0 100644 --- a/composer.json +++ b/composer.json @@ -90,6 +90,7 @@ "check-dependencies": "composer-require-checker", "mutation": "infection", "psalm": "psalm", - "test": "phpunit --testdox --no-interaction" + "test": "phpunit --testdox --no-interaction", + "cs-fix": "php-cs-fixer fix" } } diff --git a/src/Command/CreateCommand.php b/src/Command/CreateCommand.php index 1ea1b4ad..1e57e263 100644 --- a/src/Command/CreateCommand.php +++ b/src/Command/CreateCommand.php @@ -4,6 +4,7 @@ namespace Yiisoft\Db\Migration\Command; +use LogicException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -150,10 +151,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::INVALID; } - $migrationPath = $this->migrationService->findMigrationPath(); + try { + $migrationPath = $this->migrationService->findMigrationPath(); + } catch (LogicException $e) { + $io->error($e->getMessage()); + + return Command::INVALID; + } if (!is_dir($migrationPath)) { - $io->error("Invalid path directory $migrationPath"); + $io->error("Invalid path directory \"$migrationPath\""); return Command::INVALID; } diff --git a/src/Service/MigrationService.php b/src/Service/MigrationService.php index 52383451..7be8fbc7 100644 --- a/src/Service/MigrationService.php +++ b/src/Service/MigrationService.php @@ -16,9 +16,12 @@ use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\RevertibleMigrationInterface; +use function array_keys; use function array_map; +use function array_merge; use function array_unique; use function array_values; +use function class_exists; use function closedir; use function dirname; use function gmdate; @@ -33,6 +36,7 @@ use function readdir; use function realpath; use function reset; +use function rtrim; use function str_contains; use function str_replace; use function str_starts_with; @@ -128,40 +132,8 @@ public function getNewMigrations(): array $applied[trim($class, '\\')] = true; } - $migrations = []; - $migrationPaths = $this->findSourcePaths(); - - foreach ($migrationPaths as [$sourcePath, $namespace]) { - if (!is_dir($sourcePath)) { - continue; - } - - /** @var resource $handle */ - $handle = opendir($sourcePath); - while (($file = readdir($handle)) !== false) { - if ($file === '.' || $file === '..') { - continue; - } - - $path = $sourcePath . DIRECTORY_SEPARATOR . $file; - - if (is_file($path) && preg_match('/^(M(\d{12}).*)\.php$/s', $file, $matches)) { - [, $class, $time] = $matches; - - if (!empty($namespace)) { - $class = $namespace . '\\' . $class; - } - - /** @psalm-var class-string $class */ - - if (!isset($applied[$class])) { - $migrations[$time . '\\' . $class] = $class; - } - } - } - closedir($handle); - } - + $migrations = $this->loadMigrationClasses(); + $migrations = array_filter($migrations, static fn(string $class): bool => !isset($applied[$class])); ksort($migrations); return array_values($migrations); } @@ -416,28 +388,33 @@ private function makeMigrationInstance(string $class): object } /** - * Returns the migration paths with namespaces if they are specified. + * Returns the migration paths with namespaces. * - * @return array + * @return true[][] + * @psalm-return array> */ private function findSourcePaths(): array { $paths = []; if ($this->newMigrationPath !== '') { - $paths[] = [$this->newMigrationPath, '']; + $newMigrationPath = $this->normalizePath($this->newMigrationPath); + $newMigrationNamespaces = $this->getNamespacesFromPath($newMigrationPath); + $paths[$newMigrationPath] = array_fill_keys($newMigrationNamespaces, true); } elseif ($this->newMigrationNamespace !== '') { $newMigrationPath = $this->getNamespacePath($this->newMigrationNamespace); - $paths[] = [$newMigrationPath, $this->newMigrationNamespace]; + $paths[$newMigrationPath][$this->newMigrationNamespace] = true; } - foreach ($this->sourcePaths as $sourcePaths) { - $paths[] = [$sourcePaths, '']; + foreach ($this->sourcePaths as $sourcePath) { + $sourcePath = $this->normalizePath($sourcePath); + $sourceNamespaces = $this->getNamespacesFromPath($sourcePath); + $paths[$sourcePath] = ($paths[$sourcePath] ?? []) + array_fill_keys($sourceNamespaces, true); } - foreach ($this->sourceNamespaces as $namespace) { - $sourcePath = $this->getNamespacePath($namespace); - $paths[] = [$sourcePath, $namespace]; + foreach ($this->sourceNamespaces as $sourceNamespace) { + $sourcePath = $this->getNamespacePath($sourceNamespace); + $paths[$sourcePath][$sourceNamespace] = true; } return $paths; @@ -448,6 +425,8 @@ private function findSourcePaths(): array * * @param string $namespace Namespace. * + * @throws LogicException If the namespace is invalid. + * * @return string File path. */ private function getNamespacePath(string $namespace): string @@ -462,11 +441,15 @@ private function getNamespacePath(string $namespace): string if (str_starts_with($namespace, trim($mapNamespace, '\\'))) { /** @var string $mapDirectory */ $mapDirectory = reset($mapDirectories); - return $mapDirectory . '/' . str_replace('\\', '/', substr($namespace, strlen($mapNamespace))); + $path = $mapDirectory . '/' . str_replace('\\', '/', substr($namespace, strlen($mapNamespace))); + + if (is_dir($path)) { + return $this->normalizePath($path); + } } } - throw new LogicException("Invalid namespace: \"$namespace\"."); + throw new LogicException("Invalid namespace \"$namespace\""); } /** @@ -475,6 +458,7 @@ private function getNamespacePath(string $namespace): string * @param string $path File path. * * @return string[] Namespaces. + * @psalm-return list */ private function getNamespacesFromPath(string $path): array { @@ -513,7 +497,8 @@ private function getNamespacesFromPath(string $path): array krsort($namespaces); - return array_values(reset($namespaces)); + /** @psalm-var list */ + return array_values(array_unique(array_merge(...$namespaces))); } private function getVendorDir(): string @@ -521,4 +506,73 @@ private function getVendorDir(): string $class = new ReflectionClass(ClassLoader::class); return dirname($class->getFileName(), 2); } + + /** + * Loads migration classes. + * + * @return string[] List of migration classes indexed by their time stamp and file path. + * @psalm-return class-string[] + */ + private function loadMigrationClasses(): array + { + $migrationPaths = $this->findSourcePaths(); + + $migrations = []; + + foreach ($migrationPaths as $path => $namespaces) { + /** @var resource $handle */ + $handle = opendir($path); + while (($file = readdir($handle)) !== false) { + if ($file === '.' || $file === '..') { + continue; + } + + $filePath = "$path/$file"; + + if (!is_file($filePath)) { + continue; + } + + if (preg_match('/^(M(\d{12}).*)\.php$/s', $file, $matches) !== 1) { + continue; + } + + [, $class, $time] = $matches; + + $sortKey = "$time/$filePath"; + + if (isset($migrations[$sortKey])) { + continue; + } + + require_once $filePath; + + foreach (array_keys($namespaces) as $namespace) { + if (class_exists($namespace . '\\' . $class, false)) { + /** @psalm-var class-string */ + $migrations[$sortKey] = $namespace . '\\' . $class; + break; + } + + if (class_exists($class, false)) { + /** @psalm-var class-string $class */ + $migrations[$sortKey] = $class; + break; + } + } + } + closedir($handle); + } + + return $migrations; + } + + private function normalizePath(string $path): string + { + if (!is_dir($path)) { + throw new LogicException("Invalid path directory \"$path\""); + } + + return rtrim(str_replace('\\', '/', $path), '/'); + } } diff --git a/tests/Common/Command/AbstractCreateCommandTest.php b/tests/Common/Command/AbstractCreateCommandTest.php index 5a4ac47b..b6d12d24 100644 --- a/tests/Common/Command/AbstractCreateCommandTest.php +++ b/tests/Common/Command/AbstractCreateCommandTest.php @@ -1025,7 +1025,7 @@ public function testIncorrectNewMigrationNamespace(): void $output = preg_replace('/(\R|\s)+/', ' ', $command->getDisplay(true)); $this->assertSame(Command::INVALID, $exitCode); - $this->assertStringContainsString('Invalid path directory', $output); + $this->assertStringContainsString('Invalid namespace "Yiisoft\Db\Migration\TestsRuntime\NotExists"', $output); } public function testWithoutNewMigrationNamespace(): void diff --git a/tests/Common/Service/AbstractMigrationServiceTest.php b/tests/Common/Service/AbstractMigrationServiceTest.php index e2e53f88..30b4d7b7 100644 --- a/tests/Common/Service/AbstractMigrationServiceTest.php +++ b/tests/Common/Service/AbstractMigrationServiceTest.php @@ -4,11 +4,11 @@ namespace Yiisoft\Db\Migration\Tests\Common\Service; +use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use ReflectionMethod; -use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; use Yiisoft\Db\Migration\Tests\Support\Helper\MigrationHelper; @@ -28,24 +28,6 @@ public function testVersion(): void public function testGetNewMigrationsWithNotExistNamespace(): void { MigrationHelper::useMigrationsNamespace($this->container); - - $className = MigrationHelper::createMigration( - $this->container, - 'Create_Post', - 'table', - 'post', - ['name:string(50)'], - ); - $this->container->get(Migrator::class)->up(new $className()); - - $className = MigrationHelper::createMigration( - $this->container, - 'Create_User', - 'table', - 'user', - ['name:string(32)'], - ); - $service = $this->container->get(MigrationService::class); $service->setSourceNamespaces([ @@ -53,9 +35,10 @@ public function testGetNewMigrationsWithNotExistNamespace(): void 'Yiisoft\\Db\\Migration\\TestsRuntime\\NotExists', ]); - $migrations = $service->getNewMigrations(); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid namespace "Yiisoft\Db\Migration\TestsRuntime\NotExists"'); - $this->assertSame([$className], $migrations); + $service->getNewMigrations(); } public static function getNewMigrationsDataProvider(): array @@ -67,17 +50,26 @@ public static function getNewMigrationsDataProvider(): array 'non exists newMigrationNamespace' => [ 'expected' => [], 'newMigrationNamespace' => 'Yiisoft\Db\Migration\TestsRuntime\NotExists', + 'newMigrationPath' => '', + 'sourceNamespaces' => [], + 'sourcePaths' => [], + 'errorMessage' => 'Invalid namespace "Yiisoft\Db\Migration\TestsRuntime\NotExists"', ], 'non exists newMigrationPath' => [ 'expected' => [], 'newMigrationNamespace' => '', 'newMigrationPath' => dirname(__DIR__, 2) . '/non-exists-directory', + 'sourceNamespaces' => [], + 'sourcePaths' => [], + 'errorMessage' => 'Invalid path directory "' . dirname(__DIR__, 2) . '/non-exists-directory"', ], 'non exists sourceNamespaces' => [ 'expected' => [], 'newMigrationNamespace' => '', 'newMigrationPath' => '', 'sourceNamespaces' => ['Yiisoft\Db\Migration\TestsRuntime\NotExists'], + 'sourcePaths' => [], + 'errorMessage' => 'Invalid namespace "Yiisoft\Db\Migration\TestsRuntime\NotExists"', ], 'non exists sourcePaths' => [ 'expected' => [], @@ -85,13 +77,17 @@ public static function getNewMigrationsDataProvider(): array 'newMigrationPath' => '', 'sourceNamespaces' => [], 'sourcePaths' => [dirname(__DIR__, 2) . '/non-exists-directory'], + 'errorMessage' => 'Invalid path directory "' . dirname(__DIR__, 2) . '/non-exists-directory"', ], 'with newMigrationNamespace' => [ 'expected' => ['Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty'], 'newMigrationNamespace' => 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra', ], 'with newMigrationPath' => [ - 'expected' => ['M231108183919Empty'], + 'expected' => [ + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', + ], 'newMigrationNamespace' => '', 'newMigrationPath' => dirname(__DIR__, 2) . '/Support/MigrationsExtra', ], @@ -112,8 +108,8 @@ public static function getNewMigrationsDataProvider(): array ], 'with different sourceNamespaces with the same path' => [ 'expected' => [ - 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183919Empty', 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', ], 'newMigrationNamespace' => '', 'newMigrationPath' => '', @@ -124,8 +120,9 @@ public static function getNewMigrationsDataProvider(): array ], 'with sourcePaths with different paths' => [ 'expected' => [ - 'M231108183919Empty', - 'M231108183919Empty2', + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty2', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', ], 'newMigrationNamespace' => '', 'newMigrationPath' => '', @@ -137,8 +134,8 @@ public static function getNewMigrationsDataProvider(): array ], 'with sourceNamespaces and sourcePaths with the same path' => [ 'expected' => [ - 'M231108183919Empty', 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', ], 'newMigrationNamespace' => '', 'newMigrationPath' => '', @@ -147,8 +144,8 @@ public static function getNewMigrationsDataProvider(): array ], 'with sourceNamespaces and sourcePaths with different paths' => [ 'expected' => [ - 'M231108183919Empty2', 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty2', ], 'newMigrationNamespace' => '', 'newMigrationPath' => '', @@ -162,7 +159,10 @@ public static function getNewMigrationsDataProvider(): array 'sourceNamespaces' => ['Yiisoft\Db\Migration\Tests\Support\MigrationsExtra'], ], 'with newMigrationPath and sourcePaths with the same path' => [ - 'expected' => ['M231108183919Empty'], + 'expected' => [ + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', + ], 'newMigrationNamespace' => '', 'newMigrationPath' => dirname(__DIR__, 2) . '/Support/MigrationsExtra', 'sourceNamespaces' => [], @@ -170,8 +170,8 @@ public static function getNewMigrationsDataProvider(): array ], 'with newMigrationNamespace and sourcePaths with the same path' => [ 'expected' => [ - 'M231108183919Empty', 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\ForTest\MigrationsExtra\M231108183920DifferentNamespace', ], 'newMigrationNamespace' => 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra', 'newMigrationPath' => '', @@ -192,8 +192,8 @@ public static function getNewMigrationsDataProvider(): array ], 'with newMigrationPath and sourceNamespaces with different paths' => [ 'expected' => [ - 'M231108183919Empty2', 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty', + 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty2', ], 'newMigrationNamespace' => '', 'newMigrationPath' => dirname(__DIR__, 2) . '/Support/MigrationsExtra2', @@ -209,6 +209,7 @@ public function testGetNewMigrations( string $newMigrationPath = '', array $sourceNamespaces = [], array $sourcePaths = [], + string $errorMessage = '', ): void { MigrationHelper::useMigrationsNamespace($this->container); @@ -218,9 +219,12 @@ public function testGetNewMigrations( $service->setSourceNamespaces($sourceNamespaces); $service->setSourcePaths($sourcePaths); - $migrations = $service->getNewMigrations(); - - $this->assertSame($expected, $migrations); + try { + $migrations = $service->getNewMigrations(); + $this->assertSame($expected, $migrations); + } catch (LogicException $e) { + $this->assertSame($errorMessage, $e->getMessage()); + } } public function testGetNamespacesFromPathForNoHavingNamespacePath(): void diff --git a/tests/Migration/Service/MigrationServiceTest.php b/tests/Migration/Service/MigrationServiceTest.php index 80ecfd0a..2a65bafd 100644 --- a/tests/Migration/Service/MigrationServiceTest.php +++ b/tests/Migration/Service/MigrationServiceTest.php @@ -31,7 +31,8 @@ public function testInvalidNamespace(): void $service->setNewMigrationNamespace('InvalidNamespace\\Hello'); $this->expectException(LogicException::class); - $this->expectExceptionMessage('Invalid namespace: "InvalidNamespace\Hello".'); + $this->expectExceptionMessage('Invalid namespace "InvalidNamespace\Hello"'); + $service->findMigrationPath(); } } diff --git a/tests/Support/MigrationsExtra/M231108183920DifferentNamespace.php b/tests/Support/MigrationsExtra/M231108183920DifferentNamespace.php new file mode 100644 index 00000000..64e6f5b3 --- /dev/null +++ b/tests/Support/MigrationsExtra/M231108183920DifferentNamespace.php @@ -0,0 +1,16 @@ +