Skip to content

Commit 79bf835

Browse files
committed
Look for QueryBuilders in called methods
1 parent e7915fd commit 79bf835

File tree

7 files changed

+244
-4
lines changed

7 files changed

+244
-4
lines changed

extension.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ parameters:
44
queryBuilderClass: null
55
allCollectionsSelectable: true
66
objectManagerLoader: null
7+
searchOtherMethodsForQueryBuilderBeginning: true
78

89
# setting this to false might lead to performance problems
910
# it changes the braching logic - with false, queryBuilders from all branches are analysed separately
@@ -15,6 +16,7 @@ parametersSchema:
1516
queryBuilderClass: schema(string(), nullable())
1617
allCollectionsSelectable: bool()
1718
objectManagerLoader: schema(string(), nullable())
19+
searchOtherMethodsForQueryBuilderBeginning: bool()
1820
queryBuilderFastAlgorithm: bool()
1921
])
2022

@@ -74,6 +76,7 @@ services:
7476
class: PHPStan\Type\Doctrine\QueryBuilder\QueryBuilderMethodDynamicReturnTypeExtension
7577
arguments:
7678
queryBuilderClass: %doctrine.queryBuilderClass%
79+
descendIntoOtherMethods: %doctrine.searchOtherMethodsForQueryBuilderBeginning%
7780
tags:
7881
- phpstan.broker.dynamicMethodReturnTypeExtension
7982

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ parameters:
1818
-
1919
message: '~^Variable method call on Doctrine\\ORM\\Query\\Expr\.$~'
2020
path: */src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php
21+
-
22+
message: '~^Variable property access on PhpParser\\Node\\Stmt\\Declare_\|PhpParser\\Node\\Stmt\\Namespace_\.$~'
23+
path: */src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php

rules.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ parametersSchema:
88
queryBuilderClass: schema(string(), nullable())
99
allCollectionsSelectable: bool()
1010
objectManagerLoader: schema(string(), nullable())
11+
searchOtherMethodsForQueryBuilderBeginning: bool()
1112
queryBuilderFastAlgorithm: bool()
1213
reportDynamicQueryBuilders: bool()
1314
])

src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php

Lines changed: 183 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,67 @@
22

33
namespace PHPStan\Type\Doctrine\QueryBuilder;
44

5+
use PhpParser\Node;
56
use PhpParser\Node\Expr\MethodCall;
67
use PhpParser\Node\Identifier;
8+
use PhpParser\Node\Stmt\Class_;
9+
use PhpParser\Node\Stmt\ClassMethod;
10+
use PhpParser\Node\Stmt\Declare_;
11+
use PhpParser\Node\Stmt\Namespace_;
12+
use PhpParser\Node\Stmt\Return_;
13+
use PHPStan\Analyser\NodeScopeResolver;
714
use PHPStan\Analyser\Scope;
15+
use PHPStan\Analyser\ScopeContext;
16+
use PHPStan\Analyser\ScopeFactory;
17+
use PHPStan\Broker\Broker;
18+
use PHPStan\DependencyInjection\Container;
19+
use PHPStan\Parser\Parser;
20+
use PHPStan\Reflection\BrokerAwareExtension;
821
use PHPStan\Reflection\MethodReflection;
922
use PHPStan\Reflection\ParametersAcceptorSelector;
1023
use PHPStan\Type\Doctrine\DoctrineTypeUtils;
1124
use PHPStan\Type\MixedType;
1225
use PHPStan\Type\ObjectType;
1326
use PHPStan\Type\Type;
1427
use PHPStan\Type\TypeCombinator;
28+
use PHPStan\Type\TypeWithClassName;
1529

16-
class QueryBuilderMethodDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension
30+
class QueryBuilderMethodDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension, BrokerAwareExtension
1731
{
1832

1933
private const MAX_COMBINATIONS = 16;
2034

35+
/** @var \PHPStan\DependencyInjection\Container */
36+
private $container;
37+
38+
/** @var \PHPStan\Parser\Parser */
39+
private $parser;
40+
2141
/** @var string|null */
2242
private $queryBuilderClass;
2343

24-
public function __construct(?string $queryBuilderClass)
44+
/** @var bool */
45+
private $descendIntoOtherMethods;
46+
47+
/** @var \PHPStan\Broker\Broker */
48+
private $broker;
49+
50+
public function __construct(
51+
Container $container,
52+
Parser $parser,
53+
?string $queryBuilderClass,
54+
bool $descendIntoOtherMethods
55+
)
2556
{
57+
$this->container = $container;
58+
$this->parser = $parser;
2659
$this->queryBuilderClass = $queryBuilderClass;
60+
$this->descendIntoOtherMethods = $descendIntoOtherMethods;
61+
}
62+
63+
public function setBroker(Broker $broker): void
64+
{
65+
$this->broker = $broker;
2766
}
2867

2968
public function getClass(): string
@@ -62,8 +101,16 @@ public function getTypeFromMethodCall(
62101

63102
$queryBuilderTypes = DoctrineTypeUtils::getQueryBuilderTypes($calledOnType);
64103
if (count($queryBuilderTypes) === 0) {
65-
return $calledOnType;
104+
if (!$this->descendIntoOtherMethods || !$methodCall->var instanceof MethodCall) {
105+
return $calledOnType;
106+
}
107+
108+
$queryBuilderTypes = $this->findQueryBuilderTypesInCalledMethod($scope, $methodCall->var);
109+
if (count($queryBuilderTypes) === 0) {
110+
return $calledOnType;
111+
}
66112
}
113+
67114
if (count($queryBuilderTypes) > self::MAX_COMBINATIONS) {
68115
return $calledOnType;
69116
}
@@ -76,4 +123,137 @@ public function getTypeFromMethodCall(
76123
return TypeCombinator::union(...$resultTypes);
77124
}
78125

126+
/**
127+
* @param \PHPStan\Analyser\Scope $scope
128+
* @param \PhpParser\Node\Expr\MethodCall $methodCall
129+
* @return \PHPStan\Type\Doctrine\QueryBuilder\QueryBuilderType[]
130+
*/
131+
private function findQueryBuilderTypesInCalledMethod(Scope $scope, MethodCall $methodCall): array
132+
{
133+
$methodCalledOnType = $scope->getType($methodCall->var);
134+
if (!$methodCall->name instanceof Identifier) {
135+
return [];
136+
}
137+
138+
if (!$methodCalledOnType instanceof TypeWithClassName) {
139+
return [];
140+
}
141+
142+
if (!$this->broker->hasClass($methodCalledOnType->getClassName())) {
143+
return [];
144+
}
145+
146+
$classReflection = $this->broker->getClass($methodCalledOnType->getClassName());
147+
$methodName = $methodCall->name->toString();
148+
if (!$classReflection->hasNativeMethod($methodName)) {
149+
return [];
150+
}
151+
152+
$methodReflection = $classReflection->getNativeMethod($methodName);
153+
$fileName = $methodReflection->getDeclaringClass()->getFileName();
154+
if ($fileName === false) {
155+
return [];
156+
}
157+
158+
$nodes = $this->parser->parseFile($fileName);
159+
$classNode = $this->findClassNode($methodReflection->getDeclaringClass()->getName(), $nodes);
160+
if ($classNode === null) {
161+
return [];
162+
}
163+
164+
$methodNode = $this->findMethodNode($methodReflection->getName(), $classNode->stmts);
165+
if ($methodNode === null || $methodNode->stmts === null) {
166+
return [];
167+
}
168+
169+
/** @var \PHPStan\Analyser\NodeScopeResolver $nodeScopeResolver */
170+
$nodeScopeResolver = $this->container->getByType(NodeScopeResolver::class);
171+
172+
/** @var \PHPStan\Analyser\ScopeFactory $scopeFactory */
173+
$scopeFactory = $this->container->getByType(ScopeFactory::class);
174+
175+
$methodScope = $scopeFactory->create(
176+
ScopeContext::create($fileName),
177+
$scope->isDeclareStrictTypes(),
178+
$methodReflection,
179+
$scope->getNamespace()
180+
)->enterClass($methodReflection->getDeclaringClass())->enterClassMethod($methodNode, [], null, null, false, false, false);
181+
182+
$queryBuilderTypes = [];
183+
184+
$nodeScopeResolver->processNodes($methodNode->stmts, $methodScope, function (Node $node, Scope $scope) use (&$queryBuilderTypes): void {
185+
if (!$node instanceof Return_ || $node->expr === null) {
186+
return;
187+
}
188+
189+
$exprType = $scope->getType($node->expr);
190+
if (!$exprType instanceof QueryBuilderType) {
191+
return;
192+
}
193+
194+
$queryBuilderTypes[] = $exprType;
195+
});
196+
197+
return $queryBuilderTypes;
198+
}
199+
200+
/**
201+
* @param string $className
202+
* @param \PhpParser\Node[] $nodes
203+
* @return \PhpParser\Node\Stmt\Class_|null
204+
*/
205+
private function findClassNode(string $className, array $nodes): ?Class_
206+
{
207+
foreach ($nodes as $node) {
208+
if (
209+
$node instanceof Class_
210+
&& $node->namespacedName->toString() === $className
211+
) {
212+
return $node;
213+
}
214+
215+
if (
216+
!$node instanceof Namespace_
217+
&& !$node instanceof Declare_
218+
) {
219+
continue;
220+
}
221+
$subNodeNames = $node->getSubNodeNames();
222+
foreach ($subNodeNames as $subNodeName) {
223+
$subNode = $node->{$subNodeName};
224+
if (!is_array($subNode)) {
225+
$subNode = [$subNode];
226+
}
227+
228+
$result = $this->findClassNode($className, $subNode);
229+
if ($result === null) {
230+
continue;
231+
}
232+
233+
return $result;
234+
}
235+
}
236+
237+
return null;
238+
}
239+
240+
/**
241+
* @param string $methodName
242+
* @param \PhpParser\Node\Stmt[] $classStatements
243+
* @return \PhpParser\Node\Stmt\ClassMethod|null
244+
*/
245+
private function findMethodNode(string $methodName, array $classStatements): ?ClassMethod
246+
{
247+
foreach ($classStatements as $statement) {
248+
if (
249+
$statement instanceof ClassMethod
250+
&& $statement->name->toString() === $methodName
251+
) {
252+
return $statement;
253+
}
254+
}
255+
256+
return null;
257+
}
258+
79259
}

tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleTest.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
use Doctrine\ORM\Query\Expr\Base;
66
use Doctrine\ORM\Query\Expr\OrderBy;
7+
use PHPStan\DependencyInjection\Container;
8+
use PHPStan\DependencyInjection\ContainerFactory;
9+
use PHPStan\DependencyInjection\Nette\NetteContainer;
710
use PHPStan\Rules\Rule;
811
use PHPStan\Testing\RuleTestCase;
912
use PHPStan\Type\Doctrine\ArgumentsProcessor;
@@ -95,6 +98,10 @@ public function testRule(): void
9598
'QueryBuilder: [Semantical Error] line 0, col 60 near \'transient = \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient',
9699
234,
97100
],
101+
[
102+
'QueryBuilder: [Semantical Error] line 0, col 60 near \'nonexistent =\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named nonexistent',
103+
251,
104+
],
98105
]);
99106
}
100107

@@ -164,15 +171,26 @@ public function getDynamicMethodReturnTypeExtensions(): array
164171
{
165172
$objectMetadataResolver = new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', null);
166173
$argumentsProcessor = new ArgumentsProcessor();
174+
167175
return [
168176
new CreateQueryBuilderDynamicReturnTypeExtension(null, $this->fasterVersion),
169-
new QueryBuilderMethodDynamicReturnTypeExtension(null),
177+
new QueryBuilderMethodDynamicReturnTypeExtension($this->getContainerWithDoctrineExtensions(), $this->getParser(), null, true),
170178
new QueryBuilderGetQueryDynamicReturnTypeExtension($objectMetadataResolver, $argumentsProcessor, null),
171179
new QueryGetDqlDynamicReturnTypeExtension(),
172180
new ExpressionBuilderDynamicReturnTypeExtension($objectMetadataResolver, $argumentsProcessor),
173181
];
174182
}
175183

184+
private function getContainerWithDoctrineExtensions(): Container
185+
{
186+
$rootDir = __DIR__ . '/../../../../vendor/phpstan/phpstan';
187+
$containerFactory = new ContainerFactory($rootDir);
188+
return new NetteContainer($containerFactory->create($rootDir . '/tmp', [
189+
$containerFactory->getConfigDirectory() . '/config.level7.neon',
190+
__DIR__ . '/../../../../extension.neon',
191+
], []));
192+
}
193+
176194
/**
177195
* @return \PHPStan\Type\MethodTypeSpecifyingExtension[]
178196
*/
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use Doctrine\ORM\EntityManager;
6+
use Doctrine\ORM\QueryBuilder;
7+
8+
class ClassWithQueryBuilder
9+
{
10+
11+
/** @var EntityManager */
12+
private $entityManager;
13+
14+
public function __construct(EntityManager $entityManager)
15+
{
16+
$this->entityManager = $entityManager;
17+
}
18+
19+
public function getQueryBuilder(): QueryBuilder
20+
{
21+
$queryBuilder = $this->entityManager->createQueryBuilder();
22+
$queryBuilder->select('e')
23+
->from(MyEntity::class, 'e');
24+
25+
return $queryBuilder;
26+
}
27+
28+
}

tests/Rules/Doctrine/ORM/data/query-builder-dql.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,11 @@ public function weirdTypeSpecifyingExtensionProblemCorrect(): void
244244
$queryBuilder->getQuery();
245245
}
246246

247+
public function queryBuilderFromSomewhereElse(): void
248+
{
249+
$class = new ClassWithQueryBuilder($this->entityManager);
250+
$queryBuilder = $class->getQueryBuilder()->andWhere('e.nonexistent = :test');
251+
$queryBuilder->getQuery();
252+
}
253+
247254
}

0 commit comments

Comments
 (0)