From fcb51f303340699b442229946fd9752950ab6c34 Mon Sep 17 00:00:00 2001 From: mairo744 Date: Mon, 19 Oct 2026 22:51:30 +0200 Subject: [PATCH 1/3] feat: add support for finalizing Doctrine entities Signed-off-by: mairo744 --- README.md | 10 ++ src/Command/FinalizeClassesCommand.php | 65 ++++++++++- tests/Command/FinalizeClassesCommandTest.php | 101 ++++++++++++++++++ .../Fixture/FinalizeClasses/User.php.inc | 10 ++ .../doctrine-dev-too-low-composer.json | 8 ++ .../FinalizeClasses/php-too-low-composer.json | 6 ++ .../FinalizeClasses/supported-composer.json | 6 ++ 7 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/Command/FinalizeClassesCommandTest.php create mode 100644 tests/Command/Fixture/FinalizeClasses/User.php.inc create mode 100644 tests/Command/Fixture/FinalizeClasses/doctrine-dev-too-low-composer.json create mode 100644 tests/Command/Fixture/FinalizeClasses/php-too-low-composer.json create mode 100644 tests/Command/Fixture/FinalizeClasses/supported-composer.json 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..4d0788d3c 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,24 @@ 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()) { + $this->outputPrinter->error( + '--finalize-doctrine-entities requires "php" 8.4+ and "doctrine/orm" 3.4+ in composer.json' + ); + + return ExitCode::ERROR; + } + $this->outputPrinter->title('1. Detecting parent and entity classes'); $phpFileInfos = PhpFilesFinder::find($paths, $skipFiles); @@ -127,7 +145,11 @@ public function run( $this->outputPrinter->title('2. Finalizing safe classes'); - $excludedClasses = array_merge($parentClassNames, $entityClassNames, $mockedClassNames); + $excludedClasses = array_merge($parentClassNames, $mockedClassNames); + if (! $finalizeDoctrineEntities) { + $excludedClasses = array_merge($excludedClasses, $entityClassNames); + } + $needsFinalizeAnalyzer = new NeedsFinalizeAnalyzer($excludedClasses, $this->cachedPhpParser); $finalizedFilePaths = []; @@ -177,6 +199,45 @@ public function run( return ExitCode::SUCCESS; } + private function isDoctrineEntitiesFinalizeSupported(): bool + { + $composerJsonFilePath = getcwd() . '/composer.json'; + if (! file_exists($composerJsonFilePath)) { + return false; + } + + $composerJson = JsonFileLoader::loadFileToJson($composerJsonFilePath); + $requires = (array) ($composerJson['require'] ?? []); + $requiresDev = (array) ($composerJson['require-dev'] ?? []); + + $phpConstraint = $requires['php'] ?? null; + $doctrineOrmConstraint = $requires['doctrine/orm'] ?? $requiresDev['doctrine/orm'] ?? null; + + return $this->isConstraintWithin($phpConstraint, self::MINIMAL_PHP_CONSTRAINT) + && $this->isConstraintWithin($doctrineOrmConstraint, self::MINIMAL_DOCTRINE_ORM_CONSTRAINT); + } + + /** + * @param mixed $constraint + */ + private function isConstraintWithin($constraint, string $minimalConstraint): bool + { + if (! is_string($constraint) || $constraint === '') { + return false; + } + + $versionParser = new VersionParser(); + + try { + return Intervals::isSubsetOf( + $versionParser->parseConstraints($constraint), + $versionParser->parseConstraints($minimalConstraint) + ); + } catch (UnexpectedValueException $exception) { + return false; + } + } + public function getName(): string { return 'finalize-classes'; diff --git a/tests/Command/FinalizeClassesCommandTest.php b/tests/Command/FinalizeClassesCommandTest.php new file mode 100644 index 000000000..1f2f07b45 --- /dev/null +++ b/tests/Command/FinalizeClassesCommandTest.php @@ -0,0 +1,101 @@ +finalizeClassesCommand = $this->make(FinalizeClassesCommand::class); + + $this->originalWorkingDirectory = (string) getcwd(); + $this->projectDirectory = sys_get_temp_dir() . '/swiss-knife-finalize-' . uniqid(); + $this->entityFilePath = $this->projectDirectory . '/src/Entity/User.php'; + + mkdir($this->projectDirectory . '/src/Entity', 0777, true); + copy(__DIR__ . '/Fixture/FinalizeClasses/User.php.inc', $this->entityFilePath); + + chdir($this->projectDirectory); + } + + protected function tearDown(): void + { + chdir($this->originalWorkingDirectory); + + unlink($this->entityFilePath); + rmdir(dirname($this->entityFilePath)); + rmdir($this->projectDirectory . '/src'); + @unlink($this->projectDirectory . '/composer.json'); + rmdir($this->projectDirectory); + + parent::tearDown(); + } + + public function testDoctrineEntityIsNotFinalizedWithoutFlag(): void + { + $this->copyComposerJsonFixture('supported-composer.json'); + + $this->assertSame(ExitCode::SUCCESS, $this->runCommand(false)); + $this->assertStringNotContainsString('final class', (string) file_get_contents($this->entityFilePath)); + } + + /** + * @dataProvider provideFlagData + */ + public function testFlagRequiresPhp84AndDoctrineOrm34(?string $composerJsonFixture, int $expectedExitCode): void + { + if ($composerJsonFixture !== null) { + $this->copyComposerJsonFixture($composerJsonFixture); + } + + $this->assertSame($expectedExitCode, $this->runCommand(true)); + $isFinalized = str_contains((string) file_get_contents($this->entityFilePath), 'final class User'); + $this->assertSame($expectedExitCode === ExitCode::SUCCESS, $isFinalized); + } + + /** + * @return iterable + */ + public static function provideFlagData(): iterable + { + yield ['supported-composer.json', ExitCode::SUCCESS]; + yield ['php-too-low-composer.json', ExitCode::ERROR]; + yield ['doctrine-dev-too-low-composer.json', ExitCode::ERROR]; + yield [null, ExitCode::ERROR]; + } + + private function copyComposerJsonFixture(string $fixtureName): void + { + copy(__DIR__ . '/Fixture/FinalizeClasses/' . $fixtureName, $this->projectDirectory . '/composer.json'); + } + + private function runCommand(bool $finalizeDoctrineEntities): int + { + ob_start(); + try { + return $this->finalizeClassesCommand->run( + paths: [$this->projectDirectory . '/src'], + noProgress: true, + finalizeDoctrineEntities: $finalizeDoctrineEntities, + ); + } finally { + ob_end_clean(); + } + } +} diff --git a/tests/Command/Fixture/FinalizeClasses/User.php.inc b/tests/Command/Fixture/FinalizeClasses/User.php.inc new file mode 100644 index 000000000..b3e323d74 --- /dev/null +++ b/tests/Command/Fixture/FinalizeClasses/User.php.inc @@ -0,0 +1,10 @@ + Date: Wed, 21 Oct 2026 22:18:14 +0200 Subject: [PATCH 2/3] return list of classes to finalize for testing Signed-off-by: mairo744 --- src/Command/FinalizeClassesCommand.php | 110 +++++++++++------- tests/Command/FinalizeClassesCommandTest.php | 93 +++++---------- .../doctrine-dev-too-low-composer.json | 0 .../php-too-low-composer.json | 0 .../supported-composer.json | 0 .../FinalizeClasses/Entity/SomeEntity.php | 10 ++ .../Fixture/FinalizeClasses/SomeService.php | 7 ++ .../Fixture/FinalizeClasses/User.php.inc | 10 -- 8 files changed, 114 insertions(+), 116 deletions(-) rename tests/Command/Fixture/{FinalizeClasses => Composer}/doctrine-dev-too-low-composer.json (100%) rename tests/Command/Fixture/{FinalizeClasses => Composer}/php-too-low-composer.json (100%) rename tests/Command/Fixture/{FinalizeClasses => Composer}/supported-composer.json (100%) create mode 100644 tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php create mode 100644 tests/Command/Fixture/FinalizeClasses/SomeService.php delete mode 100644 tests/Command/Fixture/FinalizeClasses/User.php.inc diff --git a/src/Command/FinalizeClassesCommand.php b/src/Command/FinalizeClassesCommand.php index 4d0788d3c..fd610e33a 100644 --- a/src/Command/FinalizeClassesCommand.php +++ b/src/Command/FinalizeClassesCommand.php @@ -84,7 +84,7 @@ public function run( bool $noProgress = false, bool $finalizeDoctrineEntities = false ): int { - if ($finalizeDoctrineEntities && ! $this->isDoctrineEntitiesFinalizeSupported()) { + 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' ); @@ -92,6 +92,69 @@ public function run( 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); @@ -143,8 +206,6 @@ 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); @@ -152,56 +213,21 @@ public function run( $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); - } - } - - if ($finalizedFilePaths === []) { - $this->outputPrinter->success('Nothing to finalize'); - return ExitCode::SUCCESS; - } - - $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; + $filePathsToFinalize[] = $phpFileInfo->getRealPath(); } - $this->outputPrinter->success(sprintf('%d %s finalized', $countFinalizedClasses, $pluralClassText)); - - return ExitCode::SUCCESS; + return $filePathsToFinalize; } - private function isDoctrineEntitiesFinalizeSupported(): bool + public function isDoctrineEntitiesFinalizeSupported(string $composerJsonFilePath): bool { - $composerJsonFilePath = getcwd() . '/composer.json'; if (! file_exists($composerJsonFilePath)) { return false; } diff --git a/tests/Command/FinalizeClassesCommandTest.php b/tests/Command/FinalizeClassesCommandTest.php index 1f2f07b45..977b4d3cf 100644 --- a/tests/Command/FinalizeClassesCommandTest.php +++ b/tests/Command/FinalizeClassesCommandTest.php @@ -4,7 +4,6 @@ namespace Rector\SwissKnife\Tests\Command; -use Entropy\Console\Enum\ExitCode; use Rector\SwissKnife\Command\FinalizeClassesCommand; use Rector\SwissKnife\Tests\AbstractTestCase; @@ -12,90 +11,56 @@ final class FinalizeClassesCommandTest extends AbstractTestCase { private FinalizeClassesCommand $finalizeClassesCommand; - private string $originalWorkingDirectory; - - private string $projectDirectory; - - private string $entityFilePath; - protected function setUp(): void { parent::setUp(); $this->finalizeClassesCommand = $this->make(FinalizeClassesCommand::class); - - $this->originalWorkingDirectory = (string) getcwd(); - $this->projectDirectory = sys_get_temp_dir() . '/swiss-knife-finalize-' . uniqid(); - $this->entityFilePath = $this->projectDirectory . '/src/Entity/User.php'; - - mkdir($this->projectDirectory . '/src/Entity', 0777, true); - copy(__DIR__ . '/Fixture/FinalizeClasses/User.php.inc', $this->entityFilePath); - - chdir($this->projectDirectory); } - protected function tearDown(): void + public function testSkipDoctrineEntitiesByDefault(): void { - chdir($this->originalWorkingDirectory); + $filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize([ + __DIR__ . '/Fixture/FinalizeClasses', + ]); - unlink($this->entityFilePath); - rmdir(dirname($this->entityFilePath)); - rmdir($this->projectDirectory . '/src'); - @unlink($this->projectDirectory . '/composer.json'); - rmdir($this->projectDirectory); - - parent::tearDown(); + $this->assertSame([__DIR__ . '/Fixture/FinalizeClasses/SomeService.php'], $filePathsToFinalize); } - public function testDoctrineEntityIsNotFinalizedWithoutFlag(): void + public function testFinalizeDoctrineEntities(): void { - $this->copyComposerJsonFixture('supported-composer.json'); - - $this->assertSame(ExitCode::SUCCESS, $this->runCommand(false)); - $this->assertStringNotContainsString('final class', (string) file_get_contents($this->entityFilePath)); + $filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize( + [__DIR__ . '/Fixture/FinalizeClasses'], + false, + [], + true + ); + + $this->assertSame([ + __DIR__ . '/Fixture/FinalizeClasses/Entity/SomeEntity.php', + __DIR__ . '/Fixture/FinalizeClasses/SomeService.php', + ], $filePathsToFinalize); } /** - * @dataProvider provideFlagData + * @dataProvider provideComposerJsonFiles */ - public function testFlagRequiresPhp84AndDoctrineOrm34(?string $composerJsonFixture, int $expectedExitCode): void + public function testDoctrineEntitiesFinalizeSupport(string $composerJsonFilePath, bool $expectedIsSupported): void { - if ($composerJsonFixture !== null) { - $this->copyComposerJsonFixture($composerJsonFixture); - } - - $this->assertSame($expectedExitCode, $this->runCommand(true)); - $isFinalized = str_contains((string) file_get_contents($this->entityFilePath), 'final class User'); - $this->assertSame($expectedExitCode === ExitCode::SUCCESS, $isFinalized); + $this->assertSame( + $expectedIsSupported, + $this->finalizeClassesCommand->isDoctrineEntitiesFinalizeSupported($composerJsonFilePath) + ); } /** - * @return iterable + * @return iterable */ - public static function provideFlagData(): iterable - { - yield ['supported-composer.json', ExitCode::SUCCESS]; - yield ['php-too-low-composer.json', ExitCode::ERROR]; - yield ['doctrine-dev-too-low-composer.json', ExitCode::ERROR]; - yield [null, ExitCode::ERROR]; - } - - private function copyComposerJsonFixture(string $fixtureName): void - { - copy(__DIR__ . '/Fixture/FinalizeClasses/' . $fixtureName, $this->projectDirectory . '/composer.json'); - } - - private function runCommand(bool $finalizeDoctrineEntities): int + public static function provideComposerJsonFiles(): iterable { - ob_start(); - try { - return $this->finalizeClassesCommand->run( - paths: [$this->projectDirectory . '/src'], - noProgress: true, - finalizeDoctrineEntities: $finalizeDoctrineEntities, - ); - } finally { - ob_end_clean(); - } + 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/FinalizeClasses/doctrine-dev-too-low-composer.json b/tests/Command/Fixture/Composer/doctrine-dev-too-low-composer.json similarity index 100% rename from tests/Command/Fixture/FinalizeClasses/doctrine-dev-too-low-composer.json rename to tests/Command/Fixture/Composer/doctrine-dev-too-low-composer.json diff --git a/tests/Command/Fixture/FinalizeClasses/php-too-low-composer.json b/tests/Command/Fixture/Composer/php-too-low-composer.json similarity index 100% rename from tests/Command/Fixture/FinalizeClasses/php-too-low-composer.json rename to tests/Command/Fixture/Composer/php-too-low-composer.json diff --git a/tests/Command/Fixture/FinalizeClasses/supported-composer.json b/tests/Command/Fixture/Composer/supported-composer.json similarity index 100% rename from tests/Command/Fixture/FinalizeClasses/supported-composer.json rename to tests/Command/Fixture/Composer/supported-composer.json diff --git a/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php new file mode 100644 index 000000000..9eb4118c3 --- /dev/null +++ b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php @@ -0,0 +1,10 @@ + Date: Fri, 23 Oct 2026 16:15:33 +0200 Subject: [PATCH 3/3] fix: update annotation style for SomeEntity class Signed-off-by: mairo744 --- tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php index 9eb4118c3..36849eebc 100644 --- a/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php +++ b/tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php @@ -4,7 +4,9 @@ use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity] +/** + * @ORM\Entity + */ class SomeEntity { }