diff --git a/composer.json b/composer.json index 37b65509..38abfbb6 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "doctrine/lexer": "^1.2.1", "doctrine/mongodb-odm": "^1.3 || ^2.1", "doctrine/orm": "^2.11.0", - "doctrine/persistence": "^1.1 || ^2.0", + "doctrine/persistence": "^1.3.8 || ^2.2.1", "nesbot/carbon": "^2.49", "nikic/php-parser": "^4.13.2", "php-parallel-lint/php-parallel-lint": "^1.2", diff --git a/phpcs.xml b/phpcs.xml index caa1df74..bb4f98b1 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -37,6 +37,8 @@ + + @@ -49,6 +51,7 @@ + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..ea760d50 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,32 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$drivers of class PHPStan\\\\Doctrine\\\\Mapping\\\\MappingDriverChain constructor expects array\\, array\\ given\\.$#" + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: "#^Parameter \\#1 \\$entityName of class Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata constructor expects class\\-string\\, string given\\.$#" + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: "#^Parameter \\#1 \\$className \\(class\\-string\\) of method PHPStan\\\\Doctrine\\\\Mapping\\\\MappingDriverChain\\:\\:isTransient\\(\\) should be contravariant with parameter \\$className \\(string\\) of method Doctrine\\\\Persistence\\\\Mapping\\\\Driver\\\\MappingDriver\\:\\:isTransient\\(\\)$#" + count: 1 + path: src/Doctrine/Mapping/MappingDriverChain.php + + - + message: "#^Parameter \\#1 \\$className \\(class\\-string\\) of method PHPStan\\\\Doctrine\\\\Mapping\\\\MappingDriverChain\\:\\:loadMetadataForClass\\(\\) should be contravariant with parameter \\$className \\(string\\) of method Doctrine\\\\Persistence\\\\Mapping\\\\Driver\\\\MappingDriver\\:\\:loadMetadataForClass\\(\\)$#" + count: 1 + path: src/Doctrine/Mapping/MappingDriverChain.php + + - + message: "#^PHPDoc tag @return contains generic type Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadataFactory\\ but interface Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadataFactory is not generic\\.$#" + count: 1 + path: src/Type/Doctrine/ObjectMetadataResolver.php + + - + message: "#^PHPDoc tag @var for property PHPStan\\\\Type\\\\Doctrine\\\\ObjectMetadataResolver\\:\\:\\$metadataFactory contains generic type Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadataFactory\\ but interface Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadataFactory is not generic\\.$#" + count: 1 + path: src/Type/Doctrine/ObjectMetadataResolver.php + diff --git a/phpstan.neon b/phpstan.neon index df5343a6..4f20d238 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,7 @@ includes: - extension.neon - rules.neon + - phpstan-baseline.neon - vendor/phpstan/phpstan-strict-rules/rules.neon - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/rules.neon @@ -13,6 +14,8 @@ parameters: - tests/*/data-php-*/* - tests/Rules/Doctrine/ORM/entity-manager.php + reportUnmatchedIgnoredErrors: false + ignoreErrors: - message: '~^Variable method call on Doctrine\\ORM\\QueryBuilder~' diff --git a/src/Doctrine/Mapping/ClassMetadataFactory.php b/src/Doctrine/Mapping/ClassMetadataFactory.php new file mode 100644 index 00000000..464b840e --- /dev/null +++ b/src/Doctrine/Mapping/ClassMetadataFactory.php @@ -0,0 +1,46 @@ +getProperty('driver'); + $driverProperty->setAccessible(true); + + $drivers = [ + new AnnotationDriver(new AnnotationReader()), + ]; + if (class_exists(AttributeDriver::class) && PHP_VERSION_ID >= 80000) { + $drivers[] = new AttributeDriver([]); + } + + $driverProperty->setValue($this, count($drivers) === 1 ? $drivers[0] : new MappingDriverChain($drivers)); + + $evmProperty = $parentReflection->getProperty('evm'); + $evmProperty->setAccessible(true); + $evmProperty->setValue($this, new EventManager()); + $this->initialized = true; + + $targetPlatformProperty = $parentReflection->getProperty('targetPlatform'); + $targetPlatformProperty->setAccessible(true); + $targetPlatformProperty->setValue($this, new MySqlPlatform()); + } + + protected function newClassMetadataInstance($className) + { + return new ClassMetadata($className); + } + +} diff --git a/src/Doctrine/Mapping/MappingDriverChain.php b/src/Doctrine/Mapping/MappingDriverChain.php new file mode 100644 index 00000000..6ed230a8 --- /dev/null +++ b/src/Doctrine/Mapping/MappingDriverChain.php @@ -0,0 +1,65 @@ +drivers = $drivers; + } + + /** + * @param class-string $className + */ + public function loadMetadataForClass($className, ClassMetadata $metadata): void + { + foreach ($this->drivers as $driver) { + if ($driver->isTransient($className)) { + continue; + } + + $driver->loadMetadataForClass($className, $metadata); + return; + } + } + + public function getAllClassNames() + { + $all = []; + foreach ($this->drivers as $driver) { + foreach ($driver->getAllClassNames() as $className) { + $all[] = $className; + } + } + + return $all; + } + + /** + * @param class-string $className + */ + public function isTransient($className) + { + foreach ($this->drivers as $driver) { + if ($driver->isTransient($className)) { + continue; + } + + return false; + } + + return true; + } + +} diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index f2f851d3..da533f2b 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -2,7 +2,9 @@ namespace PHPStan\Type\Doctrine; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\Persistence\Mapping\ClassMetadataFactory; use Doctrine\Persistence\ObjectManager; use PHPStan\Reflection\ReflectionProvider; use function is_file; @@ -26,6 +28,9 @@ final class ObjectMetadataResolver /** @var string|null */ private $resolvedRepositoryClass; + /** @var ClassMetadataFactory|null */ + private $metadataFactory; + public function __construct( ReflectionProvider $reflectionProvider, ?string $objectManagerLoader, @@ -66,17 +71,32 @@ public function getObjectManager(): ?ObjectManager public function isTransient(string $className): bool { $objectManager = $this->getObjectManager(); - if ($objectManager === null) { - return true; - } try { + if ($objectManager === null) { + return $this->getMetadataFactory()->isTransient($className); + } + return $objectManager->getMetadataFactory()->isTransient($className); } catch (\ReflectionException $e) { return true; } } + /** + * @return ClassMetadataFactory + */ + private function getMetadataFactory(): ClassMetadataFactory + { + if ($this->metadataFactory !== null) { + return $this->metadataFactory; + } + + $metadataFactory = new \PHPStan\Doctrine\Mapping\ClassMetadataFactory(); + + return $this->metadataFactory = $metadataFactory; + } + /** * @template T of object * @param class-string $className @@ -84,17 +104,18 @@ public function isTransient(string $className): bool */ public function getClassMetadata(string $className): ?ClassMetadataInfo { - $objectManager = $this->getObjectManager(); - if ($objectManager === null) { - return null; - } - if ($this->isTransient($className)) { return null; } + $objectManager = $this->getObjectManager(); + try { - $metadata = $objectManager->getClassMetadata($className); + if ($objectManager === null) { + $metadata = $this->getMetadataFactory()->getMetadataFor($className); + } else { + $metadata = $objectManager->getClassMetadata($className); + } } catch (\Doctrine\ORM\Mapping\MappingException $e) { return null; } diff --git a/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderIntegrationTest.php b/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderIntegrationTest.php new file mode 100644 index 00000000..39dff5eb --- /dev/null +++ b/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderIntegrationTest.php @@ -0,0 +1,38 @@ +createReflectionProvider(), __DIR__ . '/entity-manager.php', null), + new ObjectMetadataResolver($this->createReflectionProvider(), $this->objectManagerLoader, null), new DescriptorRegistry([ new ArrayType(), new BigIntType(), @@ -77,9 +80,24 @@ protected function getRule(): Rule ); } - public function testRule(): void + /** + * @return array + */ + public function dataObjectManagerLoader(): array + { + return [ + [__DIR__ . '/entity-manager.php'], + [null], + ]; + } + + /** + * @dataProvider dataObjectManagerLoader + */ + public function testRule(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/MyBrokenEntity.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$id type mapping mismatch: database can contain string but property expects int|null.', @@ -140,9 +158,13 @@ public function testRule(): void ]); } - public function testRuleWithAllowedNullableProperty(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testRuleWithAllowedNullableProperty(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = true; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/MyBrokenEntity.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$id type mapping mismatch: database can contain string but property expects int|null.', @@ -191,15 +213,23 @@ public function testRuleWithAllowedNullableProperty(): void ]); } - public function testRuleOnMyEntity(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testRuleOnMyEntity(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/MyEntity.php'], []); } - public function testSuperclass(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testSuperclass(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/MyBrokenSuperclass.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenSuperclass::$five type mapping mismatch: database can contain resource but property expects int.', @@ -213,9 +243,10 @@ public function testSuperclass(): void * @param string $file * @param mixed[] $expectedErrors */ - public function testGeneratedIds(string $file, array $expectedErrors): void + public function testGeneratedIds(string $file, array $expectedErrors, ?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([$file], $expectedErrors); } @@ -224,7 +255,8 @@ public function testGeneratedIds(string $file, array $expectedErrors): void */ public function generatedIdsProvider(): Iterator { - yield 'not nullable' => [__DIR__ . '/data/GeneratedIdEntity1.php', []]; + yield 'not nullable' => [__DIR__ . '/data/GeneratedIdEntity1.php', [], __DIR__ . '/entity-manager.php']; + yield 'not nullable 2' => [__DIR__ . '/data/GeneratedIdEntity1.php', [], null]; yield 'nullable column' => [ __DIR__ . '/data/GeneratedIdEntity2.php', [ @@ -233,22 +265,47 @@ public function generatedIdsProvider(): Iterator 19, ], ], + __DIR__ . '/entity-manager.php', ]; - yield 'nullable property' => [__DIR__ . '/data/GeneratedIdEntity3.php', []]; - yield 'nullable both' => [__DIR__ . '/data/GeneratedIdEntity4.php', []]; - yield 'composite' => [__DIR__ . '/data/CompositePrimaryKeyEntity1.php', []]; - yield 'no generated value 1' => [__DIR__ . '/data/GeneratedIdEntity5.php', []]; + yield 'nullable column 2' => [ + __DIR__ . '/data/GeneratedIdEntity2.php', + [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\GeneratedIdEntity2::$id type mapping mismatch: database can contain string|null but property expects string.', + 19, + ], + ], + null, + ]; + yield 'nullable property' => [__DIR__ . '/data/GeneratedIdEntity3.php', [], __DIR__ . '/entity-manager.php']; + yield 'nullable property 2' => [__DIR__ . '/data/GeneratedIdEntity3.php', [], null]; + yield 'nullable both' => [__DIR__ . '/data/GeneratedIdEntity4.php', [], __DIR__ . '/entity-manager.php']; + yield 'nullable both 2' => [__DIR__ . '/data/GeneratedIdEntity4.php', [], null]; + yield 'composite' => [__DIR__ . '/data/CompositePrimaryKeyEntity1.php', [], __DIR__ . '/entity-manager.php']; + yield 'composite 2' => [__DIR__ . '/data/CompositePrimaryKeyEntity1.php', [], null]; + yield 'no generated value 1' => [__DIR__ . '/data/GeneratedIdEntity5.php', [], __DIR__ . '/entity-manager.php']; + yield 'no generated value 1 2' => [__DIR__ . '/data/GeneratedIdEntity5.php', [], null]; yield 'no generated value 2' => [__DIR__ . '/data/GeneratedIdEntity6.php', [ [ 'Property PHPStan\Rules\Doctrine\ORM\GeneratedIdEntity6::$id type mapping mismatch: property can contain int|null but database expects int.', 18, ], - ]]; + ], __DIR__ . '/entity-manager.php']; + yield 'no generated value 2 2' => [__DIR__ . '/data/GeneratedIdEntity6.php', [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\GeneratedIdEntity6::$id type mapping mismatch: property can contain int|null but database expects int.', + 18, + ], + ], null]; } - public function testCustomType(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testCustomType(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/EntityWithCustomType.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\EntityWithCustomType::$foo type mapping mismatch: database can contain DateTimeInterface but property expects int.', @@ -265,9 +322,13 @@ public function testCustomType(): void ]); } - public function testUnknownType(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testUnknownType(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data/EntityWithUnknownType.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\EntityWithUnknownType::$foo: Doctrine type "unknown" does not have any registered descriptor.', @@ -276,13 +337,17 @@ public function testUnknownType(): void ]); } - public function testEnumType(): void + /** + * @dataProvider dataObjectManagerLoader + */ + public function testEnumType(?string $objectManagerLoader): void { if (PHP_VERSION_ID < 80100) { self::markTestSkipped('Test requires PHP 8.1.'); } $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; $this->analyse([__DIR__ . '/data-attributes/enum-type.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type2 type mapping mismatch: database can contain PHPStan\Rules\Doctrine\ORMAttributes\FooEnum but property expects PHPStan\Rules\Doctrine\ORMAttributes\BarEnum.', diff --git a/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php b/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php index feb775c9..bdddb44d 100644 --- a/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php @@ -13,10 +13,13 @@ class EntityNotFinalRuleTest extends RuleTestCase { + /** @var string|null */ + private $objectManagerLoader; + protected function getRule(): Rule { return new EntityNotFinalRule( - new ObjectMetadataResolver($this->createReflectionProvider(), __DIR__ . '/entity-manager.php', null) + new ObjectMetadataResolver($this->createReflectionProvider(), $this->objectManagerLoader, null) ); } @@ -27,6 +30,18 @@ protected function getRule(): Rule */ public function testRule(string $file, array $expectedErrors): void { + $this->objectManagerLoader = __DIR__ . '/entity-manager.php'; + $this->analyse([$file], $expectedErrors); + } + + /** + * @dataProvider ruleProvider + * @param string $file + * @param mixed[] $expectedErrors + */ + public function testRuleWithoutObjectManagerLoader(string $file, array $expectedErrors): void + { + $this->objectManagerLoader = null; $this->analyse([$file], $expectedErrors); } diff --git a/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php index 03a65513..15be62e2 100644 --- a/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php @@ -16,10 +16,13 @@ class EntityRelationRuleTest extends RuleTestCase /** @var bool */ private $allowNullablePropertyForRequiredField; + /** @var string|null */ + private $objectManagerLoader; + protected function getRule(): Rule { return new EntityRelationRule( - new ObjectMetadataResolver($this->createReflectionProvider(), __DIR__ . '/entity-manager.php', null), + new ObjectMetadataResolver($this->createReflectionProvider(), $this->objectManagerLoader, null), $this->allowNullablePropertyForRequiredField ); } @@ -32,6 +35,19 @@ protected function getRule(): Rule public function testRule(string $file, array $expectedErrors): void { $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = __DIR__ . '/entity-manager.php'; + $this->analyse([$file], $expectedErrors); + } + + /** + * @dataProvider ruleProvider + * @param string $file + * @param mixed[] $expectedErrors + */ + public function testRuleWithoutObjectManagerLoader(string $file, array $expectedErrors): void + { + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = null; $this->analyse([$file], $expectedErrors); } @@ -167,6 +183,19 @@ public function ruleProvider(): Iterator public function testRuleWithAllowedNullableProperty(string $file, array $expectedErrors): void { $this->allowNullablePropertyForRequiredField = true; + $this->objectManagerLoader = __DIR__ . '/entity-manager.php'; + $this->analyse([$file], $expectedErrors); + } + + /** + * @dataProvider ruleWithAllowedNullablePropertyProvider + * @param string $file + * @param mixed[] $expectedErrors + */ + public function testRuleWithAllowedNullablePropertyWithoutObjectManagerLoader(string $file, array $expectedErrors): void + { + $this->allowNullablePropertyForRequiredField = true; + $this->objectManagerLoader = null; $this->analyse([$file], $expectedErrors); } diff --git a/tests/Rules/Doctrine/ORM/RepositoryMethodCallRuleWithoutObjectManagerLoaderTest.php b/tests/Rules/Doctrine/ORM/RepositoryMethodCallRuleWithoutObjectManagerLoaderTest.php new file mode 100644 index 00000000..1997453d --- /dev/null +++ b/tests/Rules/Doctrine/ORM/RepositoryMethodCallRuleWithoutObjectManagerLoaderTest.php @@ -0,0 +1,82 @@ + + */ +class RepositoryMethodCallRuleWithoutObjectManagerLoaderTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new RepositoryMethodCallRule(new ObjectMetadataResolver($this->createReflectionProvider(), null, null)); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/magic-repository-no-object-manager-loader.neon']; + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/repository-findBy-etc.php'], [ + [ + 'Call to method Doctrine\ORM\EntityRepository::findBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 23, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 24, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 25, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 25, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findOneBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 33, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findOneBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 34, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findOneBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 35, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::findOneBy() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 35, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::count() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 43, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::count() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 44, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::count() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $nonexistent.', + 45, + ], + [ + 'Call to method Doctrine\ORM\EntityRepository::count() - entity PHPStan\Rules\Doctrine\ORM\MyEntity does not have a field named $transient.', + 45, + ], + ]); + } + +} diff --git a/tests/Rules/Doctrine/ORM/magic-repository-no-object-manager-loader.neon b/tests/Rules/Doctrine/ORM/magic-repository-no-object-manager-loader.neon new file mode 100644 index 00000000..a2c8131d --- /dev/null +++ b/tests/Rules/Doctrine/ORM/magic-repository-no-object-manager-loader.neon @@ -0,0 +1,2 @@ +includes: + - ../../../../extension.neon