Skip to content

Commit 236fe03

Browse files
authored
add PHPStan integration (#31)
1 parent 5119fa4 commit 236fe03

20 files changed

+616
-35
lines changed

.github/workflows/checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
run: composer update --no-progress --${{ matrix.dependency-version }} --prefer-dist --no-interaction
5151
-
5252
name: Install correct version of Doctrine
53-
run: composer update --no-progress --${{ matrix.dependency-version }} --prefer-dist --no-interaction --with-all-dependencies doctrine/orm:${{ matrix.doctrine-version }}
53+
run: composer update --no-progress --${{ matrix.dependency-version }} --prefer-dist --no-interaction --with-all-dependencies doctrine/orm:${{ matrix.doctrine-version }} symfony/cache
5454
-
5555
name: Run tests
5656
run: composer check:tests

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ To install the library, use Composer:
4646
composer require shipmonk/doctrine-entity-preloader
4747
```
4848

49+
### PHPStan
50+
51+
This library provides PHPStan integration:
52+
53+
- **extension.neon** - Infers return types for `EntityPreloader::preload()` based on the preloaded association
54+
- **rules.neon** - Validates that the property name passed to `preload()` exists on the entity
55+
56+
If you use [PHPStan](https://phpstan.org/) and have [phpstan/extension-installer](https://github.com/phpstan/extension-installer) installed, the extension and rules are enabled automatically.
57+
58+
Otherwise, add the following to your `phpstan.neon`:
59+
60+
```neon
61+
includes:
62+
- vendor/shipmonk/doctrine-entity-preloader/extension.neon
63+
- vendor/shipmonk/doctrine-entity-preloader/rules.neon
64+
```
65+
4966
## Usage
5067

5168
Below is a basic example demonstrating how to use `EntityPreloader` to preload related entities and avoid the n+1 problem:

composer-dependency-analyser.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php declare(strict_types = 1);
2+
3+
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
4+
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
5+
6+
return (new Configuration())
7+
->ignoreUnknownFunctions(['PHPStan\Testing\assertType'])
8+
->ignoreErrorsOnPackage('nikic/php-parser', [ErrorType::DEV_DEPENDENCY_IN_PROD])
9+
->ignoreErrorsOnPackage('phpstan/phpstan', [ErrorType::DEV_DEPENDENCY_IN_PROD]);

composer.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
"editorconfig-checker/editorconfig-checker": "^10.6.0",
1717
"ergebnis/composer-normalize": "^2.42.0",
1818
"nette/utils": "^4",
19-
"phpstan/phpstan": "^2.0",
19+
"nikic/php-parser": "^5.6",
20+
"phpstan/phpstan": "^2.1.26",
2021
"phpstan/phpstan-phpunit": "^2.0",
2122
"phpstan/phpstan-strict-rules": "^2.0",
2223
"phpunit/phpunit": "^10.5",
2324
"psr/log": "^3",
2425
"shipmonk/coding-standard": "^0.2.0",
2526
"shipmonk/composer-dependency-analyser": "^1.7",
2627
"shipmonk/name-collision-detector": "^2.1.1",
28+
"shipmonk/phpstan-dev": "^0.1.2",
2729
"shipmonk/phpstan-rules": "^4.0",
2830
"symfony/cache": "^6 || ^7 || ^8"
2931
},
@@ -44,6 +46,14 @@
4446
},
4547
"sort-packages": true
4648
},
49+
"extra": {
50+
"phpstan": {
51+
"includes": [
52+
"extension.neon",
53+
"rules.neon"
54+
]
55+
}
56+
},
4757
"scripts": {
4858
"check": [
4959
"@check:composer",

extension.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
services:
2+
-
3+
class: ShipMonk\DoctrineEntityPreloader\PHPStan\EntityPreloaderReturnTypeExtension
4+
tags:
5+
- phpstan.broker.dynamicMethodReturnTypeExtension

phpcs.xml.dist

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,5 @@
99
<file>src/</file>
1010
<file>tests/</file>
1111

12-
<exclude-pattern>tests/**/Data/*</exclude-pattern>
13-
1412
<rule ref="ShipMonkCodingStandard"/>
1513
</ruleset>

phpstan.neon.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ includes:
55
- ./vendor/phpstan/phpstan-phpunit/rules.neon
66
- ./vendor/phpstan/phpstan-strict-rules/rules.neon
77
- ./vendor/shipmonk/phpstan-rules/rules.neon
8+
- ./extension.neon
9+
- ./rules.neon
810

911
parameters:
1012
paths:
@@ -13,6 +15,7 @@ parameters:
1315
excludePaths:
1416
analyse:
1517
- tests/Fixtures/Compat
18+
- tests/PHPStan/Data
1619
checkMissingCallableSignature: true
1720
checkUninitializedProperties: true
1821
checkTooWideReturnTypesInProtectedAndPublicMethods: true

rules.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
rules:
2+
- ShipMonk\DoctrineEntityPreloader\PHPStan\EntityPreloaderRule

src/EntityPreloader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function __construct(
3434

3535
/**
3636
* @param list<object> $sourceEntities
37+
* @param literal-string $sourcePropertyName
3738
* @param positive-int|null $batchSize
3839
* @param non-negative-int|null $maxFetchJoinSameFieldCount
3940
* @return list<object>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\DoctrineEntityPreloader\PHPStan;
4+
5+
use Doctrine\ORM\Mapping\ManyToMany;
6+
use Doctrine\ORM\Mapping\ManyToOne;
7+
use Doctrine\ORM\Mapping\OneToMany;
8+
use Doctrine\ORM\Mapping\OneToOne;
9+
use PhpParser\Node\Expr\MethodCall;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Type\Accessory\AccessoryArrayListType;
12+
use PHPStan\Type\ArrayType;
13+
use PHPStan\Type\IntegerType;
14+
use PHPStan\Type\IntersectionType;
15+
use PHPStan\Type\ObjectType;
16+
use PHPStan\Type\Type;
17+
use PHPStan\Type\TypeCombinator;
18+
use PHPStan\Type\TypeWithClassName;
19+
use PHPStan\Type\UnionType;
20+
use ReflectionNamedType;
21+
use ReflectionProperty;
22+
use function count;
23+
use function is_string;
24+
25+
abstract class EntityPreloaderCore
26+
{
27+
28+
protected function getPreloadedPropertyName(
29+
MethodCall $methodCall,
30+
Scope $scope,
31+
): ?string
32+
{
33+
$args = $methodCall->getArgs();
34+
35+
if (!isset($args[1])) {
36+
return null;
37+
}
38+
39+
$type = $scope->getType($args[1]->value);
40+
$constantValues = $type->getConstantScalarValues();
41+
42+
if (count($constantValues) !== 1) {
43+
return null;
44+
}
45+
46+
if (!is_string($constantValues[0])) {
47+
return null;
48+
}
49+
50+
return $constantValues[0];
51+
}
52+
53+
/**
54+
* @throws EntityPreloaderRuleException
55+
*/
56+
protected function getPreloadedEntityType(
57+
MethodCall $methodCall,
58+
Scope $scope,
59+
string $propertyName,
60+
): Type
61+
{
62+
$args = $methodCall->getArgs();
63+
64+
$sourceEntityListType = $scope->getType($args[0]->value);
65+
$sourceEntityType = $sourceEntityListType->getIterableValueType();
66+
67+
return $this->getAssociationTargetType($sourceEntityType, $propertyName);
68+
}
69+
70+
/**
71+
* @throws EntityPreloaderRuleException
72+
*/
73+
private function getAssociationTargetType(
74+
Type $type,
75+
string $propertyName,
76+
): Type
77+
{
78+
if ($type instanceof UnionType) {
79+
return $this->getAssociationTargetTypeFromUnion($type, $propertyName);
80+
81+
} elseif ($type instanceof IntersectionType) { // @phpstan-ignore phpstanApi.instanceofType
82+
return $this->getAssociationTargetTypeFromIntersection($type, $propertyName);
83+
84+
} elseif ($type instanceof TypeWithClassName) { // @phpstan-ignore phpstanApi.instanceofType
85+
return $this->getAssociationTargetTypeFromObjectType($type, $propertyName);
86+
87+
} else {
88+
throw EntityPreloaderRuleException::propertyNotFound('object', $propertyName);
89+
}
90+
}
91+
92+
/**
93+
* @throws EntityPreloaderRuleException
94+
*/
95+
private function getAssociationTargetTypeFromUnion(
96+
UnionType $type,
97+
string $propertyName,
98+
): Type
99+
{
100+
$propertyTypes = [];
101+
102+
foreach ($type->getTypes() as $innerType) {
103+
$propertyTypes[] = $this->getAssociationTargetType($innerType, $propertyName);
104+
}
105+
106+
return TypeCombinator::union(...$propertyTypes);
107+
}
108+
109+
/**
110+
* @throws EntityPreloaderRuleException
111+
*/
112+
private function getAssociationTargetTypeFromIntersection(
113+
IntersectionType $type,
114+
string $propertyName,
115+
): Type
116+
{
117+
$propertyTypes = [];
118+
$exceptions = [];
119+
120+
foreach ($type->getTypes() as $innerType) {
121+
try {
122+
$propertyTypes[] = $this->getAssociationTargetType($innerType, $propertyName);
123+
124+
} catch (EntityPreloaderRuleException $e) {
125+
$exceptions[] = $e;
126+
}
127+
}
128+
129+
if (count($propertyTypes) === 0 && count($exceptions) > 0) {
130+
throw $exceptions[0];
131+
}
132+
133+
return TypeCombinator::intersect(...$propertyTypes);
134+
}
135+
136+
/**
137+
* @throws EntityPreloaderRuleException
138+
*/
139+
private function getAssociationTargetTypeFromObjectType(
140+
TypeWithClassName $type,
141+
string $propertyName,
142+
): Type
143+
{
144+
$classReflection = $type->getClassReflection();
145+
146+
if ($classReflection === null) {
147+
throw EntityPreloaderRuleException::classNotFound($type->getClassName());
148+
}
149+
150+
for ($currentClassReflection = $classReflection; $currentClassReflection !== null; $currentClassReflection = $currentClassReflection->getParentClass()) {
151+
if ($currentClassReflection->hasInstanceProperty($propertyName)) {
152+
$propertyReflection = $currentClassReflection->getNativeProperty($propertyName)->getNativeReflection();
153+
return $this->getAssociationTargetTypeFromPropertyReflection($propertyReflection);
154+
}
155+
}
156+
157+
throw EntityPreloaderRuleException::propertyNotFound($classReflection->getName(), $propertyName);
158+
}
159+
160+
/**
161+
* @throws EntityPreloaderRuleException
162+
*/
163+
private function getAssociationTargetTypeFromPropertyReflection(ReflectionProperty $propertyReflection): Type
164+
{
165+
$associationAttributes = [
166+
OneToOne::class,
167+
OneToMany::class,
168+
ManyToOne::class,
169+
ManyToMany::class,
170+
];
171+
172+
foreach ($associationAttributes as $attributeClass) {
173+
foreach ($propertyReflection->getAttributes($attributeClass) as $attributeReflection) {
174+
$attribute = $attributeReflection->newInstance();
175+
176+
if ($attribute->targetEntity !== null) {
177+
return new ObjectType($attribute->targetEntity);
178+
179+
} elseif ($attributeClass === OneToOne::class && $propertyReflection->getType() instanceof ReflectionNamedType) {
180+
return new ObjectType($propertyReflection->getType()->getName());
181+
}
182+
}
183+
}
184+
185+
throw EntityPreloaderRuleException::invalidAssociations($propertyReflection->getDeclaringClass()->getName(), $propertyReflection->getName());
186+
}
187+
188+
protected function createListType(Type $type): Type
189+
{
190+
return TypeCombinator::intersect(
191+
new ArrayType(new IntegerType(), $type),
192+
new AccessoryArrayListType(),
193+
);
194+
}
195+
196+
}

0 commit comments

Comments
 (0)