Skip to content

Commit 0947875

Browse files
committed
Basic DQL rule
1 parent 447ffd6 commit 0947875

File tree

9 files changed

+253
-9
lines changed

9 files changed

+253
-9
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This extension provides following features:
1111

1212
* Provides correct return type for `Doctrine\ORM\EntityManager::find`, `getReference` and `getPartialReference` when `Foo::class` entity class name is provided as the first argument
1313
* Adds missing `matching` method on `Doctrine\Common\Collections\Collection`. This can be turned off by setting `parameters.doctrine.allCollectionsSelectable` to `false`.
14+
* Basic DQL validation for parse errors, unknown entity classes and unknown persistent fields.
1415

1516
## Usage
1617

@@ -20,13 +21,20 @@ To use this extension, require it in [Composer](https://getcomposer.org/):
2021
composer require --dev phpstan/phpstan-doctrine
2122
```
2223

23-
And include extension.neon in your project's PHPStan config:
24+
Include extension.neon in your project's PHPStan config:
2425

2526
```
2627
includes:
2728
- vendor/phpstan/phpstan-doctrine/extension.neon
2829
```
2930

31+
If you're interested in DQL validation, include also `rules.neon` (you will also need to provide the `objectManagerLoader`, see below):
32+
33+
```
34+
includes:
35+
- vendor/phpstan/phpstan-doctrine/rules.neon
36+
```
37+
3038
## Configuration
3139

3240
If your repositories have a common base class, you can configure it in your `phpstan.neon` and PHPStan will see additional methods you define in it:

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ includes:
77
parameters:
88
excludes_analyse:
99
- */tests/*/data/*
10+
11+
ignoreErrors:
12+
- '~^Parameter \#1 \$node \(.*\) of method .*Rule::processNode\(\) should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule::processNode\(\)$~'

rules.neon

Whitespace-only changes.

src/Rules/Doctrine/ORM/DqlRule.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use Doctrine\ORM\EntityManagerInterface;
6+
use PhpParser\Node;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\ShouldNotHappenException;
10+
use PHPStan\Type\Constant\ConstantStringType;
11+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
12+
use PHPStan\Type\ObjectType;
13+
14+
class DqlRule implements Rule
15+
{
16+
17+
/** @var ObjectMetadataResolver */
18+
private $objectMetadataResolver;
19+
20+
public function __construct(ObjectMetadataResolver $objectMetadataResolver)
21+
{
22+
$this->objectMetadataResolver = $objectMetadataResolver;
23+
}
24+
25+
public function getNodeType(): string
26+
{
27+
return Node\Expr\MethodCall::class;
28+
}
29+
30+
/**
31+
* @param \PhpParser\Node\Expr\MethodCall $node
32+
* @param \PHPStan\Analyser\Scope $scope
33+
* @return string[]
34+
*/
35+
public function processNode(Node $node, Scope $scope): array
36+
{
37+
if (!$node->name instanceof Node\Identifier) {
38+
return [];
39+
}
40+
41+
if (count($node->args) === 0) {
42+
return [];
43+
}
44+
45+
$dqlType = $scope->getType($node->args[0]->value);
46+
if (!$dqlType instanceof ConstantStringType) {
47+
return [];
48+
}
49+
50+
$methodName = $node->name->toLowerString();
51+
if ($methodName !== 'createquery') {
52+
return [];
53+
}
54+
55+
$calledOnType = $scope->getType($node->var);
56+
$entityManagerInterface = 'Doctrine\ORM\EntityManagerInterface';
57+
if (!(new ObjectType($entityManagerInterface))->isSuperTypeOf($calledOnType)->yes()) {
58+
return [];
59+
}
60+
61+
$objectManager = $this->objectMetadataResolver->getObjectManager();
62+
if ($objectManager === null) {
63+
throw new ShouldNotHappenException('Please provide the "objectManagerLoader" setting for the DQL validation.');
64+
}
65+
if (!$objectManager instanceof $entityManagerInterface) {
66+
return [];
67+
}
68+
69+
/** @var EntityManagerInterface $objectManager */
70+
$objectManager = $objectManager;
71+
72+
$dql = $dqlType->getValue();
73+
$query = $objectManager->createQuery($dql);
74+
75+
try {
76+
$query->getSQL();
77+
} catch (\Doctrine\ORM\Query\QueryException $e) {
78+
return [sprintf('DQL: %s', $e->getMessage())];
79+
}
80+
81+
return [];
82+
}
83+
84+
}

src/Type/Doctrine/ObjectMetadataResolver.php

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class ObjectMetadataResolver
1818
public function __construct(?string $objectManagerLoader, ?string $repositoryClass)
1919
{
2020
if ($objectManagerLoader !== null) {
21-
$this->objectManager = $this->getObjectManager($objectManagerLoader);
21+
$this->objectManager = $this->loadObjectManager($objectManagerLoader);
2222
}
2323
if ($repositoryClass !== null) {
2424
$this->repositoryClass = $repositoryClass;
@@ -29,14 +29,12 @@ public function __construct(?string $objectManagerLoader, ?string $repositoryCla
2929
}
3030
}
3131

32-
/**
33-
* @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint
34-
* @param string $objectManagerLoader
35-
* @return ObjectManager
36-
*/
37-
private function getObjectManager(string $objectManagerLoader)
32+
private function loadObjectManager(string $objectManagerLoader): ?ObjectManager
3833
{
39-
if (! file_exists($objectManagerLoader) && ! is_readable($objectManagerLoader)) {
34+
if (
35+
!file_exists($objectManagerLoader)
36+
|| !is_readable($objectManagerLoader)
37+
) {
4038
throw new \PHPStan\ShouldNotHappenException('Object manager could not be loaded');
4139
}
4240

@@ -68,4 +66,9 @@ public function getRepositoryClass(string $className): string
6866
return $this->repositoryClass;
6967
}
7068

69+
public function getObjectManager(): ?ObjectManager
70+
{
71+
return $this->objectManager;
72+
}
73+
7174
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
8+
9+
class DqlRuleTest extends RuleTestCase
10+
{
11+
12+
protected function getRule(): Rule
13+
{
14+
return new DqlRule(new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', null));
15+
}
16+
17+
public function testRule(): void
18+
{
19+
$this->analyse([__DIR__ . '/data/dql.php'], [
20+
[
21+
'DQL: [Syntax Error] line 0, col -1: Error: Expected Doctrine\ORM\Query\Lexer::T_IDENTIFIER, got end of string.',
22+
30,
23+
],
24+
[
25+
'DQL: [Semantical Error] line 0, col 60 near \'transient = \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient',
26+
37,
27+
],
28+
[
29+
'DQL: [Semantical Error] line 0, col 14 near \'Foo e\': Error: Class \'Foo\' is not defined.',
30+
44,
31+
],
32+
]);
33+
}
34+
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
7+
/**
8+
* @ORM\Entity()
9+
*/
10+
class MyEntity
11+
{
12+
/**
13+
* @ORM\Id()
14+
* @ORM\GeneratedValue()
15+
* @ORM\Column(type="integer")
16+
*
17+
* @var int
18+
*/
19+
private $id;
20+
21+
/**
22+
* @var string
23+
* @ORM\Column(type="string")
24+
*/
25+
private $title;
26+
27+
/**
28+
* @var string
29+
*/
30+
private $transient;
31+
32+
}

tests/Rules/Doctrine/ORM/data/dql.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use Doctrine\ORM\EntityManager;
6+
7+
class TestRepository
8+
{
9+
10+
/** @var EntityManager */
11+
private $entityManager;
12+
13+
public function __construct(EntityManager $entityManager)
14+
{
15+
$this->entityManager = $entityManager;
16+
}
17+
18+
/**
19+
* @return MyEntity[]
20+
*/
21+
public function getEntities(): array
22+
{
23+
return $this->entityManager->createQuery(
24+
'SELECT e FROM ' . MyEntity::class . ' e'
25+
)->getResult();
26+
}
27+
28+
public function parseError(): void
29+
{
30+
$this->entityManager->createQuery(
31+
'SELECT e FROM ' . MyEntity::class
32+
)->getResult();
33+
}
34+
35+
public function unknownField(): void
36+
{
37+
$this->entityManager->createQuery(
38+
'SELECT e FROM ' . MyEntity::class . ' e WHERE e.transient = :test'
39+
)->getResult();
40+
}
41+
42+
public function unknownEntity(): void
43+
{
44+
$this->entityManager->createQuery(
45+
'SELECT e FROM Foo e'
46+
)->getResult();
47+
}
48+
49+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php declare(strict_types = 1);
2+
3+
use Doctrine\Common\Annotations\AnnotationReader;
4+
use Doctrine\Common\Annotations\AnnotationRegistry;
5+
use Doctrine\Common\Cache\ArrayCache;
6+
use Doctrine\ORM\Configuration;
7+
use Doctrine\ORM\EntityManager;
8+
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
9+
10+
AnnotationRegistry::registerUniqueLoader('class_exists');
11+
12+
$config = new Configuration();
13+
$config->setProxyDir(__DIR__);
14+
$config->setProxyNamespace('PHPstan\Doctrine\OrmProxies');
15+
$config->setMetadataCacheImpl(new ArrayCache());
16+
17+
$config->setMetadataDriverImpl(
18+
new AnnotationDriver(
19+
new AnnotationReader(),
20+
[__DIR__ . '/data']
21+
)
22+
);
23+
24+
return EntityManager::create(
25+
[
26+
'driver' => 'pdo_sqlite',
27+
'memory' => true,
28+
],
29+
$config
30+
);

0 commit comments

Comments
 (0)