Skip to content

Commit 4490e56

Browse files
Query - carry TKey, with backward compatibility, understand indexBy
Co-authored-by: Jan Nedbal <[email protected]>
1 parent ec49b7a commit 4490e56

14 files changed

+215
-43
lines changed

extension.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,8 @@ services:
386386
class: PHPStan\Rules\Gedmo\PropertiesExtension
387387
tags:
388388
- phpstan.properties.readWriteExtension
389+
390+
-
391+
class: PHPStan\PhpDoc\Doctrine\QueryTypeNodeResolverExtension
392+
tags:
393+
- phpstan.phpDoc.typeNodeResolverExtension
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDoc\Doctrine;
4+
5+
use Doctrine\ORM\AbstractQuery;
6+
use Doctrine\ORM\Query;
7+
use PHPStan\Analyser\NameScope;
8+
use PHPStan\PhpDoc\TypeNodeResolver;
9+
use PHPStan\PhpDoc\TypeNodeResolverAwareExtension;
10+
use PHPStan\PhpDoc\TypeNodeResolverExtension;
11+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
12+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
13+
use PHPStan\Type\Generic\GenericObjectType;
14+
use PHPStan\Type\NullType;
15+
use PHPStan\Type\Type;
16+
use function count;
17+
18+
class QueryTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
19+
{
20+
21+
/** @var TypeNodeResolver */
22+
private $typeNodeResolver;
23+
24+
public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void
25+
{
26+
$this->typeNodeResolver = $typeNodeResolver;
27+
}
28+
29+
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
30+
{
31+
if (!$typeNode instanceof GenericTypeNode) {
32+
return null;
33+
}
34+
35+
$typeName = $nameScope->resolveStringName($typeNode->type->name);
36+
if ($typeName !== Query::class && $typeName !== AbstractQuery::class) {
37+
return null;
38+
}
39+
40+
$count = count($typeNode->genericTypes);
41+
if ($count !== 1) {
42+
return null;
43+
}
44+
45+
return new GenericObjectType(
46+
$typeName,
47+
[
48+
new NullType(),
49+
$this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope),
50+
]
51+
);
52+
}
53+
54+
}

src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public function getTypeFromMethodCall(
6363
if (!isset($args[$queryStringArgIndex])) {
6464
return new GenericObjectType(
6565
Query::class,
66-
[new MixedType()]
66+
[new MixedType(), new MixedType()]
6767
);
6868
}
6969

@@ -78,7 +78,7 @@ public function getTypeFromMethodCall(
7878

7979
$em = $this->objectMetadataResolver->getObjectManager();
8080
if (!$em instanceof EntityManagerInterface) {
81-
return new QueryType($queryString, null);
81+
return new QueryType($queryString, null, null);
8282
}
8383

8484
$typeBuilder = new QueryResultTypeBuilder();
@@ -87,14 +87,14 @@ public function getTypeFromMethodCall(
8787
$query = $em->createQuery($queryString);
8888
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry);
8989
} catch (ORMException | DBALException | NewDBALException | CommonException $e) {
90-
return new QueryType($queryString, null);
90+
return new QueryType($queryString, null, null);
9191
}
9292

93-
return new QueryType($queryString, $typeBuilder->getResultType());
93+
return new QueryType($queryString, $typeBuilder->getIndexType(), $typeBuilder->getResultType());
9494
}
9595
return new GenericObjectType(
9696
Query::class,
97-
[new MixedType()]
97+
[new MixedType(), new MixedType()]
9898
);
9999
});
100100
}

src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\Reflection\MethodReflection;
99
use PHPStan\Reflection\ParametersAcceptorSelector;
1010
use PHPStan\ShouldNotHappenException;
11+
use PHPStan\Type\Accessory\AccessoryArrayListType;
1112
use PHPStan\Type\ArrayType;
1213
use PHPStan\Type\Constant\ConstantIntegerType;
1314
use PHPStan\Type\DynamicMethodReturnTypeExtension;
@@ -71,10 +72,12 @@ public function getTypeFromMethodCall(
7172

7273
$queryType = $scope->getType($methodCall->var);
7374
$queryResultType = $this->getQueryResultType($queryType);
75+
$queryKeyType = $this->getQueryKeyType($queryType);
7476

7577
return $this->getMethodReturnTypeForHydrationMode(
7678
$methodReflection,
7779
$hydrationMode,
80+
$queryKeyType,
7881
$queryResultType
7982
);
8083
}
@@ -93,9 +96,24 @@ private function getQueryResultType(Type $queryType): Type
9396
return $resultType;
9497
}
9598

99+
private function getQueryKeyType(Type $queryType): Type
100+
{
101+
if (!$queryType instanceof TypeWithClassName) {
102+
return new MixedType();
103+
}
104+
105+
$resultType = GenericTypeVariableResolver::getType($queryType, AbstractQuery::class, 'TKey');
106+
if ($resultType === null) {
107+
return new MixedType();
108+
}
109+
110+
return $resultType;
111+
}
112+
96113
private function getMethodReturnTypeForHydrationMode(
97114
MethodReflection $methodReflection,
98115
Type $hydrationMode,
116+
Type $queryKeyType,
99117
Type $queryResultType
100118
): Type
101119
{
@@ -126,12 +144,18 @@ private function getMethodReturnTypeForHydrationMode(
126144
return TypeCombinator::addNull($queryResultType);
127145
case 'toIterable':
128146
return new IterableType(
129-
new MixedType(),
147+
$queryKeyType instanceof NullType ? new IntegerType() : $queryKeyType,
130148
$queryResultType
131149
);
132150
default:
151+
if ($queryKeyType instanceof NullType) {
152+
return AccessoryArrayListType::intersectWith(new ArrayType(
153+
new IntegerType(),
154+
$queryResultType
155+
));
156+
}
133157
return new ArrayType(
134-
new MixedType(),
158+
$queryKeyType,
135159
$queryResultType
136160
);
137161
}

src/Type/Doctrine/Query/QueryResultTypeBuilder.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
66
use PHPStan\Type\Constant\ConstantIntegerType;
77
use PHPStan\Type\Constant\ConstantStringType;
8+
use PHPStan\Type\NullType;
89
use PHPStan\Type\Type;
910
use PHPStan\Type\TypeCombinator;
1011
use PHPStan\Type\VoidType;
@@ -62,6 +63,14 @@ final class QueryResultTypeBuilder
6263
*/
6364
private $newObjects = [];
6465

66+
/** @var Type */
67+
private $indexedBy;
68+
69+
public function __construct()
70+
{
71+
$this->indexedBy = new NullType();
72+
}
73+
6574
public function setSelectQuery(): void
6675
{
6776
$this->selectQuery = true;
@@ -230,4 +239,19 @@ private function resolveOffsetType($alias): Type
230239
return new ConstantStringType($alias);
231240
}
232241

242+
243+
public function setIndexedBy(Type $type): void
244+
{
245+
$this->indexedBy = $type;
246+
}
247+
248+
public function getIndexType(): Type
249+
{
250+
if (!$this->selectQuery) {
251+
return new VoidType();
252+
}
253+
254+
return $this->indexedBy;
255+
}
256+
233257
}

src/Type/Doctrine/Query/QueryResultTypeWalker.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,10 @@ public function walkFromClause($fromClause)
312312
*/
313313
public function walkIdentificationVariableDeclaration($identificationVariableDecl)
314314
{
315+
if ($identificationVariableDecl->indexBy !== null) {
316+
$identificationVariableDecl->indexBy->dispatch($this);
317+
}
318+
315319
foreach ($identificationVariableDecl->joins as $join) {
316320
assert($join instanceof AST\Node);
317321

@@ -326,6 +330,8 @@ public function walkIdentificationVariableDeclaration($identificationVariableDec
326330
*/
327331
public function walkIndexBy($indexBy): void
328332
{
333+
$type = $this->unmarshalType($indexBy->singleValuedPathExpression->dispatch($this));
334+
$this->typeBuilder->setIndexedBy($type);
329335
}
330336

331337
/**

src/Type/Doctrine/Query/QueryType.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ class QueryType extends GenericObjectType
1414
/** @var string */
1515
private $dql;
1616

17-
public function __construct(string $dql, ?Type $resultType = null)
17+
public function __construct(string $dql, ?Type $indexType = null, ?Type $resultType = null)
1818
{
19-
$resultType = $resultType ?? new MixedType();
20-
parent::__construct('Doctrine\ORM\Query', [$resultType]);
19+
parent::__construct('Doctrine\ORM\Query', [
20+
$indexType ?? new MixedType(),
21+
$resultType ?? new MixedType(),
22+
]);
2123
$this->dql = $dql;
2224
}
2325

src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ private function getQueryType(string $dql): Type
165165
return new QueryType($dql, null);
166166
}
167167

168-
return new QueryType($dql, $typeBuilder->getResultType());
168+
return new QueryType($dql, $typeBuilder->getIndexType(), $typeBuilder->getResultType());
169169
}
170170

171171
}

stubs/ORM/AbstractQuery.stub

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace Doctrine\ORM;
55
use Doctrine\Common\Collections\ArrayCollection;
66

77
/**
8+
* @template-covariant TKey The type of column used in indexBy
89
* @template-covariant TResult The type of results returned by this query in HYDRATE_OBJECT mode
910
*/
1011
abstract class AbstractQuery

stubs/ORM/Query.stub

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
namespace Doctrine\ORM;
44

55
/**
6+
* @template-covariant TKey The type of column used in indexBy
67
* @template-covariant TResult The type of results returned by this query in HYDRATE_OBJECT mode
78
*
8-
* @extends AbstractQuery<TResult>
9+
* @extends AbstractQuery<TKey, TResult>
910
*/
1011
final class Query extends AbstractQuery
1112
{

tests/Type/Doctrine/data/QueryResult/config.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ includes:
33
parameters:
44
doctrine:
55
objectManagerLoader: entity-manager.php
6+
featureToggles:
7+
listType: true

tests/Type/Doctrine/data/QueryResult/createQuery.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,28 @@ public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): v
1515
FROM QueryResult\Entities\Many m
1616
');
1717

18-
assertType('Doctrine\ORM\Query<QueryResult\Entities\Many>', $query);
18+
assertType('Doctrine\ORM\Query<null, QueryResult\Entities\Many>', $query);
1919

2020
$query = $em->createQuery('
2121
SELECT m.intColumn, m.stringNullColumn
2222
FROM QueryResult\Entities\Many m
2323
');
2424

25-
assertType('Doctrine\ORM\Query<array{intColumn: int, stringNullColumn: string|null}>', $query);
25+
assertType('Doctrine\ORM\Query<null, array{intColumn: int, stringNullColumn: string|null}>', $query);
2626
}
2727

2828
public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(EntityManagerInterface $em, string $dql): void
2929
{
3030
$query = $em->createQuery($dql);
3131

32-
assertType('Doctrine\ORM\Query<mixed>', $query);
32+
assertType('Doctrine\ORM\Query<mixed, mixed>', $query);
3333
}
3434

3535
public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em, string $dql): void
3636
{
3737
$query = $em->createQuery('invalid');
3838

39-
assertType('Doctrine\ORM\Query<mixed>', $query);
39+
assertType('Doctrine\ORM\Query<mixed, mixed>', $query);
4040
}
4141

4242
}

0 commit comments

Comments
 (0)