Skip to content

Commit e6a149b

Browse files
authored
Enum support in query type inference
1 parent f855eba commit e6a149b

File tree

11 files changed

+1394
-1088
lines changed

11 files changed

+1394
-1088
lines changed

.gitattributes

+2
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ phpstan.neon export-ignore
1111
phpstan-baseline.neon export-ignore
1212
phpstan-baseline-dbal-3.neon export-ignore
1313
phpunit.xml export-ignore
14+
stubs/runtime/Enum/UnitEnum.php export-ignore
15+
stubs/runtime/Enum/BackedEnum.php export-ignore

composer.json

+1-4
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,7 @@
3939
"ext-mongo": "1.12",
4040
"ext-mongodb": "1.6.16"
4141
},
42-
"sort-packages": true,
43-
"allow-plugins": {
44-
"composer/package-versions-deprecated": true
45-
}
42+
"sort-packages": true
4643
},
4744
"extra": {
4845
"phpstan": {

phpstan.neon

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ parameters:
1717

1818
reportUnmatchedIgnoredErrors: false
1919

20+
bootstrapFiles:
21+
- stubs/runtime/Enum/UnitEnum.php
22+
- stubs/runtime/Enum/BackedEnum.php
23+
2024
ignoreErrors:
2125
-
2226
message: '~^Variable method call on Doctrine\\ORM\\QueryBuilder~'

src/Type/Doctrine/Query/QueryResultTypeWalker.php

+55-26
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace PHPStan\Type\Doctrine\Query;
44

5+
use BackedEnum;
56
use Doctrine\ORM\EntityManagerInterface;
67
use Doctrine\ORM\Mapping\ClassMetadata;
8+
use Doctrine\ORM\Mapping\ClassMetadataInfo;
79
use Doctrine\ORM\Query;
810
use Doctrine\ORM\Query\AST;
911
use Doctrine\ORM\Query\AST\TypedExpression;
@@ -15,6 +17,7 @@
1517
use PHPStan\Type\Constant\ConstantFloatType;
1618
use PHPStan\Type\Constant\ConstantIntegerType;
1719
use PHPStan\Type\Constant\ConstantStringType;
20+
use PHPStan\Type\ConstantTypeHelper;
1821
use PHPStan\Type\Doctrine\DescriptorNotRegisteredException;
1922
use PHPStan\Type\Doctrine\DescriptorRegistry;
2023
use PHPStan\Type\FloatType;
@@ -31,6 +34,7 @@
3134
use PHPStan\Type\TypeTraverser;
3235
use PHPStan\Type\TypeUtils;
3336
use PHPStan\Type\UnionType;
37+
use function array_map;
3438
use function assert;
3539
use function class_exists;
3640
use function count;
@@ -42,6 +46,7 @@
4246
use function is_numeric;
4347
use function is_object;
4448
use function is_string;
49+
use function is_subclass_of;
4550
use function serialize;
4651
use function sprintf;
4752
use function strtolower;
@@ -231,15 +236,13 @@ public function walkPathExpression($pathExpr)
231236

232237
switch ($pathExpr->type) {
233238
case AST\PathExpression::TYPE_STATE_FIELD:
234-
$typeName = $class->getTypeOfField($fieldName);
235-
236-
assert(is_string($typeName));
239+
[$typeName, $enumType] = $this->getTypeOfField($class, $fieldName);
237240

238241
$nullable = $this->isQueryComponentNullable($dqlAlias)
239242
|| $class->isNullable($fieldName)
240243
|| $this->hasAggregateWithoutGroupBy();
241244

242-
$fieldType = $this->resolveDatabaseInternalType($typeName, $nullable);
245+
$fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable);
243246

244247
return $this->marshalType($fieldType);
245248

@@ -273,14 +276,12 @@ public function walkPathExpression($pathExpr)
273276
}
274277

275278
$targetFieldName = $identifierFieldNames[0];
276-
$typeName = $targetClass->getTypeOfField($targetFieldName);
277-
278-
assert(is_string($typeName));
279+
[$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName);
279280

280281
$nullable = (bool) ($joinColumn['nullable'] ?? true)
281282
|| $this->hasAggregateWithoutGroupBy();
282283

283-
$fieldType = $this->resolveDatabaseInternalType($typeName, $nullable);
284+
$fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable);
284285

285286
return $this->marshalType($fieldType);
286287

@@ -530,16 +531,13 @@ public function walkFunction($function)
530531
$targetFieldName = $function->fieldMapping;
531532
}
532533

533-
$typeName = $targetClass->getTypeOfField($targetFieldName);
534-
if ($typeName === null) {
535-
return $this->marshalType(new MixedType());
536-
}
537-
538534
$fieldMapping = $targetClass->fieldMappings[$targetFieldName] ?? null;
539535
if ($fieldMapping === null) {
540536
return $this->marshalType(new MixedType());
541537
}
542538

539+
[$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName);
540+
543541
$joinColumn = null;
544542

545543
foreach ($assoc['joinColumns'] as $item) {
@@ -556,7 +554,7 @@ public function walkFunction($function)
556554
$nullable = (bool) ($joinColumn['nullable'] ?? true)
557555
|| $this->hasAggregateWithoutGroupBy();
558556

559-
$fieldType = $this->resolveDatabaseInternalType($typeName, $nullable);
557+
$fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable);
560558

561559
return $this->marshalType($fieldType);
562560

@@ -783,15 +781,13 @@ public function walkSelectExpression($selectExpression)
783781
$qComp = $this->queryComponents[$dqlAlias];
784782
$class = $qComp['metadata'];
785783

786-
$typeName = $class->getTypeOfField($fieldName);
787-
788-
assert(is_string($typeName));
784+
[$typeName, $enumType] = $this->getTypeOfField($class, $fieldName);
789785

790786
$nullable = $this->isQueryComponentNullable($dqlAlias)
791787
|| $class->isNullable($fieldName)
792788
|| $this->hasAggregateWithoutGroupBy();
793789

794-
$type = $this->resolveDoctrineType($typeName, $nullable);
790+
$type = $this->resolveDoctrineType($typeName, $enumType, $nullable);
795791

796792
$this->typeBuilder->addScalar($resultAlias, $type);
797793

@@ -1295,14 +1291,37 @@ private function isQueryComponentNullable(string $dqlAlias): bool
12951291
return $this->nullableQueryComponents[$dqlAlias] ?? false;
12961292
}
12971293

1298-
private function resolveDoctrineType(string $typeName, bool $nullable = false): Type
1294+
/** @return array{string, ?class-string<BackedEnum>} Doctrine type name and enum type of field */
1295+
private function getTypeOfField(ClassMetadataInfo $class, string $fieldName): array
12991296
{
1300-
try {
1301-
$type = $this->descriptorRegistry
1302-
->get($typeName)
1303-
->getWritableToPropertyType();
1304-
} catch (DescriptorNotRegisteredException $e) {
1305-
$type = new MixedType();
1297+
assert(isset($class->fieldMappings[$fieldName]));
1298+
1299+
/** @var array{type: string, enumType?: ?string} $metadata */
1300+
$metadata = $class->fieldMappings[$fieldName];
1301+
1302+
$type = $metadata['type'];
1303+
$enumType = $metadata['enumType'] ?? null;
1304+
1305+
if (!is_string($enumType) || !class_exists($enumType) || !is_subclass_of($enumType, BackedEnum::class)) {
1306+
$enumType = null;
1307+
}
1308+
1309+
return [$type, $enumType];
1310+
}
1311+
1312+
/** @param ?class-string<BackedEnum> $enumType */
1313+
private function resolveDoctrineType(string $typeName, ?string $enumType = null, bool $nullable = false): Type
1314+
{
1315+
if ($enumType !== null) {
1316+
$type = new ObjectType($enumType);
1317+
} else {
1318+
try {
1319+
$type = $this->descriptorRegistry
1320+
->get($typeName)
1321+
->getWritableToPropertyType();
1322+
} catch (DescriptorNotRegisteredException $e) {
1323+
$type = new MixedType();
1324+
}
13061325
}
13071326

13081327
if ($nullable) {
@@ -1312,7 +1331,8 @@ private function resolveDoctrineType(string $typeName, bool $nullable = false):
13121331
return $type;
13131332
}
13141333

1315-
private function resolveDatabaseInternalType(string $typeName, bool $nullable = false): Type
1334+
/** @param ?class-string<BackedEnum> $enumType */
1335+
private function resolveDatabaseInternalType(string $typeName, ?string $enumType = null, bool $nullable = false): Type
13161336
{
13171337
try {
13181338
$type = $this->descriptorRegistry
@@ -1322,6 +1342,15 @@ private function resolveDatabaseInternalType(string $typeName, bool $nullable =
13221342
$type = new MixedType();
13231343
}
13241344

1345+
if ($enumType !== null) {
1346+
$enumTypes = array_map(static function ($enumType) {
1347+
return ConstantTypeHelper::getTypeFromValue($enumType->value);
1348+
}, $enumType::cases());
1349+
$enumType = TypeCombinator::union(...$enumTypes);
1350+
$enumType = TypeCombinator::union($enumType, $enumType->toString());
1351+
$type = TypeCombinator::intersect($enumType, $type);
1352+
}
1353+
13251354
if ($nullable) {
13261355
$type = TypeCombinator::addNull($type);
13271356
}

stubs/runtime/Enum/BackedEnum.php

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
if (\PHP_VERSION_ID < 80100) {
4+
if (interface_exists('BackedEnum', false)) {
5+
return;
6+
}
7+
8+
interface BackedEnum extends UnitEnum
9+
{
10+
/**
11+
* @param int|string $value
12+
* @return static
13+
*/
14+
public static function from($value);
15+
16+
/**
17+
* @param int|string $value
18+
* @return ?static
19+
*/
20+
public static function tryFrom($value);
21+
}
22+
}

stubs/runtime/Enum/UnitEnum.php

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
if (\PHP_VERSION_ID < 80100) {
4+
if (interface_exists('UnitEnum', false)) {
5+
return;
6+
}
7+
8+
interface UnitEnum
9+
{
10+
/**
11+
* @return static[]
12+
*/
13+
public static function cases(): array;
14+
}
15+
}

0 commit comments

Comments
 (0)