Skip to content

Commit a842243

Browse files
committed
feat: get array types working (pass variable around)
1 parent b6331ba commit a842243

80 files changed

Lines changed: 961 additions & 203 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/e2e-tests.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,10 @@ jobs:
634634
echo "FAIL: tmp was created by a worker, meaning sys_temp_dir='tmp~1' was incorrectly evaluated to 'tmp'"
635635
exit 1
636636
fi
637+
- script: |
638+
cd e2e/parameter-type-extension
639+
composer install
640+
../../bin/phpstan analyze
637641
638642
steps:
639643
- name: Harden the runner (Audit all outbound calls)
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/ExprHandler.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public function processExpr(
3232
ExpressionResultStorage $storage,
3333
callable $nodeCallback,
3434
ExpressionContext $context,
35+
?Type $overriddenType,
3536
): ExpressionResult;
3637

3738
/**

‎src/Analyser/ExprHandler/ArrayDimFetchHandler.php‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
7979
);
8080
}
8181

82-
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
82+
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, ?Type $overriddenType): ExpressionResult
8383
{
8484
$beforeScope = $scope;
8585
if ($expr->dim === null) {
86-
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep());
86+
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep(), null);
8787
$scope = $varResult->getScope();
8888

8989
return $this->expressionResultFactory->create(
@@ -98,8 +98,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
9898
);
9999
}
100100

101-
$dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->dim, $scope, $storage, $nodeCallback, $context->enterDeep());
102-
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $dimResult->getScope(), $storage, $nodeCallback, $context->enterDeep());
101+
$dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->dim, $scope, $storage, $nodeCallback, $context->enterDeep(), null);
102+
$varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $dimResult->getScope(), $storage, $nodeCallback, $context->enterDeep(), null);
103103
$throwPoints = array_merge($dimResult->getThrowPoints(), $varResult->getThrowPoints());
104104
$impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints());
105105
$scope = $varResult->getScope();
@@ -113,6 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
113113
$storage,
114114
new NoopNodeCallback(),
115115
$context,
116+
null,
116117
)->getThrowPoints());
117118
}
118119

‎src/Analyser/ExprHandler/ArrayHandler.php‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use PHPStan\Node\LiteralArrayNode;
2525
use PHPStan\Reflection\InitializerExprTypeResolver;
2626
use PHPStan\Type\CallableType;
27+
use PHPStan\Type\Constant\ConstantIntegerType;
2728
use PHPStan\Type\Type;
2829
use PHPStan\Type\TypeCombinator;
2930
use function array_merge;
@@ -72,27 +73,41 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
7273
return $type;
7374
}
7475

75-
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
76+
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, ?Type $overriddenType): ExpressionResult
7677
{
7778
$beforeScope = $scope;
7879
$itemNodes = [];
7980
$hasYield = false;
8081
$throwPoints = [];
8182
$impurePoints = [];
8283
$isAlwaysTerminating = false;
84+
$nextAutoIndex = 0;
8385
foreach ($expr->items as $arrayItem) {
8486
$itemNodes[] = new LiteralArrayItem($scope, $arrayItem);
8587
$nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage);
88+
$keyType = new ConstantIntegerType($nextAutoIndex);
8689
if ($arrayItem->key !== null) {
87-
$keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep());
90+
$keyType = $scope->getType($arrayItem->key);
91+
$keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep(), null);
8892
$hasYield = $hasYield || $keyResult->hasYield();
8993
$throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints());
9094
$impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints());
9195
$isAlwaysTerminating = $isAlwaysTerminating || $keyResult->isAlwaysTerminating();
9296
$scope = $keyResult->getScope();
9397
}
9498

95-
$valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep());
99+
$overriddenValueType = null;
100+
if ($overriddenType !== null && $overriddenType->hasOffsetValueType($keyType)->yes()) {
101+
$overriddenValueType = $overriddenType->getOffsetValueType($keyType);
102+
}
103+
104+
if ($arrayItem->key === null) {
105+
$nextAutoIndex++;
106+
} elseif ($keyType instanceof ConstantIntegerType) {
107+
$nextAutoIndex = $keyType->getValue() + 1;
108+
}
109+
110+
$valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep(), $overriddenValueType);
96111
$hasYield = $hasYield || $valueResult->hasYield();
97112
$throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints());
98113
$impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints());

0 commit comments

Comments
 (0)