Skip to content

Commit b24ce51

Browse files
committed
Type entity properties from their backing database columns
1 parent 8a8b1c0 commit b24ce51

12 files changed

Lines changed: 302 additions & 15 deletions

bootstrap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@
2929
}
3030
}
3131
}
32+
33+
service('autoloader')->addNamespace('CodeIgniter\\PHPStan\\Tests\\Fixtures', __DIR__ . '/tests/Fixtures');

extension.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ parameters:
1515
checkArgumentTypeOfModel: true
1616
checkArgumentTypeOfFactories: true
1717
schemaCacheDirectory: %currentWorkingDirectory%/tmp
18+
schemaNamespace: null
1819

1920
parametersSchema:
2021
codeigniter: structure([
@@ -26,6 +27,7 @@ parametersSchema:
2627
checkArgumentTypeOfModel: bool()
2728
checkArgumentTypeOfFactories: bool()
2829
schemaCacheDirectory: string()
30+
schemaNamespace: schema(string(), nullable())
2931
])
3032

3133
services:
@@ -45,10 +47,18 @@ services:
4547

4648
schemaProvider:
4749
class: CodeIgniter\PHPStan\Database\SchemaProvider
50+
arguments:
51+
namespace: %codeigniter.schemaNamespace%
52+
53+
modelTableMapProvider:
54+
class: CodeIgniter\PHPStan\Database\ModelTableMapProvider
4855

4956
castTypeResolver:
5057
class: CodeIgniter\PHPStan\Database\Schema\CastTypeResolver
5158

59+
columnTypeResolver:
60+
class: CodeIgniter\PHPStan\Database\Schema\ColumnTypeResolver
61+
5262
factoriesReturnTypeHelper:
5363
class: CodeIgniter\PHPStan\Helpers\FactoriesReturnTypeHelper
5464
arguments:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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\Database;
15+
16+
use CodeIgniter\Model;
17+
use ReflectionClass;
18+
19+
/**
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.
22+
*/
23+
final class ModelTableMapProvider
24+
{
25+
/**
26+
* @var array<class-string, string>|null
27+
*/
28+
private ?array $map = null;
29+
30+
public function getTableForEntity(string $entityClass): ?string
31+
{
32+
return $this->map()[$entityClass] ?? null;
33+
}
34+
35+
/**
36+
* @return array<class-string, string>
37+
*/
38+
private function map(): array
39+
{
40+
if ($this->map !== null) {
41+
return $this->map;
42+
}
43+
44+
$map = [];
45+
46+
foreach ($this->discoverModels() as $model) {
47+
$defaults = (new ReflectionClass($model))->getDefaultProperties();
48+
$returnType = $defaults['returnType'] ?? null;
49+
$table = $defaults['table'] ?? null;
50+
51+
if (! is_string($returnType) || ! is_string($table) || $table === '' || ! class_exists($returnType)) {
52+
continue;
53+
}
54+
55+
$map[$returnType] = $table;
56+
}
57+
58+
$this->map = $map;
59+
60+
return $this->map;
61+
}
62+
63+
/**
64+
* @return list<class-string<Model>>
65+
*/
66+
private function discoverModels(): array
67+
{
68+
$locator = service('locator');
69+
$models = [];
70+
71+
foreach ($locator->listFiles('Models/') as $file) {
72+
if ($file === '') {
73+
continue;
74+
}
75+
76+
$class = $locator->getClassname($file);
77+
78+
if ($class === '' || ! class_exists($class) || ! is_subclass_of($class, Model::class)) {
79+
continue;
80+
}
81+
82+
$models[] = $class;
83+
}
84+
85+
return $models;
86+
}
87+
}

src/Database/Schema/ColumnTypeResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function resolve(Column $column): Type
2828
{
2929
$type = $this->mapDeclaredType($column->type);
3030

31-
return $column->nullable ? TypeCombinator::addNull($type) : $type;
31+
return $column->nullable && ! $column->primaryKey ? TypeCombinator::addNull($type) : $type;
3232
}
3333

3434
private function mapDeclaredType(string $declaredType): Type

src/Reflection/EntityPropertiesClassReflectionExtension.php

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,46 +14,70 @@
1414
namespace CodeIgniter\PHPStan\Reflection;
1515

1616
use CodeIgniter\Entity\Entity;
17+
use CodeIgniter\I18n\Time;
18+
use CodeIgniter\PHPStan\Database\ModelTableMapProvider;
1719
use CodeIgniter\PHPStan\Database\Schema\CastTypeResolver;
20+
use CodeIgniter\PHPStan\Database\Schema\Column;
21+
use CodeIgniter\PHPStan\Database\Schema\ColumnTypeResolver;
22+
use CodeIgniter\PHPStan\Database\SchemaProvider;
1823
use PHPStan\Reflection\ClassReflection;
1924
use PHPStan\Reflection\ParametersAcceptorSelector;
2025
use PHPStan\Reflection\PropertiesClassReflectionExtension;
2126
use PHPStan\Reflection\PropertyReflection;
2227
use PHPStan\Reflection\ReflectionProvider;
2328
use PHPStan\Type\MixedType;
29+
use PHPStan\Type\ObjectType;
2430
use PHPStan\Type\Type;
2531
use PHPStan\Type\TypeCombinator;
2632

2733
/**
28-
* Types virtual properties on `CodeIgniter\Entity\Entity` subclasses from their `$casts` entries,
29-
* reflecting custom `$castHandlers` for casts the framework does not define.
34+
* Types virtual properties on `CodeIgniter\Entity\Entity` subclasses, layering `$dates` and `$casts`
35+
* over the raw type of the backing database column, and reflecting custom `$castHandlers`.
3036
*/
3137
final class EntityPropertiesClassReflectionExtension implements PropertiesClassReflectionExtension
3238
{
3339
public function __construct(
3440
private readonly ReflectionProvider $reflectionProvider,
3541
private readonly CastTypeResolver $castTypeResolver,
42+
private readonly SchemaProvider $schemaProvider,
43+
private readonly ModelTableMapProvider $modelTableMapProvider,
44+
private readonly ColumnTypeResolver $columnTypeResolver,
3645
) {}
3746

3847
public function hasProperty(ClassReflection $classReflection, string $propertyName): bool
3948
{
40-
if (! $classReflection->is(Entity::class)) {
41-
return false;
42-
}
43-
44-
$casts = $this->readStringMap($classReflection, 'casts');
45-
$column = $this->mapColumn($classReflection, $propertyName);
46-
47-
return isset($casts[$column]);
49+
return $classReflection->is(Entity::class);
4850
}
4951

5052
public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection
5153
{
52-
$casts = $this->readStringMap($classReflection, 'casts');
54+
return new EntityPropertyReflection(
55+
$classReflection,
56+
$this->resolveType($classReflection, $propertyName) ?? new MixedType(),
57+
);
58+
}
59+
60+
private function resolveType(ClassReflection $classReflection, string $propertyName): ?Type
61+
{
5362
$column = $this->mapColumn($classReflection, $propertyName);
54-
$cast = $casts[$column] ?? '';
63+
$casts = $this->readStringMap($classReflection, 'casts');
64+
$schema = $this->lookupColumn($classReflection, $column);
5565

56-
return new EntityPropertyReflection($classReflection, $this->resolveCastType($classReflection, $cast));
66+
// `__get()` mutates date fields to Time before any cast. Only claim real columns so that
67+
// the framework's default `$dates` don't fabricate Time properties on unrelated entities.
68+
if ($schema !== null && in_array($column, $this->readStringList($classReflection, 'dates'), true)) {
69+
return new ObjectType(Time::class);
70+
}
71+
72+
if (isset($casts[$column])) {
73+
return $this->resolveCastType($classReflection, $casts[$column]);
74+
}
75+
76+
if ($schema !== null && ! $this->hasGetter($classReflection, $column)) {
77+
return $this->columnTypeResolver->resolve($schema);
78+
}
79+
80+
return null;
5781
}
5882

5983
private function resolveCastType(ClassReflection $classReflection, string $cast): Type
@@ -86,6 +110,24 @@ private function resolveCustomHandlerType(ClassReflection $classReflection, stri
86110
return $nullable ? TypeCombinator::addNull($type) : $type;
87111
}
88112

113+
private function lookupColumn(ClassReflection $classReflection, string $column): ?Column
114+
{
115+
$table = $this->modelTableMapProvider->getTableForEntity($classReflection->getName());
116+
117+
if ($table === null) {
118+
return null;
119+
}
120+
121+
return $this->schemaProvider->get()->getTable($table)?->getColumn($column);
122+
}
123+
124+
private function hasGetter(ClassReflection $classReflection, string $column): bool
125+
{
126+
$method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $column)));
127+
128+
return $classReflection->hasNativeMethod($method) || $classReflection->hasNativeMethod('_' . $method);
129+
}
130+
89131
private function castName(string $cast): string
90132
{
91133
if (preg_match('/\A(.+)\[.+\]\z/', $cast, $matches) === 1) {
@@ -123,4 +165,26 @@ private function readStringMap(ClassReflection $classReflection, string $propert
123165

124166
return $map;
125167
}
168+
169+
/**
170+
* @return list<string>
171+
*/
172+
private function readStringList(ClassReflection $classReflection, string $property): array
173+
{
174+
$value = $classReflection->getNativeReflection()->getDefaultProperties()[$property] ?? [];
175+
176+
if (! is_array($value)) {
177+
return [];
178+
}
179+
180+
$list = [];
181+
182+
foreach ($value as $item) {
183+
if (is_string($item)) {
184+
$list[] = $item;
185+
}
186+
}
187+
188+
return $list;
189+
}
126190
}

tests/Database/Schema/ColumnTypeResolverTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,11 @@ public static function provideMapsDeclaredTypeToPhpStanTypeCases(): iterable
6565

6666
yield 'nullable VARCHAR' => ['VARCHAR', true, 'string|null'];
6767
}
68+
69+
public function testPrimaryKeyColumnIsNeverNullable(): void
70+
{
71+
$type = (new ColumnTypeResolver())->resolve(new Column('id', 'INTEGER', true, true, null));
72+
73+
self::assertSame('int', $type->describe(VerbosityLevel::precise()));
74+
}
6875
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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\Database\Migrations;
15+
16+
use CodeIgniter\Database\Migration;
17+
18+
final class CreateBlogComments extends Migration
19+
{
20+
public function up(): void
21+
{
22+
$this->forge->addField([
23+
'id' => ['type' => 'INTEGER', 'auto_increment' => true],
24+
'body' => ['type' => 'TEXT', 'null' => true],
25+
'votes' => ['type' => 'INTEGER', 'null' => false],
26+
'payload' => ['type' => 'TEXT', 'null' => true],
27+
'created_at' => ['type' => 'DATETIME', 'null' => true],
28+
]);
29+
$this->forge->addKey('id', true);
30+
$this->forge->createTable('blog_comments');
31+
}
32+
33+
public function down(): void
34+
{
35+
$this->forge->dropTable('blog_comments');
36+
}
37+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 BlogComment extends Entity
19+
{
20+
protected $datamap = [
21+
'identifier' => 'id',
22+
];
23+
protected $casts = [
24+
'payload' => 'json',
25+
];
26+
}
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\Models;
15+
16+
use CodeIgniter\Model;
17+
use CodeIgniter\PHPStan\Tests\Fixtures\Entity\BlogComment;
18+
19+
final class BlogCommentModel extends Model
20+
{
21+
protected $table = 'blog_comments';
22+
protected $returnType = BlogComment::class;
23+
}

tests/data/type-inference/entity-cast-properties.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@
3131
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money', $entity->balance);
3232
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money|null', $entity->discount);
3333

34-
assertType('array<int|string, mixed>|bool|float|int|object|string|null', $entity->unknown);
34+
assertType('mixed', $entity->unknown);

0 commit comments

Comments
 (0)