Skip to content

Commit fcc4f70

Browse files
committed
feat: support dynamic relation closures
1 parent ed8d53a commit fcc4f70

12 files changed

+529
-162
lines changed

extension.neon

+13
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,19 @@ services:
543543
tags:
544544
- phpstan.broker.dynamicFunctionReturnTypeExtension
545545

546+
-
547+
class: Larastan\Larastan\Parameters\EloquentBuilderRelationParameterExtension
548+
tags:
549+
- phpstan.methodParameterClosureTypeExtension
550+
551+
-
552+
class: Larastan\Larastan\Parameters\ModelRelationParameterExtension
553+
tags:
554+
- phpstan.staticMethodParameterClosureTypeExtension
555+
556+
-
557+
class: Larastan\Larastan\Parameters\RelationClosureHelper
558+
546559
-
547560
class: Larastan\Larastan\ReturnTypes\AppMakeHelper
548561

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Larastan\Larastan\Parameters;
6+
7+
use PHPStan\Reflection\ParameterReflection;
8+
use PHPStan\Reflection\PassedByReference;
9+
use PHPStan\Type\Type;
10+
11+
final class ClosureQueryParameter implements ParameterReflection
12+
{
13+
public function __construct(
14+
private string $name,
15+
private Type $type,
16+
) {
17+
}
18+
19+
public function getName(): string
20+
{
21+
return $this->name;
22+
}
23+
24+
public function isOptional(): bool
25+
{
26+
return false;
27+
}
28+
29+
public function getType(): Type
30+
{
31+
return $this->type;
32+
}
33+
34+
public function passedByReference(): PassedByReference
35+
{
36+
return PassedByReference::createNo();
37+
}
38+
39+
public function isVariadic(): bool
40+
{
41+
return false;
42+
}
43+
44+
public function getDefaultValue(): Type|null
45+
{
46+
return null;
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Larastan\Larastan\Parameters;
6+
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Reflection\ParameterReflection;
11+
use PHPStan\Type\MethodParameterClosureTypeExtension;
12+
use PHPStan\Type\Type;
13+
14+
final class EloquentBuilderRelationParameterExtension implements MethodParameterClosureTypeExtension
15+
{
16+
public function __construct(private RelationClosureHelper $relationClosureHelper)
17+
{
18+
}
19+
20+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
21+
{
22+
return $this->relationClosureHelper->isMethodSupported($methodReflection, $parameter);
23+
}
24+
25+
public function getTypeFromMethodCall(
26+
MethodReflection $methodReflection,
27+
MethodCall $methodCall,
28+
ParameterReflection $parameter,
29+
Scope $scope,
30+
): Type|null {
31+
return $this->relationClosureHelper->getTypeFromMethodCall($methodReflection, $methodCall, $parameter, $scope);
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Larastan\Larastan\Parameters;
6+
7+
use PhpParser\Node\Expr\StaticCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Reflection\ParameterReflection;
11+
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
12+
use PHPStan\Type\Type;
13+
14+
final class ModelRelationParameterExtension implements StaticMethodParameterClosureTypeExtension
15+
{
16+
public function __construct(private RelationClosureHelper $relationClosureHelper)
17+
{
18+
}
19+
20+
public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
21+
{
22+
return $this->relationClosureHelper->isMethodSupported($methodReflection, $parameter);
23+
}
24+
25+
public function getTypeFromStaticMethodCall(
26+
MethodReflection $methodReflection,
27+
StaticCall $methodCall,
28+
ParameterReflection $parameter,
29+
Scope $scope,
30+
): Type|null {
31+
return $this->relationClosureHelper->getTypeFromMethodCall($methodReflection, $methodCall, $parameter, $scope);
32+
}
33+
}
+249
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Larastan\Larastan\Parameters;
6+
7+
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\Relation;
10+
use Larastan\Larastan\Methods\BuilderHelper;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\Expr\StaticCall;
13+
use PhpParser\Node\Name;
14+
use PhpParser\Node\VariadicPlaceholder;
15+
use PHPStan\Analyser\Scope;
16+
use PHPStan\Reflection\ClassReflection;
17+
use PHPStan\Reflection\MethodReflection;
18+
use PHPStan\Reflection\ParameterReflection;
19+
use PHPStan\Type\ClosureType;
20+
use PHPStan\Type\Constant\ConstantArrayType;
21+
use PHPStan\Type\Constant\ConstantStringType;
22+
use PHPStan\Type\MixedType;
23+
use PHPStan\Type\NeverType;
24+
use PHPStan\Type\ObjectType;
25+
use PHPStan\Type\StringType;
26+
use PHPStan\Type\Type;
27+
28+
use function array_push;
29+
use function array_shift;
30+
use function collect;
31+
use function count;
32+
use function dd;
33+
use function explode;
34+
use function in_array;
35+
use function is_string;
36+
37+
final class RelationClosureHelper
38+
{
39+
/** @var list<string> */
40+
private array $methods = [
41+
'has',
42+
'doesntHave',
43+
'whereHas',
44+
'withWhereHas',
45+
'orWhereHas',
46+
'whereDoesntHave',
47+
'orWhereDoesntHave',
48+
'whereRelation',
49+
'orWhereRelation',
50+
];
51+
52+
/** @var list<string> */
53+
private array $morphMethods = [
54+
'hasMorph',
55+
'doesntHaveMorph',
56+
'whereHasMorph',
57+
'orWhereHasMorph',
58+
'whereDoesntHaveMorph',
59+
'orWhereDoesntHaveMorph',
60+
'whereMorphRelation',
61+
'orWhereMorphRelation',
62+
];
63+
64+
public function __construct(
65+
private BuilderHelper $builderHelper,
66+
) {
67+
}
68+
69+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
70+
{
71+
if (! $methodReflection->getDeclaringClass()->is(EloquentBuilder::class)) {
72+
return false;
73+
}
74+
75+
return in_array($methodReflection->getName(), [...$this->methods, ...$this->morphMethods], strict: true);
76+
}
77+
78+
public function getTypeFromMethodCall(
79+
MethodReflection $methodReflection,
80+
MethodCall|StaticCall $methodCall,
81+
ParameterReflection $parameter,
82+
Scope $scope,
83+
): Type|null {
84+
$isMorphMethod = in_array($methodReflection->getName(), $this->morphMethods, strict: true);
85+
86+
$models = $isMorphMethod
87+
? $this->getMorphModels($methodCall, $scope)
88+
: $this->getModels($methodCall, $scope);
89+
90+
if (count($models) === 0) {
91+
return null;
92+
}
93+
94+
return new ClosureType([
95+
new ClosureQueryParameter('query', $this->builderHelper->getBuilderTypeForModels($models)),
96+
new ClosureQueryParameter('type', $isMorphMethod ? new NeverType() : new StringType()),
97+
], new MixedType());
98+
}
99+
100+
/** @return array<int, string> */
101+
private function getMorphModels(MethodCall|StaticCall $methodCall, Scope $scope): array
102+
{
103+
$models = null;
104+
105+
foreach ($methodCall->args as $i => $arg) {
106+
if ($arg instanceof VariadicPlaceholder) {
107+
continue;
108+
}
109+
110+
if (($i === 1 && $arg->name === null) || $arg->name?->toString() === 'types') {
111+
$models = $scope->getType($arg->value);
112+
break;
113+
}
114+
}
115+
116+
if ($models === null) {
117+
return [];
118+
}
119+
120+
return collect($models->getConstantArrays())
121+
->flatMap(static fn (ConstantArrayType $t) => $t->getValueTypes())
122+
->flatMap(static fn (Type $t) => $t->getConstantStrings())
123+
->merge($models->getConstantStrings())
124+
->map(static function (ConstantStringType $t) {
125+
$value = $t->getValue();
126+
127+
return $value === '*' ? Model::class : $value;
128+
})
129+
->values()
130+
->all();
131+
}
132+
133+
/** @return array<int, string> */
134+
private function getModels(MethodCall|StaticCall $methodCall, Scope $scope): array
135+
{
136+
$relations = $this->getRelationsFromMethodCall($methodCall, $scope);
137+
138+
if (count($relations) === 0) {
139+
return [];
140+
}
141+
142+
if ($methodCall instanceof MethodCall) {
143+
$calledOnModels = $scope->getType($methodCall->var)
144+
->getTemplateType(EloquentBuilder::class, 'TModel')
145+
->getObjectClassNames();
146+
} else {
147+
$calledOnModels = $methodCall->class instanceof Name
148+
? [$scope->resolveName($methodCall->class)]
149+
: dd($scope->getType($methodCall->class))->getReferencedClasses();
150+
}
151+
152+
return collect($relations)
153+
->flatMap(
154+
fn ($relation) => is_string($relation)
155+
? $this->getModelsFromStringRelation($calledOnModels, explode('.', $relation), $scope)
156+
: $this->getModelsFromRelationReflection($relation),
157+
)
158+
->values()
159+
->all();
160+
}
161+
162+
/** @return array<int, string|ClassReflection> */
163+
public function getRelationsFromMethodCall(MethodCall|StaticCall $methodCall, Scope $scope): array
164+
{
165+
$relationType = null;
166+
167+
foreach ($methodCall->args as $arg) {
168+
if ($arg instanceof VariadicPlaceholder) {
169+
continue;
170+
}
171+
172+
if ($arg->name === null || $arg->name->toString() === 'relation') {
173+
$relationType = $scope->getType($arg->value);
174+
break;
175+
}
176+
}
177+
178+
if ($relationType === null) {
179+
return [];
180+
}
181+
182+
return collect([
183+
...$relationType->getConstantStrings(),
184+
...$relationType->getObjectClassReflections(),
185+
])
186+
->map(static function ($type) {
187+
if ($type instanceof ClassReflection) {
188+
return $type->is(Relation::class) ? $type : null;
189+
}
190+
191+
return $type->getValue();
192+
})
193+
->filter()
194+
->values()
195+
->all();
196+
}
197+
198+
/**
199+
* @param list<string> $calledOnModels
200+
* @param list<string> $relationParts
201+
*
202+
* @return list<string>
203+
*/
204+
public function getModelsFromStringRelation(
205+
array $calledOnModels,
206+
array $relationParts,
207+
Scope $scope,
208+
): array {
209+
$relationName = array_shift($relationParts);
210+
211+
if ($relationName === null) {
212+
return $calledOnModels;
213+
}
214+
215+
$models = [];
216+
217+
foreach ($calledOnModels as $model) {
218+
$modelType = new ObjectType($model);
219+
if (! $modelType->hasMethod($relationName)->yes()) {
220+
continue;
221+
}
222+
223+
$relationMethod = $modelType->getMethod($relationName, $scope);
224+
$relationType = $relationMethod->getVariants()[0]->getReturnType();
225+
226+
if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
227+
continue;
228+
}
229+
230+
$relatedModels = $relationType->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames();
231+
232+
array_push($models, ...$this->getModelsFromStringRelation($relatedModels, $relationParts, $scope));
233+
}
234+
235+
return $models;
236+
}
237+
238+
/** @return list<string> */
239+
public function getModelsFromRelationReflection(ClassReflection $relation): array
240+
{
241+
$relatedModel = $relation->getActiveTemplateTypeMap()->getType('TRelatedModel');
242+
243+
if ($relatedModel === null) {
244+
return [];
245+
}
246+
247+
return $relatedModel->getObjectClassNames();
248+
}
249+
}

tests/Integration/IntegrationTest.php

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public static function dataIntegrationTests(): iterable
4444
35 => ['Parameter #1 $columns of method Illuminate\Database\Eloquent\Builder<App\User>::first() expects array<int, model property of App\User>|model property of App\User, array<int, string> given.'],
4545
36 => ['Parameter #1 $columns of method Illuminate\Database\Eloquent\Builder<App\User>::first() expects array<int, model property of App\User>|model property of App\User, string given.'],
4646
39 => ['Parameter #1 $column of method Illuminate\Database\Eloquent\Builder<App\User>::where() expects array<int|model property of App\User, mixed>|(Closure(Illuminate\Database\Eloquent\Builder<App\User>): Illuminate\Database\Eloquent\Builder<App\User>)|(Closure(Illuminate\Database\Eloquent\Builder<App\User>): void)|Illuminate\Contracts\Database\Query\Expression|model property of App\User, \'roles.foo\' given.'],
47+
43 => ['Parameter #1 $column of method Illuminate\Database\Eloquent\Builder<App\Account>::where() expects array<int|model property of App\Account, mixed>|(Closure(Illuminate\Database\Eloquent\Builder<App\Account>): Illuminate\Database\Eloquent\Builder<App\Account>)|(Closure(Illuminate\Database\Eloquent\Builder<App\Account>): void)|Illuminate\Contracts\Database\Query\Expression|model property of App\Account, \'foo\' given.'],
4748
],
4849
];
4950

0 commit comments

Comments
 (0)