Skip to content

Commit 62e6d46

Browse files
committed
Add precise return type for fake() function
1 parent 38c4707 commit 62e6d46

9 files changed

+249
-0
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This extension provides the following features:
1313

1414
* Provides precise return types for `config()` and `model()` functions.
1515
* Provides precise return types for `service()` and `single_service()` functions.
16+
* Provides precise return types for `fake()` helper function.
1617

1718
### Rules
1819

@@ -76,6 +77,18 @@ parameters:
7677
- Acme\Blog\Config\ServiceFactory
7778
```
7879

80+
When the model passed to `fake()` has the property `$returnType` set to `array`, this extension will give a precise
81+
array shape based on the allowed fields of the model. Most of the time, the formatted fields are strings. If not a string,
82+
you can indicate the format return type for the particular field.
83+
84+
```yml
85+
parameters:
86+
codeigniter:
87+
notStringFormattedFields: # key-value pair of field => format
88+
success: bool
89+
user_id: int
90+
```
91+
7992
## Caveats
8093

8194
1. The behavior of factories functions relative to how they load classes is based on codeigniter4/framework v4.4. If you are

composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"require-dev": {
2929
"codeigniter/coding-standard": "^1.7",
3030
"codeigniter4/framework": "^4.3",
31+
"codeigniter4/shield": "^1.0@beta",
3132
"friendsofphp/php-cs-fixer": "^3.20",
3233
"nexusphp/cs-config": "^3.12",
3334
"phpstan/extension-installer": "^1.3",

extension.neon

+9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ parameters:
99
additionalConfigNamespaces: []
1010
additionalModelNamespaces: []
1111
additionalServices: []
12+
notStringFormattedFields: []
1213
checkArgumentTypeOfFactories: true
1314
checkArgumentTypeOfServices: true
1415

@@ -17,6 +18,7 @@ parametersSchema:
1718
additionalConfigNamespaces: listOf(string())
1819
additionalModelNamespaces: listOf(string())
1920
additionalServices: listOf(string())
21+
notStringFormattedFields: arrayOf(string())
2022
checkArgumentTypeOfFactories: bool()
2123
checkArgumentTypeOfServices: bool()
2224
])
@@ -41,6 +43,13 @@ services:
4143
tags:
4244
- phpstan.broker.dynamicFunctionReturnTypeExtension
4345

46+
-
47+
class: CodeIgniter\PHPStan\Type\FakeFunctionReturnTypeExtension
48+
arguments:
49+
notStringFormattedFieldsArray: %codeigniter.notStringFormattedFields%
50+
tags:
51+
- phpstan.broker.dynamicFunctionReturnTypeExtension
52+
4453
-
4554
class: CodeIgniter\PHPStan\Type\ServicesFunctionReturnTypeExtension
4655
tags:

phpstan.neon.dist

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ parameters:
1616
tmpDir: build/phpstan
1717
bootstrapFiles:
1818
- vendor/codeigniter4/framework/system/Test/bootstrap.php
19+
scanDirectories:
20+
- vendor/codeigniter4/framework/system/Helpers
1921
codeigniter:
2022
additionalModelNamespaces:
2123
- CodeIgniter\PHPStan\Tests\Fixtures\Type
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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\Type;
15+
16+
use PhpParser\Node\Expr\FuncCall;
17+
use PHPStan\Analyser\Scope;
18+
use PHPStan\Reflection\ClassReflection;
19+
use PHPStan\Reflection\FunctionReflection;
20+
use PHPStan\Reflection\ReflectionProvider;
21+
use PHPStan\ShouldNotHappenException;
22+
use PHPStan\Type\BooleanType;
23+
use PHPStan\Type\Constant\ConstantArrayType;
24+
use PHPStan\Type\Constant\ConstantStringType;
25+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
26+
use PHPStan\Type\IntegerType;
27+
use PHPStan\Type\NonAcceptingNeverType;
28+
use PHPStan\Type\ObjectType;
29+
use PHPStan\Type\ObjectWithoutClassType;
30+
use PHPStan\Type\StringType;
31+
use PHPStan\Type\Type;
32+
use stdClass;
33+
34+
final class FakeFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
35+
{
36+
/**
37+
* @var array<string, class-string<Type>>
38+
*/
39+
private static array $notStringFormattedFields = [
40+
'success' => BooleanType::class,
41+
'user_id' => IntegerType::class,
42+
];
43+
44+
/**
45+
* @var array<string, class-string<Type>>
46+
*/
47+
private static array $typeInterpolations = [
48+
'bool' => BooleanType::class,
49+
'int' => IntegerType::class,
50+
];
51+
52+
/**
53+
* @var list<string>
54+
*/
55+
private array $dateFields = [];
56+
57+
/**
58+
* @param array<string, string> $notStringFormattedFieldsArray
59+
*/
60+
public function __construct(
61+
private readonly FactoriesReturnTypeHelper $factoriesReturnTypeHelper,
62+
private readonly ReflectionProvider $reflectionProvider,
63+
array $notStringFormattedFieldsArray
64+
) {
65+
foreach ($notStringFormattedFieldsArray as $field => $type) {
66+
if (! isset(self::$typeInterpolations[$type])) {
67+
continue;
68+
}
69+
70+
self::$notStringFormattedFields[$field] = self::$typeInterpolations[$type];
71+
}
72+
}
73+
74+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
75+
{
76+
return $functionReflection->getName() === 'fake';
77+
}
78+
79+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
80+
{
81+
$arguments = $functionCall->getArgs();
82+
83+
if ($arguments === []) {
84+
return null;
85+
}
86+
87+
$modelType = $this->factoriesReturnTypeHelper->check($scope->getType($arguments[0]->value), 'model');
88+
89+
if (! $modelType->isObject()->yes()) {
90+
return new NonAcceptingNeverType();
91+
}
92+
93+
$classReflections = $modelType->getObjectClassReflections();
94+
95+
if (count($classReflections) !== 1) {
96+
return $modelType; // ObjectWithoutClassType
97+
}
98+
99+
$classReflection = current($classReflections);
100+
101+
$returnType = $this->getNativeStringPropertyValue($classReflection, $scope, 'returnType');
102+
103+
if ($returnType === 'object') {
104+
return new ObjectType(stdClass::class);
105+
}
106+
107+
if ($returnType === 'array') {
108+
return $this->getArrayReturnType($classReflection, $scope);
109+
}
110+
111+
if ($this->reflectionProvider->hasClass($returnType)) {
112+
return new ObjectType($returnType);
113+
}
114+
115+
return new ObjectWithoutClassType();
116+
}
117+
118+
private function getArrayReturnType(ClassReflection $classReflection, Scope $scope): Type
119+
{
120+
$this->fillDateFields($classReflection, $scope);
121+
$fieldsTypes = $this->getNativePropertyType($classReflection, $scope, 'allowedFields')->getConstantArrays();
122+
123+
if ($fieldsTypes === []) {
124+
return new ConstantArrayType([], []);
125+
}
126+
127+
$fields = array_filter(array_map(
128+
static fn (Type $type) => current($type->getConstantStrings()),
129+
current($fieldsTypes)->getValueTypes()
130+
));
131+
132+
return new ConstantArrayType(
133+
$fields,
134+
array_map(function (ConstantStringType $fieldType) use ($classReflection, $scope): Type {
135+
$field = $fieldType->getValue();
136+
137+
if (array_key_exists($field, self::$notStringFormattedFields)) {
138+
$type = self::$notStringFormattedFields[$field];
139+
140+
return new $type();
141+
}
142+
143+
if (
144+
in_array($field, $this->dateFields, true)
145+
&& $this->getNativeStringPropertyValue($classReflection, $scope, 'dateFormat') === 'int'
146+
) {
147+
return new IntegerType();
148+
}
149+
150+
return new StringType();
151+
}, $fields)
152+
);
153+
}
154+
155+
private function fillDateFields(ClassReflection $classReflection, Scope $scope): void
156+
{
157+
foreach (['createdAt', 'updatedAt', 'deletedAt'] as $property) {
158+
if ($classReflection->hasNativeProperty($property)) {
159+
$this->dateFields[] = $this->getNativeStringPropertyValue($classReflection, $scope, $property);
160+
}
161+
}
162+
}
163+
164+
private function getNativePropertyType(ClassReflection $classReflection, Scope $scope, string $property): Type
165+
{
166+
if (! $classReflection->hasNativeProperty($property)) {
167+
throw new ShouldNotHappenException(sprintf('Native property %s::$%s does not exist.', $classReflection->getDisplayName(), $property));
168+
}
169+
170+
return $scope->getType($classReflection->getNativeProperty($property)->getNativeReflection()->getDefaultValueExpression());
171+
}
172+
173+
private function getNativeStringPropertyValue(ClassReflection $classReflection, Scope $scope, string $property): string
174+
{
175+
$propertyType = $this->getNativePropertyType($classReflection, $scope, $property)->getConstantStrings();
176+
assert(count($propertyType) === 1);
177+
178+
return current($propertyType)->getValue();
179+
}
180+
}

tests/Fixtures/Type/BarModel.php

+1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717

1818
class BarModel extends Model
1919
{
20+
protected $returnType = 'object';
2021
protected $useAutoIncrement = false;
2122
}

tests/Fixtures/Type/fake.php

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
use CodeIgniter\PHPStan\Tests\Fixtures\Type\BarModel;
15+
use CodeIgniter\Shield\Entities\Login;
16+
use CodeIgniter\Shield\Entities\User;
17+
use CodeIgniter\Shield\Entities\UserIdentity;
18+
use CodeIgniter\Shield\Models\GroupModel;
19+
use CodeIgniter\Shield\Models\LoginModel;
20+
use CodeIgniter\Shield\Models\TokenLoginModel;
21+
use CodeIgniter\Shield\Models\UserIdentityModel;
22+
use CodeIgniter\Shield\Models\UserModel;
23+
24+
use function PHPStan\Testing\assertType;
25+
26+
assertType('never', fake('baz'));
27+
assertType(stdClass::class, fake(BarModel::class));
28+
assertType(User::class, fake(UserModel::class));
29+
assertType(UserIdentity::class, fake(UserIdentityModel::class));
30+
assertType(Login::class, fake(LoginModel::class));
31+
assertType(Login::class, fake(TokenLoginModel::class));
32+
assertType('array{user_id: int, group: string, created_at: string}', fake(GroupModel::class));

tests/Type/DynamicFunctionReturnTypeExtensionTest.php

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public static function provideFileAssertsCases(): iterable
3636
{
3737
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/config.php');
3838

39+
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/fake.php');
40+
3941
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/model.php');
4042

4143
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/services.php');

tests/bootstrap.php

+9
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,12 @@
1212
*/
1313

1414
require_once __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';
15+
16+
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../vendor/codeigniter4/framework/system/Helpers'));
17+
18+
/** @var SplFileInfo $helper */
19+
foreach ($iterator as $helper) {
20+
if ($helper->isFile()) {
21+
require_once $helper->getRealPath();
22+
}
23+
}

0 commit comments

Comments
 (0)