From 4d633b2cc06db5482018d5a4de50e396a4406fb2 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 23 Sep 2026 19:37:51 +0200 Subject: [PATCH 1/2] [doctrine] Skip UPDATE/DELETE and cross-entity from() builders in RequireQueryBuilderOnRepositoryRule --- README.md | 36 ++- .../RequireQueryBuilderOnRepositoryRule.php | 239 +++++++++++++++++- stubs/Doctrine/ORM/EntityManagerInterface.php | 2 +- stubs/Doctrine/ORM/EntityRepository.php | 5 +- stubs/Doctrine/ORM/QueryBuilder.php | 38 +++ .../Fixture/MixedBuildersInOneMethod.php | 27 ++ .../Fixture/ReportOnSelfEntityFrom.php | 29 +++ .../Fixture/SkipCrossEntityFrom.php | 30 +++ .../Fixture/SkipDeleteBuilder.php | 22 ++ .../Fixture/SkipUpdateBuilder.php | 28 ++ ...equireQueryBuilderOnRepositoryRuleTest.php | 14 + .../Source/AnotherEntity.php | 7 + 12 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 stubs/Doctrine/ORM/QueryBuilder.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/MixedBuildersInOneMethod.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/ReportOnSelfEntityFrom.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipCrossEntityFrom.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipDeleteBuilder.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipUpdateBuilder.php create mode 100644 tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Source/AnotherEntity.php diff --git a/README.md b/README.md index 737b35f5f..da94add51 100644 --- a/README.md +++ b/README.md @@ -1583,13 +1583,47 @@ final class Product {} ### RequireQueryBuilderOnRepositoryRule -Prevents using `$entityManager->createQueryBuilder('...')`, use `$repository->createQueryBuilder()` as safer. +Prevents using `$entityManager->createQueryBuilder()`, use `$repository->createQueryBuilder()` as safer. + +Builders that the repository shortcut cannot express are skipped: `update()`/`delete()` builders and `from()` on another entity than the repository's own one (e.g. cross-entity subqueries). ```yaml rules: - Symplify\PHPStanRules\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule ``` +```php +final class SomeRepository extends EntityRepository +{ + public function getSome(): array + { + return $this->getEntityManager()->createQueryBuilder() + ->select('s') + ->from(SomeEntity::class, 's') + ->getQuery() + ->getResult(); + } +} +``` + +:x: + +
+ +```php +final class SomeRepository extends EntityRepository +{ + public function getSome(): array + { + return $this->createQueryBuilder('s') + ->getQuery() + ->getResult(); + } +} +``` + +:+1: +
### NoGetRepositoryOutsideServiceRule diff --git a/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php b/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php index 6cd44c45d..e65787ab6 100644 --- a/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php +++ b/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php @@ -5,8 +5,16 @@ namespace Symplify\PHPStanRules\Rules\Doctrine; use PhpParser\Node; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; +use PHPStan\Node\InClassMethodNode; use PHPStan\Reflection\ClassReflection; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -18,24 +26,38 @@ use Symplify\PHPStanRules\Helper\NamingHelper; /** - * @implements Rule + * @implements Rule * @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule\RequireQueryBuilderOnRepositoryRuleTest */ -final class RequireQueryBuilderOnRepositoryRule implements Rule +final readonly class RequireQueryBuilderOnRepositoryRule implements Rule { public const string ERROR_MESSAGE = 'Avoid calling ->createQueryBuilder() directly on EntityManager as it requires select() + from() calls with specific values. Use $repository->createQueryBuilder() to be safe instead'; + /** + * UPDATE/DELETE builders are not plain SELECTs, the repository shortcut cannot express them + * @var string[] + */ + private const array NON_SELECT_BUILDER_METHODS = ['update', 'delete']; + + private NodeFinder $nodeFinder; + + public function __construct() + { + $this->nodeFinder = new NodeFinder(); + } + public function getNodeType(): string { - return MethodCall::class; + return InClassMethodNode::class; } /** - * @param MethodCall $node + * @param InClassMethodNode $node */ public function processNode(Node $node, Scope $scope): array { - if (! NamingHelper::isName($node->name, 'createQueryBuilder')) { + $classMethod = $node->getOriginalNode(); + if ($classMethod->stmts === null) { return []; } @@ -44,16 +66,34 @@ public function processNode(Node $node, Scope $scope): array return []; } - $callerType = $scope->getType($node->var); - if ($this->isValidRepositoryObjectType($callerType)) { - return []; - } + /** @var MethodCall[] $methodCalls */ + $methodCalls = $this->nodeFinder->findInstanceOf($classMethod->stmts, MethodCall::class); + + $repositoryEntityClassNames = $this->resolveRepositoryEntityClassNames($scope); - $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) - ->identifier(DoctrineRuleIdentifier::REQUIRE_QUERY_BUILDER_ON_REPOSITORY) - ->build(); + $ruleErrors = []; + foreach ($methodCalls as $methodCall) { + if (! NamingHelper::isName($methodCall->name, 'createQueryBuilder')) { + continue; + } + + $callerType = $scope->getType($methodCall->var); + if ($this->isValidRepositoryObjectType($callerType)) { + continue; + } - return [$identifierRuleError]; + // the query builder cannot be swapped for $this->createQueryBuilder() + if ($this->isUnconvertibleBuilder($methodCall, $methodCalls, $classMethod, $repositoryEntityClassNames, $scope)) { + continue; + } + + $ruleErrors[] = RuleErrorBuilder::message(self::ERROR_MESSAGE) + ->identifier(DoctrineRuleIdentifier::REQUIRE_QUERY_BUILDER_ON_REPOSITORY) + ->line($methodCall->getStartLine()) + ->build(); + } + + return $ruleErrors; } private function isInsideRepositoryClass(Scope $scope): bool @@ -95,4 +135,177 @@ private function isValidRepositoryObjectType(Type $type): bool return $type->isInstanceOf(DoctrineClass::CONNECTION)->yes(); } + + /** + * @param MethodCall[] $allMethodCalls + * @param string[] $repositoryEntityClassNames + */ + private function isUnconvertibleBuilder( + MethodCall $methodCall, + array $allMethodCalls, + ClassMethod $classMethod, + array $repositoryEntityClassNames, + Scope $scope + ): bool { + $builderMethodCalls = $this->collectBuilderMethodCalls($methodCall, $allMethodCalls, $classMethod); + + foreach ($builderMethodCalls as $builderMethodCall) { + if (! $builderMethodCall->name instanceof Identifier) { + continue; + } + + $calledMethodName = $builderMethodCall->name->toString(); + + if (in_array($calledMethodName, self::NON_SELECT_BUILDER_METHODS, true)) { + return true; + } + + // from() on another entity than the repository's own one cannot use the repository shortcut + if ($calledMethodName === 'from' && ! $this->isFromOnRepositoryEntity( + $builderMethodCall, + $repositoryEntityClassNames, + $scope + )) { + return true; + } + } + + return false; + } + + /** + * Collects every method call made on the query builder that this createQueryBuilder() call produces, + * both as a fluent chain and via an intermediate variable. + * + * @param MethodCall[] $allMethodCalls + * @return MethodCall[] + */ + private function collectBuilderMethodCalls( + MethodCall $createQueryBuilderCall, + array $allMethodCalls, + ClassMethod $classMethod + ): array { + $builderMethodCalls = []; + + foreach ($allMethodCalls as $methodCall) { + if ($methodCall !== $createQueryBuilderCall && $this->chainPassesThrough($methodCall, $createQueryBuilderCall)) { + $builderMethodCalls[] = $methodCall; + } + } + + $assignedVariableName = $this->resolveAssignedVariableName($createQueryBuilderCall, $classMethod); + if ($assignedVariableName !== null) { + foreach ($allMethodCalls as $allMethodCall) { + if ($this->resolveRootVariableName($allMethodCall) === $assignedVariableName) { + $builderMethodCalls[] = $allMethodCall; + } + } + } + + return $builderMethodCalls; + } + + private function chainPassesThrough(MethodCall $methodCall, MethodCall $targetMethodCall): bool + { + $current = $methodCall->var; + while ($current instanceof MethodCall) { + if ($current === $targetMethodCall) { + return true; + } + + $current = $current->var; + } + + return false; + } + + private function resolveRootVariableName(MethodCall $methodCall): ?string + { + $current = $methodCall->var; + while ($current instanceof MethodCall) { + $current = $current->var; + } + + if ($current instanceof Variable && is_string($current->name)) { + return $current->name; + } + + return null; + } + + private function resolveAssignedVariableName(MethodCall $methodCall, ClassMethod $classMethod): ?string + { + /** @var Assign[] $assigns */ + $assigns = $this->nodeFinder->findInstanceOf((array) $classMethod->stmts, Assign::class); + + foreach ($assigns as $assign) { + $isBuilderAssign = $assign->expr === $methodCall + || ($assign->expr instanceof MethodCall && $this->chainPassesThrough($assign->expr, $methodCall)); + + if (! $isBuilderAssign) { + continue; + } + + if ($assign->var instanceof Variable && is_string($assign->var->name)) { + return $assign->var->name; + } + } + + return null; + } + + /** + * @param string[] $repositoryEntityClassNames + */ + private function isFromOnRepositoryEntity(MethodCall $fromMethodCall, array $repositoryEntityClassNames, Scope $scope): bool + { + $args = $fromMethodCall->getArgs(); + if (! isset($args[0])) { + return false; + } + + $firstArg = $args[0]->value; + if (! $firstArg instanceof ClassConstFetch) { + return false; + } + + if (! $firstArg->class instanceof Name) { + return false; + } + + if (! $firstArg->name instanceof Identifier || $firstArg->name->toString() !== 'class') { + return false; + } + + $fromClassName = $scope->resolveName($firstArg->class); + + return in_array($fromClassName, $repositoryEntityClassNames, true); + } + + /** + * @return string[] + */ + private function resolveRepositoryEntityClassNames(Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if (! $classReflection instanceof ClassReflection) { + return []; + } + + $entityClassNames = []; + foreach ([DoctrineClass::ENTITY_REPOSITORY, DoctrineClass::DOCUMENT_REPOSITORY] as $repositoryBaseClass) { + $ancestorClassReflection = $classReflection->getAncestorWithClassName($repositoryBaseClass); + if (! $ancestorClassReflection instanceof ClassReflection) { + continue; + } + + foreach ($ancestorClassReflection->getActiveTemplateTypeMap()->getTypes() as $templateType) { + foreach ($templateType->getObjectClassNames() as $objectClassName) { + $entityClassNames[] = $objectClassName; + } + } + } + + return array_unique($entityClassNames); + } } diff --git a/stubs/Doctrine/ORM/EntityManagerInterface.php b/stubs/Doctrine/ORM/EntityManagerInterface.php index c38ecc95a..830ecc5e6 100644 --- a/stubs/Doctrine/ORM/EntityManagerInterface.php +++ b/stubs/Doctrine/ORM/EntityManagerInterface.php @@ -14,5 +14,5 @@ interface EntityManagerInterface */ public function getRepository(string $class): object; - public function createQueryBuilder(); + public function createQueryBuilder(): QueryBuilder; } diff --git a/stubs/Doctrine/ORM/EntityRepository.php b/stubs/Doctrine/ORM/EntityRepository.php index 8e117539a..188678948 100644 --- a/stubs/Doctrine/ORM/EntityRepository.php +++ b/stubs/Doctrine/ORM/EntityRepository.php @@ -6,9 +6,12 @@ return; } +/** + * @template T of object + */ class EntityRepository { - public function createQueryBuilder() + public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder { } } diff --git a/stubs/Doctrine/ORM/QueryBuilder.php b/stubs/Doctrine/ORM/QueryBuilder.php new file mode 100644 index 000000000..8ca83e948 --- /dev/null +++ b/stubs/Doctrine/ORM/QueryBuilder.php @@ -0,0 +1,38 @@ + + */ +final class MixedBuildersInOneMethod extends EntityRepository +{ + public function run(EntityManagerInterface $entityManager): void + { + $mainQueryBuilder = $entityManager->createQueryBuilder(); + $mainQueryBuilder->select('r') + ->from(RandomEntity::class, 'r'); + + $subQueryBuilder = $entityManager->createQueryBuilder(); + $subQueryBuilder->select('count(a.id)') + ->from(AnotherEntity::class, 'a'); + } +} diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/ReportOnSelfEntityFrom.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/ReportOnSelfEntityFrom.php new file mode 100644 index 000000000..cd65955be --- /dev/null +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/ReportOnSelfEntityFrom.php @@ -0,0 +1,29 @@ + + */ +final class ReportOnSelfEntityFrom extends EntityRepository +{ + public function fluent(EntityManagerInterface $entityManager): void + { + $entityManager->createQueryBuilder() + ->select('r') + ->from(RandomEntity::class, 'r'); + } + + public function assigned(EntityManagerInterface $entityManager): void + { + $queryBuilder = $entityManager->createQueryBuilder(); + $queryBuilder->select('r') + ->from(RandomEntity::class, 'r'); + } +} diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipCrossEntityFrom.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipCrossEntityFrom.php new file mode 100644 index 000000000..520cde801 --- /dev/null +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipCrossEntityFrom.php @@ -0,0 +1,30 @@ + + */ +final class SkipCrossEntityFrom extends EntityRepository +{ + public function fluent(EntityManagerInterface $entityManager): void + { + $entityManager->createQueryBuilder() + ->select('a') + ->from(AnotherEntity::class, 'a'); + } + + public function assigned(EntityManagerInterface $entityManager): void + { + $subQueryBuilder = $entityManager->createQueryBuilder(); + $subQueryBuilder->select('a') + ->from(AnotherEntity::class, 'a'); + } +} diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipDeleteBuilder.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipDeleteBuilder.php new file mode 100644 index 000000000..6ffffc58f --- /dev/null +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipDeleteBuilder.php @@ -0,0 +1,22 @@ + + */ +final class SkipDeleteBuilder extends EntityRepository +{ + public function assigned(EntityManagerInterface $entityManager): void + { + $queryBuilder = $entityManager->createQueryBuilder(); + $queryBuilder->delete(RandomEntity::class, 'r') + ->where('r.id = :id'); + } +} diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipUpdateBuilder.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipUpdateBuilder.php new file mode 100644 index 000000000..5d61825db --- /dev/null +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Fixture/SkipUpdateBuilder.php @@ -0,0 +1,28 @@ + + */ +final class SkipUpdateBuilder extends EntityRepository +{ + public function fluent(EntityManagerInterface $entityManager): void + { + $entityManager->createQueryBuilder() + ->update(RandomEntity::class, 'r') + ->where('r.active = :active'); + } + + public function assigned(EntityManagerInterface $entityManager): void + { + $queryBuilder = $entityManager->createQueryBuilder(); + $queryBuilder->update(RandomEntity::class, 'r'); + } +} diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/RequireQueryBuilderOnRepositoryRuleTest.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/RequireQueryBuilderOnRepositoryRuleTest.php index ae9c38a79..d412d6ca9 100644 --- a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/RequireQueryBuilderOnRepositoryRuleTest.php +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/RequireQueryBuilderOnRepositoryRuleTest.php @@ -31,6 +31,11 @@ public static function provideData(): Iterator yield [__DIR__ . '/Fixture/SkipConnection.php', []]; yield [__DIR__ . '/Fixture/SkipNonRepositoryClass.php', []]; + // builders that cannot be swapped for $this->createQueryBuilder() + yield [__DIR__ . '/Fixture/SkipUpdateBuilder.php', []]; + yield [__DIR__ . '/Fixture/SkipDeleteBuilder.php', []]; + yield [__DIR__ . '/Fixture/SkipCrossEntityFrom.php', []]; + yield [__DIR__ . '/Fixture/ReportOnEntityManager.php', [ [RequireQueryBuilderOnRepositoryRule::ERROR_MESSAGE, 14], ]]; @@ -38,6 +43,15 @@ public static function provideData(): Iterator yield [__DIR__ . '/Fixture/ReportOnDocumentManager.php', [ [RequireQueryBuilderOnRepositoryRule::ERROR_MESSAGE, 14], ]]; + + yield [__DIR__ . '/Fixture/ReportOnSelfEntityFrom.php', [ + [RequireQueryBuilderOnRepositoryRule::ERROR_MESSAGE, 18], + [RequireQueryBuilderOnRepositoryRule::ERROR_MESSAGE, 25], + ]]; + + yield [__DIR__ . '/Fixture/MixedBuildersInOneMethod.php', [ + [RequireQueryBuilderOnRepositoryRule::ERROR_MESSAGE, 19], + ]]; } protected function getRule(): Rule diff --git a/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Source/AnotherEntity.php b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Source/AnotherEntity.php new file mode 100644 index 000000000..1d8aa7735 --- /dev/null +++ b/tests/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule/Source/AnotherEntity.php @@ -0,0 +1,7 @@ + Date: Wed, 23 Sep 2026 19:40:27 +0200 Subject: [PATCH 2/2] Clarify createQueryBuilder rule message and description mention repository class --- README.md | 2 +- src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da94add51..3ea70e14e 100644 --- a/README.md +++ b/README.md @@ -1583,7 +1583,7 @@ final class Product {} ### RequireQueryBuilderOnRepositoryRule -Prevents using `$entityManager->createQueryBuilder()`, use `$repository->createQueryBuilder()` as safer. +Prevents using `$entityManager->createQueryBuilder()` inside a repository class, use `$repository->createQueryBuilder()` as safer. Builders that the repository shortcut cannot express are skipped: `update()`/`delete()` builders and `from()` on another entity than the repository's own one (e.g. cross-entity subqueries). diff --git a/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php b/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php index e65787ab6..fb19e3db8 100644 --- a/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php +++ b/src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php @@ -31,7 +31,7 @@ */ final readonly class RequireQueryBuilderOnRepositoryRule implements Rule { - public const string ERROR_MESSAGE = 'Avoid calling ->createQueryBuilder() directly on EntityManager as it requires select() + from() calls with specific values. Use $repository->createQueryBuilder() to be safe instead'; + public const string ERROR_MESSAGE = 'Avoid calling ->createQueryBuilder() directly on EntityManager inside a repository class, as it requires select() + from() calls with specific values. Use $repository->createQueryBuilder() to be safe instead'; /** * UPDATE/DELETE builders are not plain SELECTs, the repository shortcut cannot express them