Skip to content

Commit 7c2a1a2

Browse files
committed
feat: get array types working (pass variable around)
1 parent efc172e commit 7c2a1a2

16 files changed

+784
-134
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,10 @@ jobs:
405405
- script: |
406406
cd e2e/bug13425
407407
timeout 15 ../bashunit -a exit_code "1" "../../bin/phpstan analyze src/ plugins/"
408+
- script: |
409+
cd e2e/parameter-type-extension
410+
composer install
411+
../../bin/phpstan analyze
408412
409413
steps:
410414
- name: "Checkout"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor
2+
/composer.lock
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"autoload": {
3+
"psr-4": {
4+
"App\\": "src/"
5+
}
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
parameters:
2+
ignoreErrors:
3+
-
4+
message: '#^Parameter \#1 \$relations of method App\\Builder\<App\\User\>\:\:with\(\) expects array\<string, Closure\(App\\Relation\<\*, \*, \*\>\)\: mixed\>, array\{car\: Closure\(App\\HasOne\)\: App\\HasOne, monitorable\: Closure\(App\\MorphTo\)\: App\\MorphTo\} given\.$#'
5+
identifier: argument.type
6+
count: 1
7+
path: src/test.php
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
includes:
2+
- phpstan-baseline.neon
3+
parameters:
4+
level: 9
5+
paths:
6+
- src
7+
services:
8+
-
9+
class: App\ParameterTypeExtension
10+
tags:
11+
- phpstan.dynamicMethodParameterTypeExtension
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App;
6+
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\MethodReflection;
9+
use PHPStan\Reflection\Native\NativeParameterReflection;
10+
use PHPStan\Reflection\ParameterReflection;
11+
use PHPStan\Reflection\PassedByReference;
12+
use PHPStan\Type\ClosureType;
13+
use PHPStan\Type\Constant\ConstantArrayType;
14+
use PHPStan\Type\Constant\ConstantStringType;
15+
use PHPStan\Type\DynamicMethodParameterTypeExtension;
16+
use PHPStan\Type\MixedType;
17+
use PHPStan\Type\NeverType;
18+
use PHPStan\Type\ObjectType;
19+
use PHPStan\Type\StringType;
20+
use PHPStan\Type\Type;
21+
use PHPStan\Type\TypeCombinator;
22+
use PhpParser\Node\Expr\MethodCall;
23+
use PhpParser\Node\Expr\StaticCall;
24+
use PhpParser\Node\Name;
25+
use PhpParser\Node\VariadicPlaceholder;
26+
27+
final class ParameterTypeExtension implements DynamicMethodParameterTypeExtension
28+
{
29+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
30+
{
31+
if (! $methodReflection->getDeclaringClass()->is(Builder::class)) {
32+
return false;
33+
}
34+
35+
return $methodReflection->getName() === 'with';
36+
}
37+
38+
public function getTypeFromMethodCall(
39+
MethodReflection $methodReflection,
40+
MethodCall $methodCall,
41+
ParameterReflection $parameter,
42+
Scope $scope,
43+
): Type|null {
44+
$arg = $methodCall->getArgs()[0] ?? null;
45+
if (!$arg) {
46+
return null;
47+
}
48+
49+
$type = $scope->getType($arg->value)->getConstantArrays()[0] ?? null;
50+
if (!$type) {
51+
return null;
52+
}
53+
54+
$model = $scope->getType($methodCall->var)
55+
->getTemplateType(Builder::class, 'TModel')
56+
->getObjectClassNames()[0] ?? null;
57+
if (!$model) {
58+
return null;
59+
}
60+
61+
foreach ($type->getKeyTypes() as $keyType) {
62+
$relationType = $this->getRelationTypeFromModel($model, (string) $keyType->getValue(), $scope);
63+
if (!$relationType) {
64+
continue;
65+
}
66+
67+
$newType = new ClosureType([
68+
/** @phpstan-ignore phpstanApi.constructor */
69+
new NativeParameterReflection('test', false, $relationType, PassedByReference::createNo(), false, null),
70+
], new MixedType(), false);
71+
72+
$type = $type->setOffsetValueType($keyType, $newType, false);
73+
}
74+
75+
return $type;
76+
}
77+
78+
public function getRelationTypeFromModel(string $model, string $relation, Scope $scope): ?Type
79+
{
80+
$modelType = new ObjectType($model);
81+
82+
if (! $modelType->hasMethod($relation)->yes()) {
83+
return null;
84+
}
85+
86+
$relationType = $modelType->getMethod($relation, $scope)->getVariants()[0]->getReturnType();
87+
88+
if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
89+
return null;
90+
}
91+
92+
return $relationType;
93+
}
94+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace App;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
abstract class Model {}
8+
9+
class Monitor extends Model {}
10+
11+
class Car extends Model {}
12+
13+
class User extends Model
14+
{
15+
/** @return HasOne<Car, $this> */
16+
public function car(): HasOne
17+
{
18+
return new HasOne(); // @phpstan-ignore return.type
19+
}
20+
21+
/** @return MorphTo<Monitor, $this> */
22+
public function monitorable(): MorphTo
23+
{
24+
return new MorphTo(); // @phpstan-ignore return.type
25+
}
26+
}
27+
28+
/**
29+
* @template TRelatedModel of Model
30+
* @template TDeclaringModel of Model
31+
* @template TResult
32+
*/
33+
class Relation {
34+
/**
35+
* @param list<string> $columns
36+
* @return $this
37+
*/
38+
public function select(array $columns): static
39+
{
40+
return $this;
41+
}
42+
}
43+
44+
/**
45+
* @template TRelatedModel of Model
46+
* @template TDeclaringModel of Model
47+
* @extends Relation<TRelatedModel, TDeclaringModel, ?TRelatedModel>
48+
*/
49+
class HasOne extends Relation {}
50+
51+
/**
52+
* @template TRelatedModel of Model
53+
* @template TDeclaringModel of Model
54+
* @extends Relation<TRelatedModel, TDeclaringModel, ?TRelatedModel>
55+
*/
56+
class MorphTo extends Relation {
57+
/** @return $this */
58+
public function morphWith(): static
59+
{
60+
return $this;
61+
}
62+
}
63+
64+
/** @template TModel of Model */
65+
class Builder
66+
{
67+
/**
68+
* @param array<string, \Closure(Relation<*, *, *>): mixed> $relations
69+
* @return $this
70+
*/
71+
public function with(array $relations): static
72+
{
73+
return $this;
74+
}
75+
}
76+
77+
/** @param Builder<User> $query */
78+
function test(Builder $query): void
79+
{
80+
$query->with([
81+
'car' => function ($r) { assertType('App\HasOne<App\Car, App\User>', $r); },
82+
'monitorable' => function ($r) { assertType('App\MorphTo<App\Monitor, App\User>', $r); },
83+
]);
84+
$query->with([
85+
'car' => fn (HasOne $q) => $q->select(['id']),
86+
'monitorable' => fn (MorphTo $q) => $q->morphWith(),
87+
]);
88+
}

src/Analyser/MutatingScope.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ private function resolveType(string $exprString, Expr $node): Type
984984
if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
985985
$noopCallback = static function (): void {
986986
};
987-
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep());
987+
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep(), null);
988988
$rightBooleanType = $leftResult->getTruthyScope()->getType($node->right)->toBoolean();
989989
} else {
990990
$rightBooleanType = $this->filterByTruthyValue($node->left)->getType($node->right)->toBoolean();
@@ -1016,7 +1016,7 @@ private function resolveType(string $exprString, Expr $node): Type
10161016
if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
10171017
$noopCallback = static function (): void {
10181018
};
1019-
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep());
1019+
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, $noopCallback, ExpressionContext::createDeep(), null);
10201020
$rightBooleanType = $leftResult->getFalseyScope()->getType($node->right)->toBoolean();
10211021
} else {
10221022
$rightBooleanType = $this->filterByFalseyValue($node->left)->getType($node->right)->toBoolean();
@@ -1429,6 +1429,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
14291429
);
14301430
},
14311431
ExpressionContext::createDeep(),
1432+
null,
14321433
);
14331434
$throwPoints = $arrowFunctionExprResult->getThrowPoints();
14341435
$impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints());
@@ -2061,7 +2062,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
20612062
if ($node instanceof Expr\Ternary) {
20622063
$noopCallback = static function (): void {
20632064
};
2064-
$condResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->cond), $node->cond, $this, $noopCallback, ExpressionContext::createDeep());
2065+
$condResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->cond), $node->cond, $this, $noopCallback, ExpressionContext::createDeep(), null);
20652066
if ($node->if === null) {
20662067
$conditionType = $this->getType($node->cond);
20672068
$booleanConditionType = $conditionType->toBoolean();

0 commit comments

Comments
 (0)