Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7e9e87e

Browse files
committedJan 4, 2022
Support for infering the result type of queries in EntityManager::createQuery()
Adds a type parameter "TResult" on the Doctrine\ORM\Query and Doctrine\ORM\AbstractQuery stubs. This parameter represents the type of the results returned by the getResult() and execute() family of methods, when in HYDRATE_OBJECT mode. Also adds a DynamicReturnTypeExtension on EntityManager::createQuery() so that TResult is infered from the query string. Caveat: As the hydration mode influences the return type of the getResult() and execute() family of methods, we can not just declare that these methods return TResult yet. This will require a DynamicReturnTypeExtension.
1 parent 9f40e7f commit 7e9e87e

16 files changed

+2352
-0
lines changed
 

Diff for: ‎extension.neon

+7
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ parameters:
3838
- stubs/ORM/AbstractQuery.stub
3939
- stubs/ORM/Mapping/ClassMetadata.stub
4040
- stubs/ORM/Mapping/ClassMetadataInfo.stub
41+
- stubs/ORM/Query.stub
4142
- stubs/Persistence/Mapping/ClassMetadata.stub
4243
- stubs/ServiceDocumentRepository.stub
4344

@@ -120,6 +121,12 @@ services:
120121
class: PHPStan\Type\Doctrine\Query\QueryGetDqlDynamicReturnTypeExtension
121122
tags:
122123
- phpstan.broker.dynamicMethodReturnTypeExtension
124+
-
125+
class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension
126+
arguments:
127+
objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
128+
tags:
129+
- phpstan.broker.dynamicMethodReturnTypeExtension
123130
-
124131
class: PHPStan\Type\Doctrine\QueryBuilder\Expr\ExpressionBuilderDynamicReturnTypeExtension
125132
arguments:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine;
4+
5+
use Doctrine\Common\CommonException;
6+
use Doctrine\DBAL\DBALException;
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Doctrine\ORM\ORMException;
9+
use Doctrine\ORM\Query;
10+
use PhpParser\Node\Expr\MethodCall;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\Type\Constant\ConstantStringType;
14+
use PHPStan\Type\Doctrine\Query\QueryResultTypeBuilder;
15+
use PHPStan\Type\Doctrine\Query\QueryResultTypeWalker;
16+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
17+
use PHPStan\Type\Generic\GenericObjectType;
18+
use PHPStan\Type\MixedType;
19+
use PHPStan\Type\Type;
20+
21+
/**
22+
* Infers TResult in Query<TResult> on EntityManagerInterface::createQuery()
23+
*/
24+
final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
25+
{
26+
27+
/** @var ObjectMetadataResolver */
28+
private $objectMetadataResolver;
29+
30+
/** @var DescriptorRegistry */
31+
private $descriptorRegistry;
32+
33+
public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry)
34+
{
35+
$this->objectMetadataResolver = $objectMetadataResolver;
36+
$this->descriptorRegistry = $descriptorRegistry;
37+
}
38+
39+
public function getClass(): string
40+
{
41+
return EntityManagerInterface::class;
42+
}
43+
44+
public function isMethodSupported(MethodReflection $methodReflection): bool
45+
{
46+
return $methodReflection->getName() === 'createQuery';
47+
}
48+
49+
public function getTypeFromMethodCall(
50+
MethodReflection $methodReflection,
51+
MethodCall $methodCall,
52+
Scope $scope
53+
): Type
54+
{
55+
$queryStringArgIndex = 0;
56+
$args = $methodCall->getArgs();
57+
58+
if (!isset($args[$queryStringArgIndex])) {
59+
return $this->fallbackType();
60+
}
61+
62+
$argType = $scope->getType($args[$queryStringArgIndex]->value);
63+
if (!$argType instanceof ConstantStringType) {
64+
return $this->fallbackType();
65+
}
66+
67+
$queryString = $argType->getValue();
68+
69+
$em = $this->objectMetadataResolver->getObjectManager();
70+
if (!$em instanceof EntityManagerInterface) {
71+
return $this->fallbackType();
72+
}
73+
74+
$typeBuilder = new QueryResultTypeBuilder();
75+
76+
try {
77+
$query = $em->createQuery($queryString);
78+
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry);
79+
} catch (ORMException | DBALException | CommonException $e) {
80+
return $this->fallbackType();
81+
}
82+
83+
return new GenericObjectType(
84+
Query::class,
85+
[$typeBuilder->getResultType()]
86+
);
87+
}
88+
89+
private function fallbackType(): GenericObjectType
90+
{
91+
return new GenericObjectType(
92+
Query::class,
93+
[new MixedType()]
94+
);
95+
}
96+
97+
}

Diff for: ‎src/Type/Doctrine/Query/QueryResultTypeBuilder.php

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine\Query;
4+
5+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
6+
use PHPStan\Type\Constant\ConstantIntegerType;
7+
use PHPStan\Type\Constant\ConstantStringType;
8+
use PHPStan\Type\Type;
9+
use PHPStan\Type\VoidType;
10+
11+
/**
12+
* QueryResultTypeBuilder helps building the result type of a query
13+
*
14+
* Like Doctrine\ORM\Query\ResultSetMapping, but for static typing concerns
15+
*/
16+
final class QueryResultTypeBuilder
17+
{
18+
19+
/** @var bool */
20+
private $selectQuery = false;
21+
22+
/** @var array<array-key,Type> */
23+
private $entities = [];
24+
25+
/** @var array<array-key,Type> */
26+
private $scalars = [];
27+
28+
public function setSelectQuery(): void
29+
{
30+
$this->selectQuery = true;
31+
}
32+
33+
public function isSelectQuery(): bool
34+
{
35+
return $this->selectQuery;
36+
}
37+
38+
/**
39+
* @param array-key $alias
40+
*/
41+
public function addEntity($alias, Type $type): void
42+
{
43+
$this->entities[$alias] = $type;
44+
}
45+
46+
/**
47+
* @return array<array-key,Type>
48+
*/
49+
public function getEntities(): array
50+
{
51+
return $this->entities;
52+
}
53+
54+
/**
55+
* @param array-key $alias
56+
*/
57+
public function addScalar($alias, Type $type): void
58+
{
59+
$this->scalars[$alias] = $type;
60+
}
61+
62+
/**
63+
* @return array<int,Type>
64+
*/
65+
public function getScalars(): array
66+
{
67+
return $this->scalars;
68+
}
69+
70+
public function getResultType(): Type
71+
{
72+
// It makes no sense to speak about a result type for UPDATE or DELETE
73+
// queries, so we return VoidType here.
74+
if (!$this->selectQuery) {
75+
return new VoidType();
76+
}
77+
78+
if (count($this->entities) === 1 && count($this->scalars) === 0) {
79+
foreach ($this->entities as $entityType) {
80+
return $entityType;
81+
}
82+
}
83+
84+
$builder = ConstantArrayTypeBuilder::createEmpty();
85+
86+
foreach ($this->entities as $alias => $entityType) {
87+
$offsetType = $this->resolveOffsetType($alias);
88+
$builder->setOffsetValueType($offsetType, $entityType);
89+
}
90+
91+
foreach ($this->scalars as $alias => $scalarType) {
92+
$offsetType = $this->resolveOffsetType($alias);
93+
$builder->setOffsetValueType($offsetType, $scalarType);
94+
}
95+
96+
return $builder->getArray();
97+
}
98+
99+
/**
100+
* @param array-key $alias
101+
*/
102+
private function resolveOffsetType($alias): Type
103+
{
104+
if (is_int($alias)) {
105+
return new ConstantIntegerType($alias);
106+
}
107+
108+
return new ConstantStringType($alias);
109+
}
110+
111+
}

Diff for: ‎src/Type/Doctrine/Query/QueryResultTypeWalker.php

+1,183
Large diffs are not rendered by default.

Diff for: ‎stubs/ORM/AbstractQuery.stub

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ namespace Doctrine\ORM;
44

55
use Doctrine\Common\Collections\ArrayCollection;
66

7+
/**
8+
* @template TResult The type of results returned by this query in HYDRATE_OBJECT mode
9+
*/
710
abstract class AbstractQuery
811
{
912

Diff for: ‎stubs/ORM/Query.stub

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Doctrine\ORM;
4+
5+
/**
6+
* @template TResult The type of results returned by this query in HYDRATE_OBJECT mode
7+
*
8+
* @extends AbstractQuery<TResult>
9+
*/
10+
final class Query extends AbstractQuery
11+
{
12+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[
2+
{
3+
"message": "Method PHPStan\\DoctrineIntegration\\ORM\\QueryBuilder\\Foo::doFoo() return type with generic class Doctrine\\ORM\\Query does not specify its types: TResult",
4+
"line": 22,
5+
"ignorable": true
6+
},
7+
{
8+
"message": "Method PHPStan\\DoctrineIntegration\\ORM\\QueryBuilder\\Foo::doBar() return type with generic class Doctrine\\ORM\\Query does not specify its types: TResult",
9+
"line": 35,
10+
"ignorable": true
11+
},
12+
{
13+
"message": "Method PHPStan\\DoctrineIntegration\\ORM\\QueryBuilder\\Foo::dynamicQueryBuilder() return type with generic class Doctrine\\ORM\\Query does not specify its types: TResult",
14+
"line": 50,
15+
"ignorable": true
16+
}
17+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
7+
class CreateQueryDynamicReturnTypeExtensionTest extends TypeInferenceTestCase
8+
{
9+
10+
/** @return iterable<mixed> */
11+
public function dataFileAsserts(): iterable
12+
{
13+
yield from $this->gatherAssertTypes(__DIR__ . '/Query/data/QueryResult/createQuery.php');
14+
}
15+
16+
/**
17+
* @dataProvider dataFileAsserts
18+
* @param string $assertType
19+
* @param string $file
20+
* @param mixed ...$args
21+
*/
22+
public function testFileAsserts(
23+
string $assertType,
24+
string $file,
25+
...$args
26+
): void
27+
{
28+
$this->assertFileAsserts($assertType, $file, ...$args);
29+
}
30+
31+
/** @return string[] */
32+
public static function getAdditionalConfigFiles(): array
33+
{
34+
return [__DIR__ . '/Query/data/QueryResult/config.neon'];
35+
}
36+
37+
}

Diff for: ‎tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php

+673
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace QueryResult\Entities;
4+
5+
use Doctrine\ORM\Mapping\Column;
6+
use Doctrine\ORM\Mapping\Entity;
7+
use Doctrine\ORM\Mapping\Id;
8+
use Doctrine\ORM\Mapping\JoinColumn;
9+
use Doctrine\ORM\Mapping\ManyToOne;
10+
11+
/**
12+
* @Entity
13+
*/
14+
class Many
15+
{
16+
/**
17+
* @Column(type="bigint")
18+
* @Id
19+
*
20+
* @var string
21+
*/
22+
public $id;
23+
24+
/**
25+
* @Column(type="integer")
26+
*
27+
* @var int
28+
*/
29+
public $intColumn;
30+
31+
/**
32+
* @Column(type="string")
33+
*
34+
* @var string
35+
*/
36+
public $stringColumn;
37+
38+
/**
39+
* @Column(type="string", nullable=true)
40+
*
41+
* @var string|null
42+
*/
43+
public $stringNullColumn;
44+
45+
/**
46+
* @Column(type="datetime")
47+
*
48+
* @var \DateTime
49+
*/
50+
public $datetimeColumn;
51+
52+
/**
53+
* @Column(type="datetime_immutable")
54+
*
55+
* @var \DateTimeImmutable
56+
*/
57+
public $datetimeImmutableColumn;
58+
59+
/**
60+
* @ManyToOne(targetEntity="QueryResult\Entities\One", inversedBy="one")
61+
* @JoinColumn(nullable=true)
62+
*
63+
* @var One|null
64+
*/
65+
public $one;
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace QueryResult\Entities;
4+
5+
class ManyId
6+
{
7+
/** @var string */
8+
public $id;
9+
10+
public function __construct(string $id)
11+
{
12+
$this->id = $id;
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace QueryResult\Entities;
4+
5+
use Doctrine\Common\Collections\Collection;
6+
use Doctrine\ORM\Mapping\Column;
7+
use Doctrine\ORM\Mapping\Entity;
8+
use Doctrine\ORM\Mapping\Id;
9+
use Doctrine\ORM\Mapping\JoinColumn;
10+
use Doctrine\ORM\Mapping\ManyToOne;
11+
use Doctrine\ORM\Mapping\OneToMany;
12+
13+
/**
14+
* @Entity
15+
*/
16+
class One
17+
{
18+
/**
19+
* @Column(type="bitint")
20+
* @Id
21+
*
22+
* @var string
23+
*/
24+
public $id;
25+
26+
/**
27+
* @Column(type="integer")
28+
* @Id
29+
*
30+
* @var int
31+
*/
32+
public $intColumn;
33+
34+
/**
35+
* @Column(type="string")
36+
*
37+
* @var string
38+
*/
39+
public $stringColumn;
40+
41+
/**
42+
* @Column(type="string", nullable=true)
43+
*
44+
* @var string|null
45+
*/
46+
public $stringNullColumn;
47+
48+
/**
49+
* @OneToMany(targetEntity="QueryResult\Entities\Many", mappedBy="one")
50+
* @JoinColumn(nullable=true)
51+
*
52+
* @var Collection<int,Many>
53+
*/
54+
public $manies;
55+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
includes:
2+
- ../../../../../../extension.neon
3+
parameters:
4+
doctrine:
5+
objectManagerLoader: entity-manager.php
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace QueryResult\CreateQuery;
4+
5+
use Doctrine\ORM\AbstractQuery;
6+
use Doctrine\ORM\EntityManagerInterface;
7+
use function PHPStan\Testing\assertType;
8+
9+
class CreateQuery
10+
{
11+
public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): void
12+
{
13+
$query = $em->createQuery('
14+
SELECT m
15+
FROM QueryResult\Entities\Many m
16+
');
17+
18+
assertType('Doctrine\ORM\Query<QueryResult\Entities\Many>', $query);
19+
20+
$query = $em->createQuery('
21+
SELECT m.intColumn, m.stringNullColumn
22+
FROM QueryResult\Entities\Many m
23+
');
24+
25+
assertType('Doctrine\ORM\Query<array{intColumn: int, stringNullColumn: string|null}>', $query);
26+
}
27+
28+
public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(EntityManagerInterface $em, string $dql): void
29+
{
30+
$query = $em->createQuery($dql);
31+
32+
assertType('Doctrine\ORM\Query<mixed>', $query);
33+
}
34+
35+
public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em, string $dql): void
36+
{
37+
$query = $em->createQuery('invalid');
38+
39+
assertType('Doctrine\ORM\Query<mixed>', $query);
40+
}
41+
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
use Doctrine\Common\Annotations\AnnotationReader;
4+
use Doctrine\ORM\Configuration;
5+
use Doctrine\ORM\EntityManager;
6+
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
7+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
8+
use Symfony\Component\Cache\DoctrineProvider;
9+
10+
$config = new Configuration();
11+
$config->setProxyDir(__DIR__);
12+
$config->setProxyNamespace('PHPstan\Doctrine\OrmProxies');
13+
$config->setMetadataCacheImpl(new DoctrineProvider(new ArrayAdapter()));
14+
15+
$config->setMetadataDriverImpl(
16+
new AnnotationDriver(
17+
new AnnotationReader(),
18+
[__DIR__ . '/Entities']
19+
)
20+
);
21+
22+
return EntityManager::create(
23+
[
24+
'driver' => 'pdo_sqlite',
25+
'memory' => true,
26+
],
27+
$config
28+
);

Diff for: ‎tests/bootstrap.php

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
<?php declare(strict_types = 1);
22

33
require_once __DIR__ . '/../vendor/autoload.php';
4+
5+
\PHPStan\Testing\PHPStanTestCase::getContainer();

0 commit comments

Comments
 (0)
Please sign in to comment.