Skip to content

Commit

Permalink
Add precise return type for fake() function
Browse files Browse the repository at this point in the history
  • Loading branch information
paulbalandan committed Sep 16, 2023
1 parent 38c4707 commit 62e6d46
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 0 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This extension provides the following features:

* Provides precise return types for `config()` and `model()` functions.
* Provides precise return types for `service()` and `single_service()` functions.
* Provides precise return types for `fake()` helper function.

### Rules

Expand Down Expand Up @@ -76,6 +77,18 @@ parameters:
- Acme\Blog\Config\ServiceFactory
```

When the model passed to `fake()` has the property `$returnType` set to `array`, this extension will give a precise
array shape based on the allowed fields of the model. Most of the time, the formatted fields are strings. If not a string,
you can indicate the format return type for the particular field.

```yml
parameters:
codeigniter:
notStringFormattedFields: # key-value pair of field => format
success: bool
user_id: int
```

## Caveats

1. The behavior of factories functions relative to how they load classes is based on codeigniter4/framework v4.4. If you are
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"require-dev": {
"codeigniter/coding-standard": "^1.7",
"codeigniter4/framework": "^4.3",
"codeigniter4/shield": "^1.0@beta",
"friendsofphp/php-cs-fixer": "^3.20",
"nexusphp/cs-config": "^3.12",
"phpstan/extension-installer": "^1.3",
Expand Down
9 changes: 9 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ parameters:
additionalConfigNamespaces: []
additionalModelNamespaces: []
additionalServices: []
notStringFormattedFields: []
checkArgumentTypeOfFactories: true
checkArgumentTypeOfServices: true

Expand All @@ -17,6 +18,7 @@ parametersSchema:
additionalConfigNamespaces: listOf(string())
additionalModelNamespaces: listOf(string())
additionalServices: listOf(string())
notStringFormattedFields: arrayOf(string())
checkArgumentTypeOfFactories: bool()
checkArgumentTypeOfServices: bool()
])
Expand All @@ -41,6 +43,13 @@ services:
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: CodeIgniter\PHPStan\Type\FakeFunctionReturnTypeExtension
arguments:
notStringFormattedFieldsArray: %codeigniter.notStringFormattedFields%
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: CodeIgniter\PHPStan\Type\ServicesFunctionReturnTypeExtension
tags:
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ parameters:
tmpDir: build/phpstan
bootstrapFiles:
- vendor/codeigniter4/framework/system/Test/bootstrap.php
scanDirectories:
- vendor/codeigniter4/framework/system/Helpers
codeigniter:
additionalModelNamespaces:
- CodeIgniter\PHPStan\Tests\Fixtures\Type
Expand Down
180 changes: 180 additions & 0 deletions src/Type/FakeFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) 2023 CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\PHPStan\Type;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\BooleanType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\NonAcceptingNeverType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use stdClass;

final class FakeFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
/**
* @var array<string, class-string<Type>>
*/
private static array $notStringFormattedFields = [
'success' => BooleanType::class,
'user_id' => IntegerType::class,
];

/**
* @var array<string, class-string<Type>>
*/
private static array $typeInterpolations = [
'bool' => BooleanType::class,
'int' => IntegerType::class,
];

/**
* @var list<string>
*/
private array $dateFields = [];

/**
* @param array<string, string> $notStringFormattedFieldsArray
*/
public function __construct(
private readonly FactoriesReturnTypeHelper $factoriesReturnTypeHelper,
private readonly ReflectionProvider $reflectionProvider,
array $notStringFormattedFieldsArray
) {
foreach ($notStringFormattedFieldsArray as $field => $type) {
if (! isset(self::$typeInterpolations[$type])) {
continue;
}

self::$notStringFormattedFields[$field] = self::$typeInterpolations[$type];
}
}

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->getName() === 'fake';
}

public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
{
$arguments = $functionCall->getArgs();

if ($arguments === []) {
return null;
}

$modelType = $this->factoriesReturnTypeHelper->check($scope->getType($arguments[0]->value), 'model');

if (! $modelType->isObject()->yes()) {
return new NonAcceptingNeverType();
}

$classReflections = $modelType->getObjectClassReflections();

if (count($classReflections) !== 1) {
return $modelType; // ObjectWithoutClassType
}

$classReflection = current($classReflections);

$returnType = $this->getNativeStringPropertyValue($classReflection, $scope, 'returnType');

if ($returnType === 'object') {
return new ObjectType(stdClass::class);
}

if ($returnType === 'array') {
return $this->getArrayReturnType($classReflection, $scope);
}

if ($this->reflectionProvider->hasClass($returnType)) {
return new ObjectType($returnType);
}

return new ObjectWithoutClassType();
}

private function getArrayReturnType(ClassReflection $classReflection, Scope $scope): Type
{
$this->fillDateFields($classReflection, $scope);
$fieldsTypes = $this->getNativePropertyType($classReflection, $scope, 'allowedFields')->getConstantArrays();

if ($fieldsTypes === []) {
return new ConstantArrayType([], []);
}

$fields = array_filter(array_map(
static fn (Type $type) => current($type->getConstantStrings()),
current($fieldsTypes)->getValueTypes()
));

return new ConstantArrayType(
$fields,
array_map(function (ConstantStringType $fieldType) use ($classReflection, $scope): Type {
$field = $fieldType->getValue();

if (array_key_exists($field, self::$notStringFormattedFields)) {
$type = self::$notStringFormattedFields[$field];

return new $type();
}

if (
in_array($field, $this->dateFields, true)
&& $this->getNativeStringPropertyValue($classReflection, $scope, 'dateFormat') === 'int'
) {
return new IntegerType();
}

return new StringType();
}, $fields)
);
}

private function fillDateFields(ClassReflection $classReflection, Scope $scope): void
{
foreach (['createdAt', 'updatedAt', 'deletedAt'] as $property) {
if ($classReflection->hasNativeProperty($property)) {
$this->dateFields[] = $this->getNativeStringPropertyValue($classReflection, $scope, $property);
}
}
}

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

return $scope->getType($classReflection->getNativeProperty($property)->getNativeReflection()->getDefaultValueExpression());
}

private function getNativeStringPropertyValue(ClassReflection $classReflection, Scope $scope, string $property): string
{
$propertyType = $this->getNativePropertyType($classReflection, $scope, $property)->getConstantStrings();
assert(count($propertyType) === 1);

return current($propertyType)->getValue();
}
}
1 change: 1 addition & 0 deletions tests/Fixtures/Type/BarModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@

class BarModel extends Model
{
protected $returnType = 'object';
protected $useAutoIncrement = false;
}
32 changes: 32 additions & 0 deletions tests/Fixtures/Type/fake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) 2023 CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

use CodeIgniter\PHPStan\Tests\Fixtures\Type\BarModel;
use CodeIgniter\Shield\Entities\Login;
use CodeIgniter\Shield\Entities\User;
use CodeIgniter\Shield\Entities\UserIdentity;
use CodeIgniter\Shield\Models\GroupModel;
use CodeIgniter\Shield\Models\LoginModel;
use CodeIgniter\Shield\Models\TokenLoginModel;
use CodeIgniter\Shield\Models\UserIdentityModel;
use CodeIgniter\Shield\Models\UserModel;

use function PHPStan\Testing\assertType;

assertType('never', fake('baz'));
assertType(stdClass::class, fake(BarModel::class));
assertType(User::class, fake(UserModel::class));
assertType(UserIdentity::class, fake(UserIdentityModel::class));
assertType(Login::class, fake(LoginModel::class));
assertType(Login::class, fake(TokenLoginModel::class));
assertType('array{user_id: int, group: string, created_at: string}', fake(GroupModel::class));
2 changes: 2 additions & 0 deletions tests/Type/DynamicFunctionReturnTypeExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public static function provideFileAssertsCases(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/config.php');

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

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

yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/services.php');
Expand Down
9 changes: 9 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,12 @@
*/

require_once __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';

$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../vendor/codeigniter4/framework/system/Helpers'));

/** @var SplFileInfo $helper */
foreach ($iterator as $helper) {
if ($helper->isFile()) {
require_once $helper->getRealPath();
}
}

0 comments on commit 62e6d46

Please sign in to comment.