Skip to content

Commit c19bd2b

Browse files
committed
Allow dynamic return type transformation
1 parent 5ab82a4 commit c19bd2b

7 files changed

+139
-15
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This extension provides the following features:
1515
* Provides precise return types for `service()` and `single_service()` functions.
1616
* Provides precise return types for `fake()` helper function.
1717
* Provides precise return types for `CodeIgniter\Model`'s `find()`, `findAll()`, and `first()` methods.
18+
* Allows dynamic return type transformation of `CodeIgniter\Model` when `asArray()` or `asObject()` is called.
1819

1920
### Rules
2021

extension.neon

+6
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ services:
4444
superglobalRuleHelper:
4545
class: CodeIgniter\PHPStan\Rules\Superglobals\SuperglobalRuleHelper
4646

47+
# Node Visitors
48+
-
49+
class: CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor
50+
tags:
51+
- phpstan.parser.richParserNodeVisitor
52+
4753
# DynamicFunctionReturnTypeExtension
4854
-
4955
class: CodeIgniter\PHPStan\Type\FactoriesFunctionReturnTypeExtension
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\NodeVisitor;
15+
16+
use PhpParser\Node;
17+
use PhpParser\Node\Expr\MethodCall;
18+
use PhpParser\Node\Identifier;
19+
use PhpParser\Node\Scalar;
20+
use PhpParser\NodeVisitorAbstract;
21+
22+
final class ModelReturnTypeTransformVisitor extends NodeVisitorAbstract
23+
{
24+
public const RETURN_TYPE = 'returnType';
25+
26+
/**
27+
* @var list<string>
28+
*/
29+
private const RETURN_TYPE_GETTERS = ['find', 'findAll', 'first'];
30+
31+
/**
32+
* @var list<string>
33+
*/
34+
private const RETURN_TYPE_TRANSFORMERS = ['asArray', 'asObject'];
35+
36+
/**
37+
* @return null
38+
*/
39+
public function enterNode(Node $node)
40+
{
41+
if (! $node instanceof MethodCall) {
42+
return null;
43+
}
44+
45+
if (! $node->name instanceof Identifier) {
46+
return null;
47+
}
48+
49+
if (! in_array($node->name->name, self::RETURN_TYPE_GETTERS, true)) {
50+
return null;
51+
}
52+
53+
$lastNode = $node;
54+
55+
while ($node->var instanceof MethodCall) {
56+
$node = $node->var;
57+
58+
if (! $node->name instanceof Identifier) {
59+
continue;
60+
}
61+
62+
if (! in_array($node->name->name, self::RETURN_TYPE_TRANSFORMERS, true)) {
63+
continue;
64+
}
65+
66+
if ($node->name->name === 'asArray') {
67+
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('array'));
68+
break;
69+
}
70+
71+
$args = $node->getArgs();
72+
73+
if ($args === []) {
74+
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('object'));
75+
break;
76+
}
77+
78+
$lastNode->setAttribute(self::RETURN_TYPE, $args[0]->value);
79+
break;
80+
}
81+
82+
return null;
83+
}
84+
}

src/Type/FakeFunctionReturnTypeExtension.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5454

5555
$classReflection = current($classReflections);
5656

57-
return $this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $scope);
57+
return $this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, null, $scope);
5858
}
5959
}

src/Type/ModelFetchedReturnTypeHelper.php

+28-11
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
namespace CodeIgniter\PHPStan\Type;
1515

16+
use CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor;
17+
use PhpParser\Node\Expr;
18+
use PhpParser\Node\Expr\MethodCall;
1619
use PHPStan\Analyser\Scope;
1720
use PHPStan\Reflection\ClassReflection;
1821
use PHPStan\Reflection\ReflectionProvider;
@@ -33,8 +36,11 @@ final class ModelFetchedReturnTypeHelper
3336
* @var array<string, class-string<Type>>
3437
*/
3538
private static array $notStringFormattedFields = [
36-
'success' => BooleanType::class,
37-
'user_id' => IntegerType::class,
39+
'active' => BooleanType::class,
40+
'force_reset' => BooleanType::class,
41+
'id' => IntegerType::class,
42+
'success' => BooleanType::class,
43+
'user_id' => IntegerType::class,
3844
];
3945

4046
/**
@@ -66,9 +72,15 @@ public function __construct(
6672
}
6773
}
6874

69-
public function getFetchedReturnType(ClassReflection $classReflection, Scope $scope): Type
75+
public function getFetchedReturnType(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): Type
7076
{
71-
$returnType = $this->getNativeStringPropertyValue($classReflection, $scope, 'returnType');
77+
$returnType = $this->getNativeStringPropertyValue($classReflection, $scope, ModelReturnTypeTransformVisitor::RETURN_TYPE);
78+
79+
if ($methodCall !== null && $methodCall->hasAttribute(ModelReturnTypeTransformVisitor::RETURN_TYPE)) {
80+
/** @var Expr $returnExpr */
81+
$returnExpr = $methodCall->getAttribute(ModelReturnTypeTransformVisitor::RETURN_TYPE);
82+
$returnType = $this->getStringValueFromExpr($returnExpr, $scope);
83+
}
7284

7385
if ($returnType === 'object') {
7486
return new ObjectType(stdClass::class);
@@ -88,7 +100,9 @@ public function getFetchedReturnType(ClassReflection $classReflection, Scope $sc
88100
private function getArrayReturnType(ClassReflection $classReflection, Scope $scope): Type
89101
{
90102
$this->fillDateFields($classReflection, $scope);
91-
$fieldsTypes = $this->getNativePropertyType($classReflection, $scope, 'allowedFields')->getConstantArrays();
103+
$fieldsTypes = $scope->getType(
104+
$classReflection->getNativeProperty('allowedFields')->getNativeReflection()->getDefaultValueExpression()
105+
)->getConstantArrays();
92106

93107
if ($fieldsTypes === []) {
94108
return new ConstantArrayType([], []);
@@ -131,20 +145,23 @@ private function fillDateFields(ClassReflection $classReflection, Scope $scope):
131145
}
132146
}
133147

134-
private function getNativePropertyType(ClassReflection $classReflection, Scope $scope, string $property): Type
148+
private function getNativeStringPropertyValue(ClassReflection $classReflection, Scope $scope, string $property): string
135149
{
136150
if (! $classReflection->hasNativeProperty($property)) {
137151
throw new ShouldNotHappenException(sprintf('Native property %s::$%s does not exist.', $classReflection->getDisplayName(), $property));
138152
}
139153

140-
return $scope->getType($classReflection->getNativeProperty($property)->getNativeReflection()->getDefaultValueExpression());
154+
return $this->getStringValueFromExpr(
155+
$classReflection->getNativeProperty($property)->getNativeReflection()->getDefaultValueExpression(),
156+
$scope
157+
);
141158
}
142159

143-
private function getNativeStringPropertyValue(ClassReflection $classReflection, Scope $scope, string $property): string
160+
private function getStringValueFromExpr(Expr $expr, Scope $scope): string
144161
{
145-
$propertyType = $this->getNativePropertyType($classReflection, $scope, $property)->getConstantStrings();
146-
assert(count($propertyType) === 1);
162+
$exprType = $scope->getType($expr)->getConstantStrings();
163+
assert(count($exprType) === 1);
147164

148-
return current($propertyType)->getValue();
165+
return current($exprType)->getValue();
149166
}
150167
}

src/Type/ModelFindReturnTypeExtension.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
5858

5959
$classReflection = $this->getClassReflection($methodCall, $scope);
6060

61-
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $scope));
61+
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope));
6262
}
6363

6464
private function getClassReflection(MethodCall $methodCall, Scope $scope): ClassReflection
@@ -91,7 +91,7 @@ function (Type $idType, callable $traverse) use ($methodReflection, $methodCall,
9191
if ($idType->isInteger()->yes() || $idType->isString()->yes()) {
9292
$classReflection = $this->getClassReflection($methodCall, $scope);
9393

94-
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $scope));
94+
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope));
9595
}
9696

9797
return $this->getTypeFromFindAll($methodReflection, $methodCall, $scope);
@@ -106,7 +106,7 @@ private function getTypeFromFindAll(MethodReflection $methodReflection, MethodCa
106106
return AccessoryArrayListType::intersectWith(
107107
new ArrayType(
108108
new IntegerType(),
109-
$this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $scope)
109+
$this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope)
110110
)
111111
);
112112
}

tests/Fixtures/Type/model-find.php

+16
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
* the LICENSE file that was distributed with this source code.
1212
*/
1313

14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Type;
15+
16+
use CodeIgniter\Shield\Entities\AccessToken;
1417
use CodeIgniter\Shield\Models\GroupModel;
1518
use CodeIgniter\Shield\Models\UserModel;
1619

@@ -37,3 +40,16 @@ function bar($id): void
3740

3841
assertType('list<CodeIgniter\Shield\Entities\User>|CodeIgniter\Shield\Entities\User|null', $model->find($id));
3942
}
43+
44+
function foo(): void
45+
{
46+
$model = model(UserModel::class);
47+
48+
assertType('CodeIgniter\Shield\Entities\AccessToken|null', $model->asObject(AccessToken::class)->first());
49+
assertType('stdClass|null', $model->asObject()->find(1));
50+
assertType('stdClass|null', $model->asObject('object')->find(45));
51+
assertType('list<array{username: string, status: string, status_message: string, active: bool, last_active: string, deleted_at: string}>', $model->asArray()->findAll());
52+
53+
assertType('stdClass|null', $model->asArray()->asObject()->first());
54+
assertType('array{username: string, status: string, status_message: string, active: bool, last_active: string, deleted_at: string}|null', $model->asObject()->asArray()->first());
55+
}

0 commit comments

Comments
 (0)