Skip to content

Commit 0e8d796

Browse files
committed
Type Model find/findAll/first returns from the live schema
1 parent b24ce51 commit 0e8d796

6 files changed

Lines changed: 371 additions & 0 deletions

File tree

extension.neon

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ services:
5959
columnTypeResolver:
6060
class: CodeIgniter\PHPStan\Database\Schema\ColumnTypeResolver
6161

62+
modelFetchedReturnTypeHelper:
63+
class: CodeIgniter\PHPStan\Type\ModelFetchedReturnTypeHelper
64+
6265
factoriesReturnTypeHelper:
6366
class: CodeIgniter\PHPStan\Helpers\FactoriesReturnTypeHelper
6467
arguments:
@@ -86,6 +89,11 @@ services:
8689
tags:
8790
- phpstan.parser.richParserNodeVisitor
8891

92+
-
93+
class: CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor
94+
tags:
95+
- phpstan.parser.richParserNodeVisitor
96+
8997
-
9098
class: CodeIgniter\PHPStan\Type\ReflectionHelperMethodInvokerReturnTypeExtension
9199
arguments:
@@ -115,6 +123,11 @@ services:
115123
tags:
116124
- phpstan.broker.dynamicMethodReturnTypeExtension
117125

126+
-
127+
class: CodeIgniter\PHPStan\Type\ModelFindReturnTypeExtension
128+
tags:
129+
- phpstan.broker.dynamicMethodReturnTypeExtension
130+
118131
-
119132
class: CodeIgniter\PHPStan\Type\ServicesGetSharedInstanceReturnTypeExtension
120133
tags:
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 <admin@codeigniter.com>
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+
/**
23+
* Annotates `find()`/`findAll()`/`first()` calls with the effective return type forced by an
24+
* `asArray()` or `asObject()` earlier in the same call chain.
25+
*/
26+
final class ModelReturnTypeTransformVisitor extends NodeVisitorAbstract
27+
{
28+
public const RETURN_TYPE = 'returnType';
29+
30+
private const RETURN_TYPE_GETTERS = ['find', 'findAll', 'first'];
31+
32+
private const RETURN_TYPE_TRANSFORMERS = ['asArray', 'asObject'];
33+
34+
/**
35+
* @return null
36+
*/
37+
public function enterNode(Node $node)
38+
{
39+
if (! $node instanceof MethodCall) {
40+
return null;
41+
}
42+
43+
if (! $node->name instanceof Identifier) {
44+
return null;
45+
}
46+
47+
if (! in_array($node->name->name, self::RETURN_TYPE_GETTERS, true)) {
48+
return null;
49+
}
50+
51+
$lastNode = $node;
52+
53+
while ($node->var instanceof MethodCall) {
54+
$node = $node->var;
55+
56+
if (! $node->name instanceof Identifier) {
57+
continue;
58+
}
59+
60+
if (! in_array($node->name->name, self::RETURN_TYPE_TRANSFORMERS, true)) {
61+
continue;
62+
}
63+
64+
if ($node->name->name === 'asArray') {
65+
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('array'));
66+
67+
break;
68+
}
69+
70+
$args = $node->getArgs();
71+
72+
if ($args === []) {
73+
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('object'));
74+
75+
break;
76+
}
77+
78+
$lastNode->setAttribute(self::RETURN_TYPE, $args[0]->value);
79+
80+
break;
81+
}
82+
83+
return null;
84+
}
85+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 <admin@codeigniter.com>
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 CodeIgniter\PHPStan\Database\Schema\ColumnTypeResolver;
17+
use CodeIgniter\PHPStan\Database\SchemaProvider;
18+
use CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor;
19+
use PhpParser\Node\Expr;
20+
use PhpParser\Node\Expr\MethodCall;
21+
use PHPStan\Analyser\Scope;
22+
use PHPStan\Reflection\ClassReflection;
23+
use PHPStan\Reflection\ReflectionProvider;
24+
use PHPStan\Type\ArrayType;
25+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
26+
use PHPStan\Type\Constant\ConstantStringType;
27+
use PHPStan\Type\MixedType;
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+
/**
35+
* Resolves the type of a single fetched row for a model, honoring its `$returnType` (or the
36+
* `asArray()`/`asObject()` override) and shaping array rows from the live table columns.
37+
*/
38+
final class ModelFetchedReturnTypeHelper
39+
{
40+
public function __construct(
41+
private readonly ReflectionProvider $reflectionProvider,
42+
private readonly SchemaProvider $schemaProvider,
43+
private readonly ColumnTypeResolver $columnTypeResolver,
44+
) {}
45+
46+
public function getFetchedReturnType(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): Type
47+
{
48+
$returnType = $this->resolveReturnType($classReflection, $methodCall, $scope);
49+
50+
if ($returnType === 'object') {
51+
return new ObjectType(stdClass::class);
52+
}
53+
54+
if ($returnType === 'array') {
55+
return $this->resolveArrayRowType($classReflection);
56+
}
57+
58+
if ($this->reflectionProvider->hasClass($returnType)) {
59+
return new ObjectType($returnType);
60+
}
61+
62+
return new ObjectWithoutClassType();
63+
}
64+
65+
private function resolveReturnType(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): string
66+
{
67+
if ($methodCall !== null && $methodCall->hasAttribute(ModelReturnTypeTransformVisitor::RETURN_TYPE)) {
68+
$expr = $methodCall->getAttribute(ModelReturnTypeTransformVisitor::RETURN_TYPE);
69+
70+
if ($expr instanceof Expr) {
71+
$strings = $scope->getType($expr)->getConstantStrings();
72+
73+
if (count($strings) === 1) {
74+
return $strings[0]->getValue();
75+
}
76+
}
77+
}
78+
79+
$returnType = $classReflection->getNativeReflection()->getDefaultProperties()['returnType'] ?? 'array';
80+
81+
return is_string($returnType) ? $returnType : 'array';
82+
}
83+
84+
private function resolveArrayRowType(ClassReflection $classReflection): Type
85+
{
86+
$tableName = $classReflection->getNativeReflection()->getDefaultProperties()['table'] ?? null;
87+
88+
$table = is_string($tableName) && $tableName !== '' ? $this->schemaProvider->get()->getTable($tableName) : null;
89+
90+
if ($table === null) {
91+
return new ArrayType(new StringType(), new MixedType());
92+
}
93+
94+
$builder = ConstantArrayTypeBuilder::createEmpty();
95+
96+
foreach ($table->columns as $column) {
97+
$builder->setOffsetValueType(new ConstantStringType($column->name), $this->columnTypeResolver->resolve($column));
98+
}
99+
100+
return $builder->getArray();
101+
}
102+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 <admin@codeigniter.com>
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 CodeIgniter\Model;
17+
use PhpParser\Node\Expr\MethodCall;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\Reflection\ClassReflection;
20+
use PHPStan\Reflection\MethodReflection;
21+
use PHPStan\Type\Accessory\AccessoryArrayListType;
22+
use PHPStan\Type\ArrayType;
23+
use PHPStan\Type\Constant\ConstantArrayType;
24+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
25+
use PHPStan\Type\IntegerType;
26+
use PHPStan\Type\IntersectionType;
27+
use PHPStan\Type\Type;
28+
use PHPStan\Type\TypeCombinator;
29+
use PHPStan\Type\TypeTraverser;
30+
use PHPStan\Type\UnionType;
31+
32+
final class ModelFindReturnTypeExtension implements DynamicMethodReturnTypeExtension
33+
{
34+
public function __construct(
35+
private readonly ModelFetchedReturnTypeHelper $modelFetchedReturnTypeHelper,
36+
) {}
37+
38+
public function getClass(): string
39+
{
40+
return Model::class;
41+
}
42+
43+
public function isMethodSupported(MethodReflection $methodReflection): bool
44+
{
45+
return in_array($methodReflection->getName(), ['find', 'findAll', 'first'], true);
46+
}
47+
48+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
49+
{
50+
$methodName = $methodReflection->getName();
51+
52+
if ($methodName === 'find') {
53+
return $this->getTypeFromFind($methodCall, $scope);
54+
}
55+
56+
if ($methodName === 'findAll') {
57+
return $this->getTypeFromFindAll($methodCall, $scope);
58+
}
59+
60+
$classReflection = $this->getClassReflection($methodCall, $scope);
61+
62+
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope));
63+
}
64+
65+
private function getClassReflection(MethodCall $methodCall, Scope $scope): ClassReflection
66+
{
67+
$classTypes = $scope->getType($methodCall->var)->getObjectClassReflections();
68+
assert(count($classTypes) === 1);
69+
70+
return current($classTypes);
71+
}
72+
73+
private function getTypeFromFind(MethodCall $methodCall, Scope $scope): Type
74+
{
75+
$args = $methodCall->getArgs();
76+
77+
if (! isset($args[0])) {
78+
return $this->getTypeFromFindAll($methodCall, $scope);
79+
}
80+
81+
return TypeTraverser::map(
82+
$scope->getType($args[0]->value),
83+
function (Type $idType, callable $traverse) use ($methodCall, $scope): Type {
84+
if ($idType instanceof UnionType || $idType instanceof IntersectionType) {
85+
return $traverse($idType);
86+
}
87+
88+
if ($idType->isArray()->yes() && ! $idType->isIterableAtLeastOnce()->yes()) {
89+
return new ConstantArrayType([], []);
90+
}
91+
92+
if ($idType->isInteger()->yes() || $idType->isString()->yes()) {
93+
$classReflection = $this->getClassReflection($methodCall, $scope);
94+
95+
return TypeCombinator::addNull($this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope));
96+
}
97+
98+
return $this->getTypeFromFindAll($methodCall, $scope);
99+
},
100+
);
101+
}
102+
103+
private function getTypeFromFindAll(MethodCall $methodCall, Scope $scope): Type
104+
{
105+
$classReflection = $this->getClassReflection($methodCall, $scope);
106+
107+
return TypeCombinator::intersect(
108+
new ArrayType(
109+
new IntegerType(),
110+
$this->modelFetchedReturnTypeHelper->getFetchedReturnType($classReflection, $methodCall, $scope),
111+
),
112+
new AccessoryArrayListType(),
113+
);
114+
}
115+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 <admin@codeigniter.com>
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\Tests\Fixtures\Models;
15+
16+
use CodeIgniter\Model;
17+
18+
final class BlogPostModel extends Model
19+
{
20+
protected $table = 'blog_posts';
21+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 <admin@codeigniter.com>
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\Tests\Type;
15+
16+
use CodeIgniter\PHPStan\Tests\Fixtures\Models\BlogCommentModel;
17+
use CodeIgniter\PHPStan\Tests\Fixtures\Models\BlogPostModel;
18+
19+
use function PHPStan\Testing\assertType;
20+
21+
$comments = new BlogCommentModel();
22+
23+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment|null', $comments->find(1));
24+
assertType('list<CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment>', $comments->find([1, 2]));
25+
assertType('list<CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment>', $comments->find());
26+
assertType('list<CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment>', $comments->findAll());
27+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment|null', $comments->first());
28+
29+
assertType('array{id: int, body: string|null, votes: int, payload: string|null, created_at: string|null}|null', $comments->asArray()->first());
30+
assertType('stdClass|null', $comments->asObject()->first());
31+
32+
$posts = new BlogPostModel();
33+
34+
assertType('array{id: int, user_id: int, title: string}|null', $posts->first());
35+
assertType('list<array{id: int, user_id: int, title: string}>', $posts->findAll());

0 commit comments

Comments
 (0)