Skip to content

Commit ab2fafd

Browse files
committed
feat: get array types working (pass variable around)
1 parent 176e995 commit ab2fafd

16 files changed

+879
-132
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,10 @@ jobs:
414414
cd e2e/bug-14036
415415
composer install
416416
../../bin/phpstan analyze
417+
- script: |
418+
cd e2e/parameter-type-extension
419+
composer install
420+
../../bin/phpstan analyze
417421
418422
steps:
419423
- 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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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\ParameterReflection;
10+
use PHPStan\Reflection\PassedByReference;
11+
use PHPStan\Type\ClosureType;
12+
use PHPStan\Type\DynamicMethodParameterTypeExtension;
13+
use PHPStan\Type\MixedType;
14+
use PHPStan\Type\ObjectType;
15+
use PHPStan\Type\Type;
16+
use PhpParser\Node\Expr\MethodCall;
17+
18+
final class ParameterTypeExtension implements DynamicMethodParameterTypeExtension
19+
{
20+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
21+
{
22+
if (! $methodReflection->getDeclaringClass()->is(Builder::class)) {
23+
return false;
24+
}
25+
26+
return $methodReflection->getName() === 'with';
27+
}
28+
29+
public function getTypeFromMethodCall(
30+
MethodReflection $methodReflection,
31+
MethodCall $methodCall,
32+
ParameterReflection $parameter,
33+
Scope $scope,
34+
): Type|null {
35+
$arg = $methodCall->getArgs()[0] ?? null;
36+
if (!$arg) {
37+
return null;
38+
}
39+
40+
$type = $scope->getType($arg->value)->getConstantArrays()[0] ?? null;
41+
if (!$type) {
42+
return null;
43+
}
44+
45+
$model = $scope->getType($methodCall->var)
46+
->getTemplateType(Builder::class, 'TModel')
47+
->getObjectClassNames()[0] ?? null;
48+
if (!$model) {
49+
return null;
50+
}
51+
52+
foreach ($type->getKeyTypes() as $keyType) {
53+
$relationType = $this->getRelationTypeFromModel($model, (string) $keyType->getValue(), $scope);
54+
if (!$relationType) {
55+
continue;
56+
}
57+
58+
$newType = new ClosureType([
59+
new class('test', $relationType) implements ParameterReflection {
60+
public function __construct(private string $name, private Type $type) {}
61+
public function getName(): string
62+
{
63+
return $this->name;
64+
}
65+
public function isOptional(): bool
66+
{
67+
return false;
68+
}
69+
public function getType(): Type
70+
{
71+
return $this->type;
72+
}
73+
public function passedByReference(): PassedByReference
74+
{
75+
return PassedByReference::createNo();
76+
}
77+
public function isVariadic(): bool
78+
{
79+
return false;
80+
}
81+
public function getDefaultValue(): ?Type
82+
{
83+
return null;
84+
}
85+
},
86+
], new MixedType(), false);
87+
88+
$type = $type->setOffsetValueType($keyType, $newType, false);
89+
}
90+
91+
return $type;
92+
}
93+
94+
public function getRelationTypeFromModel(string $model, string $relation, Scope $scope): ?Type
95+
{
96+
$modelType = new ObjectType($model);
97+
98+
if (! $modelType->hasMethod($relation)->yes()) {
99+
return null;
100+
}
101+
102+
$relationType = $modelType->getMethod($relation, $scope)->getVariants()[0]->getReturnType();
103+
104+
if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
105+
return null;
106+
}
107+
108+
return $relationType;
109+
}
110+
}
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
@@ -1066,7 +1066,7 @@ private function resolveType(string $exprString, Expr $node): Type
10661066
}
10671067

10681068
if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
1069-
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep());
1069+
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep(), null);
10701070
$rightBooleanType = $leftResult->getTruthyScope()->getType($node->right)->toBoolean();
10711071
} else {
10721072
$rightBooleanType = $this->filterByTruthyValue($node->left)->getType($node->right)->toBoolean();
@@ -1096,7 +1096,7 @@ private function resolveType(string $exprString, Expr $node): Type
10961096
}
10971097

10981098
if ($this->getBooleanExpressionDepth($node->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
1099-
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep());
1099+
$leftResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->left), $node->left, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep(), null);
11001100
$rightBooleanType = $leftResult->getFalseyScope()->getType($node->right)->toBoolean();
11011101
} else {
11021102
$rightBooleanType = $this->filterByFalseyValue($node->left)->getType($node->right)->toBoolean();
@@ -5768,6 +5768,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
57685768
);
57695769
},
57705770
ExpressionContext::createDeep(),
5771+
null,
57715772
);
57725773
$throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints());
57735774
$impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints());
@@ -6463,7 +6464,7 @@ private function getMethodCallType(MethodCall $node): ?Type
64636464

64646465
private function getTernaryType(Expr\Ternary $node): Type
64656466
{
6466-
$condResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->cond), $node->cond, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep());
6467+
$condResult = $this->nodeScopeResolver->processExprNode(new Node\Stmt\Expression($node->cond), $node->cond, $this, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep(), null);
64676468
if ($node->if === null) {
64686469
$conditionType = $this->getType($node->cond);
64696470
$booleanConditionType = $conditionType->toBoolean();

0 commit comments

Comments
 (0)