Skip to content

Commit 7c605c1

Browse files
committed
fix: prevent return type errors when type has been overridden
1 parent a0f7c27 commit 7c605c1

File tree

11 files changed

+471
-2
lines changed

11 files changed

+471
-2
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ jobs:
264264
cd e2e/bug-11857
265265
composer install
266266
../../bin/phpstan
267+
- script: |
268+
cd e2e/bug-12585
269+
composer install
270+
../../bin/phpstan
267271
- script: |
268272
cd e2e/result-cache-meta-extension
269273
composer install

e2e/bug-12585/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor
2+
composer.lock

e2e/bug-12585/composer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"autoload-dev": {
3+
"classmap": ["src/"]
4+
}
5+
}

e2e/bug-12585/phpstan.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
parameters:
2+
level: 8
3+
paths:
4+
- src
5+
6+
services:
7+
-
8+
class: Bug12585\EloquentBuilderRelationParameterExtension
9+
tags:
10+
- phpstan.dynamicMethodParameterTypeExtension

e2e/bug-12585/src/extension.php

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
<?php
2+
3+
namespace Bug12585;
4+
5+
use PHPStan\Analyser\Scope;
6+
use PHPStan\Reflection\MethodReflection;
7+
use PHPStan\Reflection\ParameterReflection;
8+
use PHPStan\Reflection\PassedByReference;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Type\ClosureType;
11+
use PHPStan\Type\DynamicMethodParameterTypeExtension;
12+
use PHPStan\Type\Generic\GenericObjectType;
13+
use PHPStan\Type\MixedType;
14+
use PHPStan\Type\ObjectType;
15+
use PHPStan\Type\Type;
16+
use PHPStan\Type\TypeWithClassName;
17+
use PHPStan\Type\TypeCombinator;
18+
use PHPStan\Type\VerbosityLevel;
19+
use PhpParser\Node\Expr\MethodCall;
20+
use PhpParser\Node\VariadicPlaceholder;
21+
22+
use function array_push;
23+
use function array_shift;
24+
use function count;
25+
use function explode;
26+
use function in_array;
27+
28+
final class EloquentBuilderRelationParameterExtension implements DynamicMethodParameterTypeExtension
29+
{
30+
/** @var list<string> */
31+
private array $methods = ['whereHas', 'withWhereHas'];
32+
33+
public function __construct(private ReflectionProvider $reflectionProvider)
34+
{
35+
}
36+
37+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
38+
{
39+
if (! $methodReflection->getDeclaringClass()->is(Builder::class)) {
40+
return false;
41+
}
42+
43+
if (! in_array($methodReflection->getName(), $this->methods, strict: true)) {
44+
return false;
45+
}
46+
47+
return $parameter->getName() === 'callback';
48+
}
49+
50+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): Type|null
51+
{
52+
$method = $methodReflection->getName();
53+
$relations = $this->getRelationsFromMethodCall($methodCall, $scope);
54+
$models = $this->getModelsFromRelations($relations);
55+
56+
if (count($models) === 0) {
57+
return null;
58+
}
59+
60+
$type = $this->getBuilderTypeForModels($models);
61+
62+
if ($method === 'withWhereHas') {
63+
$type = TypeCombinator::union($type, ...$relations);
64+
}
65+
66+
return new ClosureType([new ClosureQueryParameter('query', $type)], new MixedType(), false);
67+
}
68+
69+
/**
70+
* @param array<int, Type> $relations
71+
* @return array<int, string>
72+
*/
73+
private function getModelsFromRelations(array $relations): array
74+
{
75+
$models = [];
76+
77+
foreach ($relations as $relation) {
78+
$classNames = $relation->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames();
79+
foreach ($classNames as $className) {
80+
$models[] = $className;
81+
}
82+
}
83+
84+
return $models;
85+
}
86+
87+
/** @return array<int, Type> */
88+
private function getRelationsFromMethodCall(MethodCall $methodCall, Scope $scope): array
89+
{
90+
$relationType = null;
91+
92+
foreach ($methodCall->args as $arg) {
93+
if ($arg instanceof VariadicPlaceholder) {
94+
continue;
95+
}
96+
97+
if ($arg->name === null || $arg->name->toString() === 'relation') {
98+
$relationType = $scope->getType($arg->value);
99+
break;
100+
}
101+
}
102+
103+
if ($relationType === null) {
104+
return [];
105+
}
106+
107+
$calledOnModels = $scope->getType($methodCall->var)
108+
->getTemplateType(Builder::class, 'TModel')
109+
->getObjectClassNames();
110+
111+
$values = array_map(fn ($type) => $type->getValue(), $relationType->getConstantStrings());
112+
$relationTypes = [$relationType];
113+
114+
foreach ($values as $relation) {
115+
$relationTypes = array_merge(
116+
$relationTypes,
117+
$this->getRelationTypeFromString($calledOnModels, explode('.', $relation), $scope)
118+
);
119+
}
120+
121+
return array_values(array_filter(
122+
$relationTypes,
123+
static fn ($r) => (new ObjectType(Relation::class))->isSuperTypeOf($r)->yes()
124+
));
125+
}
126+
127+
/**
128+
* @param list<string> $calledOnModels
129+
* @param list<string> $relationParts
130+
* @return list<Type>
131+
*/
132+
private function getRelationTypeFromString(array $calledOnModels, array $relationParts, Scope $scope): array
133+
{
134+
$relations = [];
135+
136+
while ($relationName = array_shift($relationParts)) {
137+
$relations = [];
138+
$relatedModels = [];
139+
140+
foreach ($calledOnModels as $model) {
141+
$modelType = new ObjectType($model);
142+
143+
if (! $modelType->hasMethod($relationName)->yes()) {
144+
continue;
145+
}
146+
147+
$relationType = $modelType->getMethod($relationName, $scope)->getVariants()[0]->getReturnType();
148+
149+
if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
150+
continue;
151+
}
152+
153+
$relations[] = $relationType;
154+
155+
array_push($relatedModels, ...$relationType->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames());
156+
}
157+
158+
$calledOnModels = $relatedModels;
159+
}
160+
161+
return $relations;
162+
}
163+
164+
private function determineBuilderName(string $modelClassName): string
165+
{
166+
$method = $this->reflectionProvider->getClass($modelClassName)->getNativeMethod('query');
167+
168+
$returnType = $method->getVariants()[0]->getReturnType();
169+
170+
if (in_array(Builder::class, $returnType->getReferencedClasses(), true)) {
171+
return Builder::class;
172+
}
173+
174+
$classNames = $returnType->getObjectClassNames();
175+
176+
if (count($classNames) === 1) {
177+
return $classNames[0];
178+
}
179+
180+
return $returnType->describe(VerbosityLevel::value());
181+
}
182+
183+
/**
184+
* @param array<int, string|TypeWithClassName>|string|TypeWithClassName $models
185+
* @return ($models is array<int, string|TypeWithClassName> ? Type : ObjectType)
186+
*/
187+
private function getBuilderTypeForModels(array|string|TypeWithClassName $models): Type
188+
{
189+
$models = is_array($models) ? $models : [$models];
190+
$models = array_unique($models, SORT_REGULAR);
191+
192+
$mappedModels = [];
193+
foreach ($models as $model) {
194+
if (is_string($model)) {
195+
$mappedModels[$model] = new ObjectType($model);
196+
} else {
197+
$mappedModels[$model->getClassName()] = $model;
198+
}
199+
}
200+
201+
$groupedByBuilder = [];
202+
foreach ($mappedModels as $class => $type) {
203+
$builderName = $this->determineBuilderName($class);
204+
$groupedByBuilder[$builderName][] = $type;
205+
}
206+
207+
$builderTypes = [];
208+
foreach ($groupedByBuilder as $builder => $models) {
209+
$builderReflection = $this->reflectionProvider->getClass($builder);
210+
211+
$builderTypes[] = $builderReflection->isGeneric()
212+
? new GenericObjectType($builder, [TypeCombinator::union(...$models)])
213+
: new ObjectType($builder);
214+
}
215+
216+
return TypeCombinator::union(...$builderTypes);
217+
}
218+
}
219+
220+
final class ClosureQueryParameter implements ParameterReflection
221+
{
222+
public function __construct(private string $name, private Type $type)
223+
{
224+
}
225+
226+
public function getName(): string
227+
{
228+
return $this->name;
229+
}
230+
231+
public function isOptional(): bool
232+
{
233+
return false;
234+
}
235+
236+
public function getType(): Type
237+
{
238+
return $this->type;
239+
}
240+
241+
public function passedByReference(): PassedByReference
242+
{
243+
return PassedByReference::createNo();
244+
}
245+
246+
public function isVariadic(): bool
247+
{
248+
return false;
249+
}
250+
251+
public function getDefaultValue(): Type|null
252+
{
253+
return null;
254+
}
255+
}

0 commit comments

Comments
 (0)