Skip to content

Commit 69f6951

Browse files
committed
feat: respect custom collection types on builders
1 parent 80a99ea commit 69f6951

File tree

7 files changed

+183
-0
lines changed

7 files changed

+183
-0
lines changed

extension.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ services:
1414
tags: [phpstan.broker.dynamicFunctionReturnTypeExtension]
1515
- class: Recoded\PHPStanLaravel\Extensions\Contracts\Container\ContainerDynamicReturnType
1616
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
17+
- class: Recoded\PHPStanLaravel\Extensions\Eloquent\BuilderCollectionDynamicReturnType
18+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
1719
- class: Recoded\PHPStanLaravel\Extensions\StubExtension
1820
tags: [phpstan.stubFilesExtension]
1921
- class: Recoded\PHPStanLaravel\DependencyRegistry
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Recoded\PHPStanLaravel\Extensions\Eloquent;
6+
7+
use Illuminate\Contracts\Database\Eloquent\Builder;
8+
use PhpParser\Node\Arg;
9+
use PhpParser\Node\Expr\MethodCall;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Reflection\MethodReflection;
12+
use PHPStan\Reflection\ParametersAcceptorSelector;
13+
use PHPStan\Type\ArrayType;
14+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
15+
use PHPStan\Type\Generic\GenericObjectType;
16+
use PHPStan\Type\Generic\TemplateUnionType;
17+
use PHPStan\Type\IntegerType;
18+
use PHPStan\Type\ObjectType;
19+
use PHPStan\Type\StringType;
20+
use PHPStan\Type\Type;
21+
use PHPStan\Type\UnionType;
22+
use PHPStan\Type\VerbosityLevel;
23+
24+
final class BuilderCollectionDynamicReturnType implements DynamicMethodReturnTypeExtension
25+
{
26+
public function getClass(): string
27+
{
28+
return Builder::class;
29+
}
30+
31+
public function isMethodSupported(MethodReflection $methodReflection): bool
32+
{
33+
$modelType = $methodReflection
34+
->getDeclaringClass()
35+
->getAncestorWithClassName(Builder::class)
36+
?->getActiveTemplateTypeMap()
37+
->getType('TModel');
38+
39+
if ($modelType === null) {
40+
return false;
41+
}
42+
43+
$model = new ObjectType('Illuminate\Database\Eloquent\Model');
44+
45+
if (!$model->isSuperTypeOf($modelType)->yes()) {
46+
return false;
47+
}
48+
49+
foreach ($methodReflection->getVariants() as $variant) {
50+
$eloquentCollection = new GenericObjectType('Illuminate\Database\Eloquent\Collection', [
51+
new IntegerType(),
52+
$modelType,
53+
]);
54+
55+
if ($eloquentCollection->isSuperTypeOf($variant->getReturnType())->yes()) {
56+
return true;
57+
}
58+
}
59+
60+
return false;
61+
}
62+
63+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
64+
{
65+
/** @var \PHPStan\Type\ObjectType $modelType */
66+
$modelType = $methodReflection
67+
->getDeclaringClass()
68+
->getAncestorWithClassName(Builder::class)
69+
?->getActiveTemplateTypeMap()
70+
->getType('TModel');
71+
72+
$regularReturnType = ParametersAcceptorSelector::selectFromArgs(
73+
$scope,
74+
$methodCall->getArgs(),
75+
$methodReflection->getVariants()
76+
)->getReturnType();
77+
78+
$eloquentCollection = new GenericObjectType('Illuminate\Database\Eloquent\Collection', [
79+
new IntegerType(),
80+
$modelType,
81+
]);
82+
83+
if (!$eloquentCollection->isSuperTypeOf($regularReturnType)->yes()) {
84+
return null;
85+
}
86+
87+
$newCollectionReflection = $scope->getMethodReflection($modelType, 'newCollection');
88+
89+
if ($newCollectionReflection === null) {
90+
return null;
91+
}
92+
93+
$variant = ParametersAcceptorSelector::selectFromTypes([
94+
new ArrayType(new IntegerType(), $modelType),
95+
], $newCollectionReflection->getVariants(), false);
96+
97+
return $variant->getReturnType();
98+
}
99+
}

stubs/database/eloquent/model.stub

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ namespace Illuminate\Database\Eloquent;
44

55
class Model
66
{
7+
/**
8+
* @template TKey of array-key
9+
* @param array<TKey, static> $models
10+
* @return \Illuminate\Database\Eloquent\Collection<TKey, static>
11+
*/
12+
public function newCollection(array $models = []);
13+
714
/**
815
* @param \Illuminate\Database\Query\Builder $query
916
* @return \Illuminate\Database\Eloquent\Builder<static>

tests/Types/Fakes/Activity.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Types\Fakes;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use Tests\Types\Fakes\Collections\ActivityCollection;
9+
10+
final class Activity extends Model
11+
{
12+
/**
13+
* @template TKey of array-key
14+
* @param array<TKey, self> $models
15+
* @return \Tests\Types\Fakes\Collections\ActivityCollection<TKey>
16+
*/
17+
public function newCollection(array $models = []): ActivityCollection
18+
{
19+
return new ActivityCollection($models);
20+
}
21+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Types\Fakes\Collections;
6+
7+
use Illuminate\Database\Eloquent\Collection;
8+
9+
/**
10+
* @template TKey of array-key
11+
* @extends \Illuminate\Database\Eloquent\Collection<TKey, \Tests\Types\Fakes\Activity>
12+
*/
13+
final class ActivityCollection extends Collection
14+
{
15+
//
16+
}

tests/Types/data/database/eloquent/builder.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,22 @@
193193
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\User>', $builder->orWhereDoesntHaveMorph('related', '*', function ($param1) {
194194
assertType('Illuminate\Database\Eloquent\Builder<Illuminate\Database\Eloquent\Model>', $param1);
195195
}));
196+
197+
// Custom collections
198+
199+
/** @var \Illuminate\Database\Eloquent\Builder<\Tests\Types\Fakes\Activity> $builder */
200+
/** @var \Illuminate\Contracts\Support\Arrayable<array-key, mixed> $arrayable */
201+
202+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->hydrate([]));
203+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->fromQuery(''));
204+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->find([]));
205+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->find($arrayable));
206+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findMany([]));
207+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findMany($arrayable));
208+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOrFail([]));
209+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOrFail($arrayable));
210+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOrNew([]));
211+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOrNew($arrayable));
212+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOr([]));
213+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->findOr($arrayable));
214+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $builder->get());

tests/Types/data/database/eloquent/relations/relation.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,22 @@
6868
assertType('Illuminate\Database\Eloquent\Relations\Relation<Tests\Types\Fakes\Post, Tests\Types\Fakes\User>', $relation->orWhereDoesntHaveMorph('related', [Post::class, 'media'], function ($param1) {
6969
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\Post>|Tests\Types\Fakes\Builders\MediaBuilder', $param1);
7070
}));
71+
72+
// Custom collections
73+
74+
/** @var \Illuminate\Database\Eloquent\Relations\Relation<\Tests\Types\Fakes\User, \Tests\Types\Fakes\Activity> $relation */
75+
/** @var \Illuminate\Contracts\Support\Arrayable<array-key, mixed> $arrayable */
76+
77+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->hydrate([]));
78+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->fromQuery(''));
79+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->find([]));
80+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->find($arrayable));
81+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findMany([]));
82+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findMany($arrayable));
83+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOrFail([]));
84+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOrFail($arrayable));
85+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOrNew([]));
86+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOrNew($arrayable));
87+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOr([]));
88+
// assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->findOr($arrayable));
89+
assertType('Tests\Types\Fakes\Collections\ActivityCollection<int>', $relation->get());

0 commit comments

Comments
 (0)