Skip to content

Commit cd50a48

Browse files
committed
Add custom Rector rule to add ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT') attributes to Doctrine entities
Signed-off-by: Tim Goudriaan <tim@codedmonkey.com>
1 parent 261d493 commit cd50a48

File tree

2 files changed

+209
-0
lines changed

2 files changed

+209
-0
lines changed

rector.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use CodedMonkey\Dirigent\Rector\DoctrineAddDeferredExplicitChangeTrackingPolicyRector;
56
use Rector\Config\RectorConfig;
67
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
78

@@ -26,6 +27,9 @@
2627
doctrine: true,
2728
phpunit: true,
2829
)
30+
->withRules([
31+
DoctrineAddDeferredExplicitChangeTrackingPolicyRector::class,
32+
])
2933
->withSkip([
3034
// Exclude promotion of properties to the constructor for Doctrine entities
3135
ClassPropertyAssignToConstructorPromotionRector::class => [__DIR__ . '/src/Doctrine/Entity'],
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<?php
2+
3+
namespace CodedMonkey\Dirigent\Rector;
4+
5+
use Doctrine\ORM\Mapping\ChangeTrackingPolicy;
6+
use Doctrine\ORM\Mapping\Entity;
7+
use PhpParser\Node;
8+
use PhpParser\Node\Arg;
9+
use PhpParser\Node\Attribute;
10+
use PhpParser\Node\AttributeGroup;
11+
use PhpParser\Node\Expr\ConstFetch;
12+
use PhpParser\Node\Identifier;
13+
use PhpParser\Node\Name;
14+
use PhpParser\Node\Name\FullyQualified;
15+
use PhpParser\Node\Scalar\String_;
16+
use PhpParser\Node\Stmt\Class_;
17+
use PhpParser\Node\Stmt\GroupUse;
18+
use Rector\Naming\Naming\UseImportsResolver;
19+
use Rector\Rector\AbstractRector;
20+
use Rector\ValueObject\PhpVersionFeature;
21+
use Rector\VersionBonding\Contract\MinPhpVersionInterface;
22+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
23+
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
24+
25+
/**
26+
* Adds an ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT') attribute to all Doctrine entities.
27+
*/
28+
final class DoctrineAddDeferredExplicitChangeTrackingPolicyRector extends AbstractRector implements MinPhpVersionInterface
29+
{
30+
private const string CHANGE_TRACKING_POLICY_ATTRIBUTE = ChangeTrackingPolicy::class;
31+
private const string DOCTRINE_ORM_MAPPING = 'Doctrine\ORM\Mapping';
32+
private const string ENTITY_ATTRIBUTE = Entity::class;
33+
34+
public function __construct(
35+
private readonly UseImportsResolver $useImportsResolver,
36+
) {
37+
}
38+
39+
public function getRuleDefinition(): RuleDefinition
40+
{
41+
return new RuleDefinition(
42+
'Add ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT") to Doctrine entities',
43+
[
44+
new CodeSample(
45+
<<<'CODE_SAMPLE'
46+
use Doctrine\ORM\Mapping as ORM;
47+
48+
#[ORM\Entity]
49+
class Product
50+
{
51+
}
52+
CODE_SAMPLE,
53+
<<<'CODE_SAMPLE'
54+
use Doctrine\ORM\Mapping as ORM;
55+
56+
#[ORM\Entity]
57+
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
58+
class Product
59+
{
60+
}
61+
CODE_SAMPLE,
62+
),
63+
],
64+
);
65+
}
66+
67+
/**
68+
* @return array<class-string<Node>>
69+
*/
70+
public function getNodeTypes(): array
71+
{
72+
return [Class_::class];
73+
}
74+
75+
public function refactor(Node $node): ?Node
76+
{
77+
if (!$node instanceof Class_ || $node->isAnonymous() || $node->isAbstract()) {
78+
return null;
79+
}
80+
81+
// Check if the class has an Entity attribute
82+
$entityAttribute = $this->findEntityAttribute($node);
83+
if (null === $entityAttribute) {
84+
return null;
85+
}
86+
87+
// Skip readonly entities - they don't need change tracking
88+
if ($this->isReadOnlyEntity($entityAttribute)) {
89+
return null;
90+
}
91+
92+
// Check if the class already has ORM\ChangeTrackingPolicy attribute
93+
if ($this->hasChangeTrackingPolicyAttribute($node)) {
94+
return null;
95+
}
96+
97+
// Find the position of the Entity attribute to insert after it
98+
$entityAttrGroupIndex = $this->findEntityAttributeGroupIndex($node);
99+
100+
// Create the ChangeTrackingPolicy attribute using the same alias as the imports
101+
$changeTrackingPolicyAttrGroup = $this->createChangeTrackingPolicyAttributeGroup();
102+
103+
// Insert the new attribute group after the Entity attribute
104+
array_splice($node->attrGroups, $entityAttrGroupIndex + 1, 0, [$changeTrackingPolicyAttrGroup]);
105+
106+
return $node;
107+
}
108+
109+
public function provideMinPhpVersion(): int
110+
{
111+
return PhpVersionFeature::ATTRIBUTES;
112+
}
113+
114+
private function findEntityAttribute(Class_ $node): ?Attribute
115+
{
116+
foreach ($node->attrGroups as $attrGroup) {
117+
foreach ($attrGroup->attrs as $attribute) {
118+
if ($this->isName($attribute, self::ENTITY_ATTRIBUTE)) {
119+
return $attribute;
120+
}
121+
}
122+
}
123+
124+
return null;
125+
}
126+
127+
private function isReadOnlyEntity(Attribute $entityAttribute): bool
128+
{
129+
foreach ($entityAttribute->args as $arg) {
130+
if ($arg->name instanceof Identifier && 'readOnly' === $arg->name->toString()) {
131+
// Check if the value is true
132+
if ($arg->value instanceof ConstFetch && $this->isName($arg->value, 'true')) {
133+
return true;
134+
}
135+
}
136+
}
137+
138+
return false;
139+
}
140+
141+
private function hasChangeTrackingPolicyAttribute(Class_ $node): bool
142+
{
143+
foreach ($node->attrGroups as $attrGroup) {
144+
foreach ($attrGroup->attrs as $attribute) {
145+
if ($this->isName($attribute, self::CHANGE_TRACKING_POLICY_ATTRIBUTE)) {
146+
return true;
147+
}
148+
}
149+
}
150+
151+
return false;
152+
}
153+
154+
private function findEntityAttributeGroupIndex(Class_ $node): int
155+
{
156+
foreach ($node->attrGroups as $index => $attrGroup) {
157+
foreach ($attrGroup->attrs as $attribute) {
158+
if ($this->isName($attribute, self::ENTITY_ATTRIBUTE)) {
159+
return $index;
160+
}
161+
}
162+
}
163+
164+
return 0;
165+
}
166+
167+
private function createChangeTrackingPolicyAttributeGroup(): AttributeGroup
168+
{
169+
// Find the alias used for Doctrine\ORM\Mapping (e.g., "ORM")
170+
$alias = $this->findDoctrineOrmMappingAlias();
171+
172+
// Create the attribute name using the alias
173+
if (null !== $alias) {
174+
$attributeName = new Name([$alias, 'ChangeTrackingPolicy']);
175+
} else {
176+
// Fallback to fully qualified name if no alias found
177+
$attributeName = new FullyQualified(self::CHANGE_TRACKING_POLICY_ATTRIBUTE);
178+
}
179+
180+
$arg = new Arg(new String_('DEFERRED_EXPLICIT'));
181+
$attribute = new Attribute($attributeName, [$arg]);
182+
183+
return new AttributeGroup([$attribute]);
184+
}
185+
186+
private function findDoctrineOrmMappingAlias(): ?string
187+
{
188+
$uses = $this->useImportsResolver->resolve();
189+
190+
foreach ($uses as $use) {
191+
if ($use instanceof GroupUse) {
192+
continue;
193+
}
194+
195+
foreach ($use->uses as $useUse) {
196+
$useName = $useUse->name->toString();
197+
if (self::DOCTRINE_ORM_MAPPING === $useName && $useUse->alias instanceof Identifier) {
198+
return $useUse->alias->toString();
199+
}
200+
}
201+
}
202+
203+
return null;
204+
}
205+
}

0 commit comments

Comments
 (0)