Skip to content

Commit acbf030

Browse files
committed
Add optional phpstan-doctrine integration for metadata resolution
When phpstan/phpstan-doctrine is installed and configured with an ObjectManager loader, the PHPStan extension now uses real Doctrine ClassMetadata for resolving association target types. This provides more accurate type inference for entities using XML/YAML mappings. Falls back to attribute-based resolution when metadata is unavailable.
1 parent 236fe03 commit acbf030

10 files changed

+162
-15
lines changed

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/"

rules.neon

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

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)