Skip to content

Commit 26707b2

Browse files
committed
Infer Query result type
1 parent ea39192 commit 26707b2

File tree

6 files changed

+320
-0
lines changed

6 files changed

+320
-0
lines changed

extension.neon

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ parameters:
3030
- stubs/Collections/Selectable.stub
3131
- stubs/ORM/QueryBuilder.stub
3232
- stubs/ORM/AbstractQuery.stub
33+
- stubs/ORM/Query.stub
3334
- stubs/ServiceDocumentRepository.stub
3435

3536
parametersSchema:
@@ -111,6 +112,16 @@ services:
111112
class: PHPStan\Type\Doctrine\Query\QueryGetDqlDynamicReturnTypeExtension
112113
tags:
113114
- phpstan.broker.dynamicMethodReturnTypeExtension
115+
-
116+
class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension
117+
arguments:
118+
objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
119+
tags:
120+
- phpstan.broker.dynamicMethodReturnTypeExtension
121+
-
122+
class: PHPStan\Type\Doctrine\Query\QueryResultDynamicReturnTypeExtension
123+
tags:
124+
- phpstan.broker.dynamicMethodReturnTypeExtension
114125
-
115126
class: PHPStan\Type\Doctrine\QueryBuilder\Expr\ExpressionBuilderDynamicReturnTypeExtension
116127
arguments:
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 Doctrine\ORM\Query\ResultSetMapping;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PHPStan\Analyser\Scope;
13+
use PHPStan\Reflection\MethodReflection;
14+
use PHPStan\Type\Constant\ConstantStringType;
15+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
16+
use PHPStan\Type\Doctrine\Query\ResultSetMappingTypeResolver;
17+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
18+
use PHPStan\Type\Generic\GenericObjectType;
19+
use PHPStan\Type\MixedType;
20+
use PHPStan\Type\Type;
21+
22+
final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
23+
{
24+
private ObjectMetadataResolver $objectMetadataResolver;
25+
26+
public function __construct(ObjectMetadataResolver $objectMetadataResolver)
27+
{
28+
$this->objectMetadataResolver = $objectMetadataResolver;
29+
}
30+
31+
public function getClass(): string
32+
{
33+
return EntityManagerInterface::class;
34+
}
35+
36+
public function isMethodSupported(MethodReflection $methodReflection): bool
37+
{
38+
return $methodReflection->getName() === 'createQuery';
39+
}
40+
41+
public function getTypeFromMethodCall(
42+
MethodReflection $methodReflection,
43+
MethodCall $methodCall,
44+
Scope $scope
45+
): Type {
46+
$queryStringArgIndex = 0;
47+
48+
if (!isset($methodCall->args[$queryStringArgIndex])) {
49+
return $this->fallbackType();
50+
}
51+
52+
$argType = $scope->getType($methodCall->args[$queryStringArgIndex]->value);
53+
if (!$argType instanceof ConstantStringType) {
54+
return $this->fallbackType();
55+
}
56+
57+
$queryString = $argType->getValue();
58+
59+
$em = $this->objectMetadataResolver->getObjectManager();
60+
61+
if (!$em instanceof EntityManagerInterface) {
62+
return $this->fallbackType();
63+
}
64+
65+
$getResultSetMapping = new \ReflectionMethod(
66+
Query::class,
67+
'getResultSetMapping',
68+
);
69+
70+
$getResultSetMapping->setAccessible(true);
71+
72+
try {
73+
$query = $em->createQuery($queryString);
74+
$resultSetMapping = $getResultSetMapping->invoke($query);
75+
} catch (ORMException | DBALException | CommonException $e) {
76+
return $this->fallbackType();
77+
}
78+
79+
if (!$resultSetMapping instanceof ResultSetMapping) {
80+
return $this->fallbackType();
81+
}
82+
83+
$resolver = new ResultSetMappingTypeResolver($resultSetMapping);
84+
85+
return new GenericObjectType(
86+
Query::class,
87+
[$resolver->resolveType()],
88+
);
89+
}
90+
91+
private function fallbackType(): GenericObjectType
92+
{
93+
return new GenericObjectType(
94+
Query::class,
95+
[new MixedType(true)],
96+
);
97+
}
98+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine\Query;
4+
5+
use Doctrine\ORM\AbstractQuery;
6+
use Doctrine\ORM\Query;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Reflection\ParametersAcceptorSelector;
11+
use PHPStan\Type\ArrayType;
12+
use PHPStan\Type\Constant\ConstantIntegerType;
13+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
14+
use PHPStan\Type\Generic\GenericObjectType;
15+
use PHPStan\Type\IntegerType;
16+
use PHPStan\Type\MixedType;
17+
use PHPStan\Type\Type;
18+
use PHPStan\Type\TypeCombinator;
19+
use PHPStan\Type\VoidType;
20+
21+
final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
22+
{
23+
private const METHOD_HYDRATION_MODE_ARG = [
24+
'getResult' => 0,
25+
'execute' => 1,
26+
'executeIgnoreQueryCache' => 1,
27+
'executeUsingQueryCache' => 1,
28+
'getSingleResult' => 0,
29+
];
30+
31+
private const METHOD_RETURNS_SINGLE = [
32+
'getSingleResult' => true,
33+
'getOneOrNullResult' => true,
34+
];
35+
36+
private const METHOD_RETURNS_NULL = [
37+
'getOneOrNullResult' => true,
38+
];
39+
40+
public function getClass(): string
41+
{
42+
return AbstractQuery::class;
43+
}
44+
45+
public function isMethodSupported(MethodReflection $methodReflection): bool
46+
{
47+
return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]);
48+
}
49+
50+
public function getTypeFromMethodCall(
51+
MethodReflection $methodReflection,
52+
MethodCall $methodCall,
53+
Scope $scope
54+
): Type {
55+
$methodName = $methodReflection->getName();
56+
$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName] ?? 0;
57+
58+
$isHydrationModeObject = $this->isHydrationModeObject($methodCall, $scope, $argIndex);
59+
60+
if (!$isHydrationModeObject) {
61+
return $this->fallbackType($methodReflection);
62+
}
63+
64+
$queryType = $scope->getType($methodCall->var);
65+
66+
if (!$queryType instanceof GenericObjectType) {
67+
return $this->fallbackType($methodReflection);
68+
}
69+
70+
$types = $queryType->getTypes();
71+
72+
if (!isset($types[0])) {
73+
return $this->fallbackType($methodReflection);
74+
}
75+
76+
$resultType = $types[0];
77+
78+
if ($resultType instanceof VoidType) {
79+
return $this->fallbackType($methodReflection);
80+
}
81+
82+
if ($methodName === 'getOneOrNullResult') {
83+
return TypeCombinator::addNull($resultType);
84+
}
85+
86+
if ($methodName === 'getSingleResult') {
87+
return $resultType;
88+
}
89+
90+
return new ArrayType(
91+
new IntegerType(),
92+
$resultType,
93+
);
94+
}
95+
96+
private function isHydrationModeObject(
97+
MethodCall $methodCall,
98+
Scope $scope,
99+
int $argIndex
100+
): bool {
101+
if (!isset($methodCall->args[$argIndex])) {
102+
return true;
103+
}
104+
105+
$argType = $scope->getType($methodCall->args[$argIndex]->value);
106+
if (!$argType instanceof ConstantIntegerType) {
107+
return false;
108+
}
109+
110+
return $argType->getValue() === Query::HYDRATE_OBJECT;
111+
}
112+
113+
private function fallbackType(MethodReflection $methodReflection): Type
114+
{
115+
$parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants());
116+
117+
return $parametersAcceptor->getReturnType();
118+
}
119+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine\Query;
4+
5+
use Doctrine\ORM\Query\ResultSetMapping;
6+
use Mention\Kebab\Json\JsonUtils;
7+
use PHPStan\Type\ArrayType;
8+
use PHPStan\Type\MixedType;
9+
use PHPStan\Type\ObjectType;
10+
use PHPStan\Type\Type;
11+
use PHPStan\Type\VoidType;
12+
13+
final class ResultSetMappingTypeResolver
14+
{
15+
private ResultSetMapping $rsm;
16+
17+
public function __construct(ResultSetMapping $rsm)
18+
{
19+
$this->rsm = $rsm;
20+
}
21+
22+
public function resolveType(): Type
23+
{
24+
if (!$this->rsm->isSelect) {
25+
return new VoidType();
26+
}
27+
28+
// Mixed results are arrays containing more than one value.
29+
//
30+
// ResultSetMapping has no info about nullability of elements in a mixed
31+
// result, so we just return array<mixed> for now.
32+
//
33+
// Example queries that trigger a mixed result:
34+
//
35+
// In the following example query, result has type array{alert: Alert, id: int}:
36+
// "SELECT a, a.id FROM Alert"
37+
//
38+
// In the following example query, result has type array{alert1: Alert, alert2: Alert}:
39+
// "SELECT a1 AS alert1, a2 AS alert2 FROM Alert a1 JOIN Alert a2 WITH a1.id = a2.id"
40+
//
41+
// Example query that does not trigger a mixed result:
42+
//
43+
// In the following example query, result has type Alert:
44+
// "SELECT a, o FROM Alert a JOIN a.owner o"
45+
if ($this->rsm->isMixed || count($this->rsm->aliasMap) === 0) {
46+
return new ArrayType(
47+
new MixedType(true),
48+
new MixedType(true),
49+
);
50+
}
51+
52+
$rootClassName = null;
53+
54+
foreach ($this->rsm->aliasMap as $alias => $className) {
55+
if (isset($this->rsm->parentAliasMap[$alias])) {
56+
continue;
57+
}
58+
if ($rootClassName === null) {
59+
$rootClassName = $className;
60+
continue;
61+
}
62+
throw new \Exception(sprintf(
63+
'Unexpectedly found more than 1 root in a non-mixed ResultSetMapping (aliasMap: %s)',
64+
JsonUtils::encode($this->rsm->aliasMap,
65+
)));
66+
}
67+
68+
if ($rootClassName === null) {
69+
throw new \Exception(sprintf(
70+
'Unexpectedly did not found any root in a non-mixed ResultSetMapping (aliasMap: %s)',
71+
JsonUtils::encode($this->rsm->aliasMap,
72+
)));
73+
}
74+
75+
return new ObjectType($rootClassName);
76+
}
77+
}

stubs/ORM/AbstractQuery.stub

Lines changed: 3 additions & 0 deletions
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 ResultT
9+
*/
710
abstract class AbstractQuery
811
{
912

stubs/ORM/Query.stub

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Doctrine\ORM;
4+
5+
/**
6+
* @template ResultT
7+
*
8+
* @extends AbstractQuery<ResultT>
9+
*/
10+
final class Query extends AbstractQuery
11+
{
12+
}

0 commit comments

Comments
 (0)