Skip to content

Commit 8a236fb

Browse files
authored
Add optional phpstan-doctrine integration for metadata resolution (#33)
1 parent 236fe03 commit 8a236fb

10 files changed

+159
-13
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ includes:
6363
- vendor/shipmonk/doctrine-entity-preloader/rules.neon
6464
```
6565

66+
If [phpstan/phpstan-doctrine](https://github.com/phpstan/phpstan-doctrine) is installed, real Doctrine metadata will be used for more accurate type inference.
67+
6668
## Usage
6769

6870
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
return (new Configuration())
77
->ignoreUnknownFunctions(['PHPStan\Testing\assertType'])
88
->ignoreErrorsOnPackage('nikic/php-parser', [ErrorType::DEV_DEPENDENCY_IN_PROD])
9-
->ignoreErrorsOnPackage('phpstan/phpstan', [ErrorType::DEV_DEPENDENCY_IN_PROD]);
9+
->ignoreErrorsOnPackage('phpstan/phpstan', [ErrorType::DEV_DEPENDENCY_IN_PROD])
10+
->ignoreErrorsOnPackage('phpstan/phpstan-doctrine', [ErrorType::DEV_DEPENDENCY_IN_PROD]);

composer.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@
1212
"require-dev": {
1313
"composer/semver": "^3.0",
1414
"doctrine/collections": "^2.2",
15-
"doctrine/persistence": "^3.3 || ^4.0",
15+
"doctrine/persistence": "^3.4.3 || ^4.0",
1616
"editorconfig-checker/editorconfig-checker": "^10.6.0",
1717
"ergebnis/composer-normalize": "^2.42.0",
1818
"nette/utils": "^4",
1919
"nikic/php-parser": "^5.6",
2020
"phpstan/phpstan": "^2.1.26",
21+
"phpstan/phpstan-doctrine": "^2.0",
2122
"phpstan/phpstan-phpunit": "^2.0",
2223
"phpstan/phpstan-strict-rules": "^2.0",
23-
"phpunit/phpunit": "^10.5",
24+
"phpunit/phpunit": "^10.5.60",
2425
"psr/log": "^3",
2526
"shipmonk/coding-standard": "^0.2.0",
2627
"shipmonk/composer-dependency-analyser": "^1.7",
@@ -29,6 +30,9 @@
2930
"shipmonk/phpstan-rules": "^4.0",
3031
"symfony/cache": "^6 || ^7 || ^8"
3132
},
33+
"suggest": {
34+
"phpstan/phpstan-doctrine": "For enhanced Doctrine metadata resolution using real ORM mappings (^2.0)"
35+
},
3236
"autoload": {
3337
"psr-4": {
3438
"ShipMonk\\DoctrineEntityPreloader\\": "src/"

src/PHPStan/EntityPreloaderCore.php

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Analyser\Scope;
1111
use PHPStan\Type\Accessory\AccessoryArrayListType;
1212
use PHPStan\Type\ArrayType;
13+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
1314
use PHPStan\Type\IntegerType;
1415
use PHPStan\Type\IntersectionType;
1516
use PHPStan\Type\ObjectType;
@@ -25,6 +26,12 @@
2526
abstract class EntityPreloaderCore
2627
{
2728

29+
public function __construct(
30+
private ?ObjectMetadataResolver $objectMetadataResolver = null,
31+
)
32+
{
33+
}
34+
2835
protected function getPreloadedPropertyName(
2936
MethodCall $methodCall,
3037
Scope $scope,
@@ -147,20 +154,58 @@ private function getAssociationTargetTypeFromObjectType(
147154
throw EntityPreloaderRuleException::classNotFound($type->getClassName());
148155
}
149156

157+
if ($this->objectMetadataResolver !== null) {
158+
return $this->getAssociationTargetTypeFromMetadata(
159+
$this->objectMetadataResolver,
160+
$classReflection->getName(),
161+
$propertyName,
162+
);
163+
}
164+
150165
for ($currentClassReflection = $classReflection; $currentClassReflection !== null; $currentClassReflection = $currentClassReflection->getParentClass()) {
151166
if ($currentClassReflection->hasInstanceProperty($propertyName)) {
152167
$propertyReflection = $currentClassReflection->getNativeProperty($propertyName)->getNativeReflection();
153-
return $this->getAssociationTargetTypeFromPropertyReflection($propertyReflection);
168+
return $this->getAssociationTargetTypeFromPropertyReflection($classReflection->getName(), $propertyReflection);
154169
}
155170
}
156171

157172
throw EntityPreloaderRuleException::propertyNotFound($classReflection->getName(), $propertyName);
158173
}
159174

160175
/**
176+
* @param class-string $className
177+
*
161178
* @throws EntityPreloaderRuleException
162179
*/
163-
private function getAssociationTargetTypeFromPropertyReflection(ReflectionProperty $propertyReflection): Type
180+
private function getAssociationTargetTypeFromMetadata(
181+
ObjectMetadataResolver $metadataResolver,
182+
string $className,
183+
string $propertyName,
184+
): Type
185+
{
186+
$classMetadata = $metadataResolver->getClassMetadata($className);
187+
188+
if ($classMetadata === null) {
189+
throw EntityPreloaderRuleException::classNotFound($className);
190+
}
191+
192+
if (!$classMetadata->hasAssociation($propertyName)) {
193+
throw $classMetadata->hasField($propertyName)
194+
? EntityPreloaderRuleException::invalidAssociations($className, $propertyName)
195+
: EntityPreloaderRuleException::propertyNotFound($className, $propertyName);
196+
}
197+
198+
$associationMapping = $classMetadata->getAssociationMapping($propertyName);
199+
return new ObjectType($associationMapping['targetEntity']);
200+
}
201+
202+
/**
203+
* @throws EntityPreloaderRuleException
204+
*/
205+
private function getAssociationTargetTypeFromPropertyReflection(
206+
string $className,
207+
ReflectionProperty $propertyReflection,
208+
): Type
164209
{
165210
$associationAttributes = [
166211
OneToOne::class,
@@ -182,7 +227,7 @@ private function getAssociationTargetTypeFromPropertyReflection(ReflectionProper
182227
}
183228
}
184229

185-
throw EntityPreloaderRuleException::invalidAssociations($propertyReflection->getDeclaringClass()->getName(), $propertyReflection->getName());
230+
throw EntityPreloaderRuleException::invalidAssociations($className, $propertyReflection->getName());
186231
}
187232

188233
protected function createListType(Type $type): Type

tests/PHPStan/Data/EntityPreloaderRuleTestData.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function preloadOneHasMany(): void
3737
assertType('list<ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Article>', $this->entityPreloader->preload($categories, 'articles'));
3838
assertType('list<object>', $this->entityPreloader->preload($categories, 'notFound')); // error: Property 'ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Category::$notFound' not found.
3939
assertType('list<object>', $this->entityPreloader->preload($categories, 'name')); // error: Property 'ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Category::$name' is not a valid Doctrine association.
40-
assertType('list<object>', $this->entityPreloader->preload($categories, 'id')); // error: Property 'ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\TestEntityWithCustomPrimaryKey::$id' is not a valid Doctrine association.
40+
assertType('list<object>', $this->entityPreloader->preload($categories, 'id')); // error: Property 'ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Category::$id' is not a valid Doctrine association.
4141

4242
$bots = $this->entityManager->getRepository(Bot::class)->findAll();
4343
assertType('list<ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Comment>', $this->entityPreloader->preload($bots, 'comments'));

tests/PHPStan/EntityPreloaderReturnTypeExtensionTest.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,10 @@
44

55
use PHPStan\Testing\TypeInferenceTestCase;
66
use PHPUnit\Framework\Attributes\DataProvider;
7-
use ShipMonk\DoctrineEntityPreloader\PHPStan\EntityPreloaderRule;
87

98
final class EntityPreloaderReturnTypeExtensionTest extends TypeInferenceTestCase
109
{
1110

12-
protected function getRule(): EntityPreloaderRule
13-
{
14-
return new EntityPreloaderRule();
15-
}
16-
1711
#[DataProvider('provideTypeAssertsData')]
1812
public function testTypeAsserts(
1913
string $assertType,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\DoctrineEntityPreloader\PHPStan;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
8+
final class EntityPreloaderReturnTypeExtensionWithMetadataTest extends TypeInferenceTestCase
9+
{
10+
11+
#[DataProvider('provideTypeAssertsData')]
12+
public function testTypeAsserts(
13+
string $assertType,
14+
string $file,
15+
mixed ...$args,
16+
): void
17+
{
18+
$this->assertFileAsserts($assertType, $file, ...$args);
19+
}
20+
21+
public static function provideTypeAssertsData(): iterable
22+
{
23+
yield from self::gatherAssertTypes(__DIR__ . '/Data/EntityPreloaderRuleTestData.php');
24+
}
25+
26+
/**
27+
* @return list<string>
28+
*/
29+
public static function getAdditionalConfigFiles(): array
30+
{
31+
return [
32+
__DIR__ . '/phpstan-doctrine.neon',
33+
__DIR__ . '/../../extension.neon',
34+
];
35+
}
36+
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\DoctrineEntityPreloader\PHPStan;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
7+
use ShipMonk\DoctrineEntityPreloader\PHPStan\EntityPreloaderRule;
8+
use ShipMonk\PHPStanDev\RuleTestCase;
9+
10+
/**
11+
* @extends RuleTestCase<EntityPreloaderRule>
12+
*/
13+
final class EntityPreloaderRuleWithMetadataTest extends RuleTestCase
14+
{
15+
16+
protected function getRule(): Rule
17+
{
18+
return new EntityPreloaderRule(new ObjectMetadataResolver( // @phpstan-ignore phpstanApi.constructor
19+
__DIR__ . '/object-manager-loader.php',
20+
__DIR__ . '/../../cache',
21+
));
22+
}
23+
24+
public function testRule(): void
25+
{
26+
$this->analyzeFiles([__DIR__ . '/Data/EntityPreloaderRuleTestData.php']);
27+
}
28+
29+
/**
30+
* @return list<string>
31+
*/
32+
public static function getAdditionalConfigFiles(): array
33+
{
34+
return [__DIR__ . '/../../extension.neon'];
35+
}
36+
37+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types = 1);
2+
3+
use Doctrine\DBAL\DriverManager;
4+
use Doctrine\ORM\EntityManager;
5+
use Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
6+
use Doctrine\ORM\ORMSetup;
7+
8+
// Use new non-deprecated API on Doctrine ORM 3.5+ with PHP 8.4+
9+
if (PHP_VERSION_ID >= 8_04_00 && method_exists(ORMSetup::class, 'createAttributeMetadataConfig')) { // @phpstan-ignore function.alreadyNarrowedType (BC for older Doctrine)
10+
$config = ORMSetup::createAttributeMetadataConfig([__DIR__ . '/../Fixtures'], isDevMode: true);
11+
$config->enableNativeLazyObjects(true);
12+
} else {
13+
$config = ORMSetup::createAttributeMetadataConfiguration([__DIR__ . '/../Fixtures'], isDevMode: true, proxyDir: __DIR__ . '/../../cache/proxies');
14+
}
15+
16+
$config->setNamingStrategy(new UnderscoreNamingStrategy());
17+
18+
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config);
19+
20+
return new EntityManager($connection, $config);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
includes:
2+
- ../../vendor/phpstan/phpstan-doctrine/extension.neon
3+
4+
parameters:
5+
doctrine:
6+
objectManagerLoader: %rootDir%/../../../tests/PHPStan/object-manager-loader.php

0 commit comments

Comments
 (0)