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 @@
+