Skip to content

Commit a3575f5

Browse files
committed
Shape model rows from select() fields, aliases, and joins
1 parent d7e5053 commit a3575f5

3 files changed

Lines changed: 188 additions & 32 deletions

File tree

src/NodeVisitor/ModelReturnTypeTransformVisitor.php

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@
2121

2222
/**
2323
* Annotates `find()`/`findAll()`/`first()` calls with the effective return type forced by an
24-
* `asArray()` or `asObject()` earlier in the same call chain.
24+
* `asArray()`/`asObject()` and with the `select()` argument expressions earlier in the same chain.
2525
*/
2626
final class ModelReturnTypeTransformVisitor extends NodeVisitorAbstract
2727
{
28-
public const RETURN_TYPE = 'returnType';
29-
30-
private const RETURN_TYPE_GETTERS = ['find', 'findAll', 'first'];
31-
28+
public const RETURN_TYPE = 'returnType';
29+
public const SELECTS = 'selects';
30+
private const RETURN_TYPE_GETTERS = ['find', 'findAll', 'first'];
3231
private const RETURN_TYPE_TRANSFORMERS = ['asArray', 'asObject'];
3332

3433
/**
@@ -48,7 +47,9 @@ public function enterNode(Node $node)
4847
return null;
4948
}
5049

51-
$lastNode = $node;
50+
$getter = $node;
51+
$selects = [];
52+
$returnTypeResolved = false;
5253

5354
while ($node->var instanceof MethodCall) {
5455
$node = $node->var;
@@ -57,27 +58,35 @@ public function enterNode(Node $node)
5758
continue;
5859
}
5960

60-
if (! in_array($node->name->name, self::RETURN_TYPE_TRANSFORMERS, true)) {
61+
if ($node->name->name === 'select') {
62+
$args = $node->getArgs();
63+
64+
if (isset($args[0])) {
65+
$selects[] = $args[0]->value;
66+
}
67+
6168
continue;
6269
}
6370

64-
if ($node->name->name === 'asArray') {
65-
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('array'));
66-
67-
break;
71+
if ($returnTypeResolved || ! in_array($node->name->name, self::RETURN_TYPE_TRANSFORMERS, true)) {
72+
continue;
6873
}
6974

70-
$args = $node->getArgs();
75+
$returnTypeResolved = true;
7176

72-
if ($args === []) {
73-
$lastNode->setAttribute(self::RETURN_TYPE, new Scalar\String_('object'));
77+
if ($node->name->name === 'asArray') {
78+
$getter->setAttribute(self::RETURN_TYPE, new Scalar\String_('array'));
7479

75-
break;
80+
continue;
7681
}
7782

78-
$lastNode->setAttribute(self::RETURN_TYPE, $args[0]->value);
83+
$args = $node->getArgs();
84+
85+
$getter->setAttribute(self::RETURN_TYPE, $args === [] ? new Scalar\String_('object') : $args[0]->value);
86+
}
7987

80-
break;
88+
if ($selects !== []) {
89+
$getter->setAttribute(self::SELECTS, array_reverse($selects));
8190
}
8291

8392
return null;

src/Type/ModelFetchedReturnTypeHelper.php

Lines changed: 143 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use CodeIgniter\PHPStan\Database\Schema\CastFieldTypeResolver;
1717
use CodeIgniter\PHPStan\Database\Schema\Column;
1818
use CodeIgniter\PHPStan\Database\Schema\ColumnTypeResolver;
19+
use CodeIgniter\PHPStan\Database\Schema\Table;
1920
use CodeIgniter\PHPStan\Database\SchemaProvider;
2021
use CodeIgniter\PHPStan\NodeVisitor\ModelReturnTypeTransformVisitor;
2122
use PhpParser\Node\Expr;
@@ -36,7 +37,7 @@
3637

3738
/**
3839
* Resolves the type of a single fetched row for a model, honoring its `$returnType` (or the
39-
* `asArray()`/`asObject()` override) and shaping array rows from the live columns and `$casts`.
40+
* `asArray()`/`asObject()` override), the `select()` field list, the live columns, and `$casts`.
4041
*/
4142
final class ModelFetchedReturnTypeHelper
4243
{
@@ -52,11 +53,11 @@ public function getFetchedReturnType(ClassReflection $classReflection, ?MethodCa
5253
$returnType = $this->resolveReturnType($classReflection, $methodCall, $scope);
5354

5455
if ($returnType === 'object') {
55-
return $this->resolveObjectRowType($classReflection);
56+
return $this->resolveObjectRowType($classReflection, $methodCall, $scope);
5657
}
5758

5859
if ($returnType === 'array') {
59-
return $this->resolveArrayRowType($classReflection);
60+
return $this->resolveArrayRowType($classReflection, $methodCall, $scope);
6061
}
6162

6263
if ($this->reflectionProvider->hasClass($returnType)) {
@@ -85,9 +86,9 @@ private function resolveReturnType(ClassReflection $classReflection, ?MethodCall
8586
return is_string($returnType) ? $returnType : 'array';
8687
}
8788

88-
private function resolveArrayRowType(ClassReflection $classReflection): Type
89+
private function resolveArrayRowType(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): Type
8990
{
90-
$fields = $this->resolveRowFieldTypes($classReflection);
91+
$fields = $this->resolveRowFieldTypes($classReflection, $methodCall, $scope);
9192

9293
if ($fields === null) {
9394
return new ArrayType(new StringType(), new MixedType());
@@ -102,9 +103,9 @@ private function resolveArrayRowType(ClassReflection $classReflection): Type
102103
return $builder->getArray();
103104
}
104105

105-
private function resolveObjectRowType(ClassReflection $classReflection): Type
106+
private function resolveObjectRowType(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): Type
106107
{
107-
$fields = $this->resolveRowFieldTypes($classReflection);
108+
$fields = $this->resolveRowFieldTypes($classReflection, $methodCall, $scope);
108109

109110
if ($fields === null) {
110111
return new ObjectType(stdClass::class);
@@ -114,9 +115,9 @@ private function resolveObjectRowType(ClassReflection $classReflection): Type
114115
}
115116

116117
/**
117-
* @return array<string, Type>|null Null when the model's table cannot be resolved.
118+
* @return array<string, Type>|null Null when the row shape cannot be resolved.
118119
*/
119-
private function resolveRowFieldTypes(ClassReflection $classReflection): ?array
120+
private function resolveRowFieldTypes(ClassReflection $classReflection, ?MethodCall $methodCall, Scope $scope): ?array
120121
{
121122
$tableName = $classReflection->getNativeReflection()->getDefaultProperties()['table'] ?? null;
122123

@@ -128,10 +129,137 @@ private function resolveRowFieldTypes(ClassReflection $classReflection): ?array
128129

129130
$casts = $this->readStringMap($classReflection, 'casts');
130131
$castHandlers = $this->readStringMap($classReflection, 'castHandlers');
131-
$fields = [];
132+
133+
$selects = $methodCall !== null && $methodCall->hasAttribute(ModelReturnTypeTransformVisitor::SELECTS)
134+
? $methodCall->getAttribute(ModelReturnTypeTransformVisitor::SELECTS)
135+
: null;
136+
137+
if (! is_array($selects)) {
138+
return $this->columnsToFields($table, $casts, $castHandlers);
139+
}
140+
141+
return $this->resolveSelectedFields($selects, $table, $casts, $castHandlers, $scope);
142+
}
143+
144+
/**
145+
* @param array<mixed, mixed> $selects
146+
* @param array<string, string> $casts
147+
* @param array<string, string> $castHandlers
148+
*
149+
* @return array<string, Type>|null
150+
*/
151+
private function resolveSelectedFields(array $selects, Table $table, array $casts, array $castHandlers, Scope $scope): ?array
152+
{
153+
$fields = [];
154+
155+
foreach ($selects as $expr) {
156+
if (! $expr instanceof Expr) {
157+
return null;
158+
}
159+
160+
$strings = $scope->getType($expr)->getConstantStrings();
161+
162+
if (count($strings) !== 1) {
163+
return null;
164+
}
165+
166+
foreach ($this->splitSelect($strings[0]->getValue()) as $part) {
167+
$resolved = $this->resolveSelectPart($part, $table, $casts, $castHandlers);
168+
169+
if ($resolved === null) {
170+
return null;
171+
}
172+
173+
foreach ($resolved as $name => $type) {
174+
$fields[$name] = $type;
175+
}
176+
}
177+
}
178+
179+
return $fields;
180+
}
181+
182+
/**
183+
* @param array<string, string> $casts
184+
* @param array<string, string> $castHandlers
185+
*
186+
* @return array<string, Type>|null
187+
*/
188+
private function resolveSelectPart(string $part, Table $modelTable, array $casts, array $castHandlers): ?array
189+
{
190+
if ($part === '*') {
191+
return $this->columnsToFields($modelTable, $casts, $castHandlers);
192+
}
193+
194+
if (preg_match('/^(\w+)\.\*$/', $part, $matches) === 1) {
195+
$table = $this->schemaProvider->get()->getTable($matches[1]);
196+
197+
return $table === null ? null : $this->columnsToFields($table, $casts, $castHandlers);
198+
}
199+
200+
if (preg_match('/^(\w+)(?:\.(\w+))?(?:\s+(?:as\s+)?(\w+))?$/i', $part, $matches) === 1) {
201+
$qualified = ($matches[2] ?? '') !== '';
202+
$columnName = $qualified ? $matches[2] : $matches[1];
203+
$outputName = ($matches[3] ?? '') !== '' ? $matches[3] : $columnName;
204+
$table = $qualified ? $this->schemaProvider->get()->getTable($matches[1]) : $modelTable;
205+
206+
if ($table === null) {
207+
return null;
208+
}
209+
210+
return [$outputName => $this->fieldType($outputName, $table->getColumn($columnName), $casts, $castHandlers)];
211+
}
212+
213+
if (preg_match('/\bas\s+(\w+)\s*$/i', $part, $matches) === 1) {
214+
return [$matches[1] => $this->fieldType($matches[1], null, $casts, $castHandlers)];
215+
}
216+
217+
return null;
218+
}
219+
220+
/**
221+
* @return list<string>
222+
*/
223+
private function splitSelect(string $select): array
224+
{
225+
$parts = [];
226+
$current = '';
227+
$depth = 0;
228+
229+
foreach (str_split($select === '' ? ' ' : $select) as $char) {
230+
if ($char === '(') {
231+
$depth++;
232+
} elseif ($char === ')') {
233+
$depth--;
234+
}
235+
236+
if ($char === ',' && $depth === 0) {
237+
$parts[] = $current;
238+
$current = '';
239+
240+
continue;
241+
}
242+
243+
$current .= $char;
244+
}
245+
246+
$parts[] = $current;
247+
248+
return array_values(array_filter(array_map(trim(...), $parts), static fn (string $part): bool => $part !== ''));
249+
}
250+
251+
/**
252+
* @param array<string, string> $casts
253+
* @param array<string, string> $castHandlers
254+
*
255+
* @return array<string, Type>
256+
*/
257+
private function columnsToFields(Table $table, array $casts, array $castHandlers): array
258+
{
259+
$fields = [];
132260

133261
foreach ($table->columns as $column) {
134-
$fields[$column->name] = $this->resolveFieldType($column, $casts, $castHandlers);
262+
$fields[$column->name] = $this->fieldType($column->name, $column, $casts, $castHandlers);
135263
}
136264

137265
return $fields;
@@ -141,13 +269,13 @@ private function resolveRowFieldTypes(ClassReflection $classReflection): ?array
141269
* @param array<string, string> $casts
142270
* @param array<string, string> $castHandlers
143271
*/
144-
private function resolveFieldType(Column $column, array $casts, array $castHandlers): Type
272+
private function fieldType(string $name, ?Column $column, array $casts, array $castHandlers): Type
145273
{
146-
if (isset($casts[$column->name])) {
147-
return $this->castFieldTypeResolver->resolve($casts[$column->name], $castHandlers);
274+
if (isset($casts[$name])) {
275+
return $this->castFieldTypeResolver->resolve($casts[$name], $castHandlers);
148276
}
149277

150-
return $this->columnTypeResolver->resolve($column);
278+
return $column !== null ? $this->columnTypeResolver->resolve($column) : new MixedType();
151279
}
152280

153281
/**

tests/data/type-inference/model-return-types.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,22 @@
3333

3434
assertType('array{id: int, user_id: CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money, title: CodeIgniter\I18n\Time}|null', $posts->first());
3535
assertType('list<array{id: int, user_id: CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money, title: CodeIgniter\I18n\Time}>', $posts->findAll());
36+
37+
// select() narrows and renames the row shape.
38+
assertType('array{id: int, body: string|null}|null', $comments->select('id, body')->asArray()->first());
39+
assertType('array{id: int, note: string|null}|null', $comments->select('id, body as note')->asArray()->first());
40+
assertType('object{id: int, body: string|null}|null', $comments->select('id, body')->asObject()->first());
41+
42+
// `table.*` and a qualified field from a joined table are resolved against the live schema.
43+
assertType('array{id: int, body: string|null, votes: int, payload: string|null, created_at: string|null, author: string}|null', $comments->select('blog_comments.*, blog_users.name as author')->asArray()->first());
44+
45+
// Expressions are typed as mixed under their alias.
46+
assertType('array{id: int, lowered: mixed}|null', $comments->select('id, LOWER(body) as lowered')->asArray()->first());
47+
48+
// A selected field is still cast by the model (output name `user_id` casts via the model handler).
49+
assertType('array{user_id: CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money}|null', $posts->select('user_id')->first());
50+
51+
function selectDynamically(BlogCommentModel $model, string $columns): void
52+
{
53+
assertType('array<string, mixed>|null', $model->select($columns)->asArray()->first());
54+
}

0 commit comments

Comments
 (0)