Skip to content

Commit a54acd5

Browse files
authored
feat: adjust AddGenericReturnTypeToRelationsRector rule to be able to generate new generic style code (#263)
* feat: adjust AddGenericReturnTypeToRelationsRector rule to be able to generate new generic style code * make rule configuration default to false to keep BC
1 parent 7fbb1de commit a54acd5

28 files changed

+732
-47
lines changed

docs/rector_rules_overview.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ Adds the `@extends` annotation to Factories.
6363

6464
Add generic return type to relations in child of `Illuminate\Database\Eloquent\Model`
6565

66+
:wrench: **configure it!**
67+
6668
- class: [`RectorLaravel\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector`](../src/Rector/ClassMethod/AddGenericReturnTypeToRelationsRector.php)
6769

6870
```diff
@@ -82,6 +84,23 @@ Add generic return type to relations in child of `Illuminate\Database\Eloquent\M
8284

8385
<br>
8486

87+
```diff
88+
use App\Account;
89+
use Illuminate\Database\Eloquent\Model;
90+
use Illuminate\Database\Eloquent\Relations\HasMany;
91+
92+
class User extends Model
93+
{
94+
+ /** @return HasMany<Account, $this> */
95+
public function accounts(): HasMany
96+
{
97+
return $this->hasMany(Account::class);
98+
}
99+
}
100+
```
101+
102+
<br>
103+
85104
## AddGuardToLoginEventRector
86105

87106
Add new `$guard` argument to Illuminate\Auth\Events\Login

src/Rector/ClassMethod/AddGenericReturnTypeToRelationsRector.php

Lines changed: 152 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,30 @@
1212
use PHPStan\Analyser\Scope;
1313
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
1414
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
15+
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
1516
use PHPStan\Reflection\ClassReflection;
1617
use PHPStan\Type\Constant\ConstantStringType;
1718
use PHPStan\Type\Generic\GenericClassStringType;
1819
use PHPStan\Type\Generic\GenericObjectType;
1920
use PHPStan\Type\ObjectType;
21+
use PHPStan\Type\ThisType;
2022
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory;
2123
use Rector\BetterPhpDocParser\ValueObject\Type\FullyQualifiedIdentifierTypeNode;
2224
use Rector\Comments\NodeDocBlock\DocBlockUpdater;
25+
use Rector\Contract\Rector\ConfigurableRectorInterface;
2326
use Rector\NodeTypeResolver\TypeComparator\TypeComparator;
2427
use Rector\PhpParser\Node\BetterNodeFinder;
2528
use Rector\Rector\AbstractScopeAwareRector;
2629
use Rector\StaticTypeMapper\StaticTypeMapper;
27-
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
30+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
2831
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
32+
use Webmozart\Assert\Assert;
2933

30-
/** @see \RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\AddGenericReturnTypeToRelationsRectorTest */
31-
class AddGenericReturnTypeToRelationsRector extends AbstractScopeAwareRector
34+
/**
35+
* @see \RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\AddGenericReturnTypeToRelationsRectorNewGenericsTest
36+
* @see \RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector\AddGenericReturnTypeToRelationsRectorOldGenericsTest
37+
*/
38+
class AddGenericReturnTypeToRelationsRector extends AbstractScopeAwareRector implements ConfigurableRectorInterface
3239
{
3340
// Relation methods which are supported by this Rector.
3441
private const RELATION_METHODS = [
@@ -41,6 +48,11 @@ class AddGenericReturnTypeToRelationsRector extends AbstractScopeAwareRector
4148
// Relation methods which need the class as TChildModel.
4249
private const RELATION_WITH_CHILD_METHODS = ['belongsTo', 'morphTo'];
4350

51+
// Relation methods which need the class as TIntermediateModel.
52+
private const RELATION_WITH_INTERMEDIATE_METHODS = ['hasManyThrough', 'hasOneThrough'];
53+
54+
private bool $shouldUseNewGenerics = false;
55+
4456
public function __construct(
4557
private readonly TypeComparator $typeComparator,
4658
private readonly DocBlockUpdater $docBlockUpdater,
@@ -55,7 +67,7 @@ public function getRuleDefinition(): RuleDefinition
5567
return new RuleDefinition(
5668
'Add generic return type to relations in child of Illuminate\Database\Eloquent\Model',
5769
[
58-
new CodeSample(
70+
new ConfiguredCodeSample(
5971
<<<'CODE_SAMPLE'
6072
use App\Account;
6173
use Illuminate\Database\Eloquent\Model;
@@ -84,8 +96,39 @@ public function accounts(): HasMany
8496
return $this->hasMany(Account::class);
8597
}
8698
}
99+
CODE_SAMPLE,
100+
['shouldUseNewGenerics' => false]),
101+
new ConfiguredCodeSample(
102+
<<<'CODE_SAMPLE'
103+
use App\Account;
104+
use Illuminate\Database\Eloquent\Model;
105+
use Illuminate\Database\Eloquent\Relations\HasMany;
106+
107+
class User extends Model
108+
{
109+
public function accounts(): HasMany
110+
{
111+
return $this->hasMany(Account::class);
112+
}
113+
}
87114
CODE_SAMPLE
88-
),
115+
116+
,
117+
<<<'CODE_SAMPLE'
118+
use App\Account;
119+
use Illuminate\Database\Eloquent\Model;
120+
use Illuminate\Database\Eloquent\Relations\HasMany;
121+
122+
class User extends Model
123+
{
124+
/** @return HasMany<Account, $this> */
125+
public function accounts(): HasMany
126+
{
127+
return $this->hasMany(Account::class);
128+
}
129+
}
130+
CODE_SAMPLE,
131+
['shouldUseNewGenerics' => true]),
89132
]
90133
);
91134
}
@@ -154,6 +197,7 @@ public function refactorWithScope(Node $node, Scope $scope): ?Node
154197
}
155198

156199
$classForChildGeneric = $this->getClassForChildGeneric($scope, $relationMethodCall);
200+
$classForIntermediateGeneric = $this->getClassForIntermediateGeneric($relationMethodCall);
157201

158202
// Don't update the docblock if return type already contains the correct generics. This avoids overwriting
159203
// non-FQCN with our fully qualified ones.
@@ -163,15 +207,16 @@ public function refactorWithScope(Node $node, Scope $scope): ?Node
163207
$node,
164208
$phpDocInfo->getReturnTagValue(),
165209
$relatedClass,
166-
$classForChildGeneric
210+
$classForChildGeneric,
211+
$classForIntermediateGeneric
167212
)
168213
) {
169214
return null;
170215
}
171216

172217
$genericTypeNode = new GenericTypeNode(
173218
new FullyQualifiedIdentifierTypeNode($methodReturnTypeName),
174-
$this->getGenericTypes($relatedClass, $classForChildGeneric),
219+
$this->getGenericTypes($relatedClass, $classForChildGeneric, $classForIntermediateGeneric),
175220
);
176221

177222
// Update or add return tag
@@ -187,6 +232,24 @@ public function refactorWithScope(Node $node, Scope $scope): ?Node
187232
return $node;
188233
}
189234

235+
/**
236+
* {@inheritDoc}
237+
*/
238+
public function configure(array $configuration): void
239+
{
240+
if ($configuration === []) {
241+
$this->shouldUseNewGenerics = false;
242+
243+
return;
244+
}
245+
246+
Assert::count($configuration, 1);
247+
Assert::keyExists($configuration, 'shouldUseNewGenerics');
248+
Assert::boolean($configuration['shouldUseNewGenerics']);
249+
250+
$this->shouldUseNewGenerics = $configuration['shouldUseNewGenerics'];
251+
}
252+
190253
private function getRelatedModelClassFromMethodCall(MethodCall $methodCall): ?string
191254
{
192255
$argType = $this->getType($methodCall->getArgs()[0]->value);
@@ -243,6 +306,10 @@ private function getRelationMethodCall(ClassMethod $classMethod): ?MethodCall
243306
*/
244307
private function getClassForChildGeneric(Scope $scope, MethodCall $methodCall): ?string
245308
{
309+
if ($this->shouldUseNewGenerics) {
310+
return null;
311+
}
312+
246313
if (! $this->doesMethodHasName($methodCall, self::RELATION_WITH_CHILD_METHODS)) {
247314
return null;
248315
}
@@ -252,6 +319,45 @@ private function getClassForChildGeneric(Scope $scope, MethodCall $methodCall):
252319
return $classReflection?->getName();
253320
}
254321

322+
/**
323+
* We need the intermediate class for generics which need a TIntermediateModel.
324+
* This is the case for *through relations
325+
*/
326+
private function getClassForIntermediateGeneric(MethodCall $methodCall): ?string
327+
{
328+
if (! $this->shouldUseNewGenerics) {
329+
return null;
330+
}
331+
332+
if (! $this->doesMethodHasName($methodCall, self::RELATION_WITH_INTERMEDIATE_METHODS)) {
333+
return null;
334+
}
335+
336+
$args = $methodCall->getArgs();
337+
338+
if (count($args) < 2) {
339+
return null;
340+
}
341+
342+
$argType = $this->getType($args[1]->value);
343+
344+
if ($argType instanceof ConstantStringType && $argType->isClassStringType()->yes()) {
345+
return $argType->getValue();
346+
}
347+
348+
if (! $argType instanceof GenericClassStringType) {
349+
return null;
350+
}
351+
352+
$modelType = $argType->getGenericType();
353+
354+
if (! $modelType instanceof ObjectType) {
355+
return null;
356+
}
357+
358+
return $modelType->getClassName();
359+
}
360+
255361
private function areNativeTypeAndPhpDocReturnTypeEqual(
256362
ClassMethod $classMethod,
257363
Node $node,
@@ -279,7 +385,8 @@ private function areGenericTypesEqual(
279385
Node $node,
280386
ReturnTagValueNode $returnTagValueNode,
281387
string $relatedClass,
282-
?string $classForChildGeneric
388+
?string $classForChildGeneric,
389+
?string $classForIntermediateGeneric
283390
): bool {
284391
$phpDocPHPStanType = $this->staticTypeMapper->mapPHPStanPhpDocTypeNodeToPHPStanType(
285392
$returnTagValueNode->type,
@@ -299,16 +406,37 @@ private function areGenericTypesEqual(
299406
return false;
300407
}
301408

302-
$phpDocHasChildGeneric = count($phpDocTypes) === 2;
303-
if ($classForChildGeneric === null && ! $phpDocHasChildGeneric) {
304-
return true;
409+
if (! $this->shouldUseNewGenerics) {
410+
$phpDocHasChildGeneric = count($phpDocTypes) === 2;
411+
412+
if ($classForChildGeneric === null && ! $phpDocHasChildGeneric) {
413+
return true;
414+
}
415+
416+
if ($classForChildGeneric === null || ! $phpDocHasChildGeneric) {
417+
return false;
418+
}
419+
420+
return $this->typeComparator->areTypesEqual($phpDocTypes[1], new ObjectType($classForChildGeneric));
421+
}
422+
423+
$phpDocHasIntermediateGeneric = count($phpDocTypes) === 3;
424+
425+
if ($classForIntermediateGeneric === null && ! $phpDocHasIntermediateGeneric) {
426+
// If there is only one generic, it means method is using the old format. We should update it.
427+
if (count($phpDocTypes) === 1) {
428+
return false;
429+
}
430+
431+
// We want to convert the existing relationship definition to use `$this` as the second generic
432+
return $phpDocTypes[1] instanceof ThisType;
305433
}
306434

307-
if ($classForChildGeneric === null || ! $phpDocHasChildGeneric) {
435+
if ($classForIntermediateGeneric === null || ! $phpDocHasIntermediateGeneric) {
308436
return false;
309437
}
310438

311-
return $this->typeComparator->areTypesEqual($phpDocTypes[1], new ObjectType($classForChildGeneric));
439+
return $this->typeComparator->areTypesEqual($phpDocTypes[1], new ObjectType($classForIntermediateGeneric));
312440
}
313441

314442
private function shouldSkipNode(ClassMethod $classMethod, Scope $scope): bool
@@ -341,16 +469,24 @@ private function doesMethodHasName(MethodCall $methodCall, array $methodNames):
341469
}
342470

343471
/**
344-
* @return FullyQualifiedIdentifierTypeNode[]
472+
* @return IdentifierTypeNode[]
345473
*/
346-
private function getGenericTypes(string $relatedClass, ?string $childClass): array
474+
private function getGenericTypes(string $relatedClass, ?string $childClass, ?string $intermediateClass): array
347475
{
348476
$generics = [new FullyQualifiedIdentifierTypeNode($relatedClass)];
349477

350-
if ($childClass !== null) {
478+
if (! $this->shouldUseNewGenerics && $childClass !== null) {
351479
$generics[] = new FullyQualifiedIdentifierTypeNode($childClass);
352480
}
353481

482+
if ($this->shouldUseNewGenerics) {
483+
if ($intermediateClass !== null) {
484+
$generics[] = new FullyQualifiedIdentifierTypeNode($intermediateClass);
485+
}
486+
487+
$generics[] = new IdentifierTypeNode('$this');
488+
}
489+
354490
return $generics;
355491
}
356492
}
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\HasOneThrough')) {
6+
return;
7+
}
8+
9+
class HasOneThrough extends Relation
10+
{
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RectorLaravel\Tests\Rector\ClassMethod\AddGenericReturnTypeToRelationsRector;
6+
7+
use Iterator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
10+
11+
final class AddGenericReturnTypeToRelationsRectorNewGenericsTest extends AbstractRectorTestCase
12+
{
13+
public static function provideData(): Iterator
14+
{
15+
// yield [__DIR__ . '/Fixture/NewGenerics/has-one-through.php.inc'];
16+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture/NewGenerics');
17+
}
18+
19+
/**
20+
* @test
21+
*/
22+
#[DataProvider('provideData')]
23+
public function test(string $filePath): void
24+
{
25+
$this->doTestFile($filePath);
26+
}
27+
28+
public function provideConfigFilePath(): string
29+
{
30+
return __DIR__ . '/config/use_new_generics_configured_rule.php';
31+
}
32+
}

tests/Rector/ClassMethod/AddGenericReturnTypeToRelationsRector/AddGenericReturnTypeToRelationsRectorTest.php renamed to tests/Rector/ClassMethod/AddGenericReturnTypeToRelationsRector/AddGenericReturnTypeToRelationsRectorOldGenericsTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
use PHPUnit\Framework\Attributes\DataProvider;
99
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
1010

11-
final class AddGenericReturnTypeToRelationsRectorTest extends AbstractRectorTestCase
11+
final class AddGenericReturnTypeToRelationsRectorOldGenericsTest extends AbstractRectorTestCase
1212
{
1313
public static function provideData(): Iterator
1414
{
15-
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
15+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture/OldGenerics');
1616
}
1717

1818
/**
@@ -26,6 +26,6 @@ public function test(string $filePath): void
2626

2727
public function provideConfigFilePath(): string
2828
{
29-
return __DIR__ . '/config/configured_rule.php';
29+
return __DIR__ . '/config/use_old_generics_configured_rule.php';
3030
}
3131
}

0 commit comments

Comments
 (0)