diff --git a/extension.neon b/extension.neon index 79e73a3a..3c825c9b 100644 --- a/extension.neon +++ b/extension.neon @@ -25,6 +25,12 @@ conditionalTags: phpstan.broker.methodsClassReflectionExtension: %doctrine.allCollectionsSelectable% services: + - + class: PHPStan\Type\Doctrine\DescriptorRegistryFactory + - + class: PHPStan\Type\Doctrine\DescriptorRegistry + factory: @PHPStan\Type\Doctrine\DescriptorRegistryFactory::createRegistry + - class: PHPStan\Reflection\Doctrine\DoctrineSelectableClassReflectionExtension - @@ -177,3 +183,77 @@ services: arguments: class: Doctrine\ORM\Query\Expr argumentsProcessor: @doctrineQueryBuilderArgumentsProcessor + + # Type descriptors + - + class: PHPStan\Type\Doctrine\Descriptors\ArrayType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\BigIntType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\BinaryType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\BlobType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateImmutableType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateIntervalType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateTimeImmutableType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateTimeType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateTimeTzImmutableType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateTimeTzType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DateType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\DecimalType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\FloatType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\GuidType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\IntegerType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\JsonArrayType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\JsonType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\ObjectType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\SimpleArrayType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\SmallIntType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\StringType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\TextType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\TimeImmutableType + tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\TimeType + tags: [phpstan.doctrine.typeDescriptor] diff --git a/rules.neon b/rules.neon index 10f27997..63eaa60f 100644 --- a/rules.neon +++ b/rules.neon @@ -17,6 +17,7 @@ rules: - PHPStan\Rules\Doctrine\ORM\DqlRule - PHPStan\Rules\Doctrine\ORM\MagicRepositoryMethodCallRule - PHPStan\Rules\Doctrine\ORM\RepositoryMethodCallRule + - PHPStan\Rules\Doctrine\ORM\EntityColumnRule services: - diff --git a/src/Rules/Doctrine/ORM/EntityColumnRule.php b/src/Rules/Doctrine/ORM/EntityColumnRule.php new file mode 100644 index 00000000..d909b3c1 --- /dev/null +++ b/src/Rules/Doctrine/ORM/EntityColumnRule.php @@ -0,0 +1,100 @@ +objectMetadataResolver = $objectMetadataResolver; + $this->descriptorRegistry = $descriptorRegistry; + } + + 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->fieldMappings[$propertyName])) { + return []; + } + $fieldMapping = $metadata->fieldMappings[$propertyName]; + + $errors = []; + try { + $descriptor = $this->descriptorRegistry->get($fieldMapping['type']); + } catch (DescriptorNotRegisteredException $e) { + return []; + } + + $writableToPropertyType = $descriptor->getWritableToPropertyType(); + $writableToDatabaseType = $descriptor->getWritableToDatabaseType(); + if ($fieldMapping['nullable'] === true) { + $writableToPropertyType = TypeCombinator::addNull($writableToPropertyType); + $writableToDatabaseType = TypeCombinator::addNull($writableToDatabaseType); + } + + if (!$property->getWritableType()->isSuperTypeOf($writableToPropertyType)->yes()) { + $errors[] = sprintf('Database can contain %s but property expects %s.', $writableToPropertyType->describe(VerbosityLevel::typeOnly()), $property->getWritableType()->describe(VerbosityLevel::typeOnly())); + } + if (!$writableToDatabaseType->isSuperTypeOf($property->getReadableType())->yes()) { + $errors[] = sprintf('Property can contain %s but database expects %s.', $property->getReadableType()->describe(VerbosityLevel::typeOnly()), $writableToDatabaseType->describe(VerbosityLevel::typeOnly())); + } + return $errors; + } + +} diff --git a/src/Type/Doctrine/DescriptorNotRegisteredException.php b/src/Type/Doctrine/DescriptorNotRegisteredException.php new file mode 100644 index 00000000..5077fe1b --- /dev/null +++ b/src/Type/Doctrine/DescriptorNotRegisteredException.php @@ -0,0 +1,8 @@ + */ + private $descriptors = []; + + /** + * @param \PHPStan\Type\Doctrine\Descriptors\DoctrineTypeDescriptor[] $descriptors + */ + public function __construct(array $descriptors) + { + foreach ($descriptors as $descriptor) { + $this->descriptors[$descriptor->getType()] = $descriptor; + } + } + + public function get(string $type): DoctrineTypeDescriptor + { + if (!isset($this->descriptors[$type])) { + throw new \PHPStan\Type\Doctrine\DescriptorNotRegisteredException(); + } + return $this->descriptors[$type]; + } + +} diff --git a/src/Type/Doctrine/DescriptorRegistryFactory.php b/src/Type/Doctrine/DescriptorRegistryFactory.php new file mode 100644 index 00000000..5506d10c --- /dev/null +++ b/src/Type/Doctrine/DescriptorRegistryFactory.php @@ -0,0 +1,25 @@ +container = $container; + } + + public function createRegistry(): DescriptorRegistry + { + return new DescriptorRegistry($this->container->getServicesByTag(self::TYPE_DESCRIPTOR_TAG)); + } + +} diff --git a/src/Type/Doctrine/Descriptors/ArrayType.php b/src/Type/Doctrine/Descriptors/ArrayType.php new file mode 100644 index 00000000..0a8f0697 --- /dev/null +++ b/src/Type/Doctrine/Descriptors/ArrayType.php @@ -0,0 +1,26 @@ +analyse([__DIR__ . '/data/MyBrokenEntity.php'], [ + [ + 'Database can contain string but property expects int.', + 17, + ], + [ + 'Database can contain string|null but property expects string.', + 23, + ], + [ + 'Property can contain string|null but database expects string.', + 29, + ], + [ + 'Database can contain DateTime but property expects DateTimeImmutable.', + 35, + ], + [ + 'Database can contain DateTimeImmutable but property expects DateTime.', + 41, + ], + [ + 'Property can contain DateTime but database expects DateTimeImmutable.', + 41, + ], + ]); + } + + public function testSuperclass(): void + { + $this->analyse([__DIR__ . '/data/MyBrokenSuperclass.php'], [ + [ + 'Database can contain resource but property expects int.', + 17, + ], + ]); + } + +} diff --git a/tests/Rules/Doctrine/ORM/data/MyBrokenEntity.php b/tests/Rules/Doctrine/ORM/data/MyBrokenEntity.php new file mode 100644 index 00000000..42cea9fb --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/MyBrokenEntity.php @@ -0,0 +1,43 @@ +