diff --git a/README.md b/README.md index 0680d720d..83dd97413 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,16 @@ vendor/bin/swiss-knife finalize-classes src tests --skip-file '*Controller.php'
+Using Doctrine ORM 3.4+ on PHP 8.4+? Finalize Doctrine entities too, as they no longer need proxy subclasses: + +```bash +vendor/bin/swiss-knife finalize-classes src tests --finalize-doctrine-entities +``` + +This option works only when `composer.json` requires `php` 8.4+ and `doctrine/orm` 3.4+, otherwise the command fails. + +
+ ## 5. Privatize local class constants PHPStan can report unused private class constants, but it skips all the public ones. diff --git a/src/Command/FinalizeClassesCommand.php b/src/Command/FinalizeClassesCommand.php index 19c8c5703..fd610e33a 100644 --- a/src/Command/FinalizeClassesCommand.php +++ b/src/Command/FinalizeClassesCommand.php @@ -4,6 +4,8 @@ namespace Rector\SwissKnife\Command; +use Composer\Semver\Intervals; +use Composer\Semver\VersionParser; use Entropy\Console\Contract\CommandInterface; use Entropy\Console\Enum\ExitCode; use Entropy\Console\Output\OutputPrinter; @@ -14,9 +16,11 @@ use Rector\SwissKnife\EntityClassResolver; use Rector\SwissKnife\FileSystem\PathHelper; use Rector\SwissKnife\Finder\PhpFilesFinder; +use Rector\SwissKnife\Jack\Utils\JsonFileLoader; use Rector\SwissKnife\MockedClassResolver; use Rector\SwissKnife\ParentClassResolver; use Rector\SwissKnife\PhpParser\CachedPhpParser; +use UnexpectedValueException; final class FinalizeClassesCommand implements CommandInterface { @@ -51,6 +55,10 @@ final class FinalizeClassesCommand implements CommandInterface */ private const NEWLINE_CLASS_START_REGEX = '#^(readonly )?class\s#m'; + private const MINIMAL_PHP_CONSTRAINT = '>=8.4.0'; + + private const MINIMAL_DOCTRINE_ORM_CONSTRAINT = '>=3.4.0'; + public function __construct(OutputPrinter $outputPrinter, ParentClassResolver $parentClassResolver, EntityClassResolver $entityClassResolver, CachedPhpParser $cachedPhpParser, MockedClassResolver $mockedClassResolver) { $this->outputPrinter = $outputPrinter; @@ -66,14 +74,87 @@ public function __construct(OutputPrinter $outputPrinter, ParentClassResolver $p * @param bool $skipMocked Skip mocked classes as well (use only if unable to run bypass-finals package) * @param string[] $skipFiles Skip file or files by path * @param bool $noProgress Do not show progress bar, only results + * @param bool $finalizeDoctrineEntities Finalize Doctrine entities too. Requires PHP 8.4+ and doctrine/orm 3.4+ in composer.json */ public function run( array $paths, bool $dryRun = false, bool $skipMocked = false, array $skipFiles = [], - bool $noProgress = false + bool $noProgress = false, + bool $finalizeDoctrineEntities = false ): int { + if ($finalizeDoctrineEntities && ! $this->isDoctrineEntitiesFinalizeSupported(getcwd() . '/composer.json')) { + $this->outputPrinter->error( + '--finalize-doctrine-entities requires "php" 8.4+ and "doctrine/orm" 3.4+ in composer.json' + ); + + return ExitCode::ERROR; + } + + $filePathsToFinalize = $this->resolveFilePathsToFinalize( + $paths, + $skipMocked, + $skipFiles, + $finalizeDoctrineEntities, + $noProgress + ); + + $this->outputPrinter->title('2. Finalizing safe classes'); + + if ($filePathsToFinalize === []) { + $this->outputPrinter->success('Nothing to finalize'); + return ExitCode::SUCCESS; + } + + $finalizedFilePaths = []; + foreach ($filePathsToFinalize as $filePathToFinalize) { + $finalizedFilePaths[] = PathHelper::relativeToCwd($filePathToFinalize); + + if ($dryRun === false) { + $finalizedContents = Regex::replace( + FileSystem::read($filePathToFinalize), + self::NEWLINE_CLASS_START_REGEX, + 'final $1class ' + ); + + FileSystem::write($filePathToFinalize, $finalizedContents); + } + } + + $this->outputPrinter->listing($finalizedFilePaths); + + $countFinalizedClasses = count($finalizedFilePaths); + $pluralClassText = $countFinalizedClasses === 1 ? 'class' : 'classes'; + + // to make it fail in CI + if ($dryRun) { + $this->outputPrinter->error(sprintf( + '%d %s can be finalized', + $countFinalizedClasses, + $pluralClassText, + )); + + return ExitCode::ERROR; + } + + $this->outputPrinter->success(sprintf('%d %s finalized', $countFinalizedClasses, $pluralClassText)); + + return ExitCode::SUCCESS; + } + + /** + * @param string[] $paths + * @param string[] $skipFiles + * @return string[] + */ + public function resolveFilePathsToFinalize( + array $paths, + bool $skipMocked = false, + array $skipFiles = [], + bool $finalizeDoctrineEntities = false, + bool $noProgress = true + ): array { $this->outputPrinter->title('1. Detecting parent and entity classes'); $phpFileInfos = PhpFilesFinder::find($paths, $skipFiles); @@ -125,56 +206,62 @@ public function run( $this->outputPrinter->newline(1); - $this->outputPrinter->title('2. Finalizing safe classes'); + $excludedClasses = array_merge($parentClassNames, $mockedClassNames); + if (! $finalizeDoctrineEntities) { + $excludedClasses = array_merge($excludedClasses, $entityClassNames); + } - $excludedClasses = array_merge($parentClassNames, $entityClassNames, $mockedClassNames); $needsFinalizeAnalyzer = new NeedsFinalizeAnalyzer($excludedClasses, $this->cachedPhpParser); - $finalizedFilePaths = []; - + $filePathsToFinalize = []; foreach ($phpFileInfos as $phpFileInfo) { // should be file be finalize, is not and is not excluded? if (! $needsFinalizeAnalyzer->isNeeded($phpFileInfo->getRealPath())) { continue; } - $finalizedContents = Regex::replace( - $phpFileInfo->getContents(), - self::NEWLINE_CLASS_START_REGEX, - 'final $1class ' - ); - - $finalizedFilePaths[] = PathHelper::relativeToCwd($phpFileInfo->getRealPath()); - - if ($dryRun === false) { - FileSystem::write($phpFileInfo->getRealPath(), $finalizedContents); - } + $filePathsToFinalize[] = $phpFileInfo->getRealPath(); } - if ($finalizedFilePaths === []) { - $this->outputPrinter->success('Nothing to finalize'); - return ExitCode::SUCCESS; + return $filePathsToFinalize; + } + + public function isDoctrineEntitiesFinalizeSupported(string $composerJsonFilePath): bool + { + if (! file_exists($composerJsonFilePath)) { + return false; } - $this->outputPrinter->listing($finalizedFilePaths); + $composerJson = JsonFileLoader::loadFileToJson($composerJsonFilePath); + $requires = (array) ($composerJson['require'] ?? []); + $requiresDev = (array) ($composerJson['require-dev'] ?? []); - $countFinalizedClasses = count($finalizedFilePaths); - $pluralClassText = $countFinalizedClasses === 1 ? 'class' : 'classes'; + $phpConstraint = $requires['php'] ?? null; + $doctrineOrmConstraint = $requires['doctrine/orm'] ?? $requiresDev['doctrine/orm'] ?? null; - // to make it fail in CI - if ($dryRun) { - $this->outputPrinter->error(sprintf( - '%d %s can be finalized', - $countFinalizedClasses, - $pluralClassText, - )); + return $this->isConstraintWithin($phpConstraint, self::MINIMAL_PHP_CONSTRAINT) + && $this->isConstraintWithin($doctrineOrmConstraint, self::MINIMAL_DOCTRINE_ORM_CONSTRAINT); + } - return ExitCode::ERROR; + /** + * @param mixed $constraint + */ + private function isConstraintWithin($constraint, string $minimalConstraint): bool + { + if (! is_string($constraint) || $constraint === '') { + return false; } - $this->outputPrinter->success(sprintf('%d %s finalized', $countFinalizedClasses, $pluralClassText)); + $versionParser = new VersionParser(); - return ExitCode::SUCCESS; + try { + return Intervals::isSubsetOf( + $versionParser->parseConstraints($constraint), + $versionParser->parseConstraints($minimalConstraint) + ); + } catch (UnexpectedValueException $exception) { + return false; + } } public function getName(): string diff --git a/tests/Command/FinalizeClassesCommandTest.php b/tests/Command/FinalizeClassesCommandTest.php new file mode 100644 index 000000000..977b4d3cf --- /dev/null +++ b/tests/Command/FinalizeClassesCommandTest.php @@ -0,0 +1,66 @@ +finalizeClassesCommand = $this->make(FinalizeClassesCommand::class); + } + + public function testSkipDoctrineEntitiesByDefault(): void + { + $filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize([ + __DIR__ . '/Fixture/FinalizeClasses', + ]); + + $this->assertSame([__DIR__ . '/Fixture/FinalizeClasses/SomeService.php'], $filePathsToFinalize); + } + + public function testFinalizeDoctrineEntities(): void + { + $filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize( + [__DIR__ . '/Fixture/FinalizeClasses'], + false, + [], + true + ); + + $this->assertSame([ + __DIR__ . '/Fixture/FinalizeClasses/Entity/SomeEntity.php', + __DIR__ . '/Fixture/FinalizeClasses/SomeService.php', + ], $filePathsToFinalize); + } + + /** + * @dataProvider provideComposerJsonFiles + */ + public function testDoctrineEntitiesFinalizeSupport(string $composerJsonFilePath, bool $expectedIsSupported): void + { + $this->assertSame( + $expectedIsSupported, + $this->finalizeClassesCommand->isDoctrineEntitiesFinalizeSupported($composerJsonFilePath) + ); + } + + /** + * @return iterable + */ + public static function provideComposerJsonFiles(): iterable + { + yield [__DIR__ . '/Fixture/Composer/supported-composer.json', true]; + yield [__DIR__ . '/Fixture/Composer/php-too-low-composer.json', false]; + yield [__DIR__ . '/Fixture/Composer/doctrine-dev-too-low-composer.json', false]; + yield [__DIR__ . '/Fixture/Composer/missing-composer.json', false]; + } +} diff --git a/tests/Command/Fixture/Composer/doctrine-dev-too-low-composer.json b/tests/Command/Fixture/Composer/doctrine-dev-too-low-composer.json new file mode 100644 index 000000000..81a6c34b4 --- /dev/null +++ b/tests/Command/Fixture/Composer/doctrine-dev-too-low-composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "php": "^8.4" + }, + "require-dev": { + "doctrine/orm": "^3.3" + } +} diff --git a/tests/Command/Fixture/Composer/php-too-low-composer.json b/tests/Command/Fixture/Composer/php-too-low-composer.json new file mode 100644 index 000000000..093877f56 --- /dev/null +++ b/tests/Command/Fixture/Composer/php-too-low-composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "php": "^8.3", + "doctrine/orm": "^3.4" + } +} diff --git a/tests/Command/Fixture/Composer/supported-composer.json b/tests/Command/Fixture/Composer/supported-composer.json new file mode 100644 index 000000000..0616bf3d9 --- /dev/null +++ b/tests/Command/Fixture/Composer/supported-composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "php": "^8.4", + "doctrine/orm": "^3.4" + } +} diff --git a/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php new file mode 100644 index 000000000..36849eebc --- /dev/null +++ b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php @@ -0,0 +1,12 @@ +