Skip to content

Commit d1f49b0

Browse files
committed
feat: overwrite hasMorph callback builder with correct type
1 parent b736cca commit d1f49b0

File tree

7 files changed

+156
-1
lines changed

7 files changed

+156
-1
lines changed

extension.neon

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
parameters:
2+
laravel:
3+
morphMap: []
4+
5+
parametersSchema:
6+
laravel: structure([
7+
morphMap: arrayOf(string())
8+
])
9+
110
services:
211
- class: Recoded\PHPStanLaravel\Extensions\StubExtension
312
tags: [phpstan.stubFilesExtension]
413
- class: Recoded\PHPStanLaravel\Extensions\Eloquent\Scopes
514
tags: [phpstan.broker.methodsClassReflectionExtension]
615
- class: Recoded\PHPStanLaravel\Extensions\Eloquent\WhereHasBuilderType
716
tags: [phpstan.methodParameterClosureTypeExtension]
17+
- class: Recoded\PHPStanLaravel\Extensions\Eloquent\WhereHasMorphBuilderType
18+
arguments:
19+
morphMap: %laravel.morphMap%
20+
tags: [phpstan.methodParameterClosureTypeExtension]

phpstan-baseline.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,13 @@ parameters:
44
message: "#^Creating new PHPStan\\\\Reflection\\\\Native\\\\NativeParameterReflection is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
55
count: 1
66
path: src/Extensions/Eloquent/WhereHasBuilderType.php
7+
8+
-
9+
message: "#^Creating new PHPStan\\\\Reflection\\\\Native\\\\NativeParameterReflection is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
10+
count: 1
11+
path: src/Extensions/Eloquent/WhereHasMorphBuilderType.php
12+
13+
-
14+
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#"
15+
count: 1
16+
path: src/Extensions/Eloquent/WhereHasMorphBuilderType.php
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Recoded\PHPStanLaravel\Extensions\Eloquent;
6+
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Reflection\Native\NativeParameterReflection;
11+
use PHPStan\Reflection\ParameterReflection;
12+
use PHPStan\Reflection\ParametersAcceptorSelector;
13+
use PHPStan\Reflection\PassedByReference;
14+
use PHPStan\Type\Constant\ConstantArrayType;
15+
use PHPStan\Type\Constant\ConstantStringType;
16+
use PHPStan\Type\ClosureType;
17+
use PHPStan\Type\Generic\GenericObjectType;
18+
use PHPStan\Type\MethodParameterClosureTypeExtension;
19+
use PHPStan\Type\ObjectType;
20+
use PHPStan\Type\Type;
21+
use PHPStan\Type\TypeCombinator;
22+
use PHPStan\Type\VoidType;
23+
24+
final class WhereHasMorphBuilderType implements MethodParameterClosureTypeExtension
25+
{
26+
/**
27+
* Create a new Extension instance.
28+
*
29+
* @param array<string, class-string<\Illuminate\Database\Eloquent\Model>> $morphMap
30+
* @return void
31+
*/
32+
public function __construct(
33+
private array $morphMap
34+
) {
35+
}
36+
37+
public function isMethodSupported(MethodReflection $method, ParameterReflection $parameter): bool
38+
{
39+
$methods = [
40+
'hasMorph',
41+
];
42+
43+
if (!in_array($method->getName(), $methods, true)) {
44+
return false;
45+
}
46+
47+
return $parameter->getName() === 'callback'
48+
&& $method->getDeclaringClass()->getName() === 'Illuminate\Database\Eloquent\Builder';
49+
}
50+
51+
public function getTypeFromMethodCall(MethodReflection $method, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type
52+
{
53+
$secondType = $scope->getType($methodCall->getArgs()[1]->value);
54+
55+
$types = $secondType->isConstantArray()->yes()
56+
? array_reduce($secondType->getConstantArrays(), fn (array $carry, ConstantArrayType $array) => [
57+
...$carry,
58+
...$array->getValueTypes(),
59+
], [])
60+
: $secondType->getConstantStrings();
61+
62+
$wildcardsAndNonConstantStrings = array_filter($types, function (Type $type) {
63+
return !$type instanceof ConstantStringType || $type->getValue() === '*';
64+
});
65+
66+
if ($wildcardsAndNonConstantStrings !== []) {
67+
return null;
68+
}
69+
70+
$classes = array_map(function (ConstantStringType $string) {
71+
return $string->isClassStringType()->yes()
72+
? $string->getValue()
73+
: $this->morphMap[$string->getValue()] ?? $string->getValue();
74+
}, $types);
75+
76+
$model = $method->getDeclaringClass()
77+
->getActiveTemplateTypeMap()
78+
->getType('TModel');
79+
80+
if ($model === null || !$model->isObject()->yes()) {
81+
return null;
82+
}
83+
84+
/** @var \PHPStan\Type\ObjectType $model */
85+
86+
$class = $model->getClassReflection();
87+
88+
if ($class === null) {
89+
return null;
90+
}
91+
92+
$builderTypes = array_map(function (string $class) use ($scope) {
93+
/** @var \PHPStan\Reflection\ExtendedMethodReflection $newEloquentBuilder */
94+
$newEloquentBuilder = $scope->getMethodReflection(new ObjectType($class), 'newEloquentBuilder');
95+
96+
return ParametersAcceptorSelector::selectSingle($newEloquentBuilder->getVariants())->getReturnType();
97+
}, $classes);
98+
99+
$builderTypes = array_filter($builderTypes);
100+
101+
$builderType = $builderTypes === []
102+
? new GenericObjectType('Illuminate\Database\Eloquent\Builder', [new ObjectType('Illuminate\Database\Eloquent\Model')])
103+
: TypeCombinator::union(...$builderTypes);
104+
105+
return new ClosureType([
106+
new NativeParameterReflection('callback', false, $builderType, PassedByReference::createNo(), false, null),
107+
], new VoidType());
108+
}
109+
}

stubs/database/eloquent/concerns/queries-relationships.stub

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,16 @@ trait QueriesRelationships
7575
* @return static
7676
*/
7777
public function orWhereDoesntHave($relation, ?Closure $callback = null);
78+
79+
/**
80+
* @template TBuilder of \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model>
81+
* @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation
82+
* @param string|string[] $types
83+
* @param string $operator
84+
* @param int $count
85+
* @param string $boolean
86+
* @param \Closure(TBuilder): (void|TBuilder)|null $callback
87+
* @return static
88+
*/
89+
public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null);
7890
}

tests/Types/Database/Eloquent/BuilderTest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public function testFileAsserts(
2626

2727
public static function getAdditionalConfigFiles(): array
2828
{
29-
return [__DIR__ . '/../../../../extension.neon'];
29+
return [
30+
__DIR__ . '/../../../../extension.neon',
31+
__DIR__ . '/morph-map.neon',
32+
];
3033
}
3134
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
laravel:
3+
morphMap:
4+
media: Tests\Types\Fakes\Media

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use Tests\Types\Fakes\Post;
34
use function PHPStan\Testing\assertType;
45

56
/** @var \Illuminate\Database\Eloquent\Builder<\Tests\Types\Fakes\User> $builder */
@@ -113,3 +114,6 @@
113114
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\User>', $builder->orWhereDoesntHave('posts', function ($param1) {
114115
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\Post>', $param1);
115116
}));
117+
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\User>', $builder->hasMorph('related', [Post::class, 'media'], callback: function ($param1) {
118+
assertType('Illuminate\Database\Eloquent\Builder<Tests\Types\Fakes\Post>|Tests\Types\Fakes\Builders\MediaBuilder', $param1);
119+
}));

0 commit comments

Comments
 (0)