Skip to content

Commit 61b0339

Browse files
committed
Type asObject-fetched entities by the producing model's casts
An entity fetched through a model's asObject()/asArray() is hydrated from that model's casts, not the casts of the model whose $returnType is the entity. The entity property reflection cannot see the producing model, so it fell back to the bare column type and reported false positives on legitimately-cast values. Emit a ModelCastEntityType from ModelFetchedReturnTypeHelper, where the producing model is statically known. It overrides only the producing model's cast columns at the type level and delegates every other property to the normal entity reflection, so there is no second contribution to intersect with and the value still reads as a plain entity in error messages. The entity's own $casts still win, as they run last in __get(), and the entity datamap is honored. Closes #46.
1 parent da84020 commit 61b0339

7 files changed

Lines changed: 201 additions & 5 deletions

File tree

docs/type-inference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ This extension provides precise return types for the `find()`, `findAll()`, `fir
9999
methods of `CodeIgniter\Model` subclasses.
100100
101101
A fetched row is typed from the model's `$returnType`:
102-
- an entity instance (whose properties are typed by the entity extension below),
102+
- an entity instance (whose properties are typed by the entity extension below, with the producing model's
103+
`$casts` layered on so an `asObject(SomeEntity::class)` fetch reflects that model's casts, not only the
104+
entity's own model's),
103105
- a shaped array built from the table's columns and the model's `$casts`, or
104106
- a `stdClass` with those same fields.
105107

docs/upgrading.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ Both are optional. The defaults work for a typical application.
128128
- **Models that set `$table` in the constructor are not mapped.** The entity-to-table bridge reads `$table`
129129
from the model's default property value. A model that assigns `$this->table` inside its constructor is not
130130
resolved, so its entity's non-cast properties fall back to `mixed`.
131-
- **An `asObject(SomeEntity::class)` override uses the casts of the entity's own model.** An entity's
132-
properties are typed from the `$casts` of the model whose `$returnType` is that entity. Fetching the same
133-
entity through a different model via `asObject()` or `asArray()` does not pick up that model's `$casts`.
131+
- **An explicit `@var` annotation overrides the inferred entity type.** A `/** @var SomeEntity $x */` on a
132+
fetched row replaces the cast-aware type with a plain `SomeEntity`, so its properties fall back to their
133+
framework types. Such annotations, often added when 1.x inference was wrong, are unnecessary in 2.x.
134134
- **Only migrations build the schema.** Tables created outside migrations (for example, in test setup) are
135135
not introspected.
136136
- **A non-constant `select()` degrades the shape.** When the `select()` argument is not a constant string,

phpstan.dist.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ parameters:
2222
-
2323
message: '#^Call to internal method PHPStan\\Rules\\RuleErrorBuilder::fixNode\(\) from outside its root namespace PHPStan\.$#'
2424
identifier: method.internal
25+
# Retyping an entity property by the producing model's casts has no public API and must reach into
26+
# PHPStan's property-prototype internals.
27+
-
28+
identifier: phpstanApi.method
29+
path: src/Type/ModelCastEntityType.php
30+
-
31+
identifier: phpstanApi.constructor
32+
path: src/Type/ModelCastEntityType.php
2533
exceptions:
2634
check:
2735
throwTypeCovariance: true

src/Type/ModelCastEntityType.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 PHPStan\Reflection\ClassMemberAccessAnswerer;
17+
use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection;
18+
use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection;
19+
use PHPStan\Type\ObjectType;
20+
use PHPStan\Type\Type;
21+
22+
/**
23+
* An entity object type whose listed properties are retyped by the `$casts` of the model that produced it.
24+
* CodeIgniter applies the producing model's casts before hydrating the entity, so an entity fetched through
25+
* a model's `asObject()`/`asArray()` picks up that model's casts instead of the bare column types.
26+
*/
27+
final class ModelCastEntityType extends ObjectType
28+
{
29+
/**
30+
* @param array<string, Type> $castedProperties Field name => cast-resolved type
31+
* @param array<string, string> $datamap Property name => field name
32+
*/
33+
public function __construct(
34+
string $className,
35+
private readonly array $castedProperties,
36+
private readonly array $datamap = [],
37+
) {
38+
parent::__construct($className);
39+
}
40+
41+
public function getUnresolvedInstancePropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection
42+
{
43+
return $this->withCastOverride($propertyName, parent::getUnresolvedInstancePropertyPrototype($propertyName, $scope));
44+
}
45+
46+
public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection
47+
{
48+
return $this->withCastOverride($propertyName, parent::getUnresolvedPropertyPrototype($propertyName, $scope));
49+
}
50+
51+
private function withCastOverride(string $propertyName, UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection
52+
{
53+
$field = $this->datamap[$propertyName] ?? $propertyName;
54+
55+
if (! isset($this->castedProperties[$field])) {
56+
return $prototype;
57+
}
58+
59+
$naked = $prototype->getNakedProperty();
60+
$overrideType = $this->castedProperties[$field];
61+
62+
return new CallbackUnresolvedPropertyPrototypeReflection(
63+
$naked,
64+
$naked->getDeclaringClass(),
65+
false,
66+
static fn (Type $type): Type => $overrideType,
67+
);
68+
}
69+
}

src/Type/ModelFetchedReturnTypeHelper.php

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,55 @@ public function getFetchedReturnType(ClassReflection $classReflection, ?MethodCa
6161
}
6262

6363
if ($this->reflectionProvider->hasClass($returnType)) {
64-
return new ObjectType($returnType);
64+
return $this->entityTypeWithModelCasts($classReflection, $returnType);
6565
}
6666

6767
return new ObjectWithoutClassType();
6868
}
6969

70+
/**
71+
* Types the entity with the producing model's `$casts`. The producing model is statically known here
72+
* (unlike in the entity reflection), so a model fetched through `asObject()` overrides the column types
73+
* its own model would otherwise apply. The entity's own `$casts` still win, as they run last in `__get()`.
74+
*/
75+
private function entityTypeWithModelCasts(ClassReflection $modelReflection, string $entityClass): Type
76+
{
77+
$modelCasts = $this->readStringMap($modelReflection, 'casts');
78+
79+
if ($modelCasts === []) {
80+
return new ObjectType($entityClass);
81+
}
82+
83+
$entityReflection = $this->reflectionProvider->getClass($entityClass);
84+
$entityCasts = $this->readStringMap($entityReflection, 'casts');
85+
$datamap = $this->readStringMap($entityReflection, 'datamap');
86+
$modelCastHandlers = $this->readStringMap($modelReflection, 'castHandlers');
87+
88+
$tableName = $modelReflection->getNativeReflection()->getDefaultProperties()['table'] ?? null;
89+
$table = is_string($tableName) && $tableName !== '' ? $this->schemaProvider->get()->getTable($tableName) : null;
90+
91+
$overrides = [];
92+
93+
foreach ($modelCasts as $column => $cast) {
94+
if (isset($entityCasts[$column])) {
95+
continue;
96+
}
97+
98+
$schemaColumn = $table?->getColumn($column);
99+
$overrides[$column] = $this->castFieldTypeResolver->resolve(
100+
$cast,
101+
$modelCastHandlers,
102+
$schemaColumn !== null && ! $schemaColumn->nullable,
103+
);
104+
}
105+
106+
if ($overrides === []) {
107+
return new ObjectType($entityClass);
108+
}
109+
110+
return new ModelCastEntityType($entityClass, $overrides, $datamap);
111+
}
112+
70113
/**
71114
* Resolves the type of a single column's value, as returned element-wise by `findColumn()`.
72115
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
use CodeIgniter\PHPStan\Tests\Fixtures\Entity\MoneyCast;
18+
19+
final class LegacyCommentModel extends Model
20+
{
21+
protected $table = 'blog_comments';
22+
protected array $casts = [
23+
'body' => 'json-array',
24+
'votes' => 'money',
25+
];
26+
protected array $castHandlers = [
27+
'money' => MoneyCast::class,
28+
];
29+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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\Entity\BlogComment;
17+
use CodeIgniter\PHPStan\Tests\Fixtures\Models\LegacyCommentModel;
18+
19+
use function PHPStan\Testing\assertType;
20+
21+
$legacy = new LegacyCommentModel();
22+
23+
// Fetched through asObject(), so the entity carries the producing model's casts, not its own model's.
24+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment|null', $legacy->asObject(BlogComment::class)->find(1));
25+
assertType('list<CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment>', $legacy->asObject(BlogComment::class)->findAll());
26+
27+
$one = $legacy->asObject(BlogComment::class)->find(1);
28+
29+
if ($one !== null) {
30+
// `body` is a raw string column, retyped by the producing model's json-array cast.
31+
assertType('array|null', $one->body);
32+
33+
// `votes` is retyped by the producing model's custom money handler.
34+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money', $one->votes);
35+
36+
// The entity's own `payload` cast still wins, as it runs last in `__get()`.
37+
assertType('stdClass|null', $one->payload);
38+
39+
// `id` is cast by neither, so the raw column type is used. (Reached via the entity datamap too.)
40+
assertType('int', $one->id);
41+
assertType('int', $one->identifier);
42+
}
43+
44+
// first() keeps the nullable element type.
45+
assertType('array|null', $legacy->asObject(BlogComment::class)->first()?->body);

0 commit comments

Comments
 (0)