Skip to content

Commit 19dd2dd

Browse files
VincentLangletondrejmirtes
authored andcommitted
Handle all hydration mode in QueryResultDynamicReturnTypeExtension
1 parent dfa9df9 commit 19dd2dd

File tree

4 files changed

+707
-115
lines changed

4 files changed

+707
-115
lines changed

src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php

+172-22
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@
1010
use PHPStan\ShouldNotHappenException;
1111
use PHPStan\Type\Accessory\AccessoryArrayListType;
1212
use PHPStan\Type\ArrayType;
13+
use PHPStan\Type\BenevolentUnionType;
1314
use PHPStan\Type\Constant\ConstantIntegerType;
15+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
1416
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1517
use PHPStan\Type\IntegerType;
1618
use PHPStan\Type\IterableType;
19+
use PHPStan\Type\MixedType;
1720
use PHPStan\Type\NullType;
21+
use PHPStan\Type\ObjectWithoutClassType;
1822
use PHPStan\Type\Type;
1923
use PHPStan\Type\TypeCombinator;
24+
use PHPStan\Type\TypeTraverser;
25+
use PHPStan\Type\TypeUtils;
26+
use PHPStan\Type\TypeWithClassName;
2027
use PHPStan\Type\VoidType;
28+
use function count;
2129

2230
final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
2331
{
@@ -32,14 +40,32 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn
3240
'getSingleResult' => 0,
3341
];
3442

43+
private const METHOD_HYDRATION_MODE = [
44+
'getArrayResult' => AbstractQuery::HYDRATE_ARRAY,
45+
'getScalarResult' => AbstractQuery::HYDRATE_SCALAR,
46+
'getSingleColumnResult' => AbstractQuery::HYDRATE_SCALAR_COLUMN,
47+
'getSingleScalarResult' => AbstractQuery::HYDRATE_SINGLE_SCALAR,
48+
];
49+
50+
/** @var ObjectMetadataResolver */
51+
private $objectMetadataResolver;
52+
53+
public function __construct(
54+
ObjectMetadataResolver $objectMetadataResolver
55+
)
56+
{
57+
$this->objectMetadataResolver = $objectMetadataResolver;
58+
}
59+
3560
public function getClass(): string
3661
{
3762
return AbstractQuery::class;
3863
}
3964

4065
public function isMethodSupported(MethodReflection $methodReflection): bool
4166
{
42-
return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]);
67+
return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()])
68+
|| isset(self::METHOD_HYDRATION_MODE[$methodReflection->getName()]);
4369
}
4470

4571
public function getTypeFromMethodCall(
@@ -50,21 +76,23 @@ public function getTypeFromMethodCall(
5076
{
5177
$methodName = $methodReflection->getName();
5278

53-
if (!isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) {
54-
throw new ShouldNotHappenException();
55-
}
56-
57-
$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName];
58-
$args = $methodCall->getArgs();
79+
if (isset(self::METHOD_HYDRATION_MODE[$methodName])) {
80+
$hydrationMode = new ConstantIntegerType(self::METHOD_HYDRATION_MODE[$methodName]);
81+
} elseif (isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) {
82+
$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName];
83+
$args = $methodCall->getArgs();
5984

60-
if (isset($args[$argIndex])) {
61-
$hydrationMode = $scope->getType($args[$argIndex]->value);
85+
if (isset($args[$argIndex])) {
86+
$hydrationMode = $scope->getType($args[$argIndex]->value);
87+
} else {
88+
$parametersAcceptor = ParametersAcceptorSelector::selectSingle(
89+
$methodReflection->getVariants()
90+
);
91+
$parameter = $parametersAcceptor->getParameters()[$argIndex];
92+
$hydrationMode = $parameter->getDefaultValue() ?? new NullType();
93+
}
6294
} else {
63-
$parametersAcceptor = ParametersAcceptorSelector::selectSingle(
64-
$methodReflection->getVariants()
65-
);
66-
$parameter = $parametersAcceptor->getParameters()[$argIndex];
67-
$hydrationMode = $parameter->getDefaultValue() ?? new NullType();
95+
throw new ShouldNotHappenException();
6896
}
6997

7098
$queryType = $scope->getType($methodCall->var);
@@ -98,23 +126,54 @@ private function getMethodReturnTypeForHydrationMode(
98126
return $this->originalReturnType($methodReflection);
99127
}
100128

101-
if (!$this->isObjectHydrationMode($hydrationMode)) {
102-
// We support only HYDRATE_OBJECT. For other hydration modes, we
103-
// return the declared return type of the method.
129+
if (!$hydrationMode instanceof ConstantIntegerType) {
104130
return $this->originalReturnType($methodReflection);
105131
}
106132

133+
$singleResult = false;
134+
switch ($hydrationMode->getValue()) {
135+
case AbstractQuery::HYDRATE_OBJECT:
136+
break;
137+
case AbstractQuery::HYDRATE_ARRAY:
138+
$queryResultType = $this->getArrayHydratedReturnType($queryResultType);
139+
break;
140+
case AbstractQuery::HYDRATE_SCALAR:
141+
$queryResultType = $this->getScalarHydratedReturnType($queryResultType);
142+
break;
143+
case AbstractQuery::HYDRATE_SINGLE_SCALAR:
144+
$singleResult = true;
145+
$queryResultType = $this->getSingleScalarHydratedReturnType($queryResultType);
146+
break;
147+
case AbstractQuery::HYDRATE_SIMPLEOBJECT:
148+
$queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType);
149+
break;
150+
case AbstractQuery::HYDRATE_SCALAR_COLUMN:
151+
$queryResultType = $this->getScalarColumnHydratedReturnType($queryResultType);
152+
break;
153+
default:
154+
return $this->originalReturnType($methodReflection);
155+
}
156+
107157
switch ($methodReflection->getName()) {
108158
case 'getSingleResult':
109159
return $queryResultType;
110160
case 'getOneOrNullResult':
111-
return TypeCombinator::addNull($queryResultType);
161+
$nullableQueryResultType = TypeCombinator::addNull($queryResultType);
162+
if ($queryResultType instanceof BenevolentUnionType) {
163+
$nullableQueryResultType = TypeUtils::toBenevolentUnion($nullableQueryResultType);
164+
}
165+
166+
return $nullableQueryResultType;
112167
case 'toIterable':
113168
return new IterableType(
114169
$queryKeyType->isNull()->yes() ? new IntegerType() : $queryKeyType,
115170
$queryResultType
116171
);
117172
default:
173+
if ($singleResult) {
174+
return $queryResultType;
175+
}
176+
118177
if ($queryKeyType->isNull()->yes()) {
119178
return AccessoryArrayListType::intersectWith(new ArrayType(
120179
new IntegerType(),
@@ -128,13 +187,104 @@ private function getMethodReturnTypeForHydrationMode(
128187
}
129188
}
130189

131-
private function isObjectHydrationMode(Type $type): bool
190+
private function getArrayHydratedReturnType(Type $queryResultType): Type
191+
{
192+
$objectManager = $this->objectMetadataResolver->getObjectManager();
193+
194+
return TypeTraverser::map(
195+
$queryResultType,
196+
static function (Type $type, callable $traverse) use ($objectManager): Type {
197+
$isObject = (new ObjectWithoutClassType())->isSuperTypeOf($type);
198+
if ($isObject->no()) {
199+
return $traverse($type);
200+
}
201+
if (
202+
$isObject->maybe()
203+
|| !$type instanceof TypeWithClassName
204+
|| $objectManager === null
205+
) {
206+
return new MixedType();
207+
}
208+
209+
if (!$objectManager->getMetadataFactory()->hasMetadataFor($type->getClassName())) {
210+
return $traverse($type);
211+
}
212+
213+
// We could return `new ArrayTyp(new MixedType(), new MixedType())`
214+
// but the lack of precision in the array keys/values would give false positive
215+
// @see https://github.com/phpstan/phpstan-doctrine/pull/412#issuecomment-1497092934
216+
return new MixedType();
217+
}
218+
);
219+
}
220+
221+
private function getScalarHydratedReturnType(Type $queryResultType): Type
222+
{
223+
if (!$queryResultType->isArray()->yes()) {
224+
return new ArrayType(new MixedType(), new MixedType());
225+
}
226+
227+
foreach ($queryResultType->getArrays() as $arrayType) {
228+
$itemType = $arrayType->getItemType();
229+
230+
if (
231+
!(new ObjectWithoutClassType())->isSuperTypeOf($itemType)->no()
232+
|| !$itemType->isArray()->no()
233+
) {
234+
return new ArrayType(new MixedType(), new MixedType());
235+
}
236+
}
237+
238+
return $queryResultType;
239+
}
240+
241+
private function getSimpleObjectHydratedReturnType(Type $queryResultType): Type
242+
{
243+
if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) {
244+
return $queryResultType;
245+
}
246+
247+
return new MixedType();
248+
}
249+
250+
private function getSingleScalarHydratedReturnType(Type $queryResultType): Type
132251
{
133-
if (!$type instanceof ConstantIntegerType) {
134-
return false;
252+
$queryResultType = $this->getScalarHydratedReturnType($queryResultType);
253+
if (!$queryResultType->isConstantArray()->yes()) {
254+
return new MixedType();
255+
}
256+
257+
$types = [];
258+
foreach ($queryResultType->getConstantArrays() as $constantArrayType) {
259+
$values = $constantArrayType->getValueTypes();
260+
if (count($values) !== 1) {
261+
return new MixedType();
262+
}
263+
264+
$types[] = $constantArrayType->getFirstIterableValueType();
265+
}
266+
267+
return TypeCombinator::union(...$types);
268+
}
269+
270+
private function getScalarColumnHydratedReturnType(Type $queryResultType): Type
271+
{
272+
$queryResultType = $this->getScalarHydratedReturnType($queryResultType);
273+
if (!$queryResultType->isConstantArray()->yes()) {
274+
return new MixedType();
275+
}
276+
277+
$types = [];
278+
foreach ($queryResultType->getConstantArrays() as $constantArrayType) {
279+
$values = $constantArrayType->getValueTypes();
280+
if (count($values) !== 1) {
281+
return new MixedType();
282+
}
283+
284+
$types[] = $constantArrayType->getFirstIterableValueType();
135285
}
136286

137-
return $type->getValue() === AbstractQuery::HYDRATE_OBJECT;
287+
return TypeCombinator::union(...$types);
138288
}
139289

140290
private function originalReturnType(MethodReflection $methodReflection): Type

src/Type/Doctrine/Query/QueryResultTypeWalker.php

+34-3
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ class QueryResultTypeWalker extends SqlWalker
107107
/** @var bool */
108108
private $hasGroupByClause;
109109

110+
/** @var bool */
111+
private $hasWhereClause;
112+
110113
/**
111114
* @param Query<mixed> $query
112115
*/
@@ -135,6 +138,7 @@ public function __construct($query, $parserResult, array $queryComponents)
135138
$this->nullableQueryComponents = [];
136139
$this->hasAggregateFunction = false;
137140
$this->hasGroupByClause = false;
141+
$this->hasWhereClause = false;
138142

139143
// The object is instantiated by Doctrine\ORM\Query\Parser, so receiving
140144
// dependencies through the constructor is not an option. Instead, we
@@ -177,6 +181,7 @@ public function walkSelectStatement(AST\SelectStatement $AST)
177181
$this->typeBuilder->setSelectQuery();
178182
$this->hasAggregateFunction = $this->hasAggregateFunction($AST);
179183
$this->hasGroupByClause = $AST->groupByClause !== null;
184+
$this->hasWhereClause = $AST->whereClause !== null;
180185

181186
$this->walkFromClause($AST->fromClause);
182187

@@ -795,7 +800,7 @@ public function walkSelectExpression($selectExpression)
795800

796801
$type = $this->resolveDoctrineType($typeName, $enumType, $nullable);
797802

798-
$this->typeBuilder->addScalar($resultAlias, $type);
803+
$this->addScalar($resultAlias, $type);
799804

800805
return '';
801806
}
@@ -841,21 +846,32 @@ public function walkSelectExpression($selectExpression)
841846
// the driver and PHP version.
842847
// Here we assume that the value may or may not be casted to
843848
// string by the driver.
844-
$type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type {
849+
$casted = false;
850+
$originalType = $type;
851+
852+
$type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$casted): Type {
845853
if ($type instanceof UnionType || $type instanceof IntersectionType) {
846854
return $traverse($type);
847855
}
848856
if ($type instanceof IntegerType || $type instanceof FloatType) {
857+
$casted = true;
849858
return TypeCombinator::union($type->toString(), $type);
850859
}
851860
if ($type instanceof BooleanType) {
861+
$casted = true;
852862
return TypeCombinator::union($type->toInteger()->toString(), $type);
853863
}
854864
return $traverse($type);
855865
});
866+
867+
// Since we made supposition about possibly casted values,
868+
// we can only provide a benevolent union.
869+
if ($casted && $type instanceof UnionType && !$originalType->equals($type)) {
870+
$type = TypeUtils::toBenevolentUnion($type);
871+
}
856872
}
857873

858-
$this->typeBuilder->addScalar($resultAlias, $type);
874+
$this->addScalar($resultAlias, $type);
859875

860876
return '';
861877
}
@@ -1276,6 +1292,21 @@ public function walkResultVariable($resultVariable)
12761292
return $this->marshalType(new MixedType());
12771293
}
12781294

1295+
/**
1296+
* @param array-key $alias
1297+
*/
1298+
private function addScalar($alias, Type $type): void
1299+
{
1300+
// Since we don't check the condition inside the WHERE
1301+
// conditions, we cannot be sure all the union types are correct.
1302+
// For exemple, a condition `WHERE foo.bar IS NOT NULL` could be added.
1303+
if ($this->hasWhereClause && $type instanceof UnionType) {
1304+
$type = TypeUtils::toBenevolentUnion($type);
1305+
}
1306+
1307+
$this->typeBuilder->addScalar($alias, $type);
1308+
}
1309+
12791310
private function unmarshalType(string $marshalledType): Type
12801311
{
12811312
$type = unserialize($marshalledType);

0 commit comments

Comments
 (0)