Skip to content

Commit 3f2e233

Browse files
[WIP] UnitOfWork::getEntityChangeSet array shape
1 parent 9b118ac commit 3f2e233

File tree

8 files changed

+438
-0
lines changed

8 files changed

+438
-0
lines changed

extension.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ services:
156156
descriptorRegistry: @doctrineTypeDescriptorRegistry
157157
tags:
158158
- phpstan.broker.dynamicMethodReturnTypeExtension
159+
-
160+
class: PHPStan\Type\Doctrine\UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension
161+
arguments:
162+
metadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
163+
descriptorRegistry: @doctrineTypeDescriptorRegistry
164+
tags:
165+
- phpstan.broker.dynamicMethodReturnTypeExtension
159166
-
160167
class: PHPStan\Type\Doctrine\Query\QueryResultDynamicReturnTypeExtension
161168
tags:
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine;
4+
5+
use Doctrine\ORM\Mapping\ClassMetadata;
6+
use Doctrine\ORM\PersistentCollection;
7+
use Doctrine\ORM\UnitOfWork;
8+
use PhpParser\Node\Expr\MethodCall;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Reflection\MethodReflection;
11+
use PHPStan\Reflection\ParametersAcceptorSelector;
12+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
13+
use PHPStan\Type\Constant\ConstantIntegerType;
14+
use PHPStan\Type\Constant\ConstantStringType;
15+
use PHPStan\Type\Doctrine\DescriptorNotRegisteredException;
16+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
17+
use PHPStan\Type\MixedType;
18+
use PHPStan\Type\Type;
19+
use PHPStan\Type\ObjectType;
20+
use PHPStan\Type\TypeCombinator;
21+
use function count;
22+
use function is_array;
23+
use function is_string;
24+
25+
final class UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
26+
{
27+
28+
private ObjectMetadataResolver $metadataResolver;
29+
30+
private DescriptorRegistry $descriptorRegistry;
31+
32+
public function __construct(
33+
ObjectMetadataResolver $metadataResolver,
34+
DescriptorRegistry $descriptorRegistry
35+
)
36+
{
37+
$this->metadataResolver = $metadataResolver;
38+
$this->descriptorRegistry = $descriptorRegistry;
39+
}
40+
41+
public function getClass(): string
42+
{
43+
return UnitOfWork::class;
44+
}
45+
46+
public function isMethodSupported(MethodReflection $methodReflection): bool
47+
{
48+
return $methodReflection->getName() === 'getEntityChangeSet';
49+
}
50+
51+
public function getTypeFromMethodCall(
52+
MethodReflection $methodReflection,
53+
MethodCall $methodCall,
54+
Scope $scope
55+
): Type
56+
{
57+
if (count($methodCall->getArgs()) === 0) {
58+
return $this->getDefaultReturnType($methodReflection, $scope, $methodCall);
59+
}
60+
61+
$entityType = $scope->getType($methodCall->getArgs()[0]->value);
62+
$objectClassNames = $entityType->getObjectClassNames();
63+
64+
if (count($objectClassNames) === 0) {
65+
return $this->getDefaultReturnType($methodReflection, $scope, $methodCall);
66+
}
67+
68+
$changeSetTypes = [];
69+
70+
foreach ($objectClassNames as $className) {
71+
$metadata = $this->metadataResolver->getClassMetadata($className);
72+
if ($metadata === null) {
73+
return $this->getDefaultReturnType($methodReflection, $scope, $methodCall);
74+
}
75+
76+
$changeSetTypes[] = $this->createChangeSetType($metadata);
77+
}
78+
79+
return TypeCombinator::union(...$changeSetTypes);
80+
}
81+
82+
private function createChangeSetType(ClassMetadata $metadata): Type
83+
{
84+
$builder = ConstantArrayTypeBuilder::createEmpty();
85+
$collectionType = new ObjectType(PersistentCollection::class);
86+
87+
foreach ($metadata->fieldMappings as $fieldName => $mapping) {
88+
if ($metadata->isIdentifier($fieldName)) {
89+
continue;
90+
}
91+
92+
if (!isset($mapping['type'])) {
93+
continue;
94+
}
95+
96+
try {
97+
$type = $this->descriptorRegistry->get($mapping['type'])->getWritableToPropertyType();
98+
} catch (DescriptorNotRegisteredException $exception) {
99+
$type = new MixedType();
100+
}
101+
102+
if (($mapping['nullable'] ?? false) === true) {
103+
$type = TypeCombinator::addNull($type);
104+
}
105+
106+
$fieldBuilder = ConstantArrayTypeBuilder::createEmpty();
107+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $type);
108+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $type);
109+
110+
$builder->setOffsetValueType(
111+
new ConstantStringType($fieldName),
112+
$fieldBuilder->getArray()
113+
);
114+
}
115+
116+
foreach ($metadata->associationMappings as $fieldName => $mapping) {
117+
if (($mapping['type'] & ClassMetadata::TO_ONE) !== 0) {
118+
$targetEntity = $mapping['targetEntity'] ?? null;
119+
if (!is_string($targetEntity)) {
120+
continue;
121+
}
122+
123+
$type = new ObjectType($targetEntity);
124+
if ($this->isAssociationNullable($mapping)) {
125+
$type = TypeCombinator::addNull($type);
126+
}
127+
128+
$fieldBuilder = ConstantArrayTypeBuilder::createEmpty();
129+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $type);
130+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $type);
131+
132+
$builder->setOffsetValueType(
133+
new ConstantStringType($fieldName),
134+
$fieldBuilder->getArray()
135+
);
136+
continue;
137+
}
138+
139+
if (($mapping['type'] & ClassMetadata::TO_MANY) === 0) {
140+
continue;
141+
}
142+
143+
$fieldBuilder = ConstantArrayTypeBuilder::createEmpty();
144+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(0), $collectionType);
145+
$fieldBuilder->setOffsetValueType(new ConstantIntegerType(1), $collectionType);
146+
147+
$builder->setOffsetValueType(
148+
new ConstantStringType($fieldName),
149+
$fieldBuilder->getArray()
150+
);
151+
}
152+
153+
return $builder->getArray();
154+
}
155+
156+
/**
157+
* @param array<string, mixed> $association
158+
*/
159+
private function isAssociationNullable(array $association): bool
160+
{
161+
$joinColumns = $association['joinColumns'] ?? null;
162+
if (!is_array($joinColumns)) {
163+
return true;
164+
}
165+
166+
foreach ($joinColumns as $joinColumn) {
167+
if (!is_array($joinColumn)) {
168+
continue;
169+
}
170+
if (($joinColumn['nullable'] ?? true) === false) {
171+
return false;
172+
}
173+
}
174+
175+
return true;
176+
}
177+
178+
private function getDefaultReturnType(MethodReflection $methodReflection, Scope $scope, MethodCall $methodCall): Type
179+
{
180+
return ParametersAcceptorSelector::selectFromArgs(
181+
$scope,
182+
$methodCall->getArgs(),
183+
$methodReflection->getVariants()
184+
)->getReturnType();
185+
}
186+
187+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Doctrine;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
7+
final class UnitOfWorkGetEntityChangeSetDynamicReturnTypeExtensionTest extends TypeInferenceTestCase
8+
{
9+
10+
/** @return iterable<mixed> */
11+
public function dataFileAsserts(): iterable
12+
{
13+
yield from $this->gatherAssertTypes(__DIR__ . '/data/UnitOfWorkChangeSet/unitOfWork-change-set.php');
14+
}
15+
16+
/**
17+
* @dataProvider dataFileAsserts
18+
* @param mixed ...$args
19+
*/
20+
public function testFileAsserts(
21+
string $assertType,
22+
string $file,
23+
...$args
24+
): void
25+
{
26+
$this->assertFileAsserts($assertType, $file, ...$args);
27+
}
28+
29+
/** @return string[] */
30+
public static function getAdditionalConfigFiles(): array
31+
{
32+
return [__DIR__ . '/data/UnitOfWorkChangeSet/config.neon'];
33+
}
34+
35+
}
36+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace UnitOfWorkChangeSet\Entities;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Doctrine\ORM\Mapping\ClassMetadata;
7+
8+
#[ORM\Entity]
9+
#[ORM\Table(name: 'related_entities')]
10+
class RelatedEntity
11+
{
12+
13+
/**
14+
*/
15+
#[ORM\Id]
16+
#[ORM\Column(type: 'integer')]
17+
#[ORM\GeneratedValue]
18+
private int $id;
19+
20+
/**
21+
*/
22+
#[ORM\ManyToOne(targetEntity: SimpleEntity::class, inversedBy: 'relatedCollection')]
23+
private ?SimpleEntity $parent = null;
24+
25+
public function setParent(?SimpleEntity $parent): void
26+
{
27+
$this->parent = $parent;
28+
}
29+
30+
public static function loadMetadata(ClassMetadata $metadata): void
31+
{
32+
$metadata->setPrimaryTable(['name' => 'related_entities']);
33+
$metadata->mapField([
34+
'fieldName' => 'id',
35+
'type' => 'integer',
36+
'id' => true,
37+
]);
38+
$metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO);
39+
$metadata->mapManyToOne([
40+
'fieldName' => 'parent',
41+
'targetEntity' => SimpleEntity::class,
42+
'inversedBy' => 'relatedCollection',
43+
'joinColumns' => [[
44+
'name' => 'parent_id',
45+
'referencedColumnName' => 'id',
46+
'nullable' => true,
47+
]],
48+
]);
49+
}
50+
51+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace UnitOfWorkChangeSet\Entities;
4+
5+
use Doctrine\Common\Collections\ArrayCollection;
6+
use Doctrine\Common\Collections\Collection;
7+
use Doctrine\ORM\Mapping as ORM;
8+
use Doctrine\ORM\Mapping\ClassMetadata;
9+
10+
#[ORM\Entity]
11+
#[ORM\Table(name: 'simple_entities')]
12+
class SimpleEntity
13+
{
14+
15+
/**
16+
* @var Collection<int, RelatedEntity>
17+
*/
18+
#[ORM\OneToMany(targetEntity: RelatedEntity::class, mappedBy: 'parent')]
19+
private Collection $relatedCollection;
20+
21+
#[ORM\Id]
22+
#[ORM\Column(type: 'integer')]
23+
#[ORM\GeneratedValue]
24+
private int $id;
25+
26+
#[ORM\Column(type: 'integer')]
27+
private int $foo = 0;
28+
29+
#[ORM\Column(type: 'integer', nullable: true)]
30+
private ?int $nullableFoo = null;
31+
32+
#[ORM\ManyToOne(targetEntity: RelatedEntity::class)]
33+
#[ORM\JoinColumn(nullable: true)]
34+
private ?RelatedEntity $related = null;
35+
36+
public function __construct()
37+
{
38+
$this->relatedCollection = new ArrayCollection();
39+
}
40+
41+
public function setFoo(int $foo): void
42+
{
43+
$this->foo = $foo;
44+
}
45+
46+
public function setNullableFoo(?int $nullableFoo): void
47+
{
48+
$this->nullableFoo = $nullableFoo;
49+
}
50+
51+
public function setRelated(?RelatedEntity $related): void
52+
{
53+
$this->related = $related;
54+
}
55+
56+
/**
57+
* @param Collection<int, RelatedEntity> $relatedCollection
58+
*/
59+
public function setRelatedCollection(Collection $relatedCollection): void
60+
{
61+
$this->relatedCollection = $relatedCollection;
62+
}
63+
64+
public static function loadMetadata(ClassMetadata $metadata): void
65+
{
66+
$metadata->setPrimaryTable(['name' => 'simple_entities']);
67+
$metadata->mapField([
68+
'fieldName' => 'id',
69+
'type' => 'integer',
70+
'id' => true,
71+
]);
72+
$metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO);
73+
$metadata->mapField([
74+
'fieldName' => 'foo',
75+
'type' => 'integer',
76+
]);
77+
$metadata->mapField([
78+
'fieldName' => 'nullableFoo',
79+
'type' => 'integer',
80+
'nullable' => true,
81+
]);
82+
$metadata->mapManyToOne([
83+
'fieldName' => 'related',
84+
'targetEntity' => RelatedEntity::class,
85+
'joinColumns' => [[
86+
'name' => 'related_id',
87+
'referencedColumnName' => 'id',
88+
'nullable' => true,
89+
]],
90+
'inversedBy' => 'relatedCollection',
91+
]);
92+
$metadata->mapOneToMany([
93+
'fieldName' => 'relatedCollection',
94+
'targetEntity' => RelatedEntity::class,
95+
'mappedBy' => 'parent',
96+
]);
97+
}
98+
99+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
includes:
2+
- ../../../../../extension.neon
3+
parameters:
4+
doctrine:
5+
objectManagerLoader: entity-manager.php
6+

0 commit comments

Comments
 (0)