Skip to content

Commit 30be1c6

Browse files
committed
ci: add failing test for bug-12585
1 parent 73d7b88 commit 30be1c6

File tree

7 files changed

+440
-0
lines changed

7 files changed

+440
-0
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ jobs:
233233
cd e2e/bug-11857
234234
composer install
235235
../../bin/phpstan
236+
- script: |
237+
cd e2e/bug-12585
238+
composer install
239+
../../bin/phpstan
236240
- script: |
237241
cd e2e/result-cache-meta-extension
238242
composer install

e2e/bug-12585/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/vendor

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/composer.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.methodParameterClosureTypeExtension

e2e/bug-12585/src/extension.php

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

0 commit comments

Comments
 (0)