|
14 | 14 | namespace CodeIgniter\PHPStan\Reflection; |
15 | 15 |
|
16 | 16 | use CodeIgniter\Entity\Entity; |
| 17 | +use CodeIgniter\I18n\Time; |
| 18 | +use CodeIgniter\PHPStan\Database\ModelTableMapProvider; |
17 | 19 | 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; |
18 | 23 | use PHPStan\Reflection\ClassReflection; |
19 | 24 | use PHPStan\Reflection\ParametersAcceptorSelector; |
20 | 25 | use PHPStan\Reflection\PropertiesClassReflectionExtension; |
21 | 26 | use PHPStan\Reflection\PropertyReflection; |
22 | 27 | use PHPStan\Reflection\ReflectionProvider; |
23 | 28 | use PHPStan\Type\MixedType; |
| 29 | +use PHPStan\Type\ObjectType; |
24 | 30 | use PHPStan\Type\Type; |
25 | 31 | use PHPStan\Type\TypeCombinator; |
26 | 32 |
|
27 | 33 | /** |
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`. |
30 | 36 | */ |
31 | 37 | final class EntityPropertiesClassReflectionExtension implements PropertiesClassReflectionExtension |
32 | 38 | { |
33 | 39 | public function __construct( |
34 | 40 | private readonly ReflectionProvider $reflectionProvider, |
35 | 41 | private readonly CastTypeResolver $castTypeResolver, |
| 42 | + private readonly SchemaProvider $schemaProvider, |
| 43 | + private readonly ModelTableMapProvider $modelTableMapProvider, |
| 44 | + private readonly ColumnTypeResolver $columnTypeResolver, |
36 | 45 | ) {} |
37 | 46 |
|
38 | 47 | public function hasProperty(ClassReflection $classReflection, string $propertyName): bool |
39 | 48 | { |
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); |
48 | 50 | } |
49 | 51 |
|
50 | 52 | public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection |
51 | 53 | { |
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 | + { |
53 | 62 | $column = $this->mapColumn($classReflection, $propertyName); |
54 | | - $cast = $casts[$column] ?? ''; |
| 63 | + $casts = $this->readStringMap($classReflection, 'casts'); |
| 64 | + $schema = $this->lookupColumn($classReflection, $column); |
55 | 65 |
|
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; |
57 | 81 | } |
58 | 82 |
|
59 | 83 | private function resolveCastType(ClassReflection $classReflection, string $cast): Type |
@@ -86,6 +110,24 @@ private function resolveCustomHandlerType(ClassReflection $classReflection, stri |
86 | 110 | return $nullable ? TypeCombinator::addNull($type) : $type; |
87 | 111 | } |
88 | 112 |
|
| 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 | + |
89 | 131 | private function castName(string $cast): string |
90 | 132 | { |
91 | 133 | if (preg_match('/\A(.+)\[.+\]\z/', $cast, $matches) === 1) { |
@@ -123,4 +165,26 @@ private function readStringMap(ClassReflection $classReflection, string $propert |
123 | 165 |
|
124 | 166 | return $map; |
125 | 167 | } |
| 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 | + } |
126 | 190 | } |
0 commit comments