Skip to content

Commit da84020

Browse files
committed
Apply the model's casts to entity property types
1 parent 408f5d4 commit da84020

7 files changed

Lines changed: 169 additions & 10 deletions

File tree

docs/type-inference.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,10 @@ handler names.
187187
**Class:** `CodeIgniter\PHPStan\Reflection\EntityPropertiesClassReflectionExtension`
188188
189189
This extension types the virtual properties of `CodeIgniter\Entity\Entity` subclasses. For each property it
190-
layers the entity's `$dates` and `$casts` (resolving custom `$castHandlers` by reflecting their `get()` method)
191-
over the type of the backing database column. That column is found through the table of the model whose
192-
`$returnType` is the entity. Properties that are neither a date, a cast, nor a known column resolve to `mixed`.
190+
applies, in order, the entity's `$dates`, the entity's `$casts`, then the `$casts` of the model whose
191+
`$returnType` is the entity (CodeIgniter applies those before hydrating the entity), and finally the type of
192+
the backing database column. Custom `$castHandlers` are resolved by reflecting their `get()` method, and the
193+
backing column is found through the model's table. Properties that match none of these resolve to `mixed`.
193194
194195
> [!NOTE]
195196
> **Configuration:**

docs/upgrading.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +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`.
131134
- **Only migrations build the schema.** Tables created outside migrations (for example, in test setup) are
132135
not introspected.
133136
- **A non-constant `select()` degrades the shape.** When the `select()` argument is not a constant string,

src/Database/ModelTableMapProvider.php

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,42 @@
1717
use ReflectionClass;
1818

1919
/**
20-
* Maps an entity class to the table of the model that returns it, discovered by reflecting every
21-
* model found under the registered `Models/` namespaces.
20+
* Maps an entity class to the table and `$casts`/`$castHandlers` of the model that returns it,
21+
* discovered by reflecting every model found under the registered `Models/` namespaces.
2222
*/
2323
final class ModelTableMapProvider
2424
{
2525
/**
26-
* @var array<class-string, string>|null
26+
* @var array<class-string, array{table: string, casts: array<string, string>, handlers: array<string, string>}>|null
2727
*/
2828
private ?array $map = null;
2929

3030
public function getTableForEntity(string $entityClass): ?string
3131
{
32-
return $this->map()[$entityClass] ?? null;
32+
return $this->map()[$entityClass]['table'] ?? null;
3333
}
3434

3535
/**
36-
* @return array<class-string, string>
36+
* The `$casts` of the model returning this entity. CodeIgniter applies them before hydrating the
37+
* entity, so they describe the stored value of a property the entity itself does not cast.
38+
*
39+
* @return array<string, string>
40+
*/
41+
public function getModelCastsForEntity(string $entityClass): array
42+
{
43+
return $this->map()[$entityClass]['casts'] ?? [];
44+
}
45+
46+
/**
47+
* @return array<string, string>
48+
*/
49+
public function getModelCastHandlersForEntity(string $entityClass): array
50+
{
51+
return $this->map()[$entityClass]['handlers'] ?? [];
52+
}
53+
54+
/**
55+
* @return array<class-string, array{table: string, casts: array<string, string>, handlers: array<string, string>}>
3756
*/
3857
private function map(): array
3958
{
@@ -52,14 +71,38 @@ private function map(): array
5271
continue;
5372
}
5473

55-
$map[$returnType] = $table;
74+
$map[$returnType] = [
75+
'table' => $table,
76+
'casts' => $this->stringMap($defaults['casts'] ?? null),
77+
'handlers' => $this->stringMap($defaults['castHandlers'] ?? null),
78+
];
5679
}
5780

5881
$this->map = $map;
5982

6083
return $this->map;
6184
}
6285

86+
/**
87+
* @return array<string, string>
88+
*/
89+
private function stringMap(mixed $value): array
90+
{
91+
if (! is_array($value)) {
92+
return [];
93+
}
94+
95+
$map = [];
96+
97+
foreach ($value as $key => $cast) {
98+
if (is_string($key) && is_string($cast)) {
99+
$map[$key] = $cast;
100+
}
101+
}
102+
103+
return $map;
104+
}
105+
63106
/**
64107
* @return list<class-string<Model>>
65108
*/

src/Reflection/EntityPropertiesClassReflectionExtension.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ private function resolveType(ClassReflection $classReflection, string $propertyN
6969
return $schema->nullable ? TypeCombinator::addNull($time) : $time;
7070
}
7171

72+
// The entity's own cast is applied last in `__get()`, so it wins over a model cast and the column.
7273
if (isset($casts[$column])) {
7374
return $this->castFieldTypeResolver->resolve(
7475
$casts[$column],
@@ -77,7 +78,25 @@ private function resolveType(ClassReflection $classReflection, string $propertyN
7778
);
7879
}
7980

80-
if ($schema !== null && ! $this->hasGetter($classReflection, $column)) {
81+
// A getter returns the stored value unchanged, so its type cannot be inferred here.
82+
if ($this->hasGetter($classReflection, $column)) {
83+
return null;
84+
}
85+
86+
// The model that returns this entity casts the column before hydration, so its cast describes a
87+
// property the entity does not cast. This applies even when the column was not introspected.
88+
$entityName = $classReflection->getName();
89+
$modelCasts = $this->modelTableMapProvider->getModelCastsForEntity($entityName);
90+
91+
if (isset($modelCasts[$column])) {
92+
return $this->castFieldTypeResolver->resolve(
93+
$modelCasts[$column],
94+
$this->modelTableMapProvider->getModelCastHandlersForEntity($entityName),
95+
$schema !== null && ! $schema->nullable,
96+
);
97+
}
98+
99+
if ($schema !== null) {
81100
return $this->columnTypeResolver->resolve($schema);
82101
}
83102

tests/Fixtures/Entity/Account.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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\Entity;
15+
16+
use CodeIgniter\Entity\Entity;
17+
18+
final class Account extends Entity
19+
{
20+
protected $casts = [
21+
'payload' => 'json',
22+
];
23+
}
Lines changed: 32 additions & 0 deletions
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 <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\Account;
18+
use CodeIgniter\PHPStan\Tests\Fixtures\Entity\MoneyCast;
19+
20+
final class AccountModel extends Model
21+
{
22+
protected $table = 'blog_comments';
23+
protected $returnType = Account::class;
24+
protected array $casts = [
25+
'payload' => 'json-array',
26+
'body' => 'json-array',
27+
'votes' => 'money',
28+
];
29+
protected array $castHandlers = [
30+
'money' => MoneyCast::class,
31+
];
32+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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\Account;
17+
use CodeIgniter\PHPStan\Tests\Fixtures\Models\AccountModel;
18+
19+
use function PHPStan\Testing\assertType;
20+
21+
$account = new Account();
22+
23+
// The entity casts `payload`, so the entity cast wins over the model's cast of the same column.
24+
assertType('stdClass|null', $account->payload);
25+
26+
// `body` is cast only by the model, so the model cast applies (was the raw column type before the fix).
27+
assertType('array|null', $account->body);
28+
29+
// `votes` is cast by the model through a custom handler, reflected from the handler's get() return type.
30+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money', $account->votes);
31+
32+
// `id` is cast by neither, so the raw column type is used.
33+
assertType('int', $account->id);
34+
35+
// `created_at` is mutated to Time as a date field.
36+
assertType('CodeIgniter\I18n\Time|null', $account->created_at);
37+
38+
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Account|null', (new AccountModel())->find(1));

0 commit comments

Comments
 (0)