diff --git a/extension.neon b/extension.neon index 046d05d6..cc79c19f 100644 --- a/extension.neon +++ b/extension.neon @@ -156,6 +156,20 @@ services: descriptorRegistry: @doctrineTypeDescriptorRegistry tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension + arguments: + metadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver + descriptorRegistry: @doctrineTypeDescriptorRegistry + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtension + arguments: + metadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver + descriptorRegistry: @doctrineTypeDescriptorRegistry + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: PHPStan\Type\Doctrine\Query\QueryResultDynamicReturnTypeExtension tags: diff --git a/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php new file mode 100644 index 00000000..280d9a4e --- /dev/null +++ b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php @@ -0,0 +1,186 @@ +metadataResolver = $metadataResolver; + $this->descriptorRegistry = $descriptorRegistry; + } + + public function getClass(): string + { + return UnitOfWork::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getEntityChangeSet'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type + { + if (count($methodCall->getArgs()) === 0) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $entityType = $scope->getType($methodCall->getArgs()[0]->value); + $objectClassNames = $entityType->getObjectClassNames(); + + if (count($objectClassNames) === 0) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $changeSetTypes = []; + + foreach ($objectClassNames as $className) { + $metadata = $this->metadataResolver->getClassMetadata($className); + if ($metadata === null) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $changeSetTypes[] = $this->createChangeSetType($metadata); + } + + return TypeCombinator::union(...$changeSetTypes); + } + + private function createChangeSetType(ClassMetadata $metadata): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $collectionType = new ObjectType(PersistentCollection::class); + + foreach ($metadata->fieldMappings as $fieldName => $mapping) { + if ($metadata->isIdentifier($fieldName)) { + continue; + } + + if (!isset($mapping['type'])) { + continue; + } + + try { + $type = $this->descriptorRegistry->get($mapping['type'])->getWritableToPropertyType(); + } catch (DescriptorNotRegisteredException $exception) { + $type = new MixedType(); + } + + if (($mapping['nullable'] ?? false) === true) { + $type = TypeCombinator::addNull($type); + } + + $fieldBuilder = ConstantArrayTypeBuilder::createEmpty(); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $type); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $type); + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $fieldBuilder->getArray(), + ); + } + + foreach ($metadata->associationMappings as $fieldName => $mapping) { + if (($mapping['type'] & ClassMetadata::TO_ONE) !== 0) { + $targetEntity = $mapping['targetEntity'] ?? null; + if (!is_string($targetEntity)) { + continue; + } + + $type = new ObjectType($targetEntity); + if ($this->isAssociationNullable($mapping)) { + $type = TypeCombinator::addNull($type); + } + + $fieldBuilder = ConstantArrayTypeBuilder::createEmpty(); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $type); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $type); + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $fieldBuilder->getArray(), + ); + continue; + } + + if (($mapping['type'] & ClassMetadata::TO_MANY) === 0) { + continue; + } + + $fieldBuilder = ConstantArrayTypeBuilder::createEmpty(); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $collectionType); + $fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $collectionType); + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $fieldBuilder->getArray(), + ); + } + + return $builder->getArray(); + } + + /** + * @param array $association + */ + private function isAssociationNullable(array $association): bool + { + $joinColumns = $association['joinColumns'] ?? null; + if (!is_array($joinColumns)) { + return true; + } + + foreach ($joinColumns as $joinColumn) { + if (!is_array($joinColumn)) { + continue; + } + if (($joinColumn['nullable'] ?? true) === false) { + return false; + } + } + + return true; + } + + private function getDefaultReturnType(MethodReflection $methodReflection, Scope $scope, MethodCall $methodCall): Type + { + return ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + } + +} diff --git a/src/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtension.php b/src/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtension.php new file mode 100644 index 00000000..472c661d --- /dev/null +++ b/src/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtension.php @@ -0,0 +1,177 @@ +metadataResolver = $metadataResolver; + $this->descriptorRegistry = $descriptorRegistry; + } + + public function getClass(): string + { + return UnitOfWork::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getOriginalEntityData'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type + { + if (count($methodCall->getArgs()) === 0) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $entityType = $scope->getType($methodCall->getArgs()[0]->value); + $objectClassNames = $entityType->getObjectClassNames(); + + if (count($objectClassNames) === 0) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $dataTypes = []; + + foreach ($objectClassNames as $className) { + $metadata = $this->metadataResolver->getClassMetadata($className); + if ($metadata === null) { + return $this->getDefaultReturnType($methodReflection, $scope, $methodCall); + } + + $dataTypes[] = $this->createOriginalEntityDataType($metadata); + } + + return TypeCombinator::union(...$dataTypes); + } + + private function createOriginalEntityDataType(ClassMetadata $metadata): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $collectionType = new ObjectType(PersistentCollection::class); + + foreach ($metadata->fieldMappings as $fieldName => $mapping) { + if ($metadata->isIdentifier($fieldName) && $metadata->isIdGeneratorIdentity()) { + continue; + } + + if ($metadata->versionField === $fieldName) { + continue; + } + + if (!isset($mapping['type'])) { + continue; + } + + try { + $type = $this->descriptorRegistry->get($mapping['type'])->getWritableToPropertyType(); + } catch (DescriptorNotRegisteredException $exception) { + $type = new MixedType(); + } + + if (($mapping['nullable'] ?? false) === true) { + $type = TypeCombinator::addNull($type); + } + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $type, + ); + } + + foreach ($metadata->associationMappings as $fieldName => $mapping) { + if (($mapping['type'] & ClassMetadata::TO_ONE) !== 0) { + $targetEntity = $mapping['targetEntity'] ?? null; + if (!is_string($targetEntity)) { + continue; + } + + $type = new ObjectType($targetEntity); + if ($this->isAssociationNullable($mapping)) { + $type = TypeCombinator::addNull($type); + } + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $type, + ); + continue; + } + + if (($mapping['type'] & ClassMetadata::TO_MANY) === 0) { + continue; + } + + $builder->setOffsetValueType( + new ConstantStringType($fieldName), + $collectionType, + ); + } + + return $builder->getArray(); + } + + /** + * @param array $association + */ + private function isAssociationNullable(array $association): bool + { + $joinColumns = $association['joinColumns'] ?? null; + if (!is_array($joinColumns)) { + return true; + } + + foreach ($joinColumns as $joinColumn) { + if (!is_array($joinColumn)) { + continue; + } + if (($joinColumn['nullable'] ?? true) === false) { + return false; + } + } + + return true; + } + + private function getDefaultReturnType(MethodReflection $methodReflection, Scope $scope, MethodCall $methodCall): Type + { + return ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); + } + +} diff --git a/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..14405a9c --- /dev/null +++ b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php @@ -0,0 +1,35 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWork/unitOfWork-change-set.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/UnitOfWork/config.neon']; + } + +} diff --git a/tests/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..0ff5dc29 --- /dev/null +++ b/tests/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtensionTest.php @@ -0,0 +1,35 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWork/unitOfWork-original-entity-data.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/UnitOfWork/config.neon']; + } + +} diff --git a/tests/Type/Doctrine/data/UnitOfWork/config.neon b/tests/Type/Doctrine/data/UnitOfWork/config.neon new file mode 100644 index 00000000..6e338295 --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWork/config.neon @@ -0,0 +1,6 @@ +includes: + - ../../../../../extension.neon +parameters: + doctrine: + objectManagerLoader: entity-manager.php + diff --git a/tests/Type/Doctrine/data/UnitOfWork/entity-manager.php b/tests/Type/Doctrine/data/UnitOfWork/entity-manager.php new file mode 100644 index 00000000..bb2f681d --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWork/entity-manager.php @@ -0,0 +1,3 @@ +getEntityChangeSet($entity) + ); + } + + public function associations(UnitOfWork $unitOfWork, Many $entity): void + { + assertType( + 'array{intColumn: array{int, int}, stringColumn: array{string, string}, stringNullColumn: array{string|null, string|null}, datetimeColumn: array{DateTime, DateTime}, datetimeImmutableColumn: array{DateTimeImmutable, DateTimeImmutable}, simpleArrayColumn: array{list, list}, one: array{QueryResult\\Entities\\One, QueryResult\\Entities\\One}, oneNull: array{QueryResult\\Entities\\One|null, QueryResult\\Entities\\One|null}, oneDefaultNullability: array{QueryResult\\Entities\\One|null, QueryResult\\Entities\\One|null}, compoundPk: array{QueryResult\\Entities\\CompoundPk|null, QueryResult\\Entities\\CompoundPk|null}, compoundPkAssoc: array{QueryResult\\Entities\\CompoundPkAssoc|null, QueryResult\\Entities\\CompoundPkAssoc|null}}', + $unitOfWork->getEntityChangeSet($entity) + ); + } + + public function persistentCollection(UnitOfWork $unitOfWork, One $entity): void + { + $changeSet = $unitOfWork->getEntityChangeSet($entity); + assertType('array{Doctrine\\ORM\\PersistentCollection, Doctrine\\ORM\\PersistentCollection}', $changeSet['manies']); + } + + public function unknownEntity(UnitOfWork $unitOfWork, object $entity): void + { + assertType( + 'array', + $unitOfWork->getEntityChangeSet($entity) + ); + } +} diff --git a/tests/Type/Doctrine/data/UnitOfWork/unitOfWork-original-entity-data.php b/tests/Type/Doctrine/data/UnitOfWork/unitOfWork-original-entity-data.php new file mode 100644 index 00000000..abbdd200 --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWork/unitOfWork-original-entity-data.php @@ -0,0 +1,42 @@ +getOriginalEntityData($entity) + ); + } + + public function associations(UnitOfWork $unitOfWork, Many $entity): void + { + assertType( + 'array{id: lowercase-string&numeric-string&uppercase-string, intColumn: int, stringColumn: string, stringNullColumn: string|null, datetimeColumn: DateTime, datetimeImmutableColumn: DateTimeImmutable, simpleArrayColumn: list, one: QueryResult\\Entities\\One, oneNull: QueryResult\\Entities\\One|null, oneDefaultNullability: QueryResult\\Entities\\One|null, compoundPk: QueryResult\\Entities\\CompoundPk|null, compoundPkAssoc: QueryResult\\Entities\\CompoundPkAssoc|null}', + $unitOfWork->getOriginalEntityData($entity) + ); + } + + public function persistentCollection(UnitOfWork $unitOfWork, One $entity): void + { + $originalData = $unitOfWork->getOriginalEntityData($entity); + assertType('Doctrine\\ORM\\PersistentCollection', $originalData['manies']); + } + + public function unknownEntity(UnitOfWork $unitOfWork, object $entity): void + { + assertType( + 'array', + $unitOfWork->getOriginalEntityData($entity) + ); + } +}