diff --git a/rules.neon b/rules.neon index 63eaa60f..f38ecafe 100644 --- a/rules.neon +++ b/rules.neon @@ -18,6 +18,7 @@ rules: - PHPStan\Rules\Doctrine\ORM\MagicRepositoryMethodCallRule - PHPStan\Rules\Doctrine\ORM\RepositoryMethodCallRule - PHPStan\Rules\Doctrine\ORM\EntityColumnRule + - PHPStan\Rules\Doctrine\ORM\EntityRelationRule services: - diff --git a/src/Rules/Doctrine/ORM/EntityRelationRule.php b/src/Rules/Doctrine/ORM/EntityRelationRule.php new file mode 100644 index 00000000..0af79c85 --- /dev/null +++ b/src/Rules/Doctrine/ORM/EntityRelationRule.php @@ -0,0 +1,100 @@ +objectMetadataResolver = $objectMetadataResolver; + } + + public function getNodeType(): string + { + return Node\Stmt\PropertyProperty::class; + } + + /** + * @param \PhpParser\Node\Stmt\PropertyProperty $node + * @param \PHPStan\Analyser\Scope $scope + * @return string[] + */ + public function processNode(Node $node, Scope $scope): array + { + $class = $scope->getClassReflection(); + if ($class === null) { + return []; + } + + $objectManager = $this->objectMetadataResolver->getObjectManager(); + if ($objectManager === null) { + return []; + } + + $className = $class->getName(); + if ($objectManager->getMetadataFactory()->isTransient($className)) { + return []; + } + + /** @var \Doctrine\ORM\Mapping\ClassMetadataInfo $metadata */ + $metadata = $objectManager->getClassMetadata($className); + $classMetadataInfo = 'Doctrine\ORM\Mapping\ClassMetadataInfo'; + if (!$metadata instanceof $classMetadataInfo) { + return []; + } + + $propertyName = (string) $node->name; + try { + $property = $class->getNativeProperty($propertyName); + } catch (MissingPropertyFromReflectionException $e) { + return []; + } + + if (!isset($metadata->associationMappings[$propertyName])) { + return []; + } + $associationMapping = $metadata->associationMappings[$propertyName]; + + $columnType = null; + if ((bool) ($associationMapping['type'] & 3)) { // ClassMetadataInfo::TO_ONE + $columnType = new ObjectType($associationMapping['targetEntity']); + if ($associationMapping['joinColumns'][0]['nullable'] ?? true) { + $columnType = TypeCombinator::addNull($columnType); + } + } elseif ((bool) ($associationMapping['type'] & 12)) { // ClassMetadataInfo::TO_MANY + $columnType = TypeCombinator::intersect( + new ObjectType('Doctrine\Common\Collections\Collection'), + new IterableType(new MixedType(), new ObjectType($associationMapping['targetEntity'])) + ); + } + + $errors = []; + if ($columnType !== null) { + if (!$property->getWritableType()->isSuperTypeOf($columnType)->yes()) { + $errors[] = sprintf('Database can contain %s but property expects %s.', $columnType->describe(VerbosityLevel::typeOnly()), $property->getWritableType()->describe(VerbosityLevel::typeOnly())); + } + if (!$columnType->isSuperTypeOf($property->getReadableType())->yes()) { + $errors[] = sprintf('Property can contain %s but database expects %s.', $property->getReadableType()->describe(VerbosityLevel::typeOnly()), $columnType->describe(VerbosityLevel::typeOnly())); + } + } + + return $errors; + } + +} diff --git a/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php new file mode 100644 index 00000000..e2078842 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php @@ -0,0 +1,113 @@ +analyse([$file], $expectedErrors); + } + + public function ruleProvider(): Iterator + { + yield 'nice entity' => [__DIR__ . '/data/EntityWithRelations.php', []]; + + yield 'one to one' => [__DIR__ . '/data/EntityWithBrokenOneToOneRelations.php', + [ + [ + 'Property can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but database expects PHPStan\Rules\Doctrine\ORM\AnotherEntity.', + 31, + ], + [ + 'Database can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but property expects PHPStan\Rules\Doctrine\ORM\AnotherEntity.', + 37, + ], + [ + 'Database can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but property expects PHPStan\Rules\Doctrine\ORM\MyEntity|null.', + 50, + ], + [ + 'Property can contain PHPStan\Rules\Doctrine\ORM\MyEntity|null but database expects PHPStan\Rules\Doctrine\ORM\AnotherEntity|null.', + 50, + ], + ]]; + + yield 'many to one' => [__DIR__ . '/data/EntityWithBrokenManyToOneRelations.php', + [ + [ + 'Property can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but database expects PHPStan\Rules\Doctrine\ORM\AnotherEntity.', + 31, + ], + [ + 'Database can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but property expects PHPStan\Rules\Doctrine\ORM\AnotherEntity.', + 37, + ], + [ + 'Database can contain PHPStan\Rules\Doctrine\ORM\AnotherEntity|null but property expects PHPStan\Rules\Doctrine\ORM\MyEntity|null.', + 50, + ], + [ + 'Property can contain PHPStan\Rules\Doctrine\ORM\MyEntity|null but database expects PHPStan\Rules\Doctrine\ORM\AnotherEntity|null.', + 50, + ], + ]]; + + yield 'one to many' => [__DIR__ . '/data/EntityWithBrokenOneToManyRelations.php', + [ + [ + 'Property can contain iterable but database expects Doctrine\Common\Collections\Collection&iterable.', + 24, + ], + [ + 'Property can contain Doctrine\Common\Collections\Collection but database expects Doctrine\Common\Collections\Collection&iterable.', + 30, + ], + [ + 'Database can contain Doctrine\Common\Collections\Collection&iterable but property expects array.', + 36, + ], + [ + 'Property can contain array but database expects Doctrine\Common\Collections\Collection&iterable.', + 36, + ], + ]]; + + yield 'many to many' => [__DIR__ . '/data/EntityWithBrokenManyToManyRelations.php', + [ + [ + 'Property can contain iterable but database expects Doctrine\Common\Collections\Collection&iterable.', + 24, + ], + [ + 'Property can contain Doctrine\Common\Collections\Collection but database expects Doctrine\Common\Collections\Collection&iterable.', + 30, + ], + [ + 'Database can contain Doctrine\Common\Collections\Collection&iterable but property expects array.', + 36, + ], + [ + 'Property can contain array but database expects Doctrine\Common\Collections\Collection&iterable.', + 36, + ], + ]]; + } + +} diff --git a/tests/Rules/Doctrine/ORM/data/AnotherEntity.php b/tests/Rules/Doctrine/ORM/data/AnotherEntity.php new file mode 100644 index 00000000..70c91ee8 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/AnotherEntity.php @@ -0,0 +1,50 @@ + + */ + private $manyToManyWithIterableAnnotation; + + /** + * @ORM\ManyToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity") + * @var \Doctrine\Common\Collections\Collection + */ + private $manyToManyWithCollectionAnnotation; + + /** + * @ORM\ManyToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity") + * @var \PHPStan\Rules\Doctrine\ORM\AnotherEntity[] + */ + private $manyToManyWithArrayAnnotation; + + /** + * @ORM\ManyToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity") + * @var \Doctrine\Common\Collections\Collection&iterable<\PHPStan\Rules\Doctrine\ORM\AnotherEntity> + */ + private $manyToManyWithCorrectAnnotation; + + /** + * @ORM\ManyToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity") + * @var \Doctrine\Common\Collections\Collection|\PHPStan\Rules\Doctrine\ORM\AnotherEntity[] + */ + private $manyToManyWithCorrectOldStyleAnnotation; + +} diff --git a/tests/Rules/Doctrine/ORM/data/EntityWithBrokenManyToOneRelations.php b/tests/Rules/Doctrine/ORM/data/EntityWithBrokenManyToOneRelations.php new file mode 100644 index 00000000..c97d4567 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/EntityWithBrokenManyToOneRelations.php @@ -0,0 +1,52 @@ + + */ + private $oneToManyWithIterableAnnotation; + + /** + * @ORM\OneToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity", mappedBy="two") + * @var \Doctrine\Common\Collections\Collection + */ + private $oneToManyWithCollectionAnnotation; + + /** + * @ORM\OneToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity", mappedBy="three") + * @var \PHPStan\Rules\Doctrine\ORM\AnotherEntity[] + */ + private $oneToManyWithArrayAnnotation; + + /** + * @ORM\OneToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity", mappedBy="four") + * @var \Doctrine\Common\Collections\Collection&iterable<\PHPStan\Rules\Doctrine\ORM\AnotherEntity> + */ + private $oneToManyWithCorrectAnnotation; + + /** + * @ORM\OneToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity", mappedBy="five") + * @var \Doctrine\Common\Collections\Collection|\PHPStan\Rules\Doctrine\ORM\AnotherEntity[] + */ + private $oneToManyWithCorrectOldStyleAnnotation; + +} diff --git a/tests/Rules/Doctrine/ORM/data/EntityWithBrokenOneToOneRelations.php b/tests/Rules/Doctrine/ORM/data/EntityWithBrokenOneToOneRelations.php new file mode 100644 index 00000000..4605e98e --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/EntityWithBrokenOneToOneRelations.php @@ -0,0 +1,52 @@ + + */ + private $oneToMany; + + /** + * @ORM\ManyToMany(targetEntity="PHPStan\Rules\Doctrine\ORM\AnotherEntity") + * @var \Doctrine\Common\Collections\Collection&iterable<\PHPStan\Rules\Doctrine\ORM\AnotherEntity> + */ + private $manyToMany; + +}