Skip to content

Commit 280fb5f

Browse files
authored
Add TChildModel for relations with childs in AddGenericReturnTypeToRelationsRector (#82)
* Move getting and verifying of the relation method call to own method * Check if method name equals with doesMethodHasName * Add TChildModel for relations which needs it * Don't update the docblock if return type already contains the correct generic This avoids overwriting non-FQCN with our fully qualified ones. * Add another test * Apply Rector * Fix code style * [ci-review] Rector Rectify
1 parent 3820e61 commit 280fb5f

File tree

5 files changed

+262
-40
lines changed

5 files changed

+262
-40
lines changed

src/NodeAnalyzer/LumenRouteRegisteringMethodAnalyzer.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ public function __construct(
1919
) {
2020
}
2121

22-
public function isLumenRoutingClass(MethodCall $node): bool
22+
public function isLumenRoutingClass(MethodCall $methodCall): bool
2323
{
24-
return $this->nodeTypeResolver->isObjectType($node->var, new ObjectType('Laravel\Lumen\Routing\Router'));
24+
return $this->nodeTypeResolver->isObjectType($methodCall->var, new ObjectType('Laravel\Lumen\Routing\Router'));
2525
}
2626

2727
public function isRoutesRegisterGroup(Identifier|Expr $name): bool

src/Rector/ClassMethod/AddGenericReturnTypeToRelationsRector.php

Lines changed: 166 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,33 @@
1111
use PhpParser\Node\Stmt\ClassLike;
1212
use PhpParser\Node\Stmt\ClassMethod;
1313
use PhpParser\Node\Stmt\Return_;
14+
use PHPStan\Analyser\Scope;
1415
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
1516
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
1617
use PHPStan\Type\Constant\ConstantStringType;
1718
use PHPStan\Type\Generic\GenericClassStringType;
19+
use PHPStan\Type\Generic\GenericObjectType;
1820
use PHPStan\Type\ObjectType;
1921
use Rector\BetterPhpDocParser\ValueObject\Type\FullyQualifiedIdentifierTypeNode;
20-
use Rector\Core\Rector\AbstractRector;
22+
use Rector\Core\Rector\AbstractScopeAwareRector;
2123
use Rector\NodeTypeResolver\TypeComparator\TypeComparator;
2224
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
2325
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
2426

2527
/** @see \RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\AddGenericReturnTypeToRelationsRectorTest */
26-
class AddGenericReturnTypeToRelationsRector extends AbstractRector
28+
class AddGenericReturnTypeToRelationsRector extends AbstractScopeAwareRector
2729
{
30+
// Relation methods which are supported by this Rector.
31+
private const RELATION_METHODS = [
32+
'hasOne', 'hasOneThrough', 'morphOne',
33+
'belongsTo', 'morphTo',
34+
'hasMany', 'hasManyThrough', 'morphMany',
35+
'belongsToMany', 'morphToMany', 'morphedByMany',
36+
];
37+
38+
// Relation methods which need the class as TChildModel.
39+
private const RELATION_WITH_CHILD_METHODS = ['belongsTo', 'morphTo'];
40+
2841
public function __construct(
2942
private readonly TypeComparator $typeComparator
3043
) {
@@ -78,11 +91,12 @@ public function getNodeTypes(): array
7891
return [ClassMethod::class];
7992
}
8093

81-
/**
82-
* @param ClassMethod $node
83-
*/
84-
public function refactor(Node $node): ?Node
94+
public function refactorWithScope(Node $node, Scope $scope): ?Node
8595
{
96+
if (! $node instanceof ClassMethod) {
97+
return null;
98+
}
99+
86100
if ($this->shouldSkipNode($node)) {
87101
return null;
88102
}
@@ -111,41 +125,46 @@ public function refactor(Node $node): ?Node
111125
// Don't update an existing return type if it differs from the native return type (thus the one without generics).
112126
// E.g. we only add generics to an existing return type, but don't change the type itself.
113127
if (
114-
$phpDocInfo->getReturnTagValue() !== null &&
115-
! $this->typeComparator->arePhpParserAndPhpStanPhpDocTypesEqual(
128+
$phpDocInfo->getReturnTagValue() !== null
129+
&& ! $this->areNativeTypeAndPhpDocReturnTypeEqual(
130+
$node,
116131
$methodReturnType,
117132
$phpDocInfo->getReturnTagValue()
118-
->type,
119-
$node
120133
)
121134
) {
122135
return null;
123136
}
124137

125-
$returnStatement = $this->betterNodeFinder->findFirstInFunctionLikeScoped(
126-
$node,
127-
fn (Node $subNode): bool => $subNode instanceof Return_
128-
);
129-
130-
if (! $returnStatement instanceof Return_) {
138+
$relationMethodCall = $this->getRelationMethodCall($node);
139+
if (! $relationMethodCall instanceof MethodCall) {
131140
return null;
132141
}
133142

134-
$relationMethodCall = $this->betterNodeFinder->findFirstInstanceOf($returnStatement, MethodCall::class);
143+
$relatedClass = $this->getRelatedModelClassFromMethodCall($relationMethodCall);
135144

136-
if (! $relationMethodCall instanceof MethodCall) {
145+
if ($relatedClass === null) {
137146
return null;
138147
}
139148

140-
$relatedClass = $this->getRelatedModelClassFromMethodCall($relationMethodCall);
149+
$classForChildGeneric = $this->getClassForChildGeneric($scope, $relationMethodCall);
141150

142-
if ($relatedClass === null) {
151+
// Don't update the docblock if return type already contains the correct generics. This avoids overwriting
152+
// non-FQCN with our fully qualified ones.
153+
if (
154+
$phpDocInfo->getReturnTagValue() !== null
155+
&& $this->areGenericTypesEqual(
156+
$node,
157+
$phpDocInfo->getReturnTagValue(),
158+
$relatedClass,
159+
$classForChildGeneric
160+
)
161+
) {
143162
return null;
144163
}
145164

146165
$genericTypeNode = new GenericTypeNode(
147166
new FullyQualifiedIdentifierTypeNode($methodReturnTypeName),
148-
[new FullyQualifiedIdentifierTypeNode($relatedClass)],
167+
$this->getGenericTypes($relatedClass, $classForChildGeneric),
149168
);
150169

151170
// Update or add return tag
@@ -161,43 +180,125 @@ public function refactor(Node $node): ?Node
161180

162181
private function getRelatedModelClassFromMethodCall(MethodCall $methodCall): ?string
163182
{
164-
$methodName = $methodCall->name;
183+
$argType = $this->getType($methodCall->getArgs()[0]->value);
165184

166-
if (! $methodName instanceof Identifier) {
185+
if ($argType instanceof ConstantStringType) {
186+
return $argType->getValue();
187+
}
188+
189+
if (! $argType instanceof GenericClassStringType) {
167190
return null;
168191
}
169192

170-
// Called method should be one of the Laravel's relation methods
171-
if (! in_array($methodName->name, [
172-
'hasOne', 'hasOneThrough', 'morphOne',
173-
'belongsTo', 'morphTo',
174-
'hasMany', 'hasManyThrough', 'morphMany',
175-
'belongsToMany', 'morphToMany', 'morphedByMany',
176-
], true)) {
193+
$modelType = $argType->getGenericType();
194+
195+
if (! $modelType instanceof ObjectType) {
177196
return null;
178197
}
179198

180-
if (count($methodCall->getArgs()) < 1) {
199+
return $modelType->getClassName();
200+
}
201+
202+
private function getRelationMethodCall(ClassMethod $classMethod): ?MethodCall
203+
{
204+
$node = $this->betterNodeFinder->findFirstInFunctionLikeScoped(
205+
$classMethod,
206+
fn (Node $subNode): bool => $subNode instanceof Return_
207+
);
208+
209+
if (! $node instanceof Return_) {
181210
return null;
182211
}
183212

184-
$argType = $this->getType($methodCall->getArgs()[0]->value);
213+
$methodCall = $this->betterNodeFinder->findFirstInstanceOf($node, MethodCall::class);
185214

186-
if ($argType instanceof ConstantStringType) {
187-
return $argType->getValue();
215+
if (! $methodCall instanceof MethodCall) {
216+
return null;
188217
}
189218

190-
if (! $argType instanceof GenericClassStringType) {
219+
// Called method should be one of the Laravel's relation methods
220+
if (! $this->doesMethodHasName($methodCall, self::RELATION_METHODS)) {
191221
return null;
192222
}
193223

194-
$modelType = $argType->getGenericType();
224+
if (count($methodCall->getArgs()) < 1) {
225+
return null;
226+
}
195227

196-
if (! $modelType instanceof ObjectType) {
228+
return $methodCall;
229+
}
230+
231+
/**
232+
* We need the current class for generics which need a TChildModel. This is the case by for example the BelongsTo
233+
* relation.
234+
*/
235+
private function getClassForChildGeneric(Scope $scope, MethodCall $methodCall): ?string
236+
{
237+
if (! $this->doesMethodHasName($methodCall, self::RELATION_WITH_CHILD_METHODS)) {
197238
return null;
198239
}
199240

200-
return $modelType->getClassName();
241+
$classReflection = $scope->getClassReflection();
242+
243+
return $classReflection?->getName();
244+
}
245+
246+
private function areNativeTypeAndPhpDocReturnTypeEqual(
247+
ClassMethod $classMethod,
248+
Node $node,
249+
ReturnTagValueNode $returnTagValueNode
250+
): bool {
251+
$phpDocPHPStanType = $this->staticTypeMapper->mapPHPStanPhpDocTypeNodeToPHPStanType(
252+
$returnTagValueNode->type,
253+
$classMethod
254+
);
255+
256+
$phpDocPHPStanTypeWithoutGenerics = $phpDocPHPStanType;
257+
if ($phpDocPHPStanType instanceof GenericObjectType) {
258+
$phpDocPHPStanTypeWithoutGenerics = new ObjectType($phpDocPHPStanType->getClassName());
259+
}
260+
261+
$methodReturnTypePHPStanType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($node);
262+
263+
return $this->typeComparator->areTypesEqual(
264+
$methodReturnTypePHPStanType,
265+
$phpDocPHPStanTypeWithoutGenerics,
266+
);
267+
}
268+
269+
private function areGenericTypesEqual(
270+
Node $node,
271+
ReturnTagValueNode $returnTagValueNode,
272+
string $relatedClass,
273+
?string $classForChildGeneric
274+
): bool {
275+
$phpDocPHPStanType = $this->staticTypeMapper->mapPHPStanPhpDocTypeNodeToPHPStanType(
276+
$returnTagValueNode->type,
277+
$node
278+
);
279+
280+
if (! $phpDocPHPStanType instanceof GenericObjectType) {
281+
return false;
282+
}
283+
284+
$phpDocTypes = $phpDocPHPStanType->getTypes();
285+
if ($phpDocTypes === []) {
286+
return false;
287+
}
288+
289+
if (! $this->typeComparator->areTypesEqual($phpDocTypes[0], new ObjectType($relatedClass))) {
290+
return false;
291+
}
292+
293+
$phpDocHasChildGeneric = count($phpDocTypes) === 2;
294+
if ($classForChildGeneric === null && ! $phpDocHasChildGeneric) {
295+
return true;
296+
}
297+
298+
if ($classForChildGeneric === null || ! $phpDocHasChildGeneric) {
299+
return false;
300+
}
301+
return $this->typeComparator->areTypesEqual($phpDocTypes[1], new ObjectType($classForChildGeneric));
201302
}
202303

203304
private function shouldSkipNode(ClassMethod $classMethod): bool
@@ -218,4 +319,31 @@ private function shouldSkipNode(ClassMethod $classMethod): bool
218319

219320
return false;
220321
}
322+
323+
/**
324+
* @param array<string> $methodNames
325+
*/
326+
private function doesMethodHasName(MethodCall $methodCall, array $methodNames): bool
327+
{
328+
$methodName = $methodCall->name;
329+
330+
if (! $methodName instanceof Identifier) {
331+
return false;
332+
}
333+
return in_array($methodName->name, $methodNames, true);
334+
}
335+
336+
/**
337+
* @return FullyQualifiedIdentifierTypeNode[]
338+
*/
339+
private function getGenericTypes(string $relatedClass, ?string $childClass): array
340+
{
341+
$generics = [new FullyQualifiedIdentifierTypeNode($relatedClass)];
342+
343+
if ($childClass !== null) {
344+
$generics[] = new FullyQualifiedIdentifierTypeNode($childClass);
345+
}
346+
347+
return $generics;
348+
}
221349
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent\Relations;
4+
5+
if (class_exists('Illuminate\Database\Eloquent\Relations\BelongsTo')) {
6+
return;
7+
}
8+
9+
class BelongsTo extends Relation
10+
{
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class Account extends Model {}
9+
10+
class User extends Model
11+
{
12+
public function accounts(): BelongsTo
13+
{
14+
return $this->belongsTo(Account::class);
15+
}
16+
}
17+
18+
?>
19+
-----
20+
<?php
21+
22+
namespace RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture;
23+
24+
use Illuminate\Database\Eloquent\Model;
25+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
26+
27+
class Account extends Model {}
28+
29+
class User extends Model
30+
{
31+
/**
32+
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture\Account, \RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture\User>
33+
*/
34+
public function accounts(): BelongsTo
35+
{
36+
return $this->belongsTo(Account::class);
37+
}
38+
}
39+
40+
?>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class Account extends Model {}
9+
10+
class User extends Model
11+
{
12+
/**
13+
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture\Account, User>
14+
*/
15+
public function accounts(): BelongsTo
16+
{
17+
return $this->belongsTo(Account::class);
18+
}
19+
}
20+
21+
?>
22+
-----
23+
<?php
24+
25+
namespace RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture;
26+
27+
use Illuminate\Database\Eloquent\Model;
28+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
29+
30+
class Account extends Model {}
31+
32+
class User extends Model
33+
{
34+
/**
35+
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\Fixture\Account, User>
36+
*/
37+
public function accounts(): BelongsTo
38+
{
39+
return $this->belongsTo(Account::class);
40+
}
41+
}
42+
43+
?>

0 commit comments

Comments
 (0)