From 3f2e233e48243d5bfffe9f6d24e094515ce9a75f Mon Sep 17 00:00:00 2001 From: Nikita Starshinov Date: Fri, 10 Oct 2025 16:11:13 +0300 Subject: [PATCH 1/3] [WIP] UnitOfWork::getEntityChangeSet array shape --- extension.neon | 7 + ...ityChangeSetDynamicReturnTypeExtension.php | 187 ++++++++++++++++++ ...hangeSetDynamicReturnTypeExtensionTest.php | 36 ++++ .../Entities/RelatedEntity.php | 51 +++++ .../Entities/SimpleEntity.php | 99 ++++++++++ .../data/UnitOfWorkChangeSet/config.neon | 6 + .../UnitOfWorkChangeSet/entity-manager.php | 26 +++ .../unitOfWork-change-set.php | 26 +++ 8 files changed, 438 insertions(+) create mode 100644 src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php create mode 100644 tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php create mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php create mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php create mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/config.neon create mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php create mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php diff --git a/extension.neon b/extension.neon index 046d05d6..39b85042 100644 --- a/extension.neon +++ b/extension.neon @@ -156,6 +156,13 @@ 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\Query\QueryResultDynamicReturnTypeExtension tags: diff --git a/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php new file mode 100644 index 00000000..4b33b22d --- /dev/null +++ b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php @@ -0,0 +1,187 @@ +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/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..5256137d --- /dev/null +++ b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php @@ -0,0 +1,36 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWorkChangeSet/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/UnitOfWorkChangeSet/config.neon']; + } + +} + diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php new file mode 100644 index 00000000..7fc63fd1 --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php @@ -0,0 +1,51 @@ +parent = $parent; + } + + public static function loadMetadata(ClassMetadata $metadata): void + { + $metadata->setPrimaryTable(['name' => 'related_entities']); + $metadata->mapField([ + 'fieldName' => 'id', + 'type' => 'integer', + 'id' => true, + ]); + $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + $metadata->mapManyToOne([ + 'fieldName' => 'parent', + 'targetEntity' => SimpleEntity::class, + 'inversedBy' => 'relatedCollection', + 'joinColumns' => [[ + 'name' => 'parent_id', + 'referencedColumnName' => 'id', + 'nullable' => true, + ]], + ]); + } + +} diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php new file mode 100644 index 00000000..24459e2a --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php @@ -0,0 +1,99 @@ + + */ + #[ORM\OneToMany(targetEntity: RelatedEntity::class, mappedBy: 'parent')] + private Collection $relatedCollection; + + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private int $id; + + #[ORM\Column(type: 'integer')] + private int $foo = 0; + + #[ORM\Column(type: 'integer', nullable: true)] + private ?int $nullableFoo = null; + + #[ORM\ManyToOne(targetEntity: RelatedEntity::class)] + #[ORM\JoinColumn(nullable: true)] + private ?RelatedEntity $related = null; + + public function __construct() + { + $this->relatedCollection = new ArrayCollection(); + } + + public function setFoo(int $foo): void + { + $this->foo = $foo; + } + + public function setNullableFoo(?int $nullableFoo): void + { + $this->nullableFoo = $nullableFoo; + } + + public function setRelated(?RelatedEntity $related): void + { + $this->related = $related; + } + + /** + * @param Collection $relatedCollection + */ + public function setRelatedCollection(Collection $relatedCollection): void + { + $this->relatedCollection = $relatedCollection; + } + + public static function loadMetadata(ClassMetadata $metadata): void + { + $metadata->setPrimaryTable(['name' => 'simple_entities']); + $metadata->mapField([ + 'fieldName' => 'id', + 'type' => 'integer', + 'id' => true, + ]); + $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + $metadata->mapField([ + 'fieldName' => 'foo', + 'type' => 'integer', + ]); + $metadata->mapField([ + 'fieldName' => 'nullableFoo', + 'type' => 'integer', + 'nullable' => true, + ]); + $metadata->mapManyToOne([ + 'fieldName' => 'related', + 'targetEntity' => RelatedEntity::class, + 'joinColumns' => [[ + 'name' => 'related_id', + 'referencedColumnName' => 'id', + 'nullable' => true, + ]], + 'inversedBy' => 'relatedCollection', + ]); + $metadata->mapOneToMany([ + 'fieldName' => 'relatedCollection', + 'targetEntity' => RelatedEntity::class, + 'mappedBy' => 'parent', + ]); + } + +} diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/config.neon b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/config.neon new file mode 100644 index 00000000..6e338295 --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/config.neon @@ -0,0 +1,6 @@ +includes: + - ../../../../../extension.neon +parameters: + doctrine: + objectManagerLoader: entity-manager.php + diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php new file mode 100644 index 00000000..d7937d8b --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php @@ -0,0 +1,26 @@ +setMetadataDriverImpl(new StaticPHPDriver([__DIR__ . '/Entities'])); + +$config->setProxyNamespace('PHPStan\\Doctrine\\UnitOfWorkChangeSetProxies'); +$config->setAutoGenerateProxyClasses(true); + +return new EntityManager( + DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]), + $config +); diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php new file mode 100644 index 00000000..55d48d20 --- /dev/null +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php @@ -0,0 +1,26 @@ +getEntityChangeSet($entity) + ); + } + + public function unknownEntity(UnitOfWork $unitOfWork, object $entity): void + { + assertType( + 'array', + $unitOfWork->getEntityChangeSet($entity) + ); + } +} From fad886a838a6d38607966d6fb6da178e9d000ed4 Mon Sep 17 00:00:00 2001 From: Nikita Starshinov Date: Fri, 10 Oct 2025 18:26:14 +0300 Subject: [PATCH 2/3] [WIP] UnitOfWork::getEntityChangeSet tests update --- ...ityChangeSetDynamicReturnTypeExtension.php | 11 +-- ...hangeSetDynamicReturnTypeExtensionTest.php | 1 - .../Entities/RelatedEntity.php | 51 ---------- .../Entities/SimpleEntity.php | 99 ------------------- .../UnitOfWorkChangeSet/entity-manager.php | 25 +---- .../unitOfWork-change-set.php | 15 ++- 6 files changed, 18 insertions(+), 184 deletions(-) delete mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php delete mode 100644 tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php diff --git a/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php index 4b33b22d..280d9a4e 100644 --- a/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension.php @@ -12,11 +12,10 @@ use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\MixedType; -use PHPStan\Type\Type; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; use function is_array; @@ -109,7 +108,7 @@ private function createChangeSetType(ClassMetadata $metadata): Type $builder->setOffsetValueType( new ConstantStringType($fieldName), - $fieldBuilder->getArray() + $fieldBuilder->getArray(), ); } @@ -131,7 +130,7 @@ private function createChangeSetType(ClassMetadata $metadata): Type $builder->setOffsetValueType( new ConstantStringType($fieldName), - $fieldBuilder->getArray() + $fieldBuilder->getArray(), ); continue; } @@ -146,7 +145,7 @@ private function createChangeSetType(ClassMetadata $metadata): Type $builder->setOffsetValueType( new ConstantStringType($fieldName), - $fieldBuilder->getArray() + $fieldBuilder->getArray(), ); } @@ -180,7 +179,7 @@ private function getDefaultReturnType(MethodReflection $methodReflection, Scope return ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), - $methodReflection->getVariants() + $methodReflection->getVariants(), )->getReturnType(); } diff --git a/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php index 5256137d..8fbc1445 100644 --- a/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php @@ -33,4 +33,3 @@ public static function getAdditionalConfigFiles(): array } } - diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php deleted file mode 100644 index 7fc63fd1..00000000 --- a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/RelatedEntity.php +++ /dev/null @@ -1,51 +0,0 @@ -parent = $parent; - } - - public static function loadMetadata(ClassMetadata $metadata): void - { - $metadata->setPrimaryTable(['name' => 'related_entities']); - $metadata->mapField([ - 'fieldName' => 'id', - 'type' => 'integer', - 'id' => true, - ]); - $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); - $metadata->mapManyToOne([ - 'fieldName' => 'parent', - 'targetEntity' => SimpleEntity::class, - 'inversedBy' => 'relatedCollection', - 'joinColumns' => [[ - 'name' => 'parent_id', - 'referencedColumnName' => 'id', - 'nullable' => true, - ]], - ]); - } - -} diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php deleted file mode 100644 index 24459e2a..00000000 --- a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/Entities/SimpleEntity.php +++ /dev/null @@ -1,99 +0,0 @@ - - */ - #[ORM\OneToMany(targetEntity: RelatedEntity::class, mappedBy: 'parent')] - private Collection $relatedCollection; - - #[ORM\Id] - #[ORM\Column(type: 'integer')] - #[ORM\GeneratedValue] - private int $id; - - #[ORM\Column(type: 'integer')] - private int $foo = 0; - - #[ORM\Column(type: 'integer', nullable: true)] - private ?int $nullableFoo = null; - - #[ORM\ManyToOne(targetEntity: RelatedEntity::class)] - #[ORM\JoinColumn(nullable: true)] - private ?RelatedEntity $related = null; - - public function __construct() - { - $this->relatedCollection = new ArrayCollection(); - } - - public function setFoo(int $foo): void - { - $this->foo = $foo; - } - - public function setNullableFoo(?int $nullableFoo): void - { - $this->nullableFoo = $nullableFoo; - } - - public function setRelated(?RelatedEntity $related): void - { - $this->related = $related; - } - - /** - * @param Collection $relatedCollection - */ - public function setRelatedCollection(Collection $relatedCollection): void - { - $this->relatedCollection = $relatedCollection; - } - - public static function loadMetadata(ClassMetadata $metadata): void - { - $metadata->setPrimaryTable(['name' => 'simple_entities']); - $metadata->mapField([ - 'fieldName' => 'id', - 'type' => 'integer', - 'id' => true, - ]); - $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); - $metadata->mapField([ - 'fieldName' => 'foo', - 'type' => 'integer', - ]); - $metadata->mapField([ - 'fieldName' => 'nullableFoo', - 'type' => 'integer', - 'nullable' => true, - ]); - $metadata->mapManyToOne([ - 'fieldName' => 'related', - 'targetEntity' => RelatedEntity::class, - 'joinColumns' => [[ - 'name' => 'related_id', - 'referencedColumnName' => 'id', - 'nullable' => true, - ]], - 'inversedBy' => 'relatedCollection', - ]); - $metadata->mapOneToMany([ - 'fieldName' => 'relatedCollection', - 'targetEntity' => RelatedEntity::class, - 'mappedBy' => 'parent', - ]); - } - -} diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php index d7937d8b..bb2f681d 100644 --- a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php @@ -1,26 +1,3 @@ setMetadataDriverImpl(new StaticPHPDriver([__DIR__ . '/Entities'])); - -$config->setProxyNamespace('PHPStan\\Doctrine\\UnitOfWorkChangeSetProxies'); -$config->setAutoGenerateProxyClasses(true); - -return new EntityManager( - DriverManager::getConnection([ - 'driver' => 'pdo_sqlite', - 'memory' => true, - ]), - $config -); +return require __DIR__ . '/../QueryResult/entity-manager.php'; diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php index 55d48d20..958785bc 100644 --- a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php +++ b/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php @@ -3,15 +3,24 @@ namespace UnitOfWorkChangeSet; use Doctrine\ORM\UnitOfWork; -use UnitOfWorkChangeSet\Entities\SimpleEntity; +use QueryResult\Entities\Many; +use QueryResult\Entities\Simple; use function PHPStan\Testing\assertType; final class UnitOfWorkChangeSetAssertions { - public function simpleField(UnitOfWork $unitOfWork, SimpleEntity $entity): void + public function simpleField(UnitOfWork $unitOfWork, Simple $entity): void { assertType( - 'array{foo: array{int, int}, nullableFoo: array{int|null, int|null}, related: array{UnitOfWorkChangeSet\\Entities\\RelatedEntity|null, UnitOfWorkChangeSet\\Entities\\RelatedEntity|null}, relatedCollection: array{Doctrine\\ORM\\PersistentCollection, Doctrine\\ORM\\PersistentCollection}}', + 'array{intColumn: array{int, int}, floatColumn: array{float, float}, decimalColumn: array{numeric-string&uppercase-string, numeric-string&uppercase-string}, stringColumn: array{string, string}, stringNullColumn: array{string|null, string|null}, mixedColumn: array{mixed, mixed}}', + $unitOfWork->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) ); } From fa7ca6877e80eb663cbbcfba955092cf5848027b Mon Sep 17 00:00:00 2001 From: Nikita Starshinov Date: Fri, 10 Oct 2025 18:57:06 +0300 Subject: [PATCH 3/3] [WIP] UnitOfWork::getOriginalEntityData --- extension.neon | 7 + ...alEntityDataDynamicReturnTypeExtension.php | 177 ++++++++++++++++++ ...hangeSetDynamicReturnTypeExtensionTest.php | 4 +- ...tityDataDynamicReturnTypeExtensionTest.php | 35 ++++ .../config.neon | 0 .../entity-manager.php | 0 .../unitOfWork-change-set.php | 7 + .../unitOfWork-original-entity-data.php | 42 +++++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtension.php create mode 100644 tests/Type/Doctrine/UnitOfWorkGetOriginalEntityDataDynamicReturnTypeExtensionTest.php rename tests/Type/Doctrine/data/{UnitOfWorkChangeSet => UnitOfWork}/config.neon (100%) rename tests/Type/Doctrine/data/{UnitOfWorkChangeSet => UnitOfWork}/entity-manager.php (100%) rename tests/Type/Doctrine/data/{UnitOfWorkChangeSet => UnitOfWork}/unitOfWork-change-set.php (85%) create mode 100644 tests/Type/Doctrine/data/UnitOfWork/unitOfWork-original-entity-data.php diff --git a/extension.neon b/extension.neon index 39b85042..cc79c19f 100644 --- a/extension.neon +++ b/extension.neon @@ -163,6 +163,13 @@ services: 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/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 index 8fbc1445..14405a9c 100644 --- a/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Doctrine/UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest.php @@ -10,7 +10,7 @@ final class UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest extends T /** @return iterable */ public function dataFileAsserts(): iterable { - yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWorkChangeSet/unitOfWork-change-set.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWork/unitOfWork-change-set.php'); } /** @@ -29,7 +29,7 @@ public function testFileAsserts( /** @return string[] */ public static function getAdditionalConfigFiles(): array { - return [__DIR__ . '/data/UnitOfWorkChangeSet/config.neon']; + 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/UnitOfWorkChangeSet/config.neon b/tests/Type/Doctrine/data/UnitOfWork/config.neon similarity index 100% rename from tests/Type/Doctrine/data/UnitOfWorkChangeSet/config.neon rename to tests/Type/Doctrine/data/UnitOfWork/config.neon diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php b/tests/Type/Doctrine/data/UnitOfWork/entity-manager.php similarity index 100% rename from tests/Type/Doctrine/data/UnitOfWorkChangeSet/entity-manager.php rename to tests/Type/Doctrine/data/UnitOfWork/entity-manager.php diff --git a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php b/tests/Type/Doctrine/data/UnitOfWork/unitOfWork-change-set.php similarity index 85% rename from tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php rename to tests/Type/Doctrine/data/UnitOfWork/unitOfWork-change-set.php index 958785bc..358cd433 100644 --- a/tests/Type/Doctrine/data/UnitOfWorkChangeSet/unitOfWork-change-set.php +++ b/tests/Type/Doctrine/data/UnitOfWork/unitOfWork-change-set.php @@ -4,6 +4,7 @@ use Doctrine\ORM\UnitOfWork; use QueryResult\Entities\Many; +use QueryResult\Entities\One; use QueryResult\Entities\Simple; use function PHPStan\Testing\assertType; @@ -25,6 +26,12 @@ public function associations(UnitOfWork $unitOfWork, Many $entity): void ); } + 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( 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) + ); + } +}