From f4ca6bfa3b20711f0ce08b6afc3ee6bf10d901cf Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 15 Aug 2022 12:11:48 +0200 Subject: [PATCH 01/13] Support custom casts using CastsAttributes --- src/Properties/ModelPropertyExtension.php | 25 ++++++++++---- src/Properties/SchemaColumn.php | 4 +-- tests/Application/app/Casts/Favorites.php | 33 +++++++++++++++++++ tests/Application/app/DTO/Favorites.php | 7 ++++ tests/Application/app/User.php | 4 ++- .../2020_01_30_000000_create_users_table.php | 1 + .../Properties/ModelPropertyExtension.php | 7 ++++ 7 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/Application/app/Casts/Favorites.php create mode 100644 tests/Application/app/DTO/Favorites.php diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 678a87bc1..0745a49ef 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -5,6 +5,7 @@ namespace NunoMaduro\Larastan\Properties; use ArrayObject; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; @@ -18,6 +19,8 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; /** * @internal @@ -136,8 +139,8 @@ public function getProperty( return new ModelProperty( $classReflection, - $this->stringResolver->resolve($column->readableType), - $this->stringResolver->resolve($column->writeableType) + $column->readableType instanceof Type ? $column->readableType : $this->stringResolver->resolve($column->readableType), + $column->writeableType instanceof Type ? $column->writeableType : $this->stringResolver->resolve($column->writeableType), ); } @@ -176,7 +179,7 @@ private function getModelDateColumns(Model $modelInstance): array * @param SchemaColumn $column * @param Model $modelInstance * @return string[] - * @phpstan-return array + * @phpstan-return array */ private function getReadableAndWritableTypes(SchemaColumn $column, Model $modelInstance): array { @@ -191,7 +194,7 @@ private function getReadableAndWritableTypes(SchemaColumn $column, Model $modelI case 'string': case 'int': case 'float': - $readableType = $writableType = $column->readableType.($column->nullable ? '|null' : ''); + $readableType = $writableType = $column->readableType instanceof Type ? TypeCombinator::addNull($column->readableType) : $column->readableType.($column->nullable ? '|null' : ''); break; case 'boolean': @@ -280,12 +283,22 @@ private function castPropertiesType(Model $modelInstance): void $realType = '\Illuminate\Support\Collection'; break; default: - $realType = class_exists($type) ? ('\\'.$type) : 'mixed'; + if (! class_exists($type)) { + $realType = 'mixed'; + } elseif ($this->reflectionProvider->getClass($type)->isSubclassOf(CastsAttributes::class)) { + $classReflection = $this->reflectionProvider->getClass($type); + $methodReflection = $classReflection->getNativeMethod('get'); + $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + $realType = $returnType; + } else { + $realType = ('\\'.$type); + } break; } if ($this->tables[$modelInstance->getTable()]->columns[$name]->nullable) { - $realType .= '|null'; + $realType = $realType instanceof Type ? TypeCombinator::addNull($realType) : $realType . '|null'; } $this->tables[$modelInstance->getTable()]->columns[$name]->readableType = $realType; diff --git a/src/Properties/SchemaColumn.php b/src/Properties/SchemaColumn.php index a09d56755..30574fdec 100644 --- a/src/Properties/SchemaColumn.php +++ b/src/Properties/SchemaColumn.php @@ -12,10 +12,10 @@ final class SchemaColumn /** @var string */ public $name; - /** @var string */ + /** @var string|\PHPStan\Type\Type */ public $readableType; - /** @var string */ + /** @var string|\PHPStan\Type\Type */ public $writeableType; /** @var bool */ diff --git a/tests/Application/app/Casts/Favorites.php b/tests/Application/app/Casts/Favorites.php new file mode 100644 index 000000000..499a231eb --- /dev/null +++ b/tests/Application/app/Casts/Favorites.php @@ -0,0 +1,33 @@ + */ @@ -50,6 +51,7 @@ class User extends Authenticatable 'floatButRoundedDecimalString' => 'decimal:1', 'options' => AsArrayObject::class, 'properties' => AsCollection::class, + 'favorites' => Favorites::class, ]; /** diff --git a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php index 34274720b..18bb0bbf2 100644 --- a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php +++ b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php @@ -26,6 +26,7 @@ public function up(): void $table->json('meta'); $table->json('options'); $table->json('properties'); + $table->json('favorites'); $table->boolean('blocked'); $table->unknownColumnType('unknown_column'); $table->rememberToken(); diff --git a/tests/Features/Properties/ModelPropertyExtension.php b/tests/Features/Properties/ModelPropertyExtension.php index cd7772b47..299b6e86d 100644 --- a/tests/Features/Properties/ModelPropertyExtension.php +++ b/tests/Features/Properties/ModelPropertyExtension.php @@ -6,6 +6,7 @@ use App\Account; use App\Address; +use App\DTO\Favorites; use App\Group; use App\GuardedModel; use App\Role; @@ -16,6 +17,7 @@ use Carbon\Carbon as BaseCarbon; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use function PHPStan\dumpType; use function PHPStan\Testing\assertType; class ModelPropertyExtension @@ -239,4 +241,9 @@ public function testForeignIdConstrainedNullable(Address $address): ?int { return $address->nullable_foreign_id_constrained; } + + public function testCustomCast(): Favorites + { + return $this->user->favorites; + } } From 90986425621e16f0eae73f5167d934295746c8af Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 15 Aug 2022 13:39:44 +0200 Subject: [PATCH 02/13] Fixes from StyleCI --- src/Properties/ModelPropertyExtension.php | 2 +- tests/Application/app/Casts/Favorites.php | 2 +- tests/Application/app/User.php | 4 ++-- tests/Features/Properties/ModelPropertyExtension.php | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 0745a49ef..cd3116b98 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -298,7 +298,7 @@ private function castPropertiesType(Model $modelInstance): void } if ($this->tables[$modelInstance->getTable()]->columns[$name]->nullable) { - $realType = $realType instanceof Type ? TypeCombinator::addNull($realType) : $realType . '|null'; + $realType = $realType instanceof Type ? TypeCombinator::addNull($realType) : $realType.'|null'; } $this->tables[$modelInstance->getTable()]->columns[$name]->readableType = $realType; diff --git a/tests/Application/app/Casts/Favorites.php b/tests/Application/app/Casts/Favorites.php index 499a231eb..c6a7498b3 100644 --- a/tests/Application/app/Casts/Favorites.php +++ b/tests/Application/app/Casts/Favorites.php @@ -20,7 +20,7 @@ public function get($model, $key, $value, $attributes) /** * Prepare the given value for storage. * - * @param Favorites $value + * @param Favorites $value */ public function set($model, $key, $value, $attributes) { diff --git a/tests/Application/app/User.php b/tests/Application/app/User.php index e4d383090..0765e9468 100644 --- a/tests/Application/app/User.php +++ b/tests/Application/app/User.php @@ -2,6 +2,7 @@ namespace App; +use App\Casts\Favorites; use function get_class; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\AsArrayObject; @@ -18,7 +19,6 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Tests\Application\HasManySyncable; -use App\Casts\Favorites; /** * @property string $propertyDefinedOnlyInAnnotation @@ -39,7 +39,7 @@ class User extends Authenticatable * @var array */ protected $fillable = [ - 'name', 'email', 'password' + 'name', 'email', 'password', ]; /** @var array */ diff --git a/tests/Features/Properties/ModelPropertyExtension.php b/tests/Features/Properties/ModelPropertyExtension.php index 299b6e86d..731b1cf6d 100644 --- a/tests/Features/Properties/ModelPropertyExtension.php +++ b/tests/Features/Properties/ModelPropertyExtension.php @@ -17,7 +17,6 @@ use Carbon\Carbon as BaseCarbon; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use function PHPStan\dumpType; use function PHPStan\Testing\assertType; class ModelPropertyExtension From 884234bcc0dd3d64be818007c276fce802c6aa69 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Fri, 2 Sep 2022 23:58:17 +0200 Subject: [PATCH 03/13] Rewrite ModelPropertyExtension with ModelCastHelper --- extension.neon | 3 + src/Properties/ModelCastHelper.php | 145 +++++++++ src/Properties/ModelPropertyExtension.php | 344 +++++++--------------- src/Properties/SchemaColumn.php | 34 +-- src/Properties/SchemaTable.php | 2 +- tests/Type/data/model-properties.php | 3 + 6 files changed, 258 insertions(+), 273 deletions(-) create mode 100644 src/Properties/ModelCastHelper.php diff --git a/extension.neon b/extension.neon index 6a0f5d7f5..69b72f2e0 100644 --- a/extension.neon +++ b/extension.neon @@ -410,6 +410,9 @@ services: arguments: schemaPaths: %squashedMigrationsPath% + - + class: NunoMaduro\Larastan\Properties\ModelCastHelper + - class: NunoMaduro\Larastan\Rules\ModelProperties\ModelPropertiesRuleHelper diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php new file mode 100644 index 000000000..e8722e1e0 --- /dev/null +++ b/src/Properties/ModelCastHelper.php @@ -0,0 +1,145 @@ + new IntegerType(), + 'real', 'float', 'double' => new FloatType(), + 'decimal' => new AccessoryNumericStringType(), + 'string' => new StringType(), + 'bool', 'boolean' => new BooleanType(), + 'object' => new ObjectType('stdClass'), + 'array', 'json' => new ArrayType(new MixedType(), new MixedType()), + 'collection' => new ObjectType('Illuminate\Support\Collection'), + 'date', 'datetime' => new ObjectType('Carbon\Carbon'), + 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), + 'timestamp' => new IntegerType(), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return new MixedType(); + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isSubclassOf(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + if ($castUsingReturn instanceof ObjectType && $castReflection = $castUsingReturn->getClassReflection()) { + $classReflection = $castReflection; + } + } + + if ($classReflection->isSubclassOf(CastsAttributes::class)) { + $methodReflection = $classReflection->getNativeMethod('get'); + + return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + } + + if ($classReflection->isSubclassOf(CastsInboundAttributes::class)) { + return $originalType; + } + + return new MixedType(); + } + + public function getWriteableType(string $cast, Type $originalType): Type + { + $attributeType = match ($cast) { + 'int', 'integer' => new IntegerType(), + 'real', 'float', 'double' => new FloatType(), + 'decimal' => new AccessoryNumericStringType(), + 'string' => new StringType(), + 'bool', 'boolean' => TypeCombinator::union(new BooleanType(), new ConstantIntegerType(0), new ConstantIntegerType(1)), + 'object' => new ObjectType('stdClass'), + 'array', 'json' => new ArrayType(new MixedType(), new MixedType()), + 'collection' => new ObjectType('Illuminate\Support\Collection'), + 'date', 'datetime' => $this->getDateType(), + 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), + 'timestamp' => new IntegerType(), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return new MixedType(); + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isSubclassOf(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + if ($castUsingReturn instanceof ObjectType && $castReflection = $castUsingReturn->getClassReflection()) { + $classReflection = $castReflection; + } + } + + if ( + $classReflection->isSubclassOf(CastsAttributes::class) + || $classReflection->isSubclassOf(CastsInboundAttributes::class) + ) { + $methodReflection = $classReflection->getNativeMethod('set'); + $parameters = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getParameters(); + + $valueParameter = Arr::first($parameters, fn (ParameterReflection $parameterReflection) => $parameterReflection->getName() === 'value'); + + return $valueParameter->getType(); + } + + return new MixedType(); + } + + public function getDateType(): Type + { + $dateClass = class_exists(\Illuminate\Support\Facades\Date::class) + ? \Illuminate\Support\Facades\Date::now()::class + : '\Illuminate\Support\Carbon'; + + if ($dateClass === '\Illuminate\Support\Carbon') { + return TypeCombinator::union(new ObjectType($dateClass), new ObjectType(\Carbon\Carbon::class)); + } + + return new ObjectType($dateClass); + } +} diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index cd3116b98..5aeb0badc 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -4,22 +4,18 @@ namespace NunoMaduro\Larastan\Properties; -use ArrayObject; -use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use NunoMaduro\Larastan\Reflection\ReflectionHelper; -use PHPStan\Broker\ClassNotFoundException; use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\PropertiesClassReflectionExtension; use PHPStan\Reflection\PropertyReflection; -use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; -use PHPStan\Type\Type; +use PHPStan\Type\StringType; use PHPStan\Type\TypeCombinator; /** @@ -28,18 +24,19 @@ final class ModelPropertyExtension implements PropertiesClassReflectionExtension { /** @var array */ - private $tables = []; - - /** @var string */ - private $dateClass; - - public function __construct(private TypeStringResolver $stringResolver, private MigrationHelper $migrationHelper, private SquashedMigrationHelper $squashedMigrationHelper, private ReflectionProvider $reflectionProvider) - { + private array $tables = []; + + public function __construct( + private TypeStringResolver $stringResolver, + private MigrationHelper $migrationHelper, + private SquashedMigrationHelper $squashedMigrationHelper, + private ModelCastHelper $modelCastHelper, + ) { } public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { - if (! $classReflection->isSubclassOf(Model::class)) { + if (!$classReflection->isSubclassOf(Model::class)) { return false; } @@ -47,7 +44,7 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa return false; } - if ($this->hasAttribute($classReflection, $propertyName)) { + if ($this->hasAccessor($classReflection, $propertyName)) { return false; } @@ -55,281 +52,140 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa return false; } - if (count($this->tables) === 0) { - // First try to create tables from squashed migrations, if there are any - // Then scan the normal migration files for further changes to tables. - $tables = $this->squashedMigrationHelper->initializeTables(); - - $this->tables = $this->migrationHelper->initializeTables($tables); - } - - if ($propertyName === 'id') { - return true; + if (!$this->migrationsLoaded()) { + $this->loadMigrations(); } - $modelName = $classReflection->getNativeReflection()->getName(); - try { - $reflect = $this->reflectionProvider->getClass($modelName); - /** @var Model $modelInstance */ - $modelInstance = $reflect->getNativeReflection()->newInstanceWithoutConstructor(); - - $tableName = $modelInstance->getTable(); - } catch (ClassNotFoundException|\ReflectionException $e) { + $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (\ReflectionException $e) { return false; } - if (! array_key_exists($tableName, $this->tables)) { - return false; + if ($propertyName === $modelInstance->getKeyName()) { + return true; } - if (! array_key_exists($propertyName, $this->tables[$tableName]->columns)) { + $tableName = $modelInstance->getTable(); + + if (!array_key_exists($tableName, $this->tables)) { return false; } - $this->castPropertiesType($modelInstance); - - $column = $this->tables[$tableName]->columns[$propertyName]; - - [$readableType, $writableType] = $this->getReadableAndWritableTypes($column, $modelInstance); - - $column->readableType = $readableType; - $column->writeableType = $writableType; - - $this->tables[$tableName]->columns[$propertyName] = $column; - - return true; + return array_key_exists($propertyName, $this->tables[$tableName]->columns); } - public function getProperty( - ClassReflection $classReflection, - string $propertyName - ): PropertyReflection { - $modelName = $classReflection->getNativeReflection()->getName(); - - try { - $reflect = $this->reflectionProvider->getClass($modelName); - - /** @var Model $modelInstance */ - $modelInstance = $reflect->getNativeReflection()->newInstanceWithoutConstructor(); + private function hasAccessor(ClassReflection $classReflection, string $propertyName): bool + { + $propertyNameStudlyCase = Str::studly($propertyName); - $tableName = $modelInstance->getTable(); - } catch (ClassNotFoundException|\ReflectionException $e) { - // `hasProperty` should return false if there was a reflection exception. - // so this should never happen - throw new ShouldNotHappenException(); + if ($classReflection->hasNativeMethod("get{$propertyNameStudlyCase}Attribute")) { + return true; } - if ( - ( - ! array_key_exists($tableName, $this->tables) - || ! array_key_exists($propertyName, $this->tables[$tableName]->columns) - ) - && $propertyName === 'id' - ) { - return new ModelProperty( - $classReflection, - $this->stringResolver->resolve($modelInstance->getKeyType()), - $this->stringResolver->resolve($modelInstance->getKeyType()) - ); - } + $propertyNameCamelCase = Str::camel($propertyName); - $column = $this->tables[$tableName]->columns[$propertyName]; + if ($classReflection->hasNativeMethod($propertyNameCamelCase)) { + $methodReflection = $classReflection->getNativeMethod($propertyNameCamelCase); - return new ModelProperty( - $classReflection, - $column->readableType instanceof Type ? $column->readableType : $this->stringResolver->resolve($column->readableType), - $column->writeableType instanceof Type ? $column->writeableType : $this->stringResolver->resolve($column->writeableType), - ); - } + if ($methodReflection->isPublic() || $methodReflection->isPrivate()) { + return false; + } - private function getDateClass(): string - { - if (! $this->dateClass) { - $this->dateClass = class_exists(\Illuminate\Support\Facades\Date::class) - ? '\\'.get_class(\Illuminate\Support\Facades\Date::now()) - : '\Illuminate\Support\Carbon'; + $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - if ($this->dateClass === '\Illuminate\Support\Carbon') { - $this->dateClass .= '|\Carbon\Carbon'; + if (!(new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { + return false; } + + return true; } - return $this->dateClass; + return false; } - /** - * @param Model $modelInstance - * @return string[] - * @phpstan-return array - */ - private function getModelDateColumns(Model $modelInstance): array + private function migrationsLoaded(): bool { - $dateColumns = $modelInstance->getDates(); - - if (method_exists($modelInstance, 'getDeletedAtColumn')) { - $dateColumns[] = $modelInstance->getDeletedAtColumn(); - } - - return $dateColumns; + return !empty($this->tables); } - /** - * @param SchemaColumn $column - * @param Model $modelInstance - * @return string[] - * @phpstan-return array - */ - private function getReadableAndWritableTypes(SchemaColumn $column, Model $modelInstance): array + private function loadMigrations(): void { - $readableType = $column->readableType; - $writableType = $column->writeableType; - - if (in_array($column->name, $this->getModelDateColumns($modelInstance), true)) { - return [$this->getDateClass().($column->nullable ? '|null' : ''), $this->getDateClass().'|string'.($column->nullable ? '|null' : '')]; - } + // First try to create tables from squashed migrations, if there are any + // Then scan the normal migration files for further changes to tables. + $tables = $this->squashedMigrationHelper->initializeTables(); - switch ($column->readableType) { - case 'string': - case 'int': - case 'float': - $readableType = $writableType = $column->readableType instanceof Type ? TypeCombinator::addNull($column->readableType) : $column->readableType.($column->nullable ? '|null' : ''); - break; - - case 'boolean': - case 'bool': - switch ((string) config('database.default')) { - case 'sqlite': - case 'mysql': - $writableType = '0|1|bool'; - $readableType = 'bool'; - break; - default: - $readableType = $writableType = 'bool'; - break; - } - break; - case 'enum': - case 'set': - if (! $column->options) { - $readableType = $writableType = 'string'; - } else { - $readableType = $writableType = '\''.implode('\'|\'', $column->options).'\''; - } - - break; - - default: - break; - } - - return [$readableType, $writableType]; + $this->tables = $this->migrationHelper->initializeTables($tables); } - private function castPropertiesType(Model $modelInstance): void + public function getProperty( + ClassReflection $classReflection, + string $propertyName + ): PropertyReflection { - $casts = $modelInstance->getCasts(); - foreach ($casts as $name => $type) { - if (! array_key_exists($name, $this->tables[$modelInstance->getTable()]->columns)) { - continue; - } - - // Reduce encrypted castable types - if (in_array($type, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], true)) { - $type = Str::after($type, 'encrypted:'); - } - - // Truncate cast parameters - $type = Str::before($type, ':'); - - switch ($type) { - case 'boolean': - case 'bool': - $realType = 'boolean'; - break; - case 'string': - case 'decimal': - $realType = 'string'; - break; - case 'array': - case 'json': - $realType = 'array'; - break; - case 'object': - $realType = 'object'; - break; - case 'int': - case 'integer': - case 'timestamp': - $realType = 'integer'; - break; - case 'real': - case 'double': - case 'float': - $realType = 'float'; - break; - case 'date': - case 'datetime': - $realType = $this->getDateClass(); - break; - case 'collection': - $realType = '\Illuminate\Support\Collection'; - break; - case 'Illuminate\Database\Eloquent\Casts\AsArrayObject': - $realType = ArrayObject::class; - break; - case 'Illuminate\Database\Eloquent\Casts\AsCollection': - $realType = '\Illuminate\Support\Collection'; - break; - default: - if (! class_exists($type)) { - $realType = 'mixed'; - } elseif ($this->reflectionProvider->getClass($type)->isSubclassOf(CastsAttributes::class)) { - $classReflection = $this->reflectionProvider->getClass($type); - $methodReflection = $classReflection->getNativeMethod('get'); - $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - - $realType = $returnType; - } else { - $realType = ('\\'.$type); - } - break; - } + try { + /** @var Model $modelInstance */ + $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (\ReflectionException $e) { + throw new ShouldNotHappenException(); + } - if ($this->tables[$modelInstance->getTable()]->columns[$name]->nullable) { - $realType = $realType instanceof Type ? TypeCombinator::addNull($realType) : $realType.'|null'; - } + $tableName = $modelInstance->getTable(); - $this->tables[$modelInstance->getTable()]->columns[$name]->readableType = $realType; - $this->tables[$modelInstance->getTable()]->columns[$name]->writeableType = $realType; + if ( + $propertyName === $modelInstance->getKeyName() + && (!array_key_exists($tableName, $this->tables) || !array_key_exists($propertyName, $this->tables[$tableName]->columns)) + ) { + return new ModelProperty( + declaringClass: $classReflection, + readableType: $this->stringResolver->resolve($modelInstance->getKeyType()), + writableType: $this->stringResolver->resolve($modelInstance->getKeyType()), + ); } - } - private function hasAttribute(ClassReflection $classReflection, string $propertyName): bool - { - if ($classReflection->hasNativeMethod('get'.Str::studly($propertyName).'Attribute')) { - return true; - } + $column = $this->tables[$tableName]->columns[$propertyName]; - $camelCase = Str::camel($propertyName); + if ($this->hasDate($modelInstance, $propertyName)) { + $readableType = $this->modelCastHelper->getDateType(); + $writeableType = TypeCombinator::union($this->modelCastHelper->getDateType(), new StringType()); + } elseif ($modelInstance->hasCast($propertyName)) { + $cast = $modelInstance->getCasts()[$propertyName]; - if ($classReflection->hasNativeMethod($camelCase)) { - $methodReflection = $classReflection->getNativeMethod($camelCase); + $readableType = $this->modelCastHelper->getReadableType( + cast: $cast, + originalType: $this->stringResolver->resolve($column->readableType), + ); + $writeableType = $this->modelCastHelper->getWriteableType( + cast: $cast, + originalType: $this->stringResolver->resolve($column->writeableType), + ); + } else { + $readableType = $this->stringResolver->resolve($column->readableType); + $writeableType = $this->stringResolver->resolve($column->writeableType); + } - if ($methodReflection->isPublic() || $methodReflection->isPrivate()) { - return false; - } + if ($column->nullable) { + $readableType = TypeCombinator::addNull($readableType); + $writeableType = TypeCombinator::addNull($writeableType); + } - $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + return new ModelProperty( + declaringClass: $classReflection, + readableType: $readableType, + writableType: $writeableType, + ); + } - if (! (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { - return false; - } + private function hasDate(Model $modelInstance, string $propertyName): bool + { + $dates = $modelInstance->getDates(); - return true; + // In order to support SoftDeletes + if (method_exists($modelInstance, 'getDeletedAtColumn')) { + $dates[] = $modelInstance->getDeletedAtColumn(); } - return false; + return in_array($propertyName, $dates); } } diff --git a/src/Properties/SchemaColumn.php b/src/Properties/SchemaColumn.php index 30574fdec..cfe436a4d 100644 --- a/src/Properties/SchemaColumn.php +++ b/src/Properties/SchemaColumn.php @@ -9,37 +9,15 @@ */ final class SchemaColumn { - /** @var string */ - public $name; + public string $writeableType; - /** @var string|\PHPStan\Type\Type */ - public $readableType; - - /** @var string|\PHPStan\Type\Type */ - public $writeableType; - - /** @var bool */ - public $nullable; - - /** @var ?array */ - public $options; - - /** - * @param string $name - * @param string $readableType - * @param bool $nullable - * @param string[]|null $options - */ public function __construct( - string $name, - string $readableType, - bool $nullable = false, - ?array $options = null + public string $name, + public string $readableType, + public bool $nullable = false, + /** @var array */ + public ?array $options = null ) { - $this->name = $name; - $this->readableType = $readableType; $this->writeableType = $readableType; - $this->nullable = $nullable; - $this->options = $options; } } diff --git a/src/Properties/SchemaTable.php b/src/Properties/SchemaTable.php index 93d2abfad..91932fe79 100644 --- a/src/Properties/SchemaTable.php +++ b/src/Properties/SchemaTable.php @@ -12,7 +12,7 @@ final class SchemaTable /** @var string */ public $name; - /** @var SchemaColumn[] */ + /** @var array */ public $columns = []; public function __construct(string $name) diff --git a/tests/Type/data/model-properties.php b/tests/Type/data/model-properties.php index 9d3739939..6e3232155 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -11,3 +11,6 @@ assertType('string', $user->email); assertType('array', $user->allowed_ips); assertType('string', $user->floatButRoundedDecimalString); + +// CastsAttributes +assertType('App\DTO\Favorites', $user->favorites); From a435e1a8e587f77b7192eeedfda765dde272e9c8 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Sat, 3 Sep 2022 00:06:51 +0200 Subject: [PATCH 04/13] CastInboundAttribute test --- src/Properties/ModelCastHelper.php | 4 +- tests/Application/app/Casts/Hash.php | 42 +++++++++++++++++++ tests/Application/app/User.php | 2 + .../2020_01_30_000000_create_users_table.php | 1 + .../Properties/ModelPropertyExtension.php | 5 +++ 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/Application/app/Casts/Hash.php diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php index e8722e1e0..4b6518301 100644 --- a/src/Properties/ModelCastHelper.php +++ b/src/Properties/ModelCastHelper.php @@ -134,9 +134,9 @@ public function getDateType(): Type { $dateClass = class_exists(\Illuminate\Support\Facades\Date::class) ? \Illuminate\Support\Facades\Date::now()::class - : '\Illuminate\Support\Carbon'; + : \Illuminate\Support\Carbon::class; - if ($dateClass === '\Illuminate\Support\Carbon') { + if ($dateClass === \Illuminate\Support\Carbon::class) { return TypeCombinator::union(new ObjectType($dateClass), new ObjectType(\Carbon\Carbon::class)); } diff --git a/tests/Application/app/Casts/Hash.php b/tests/Application/app/Casts/Hash.php new file mode 100644 index 000000000..7c18ca8ea --- /dev/null +++ b/tests/Application/app/Casts/Hash.php @@ -0,0 +1,42 @@ +algorithm = $algorithm; + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string $value + * @param array $attributes + * @return string + */ + public function set($model, string $key, $value, $attributes) + { + return is_null($this->algorithm) + ? bcrypt($value) + : hash($this->algorithm, $value); + } +} diff --git a/tests/Application/app/User.php b/tests/Application/app/User.php index 0765e9468..483082798 100644 --- a/tests/Application/app/User.php +++ b/tests/Application/app/User.php @@ -3,6 +3,7 @@ namespace App; use App\Casts\Favorites; +use App\Casts\Hash; use function get_class; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\AsArrayObject; @@ -52,6 +53,7 @@ class User extends Authenticatable 'options' => AsArrayObject::class, 'properties' => AsCollection::class, 'favorites' => Favorites::class, + 'secret' => Hash::class.':sha256', ]; /** diff --git a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php index 18bb0bbf2..6af5657bd 100644 --- a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php +++ b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php @@ -27,6 +27,7 @@ public function up(): void $table->json('options'); $table->json('properties'); $table->json('favorites'); + $table->string('secret'); $table->boolean('blocked'); $table->unknownColumnType('unknown_column'); $table->rememberToken(); diff --git a/tests/Features/Properties/ModelPropertyExtension.php b/tests/Features/Properties/ModelPropertyExtension.php index 731b1cf6d..17ae2b5c2 100644 --- a/tests/Features/Properties/ModelPropertyExtension.php +++ b/tests/Features/Properties/ModelPropertyExtension.php @@ -245,4 +245,9 @@ public function testCustomCast(): Favorites { return $this->user->favorites; } + + public function testInboundCast(): void + { + $this->user->secret = 'secret'; + } } From 695a193f10e62fc1a48214794ade859b56b5d4e3 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Sat, 3 Sep 2022 00:07:59 +0200 Subject: [PATCH 05/13] Fixes from StyleCI --- src/Properties/ModelPropertyExtension.php | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 5aeb0badc..86423b301 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -27,16 +27,16 @@ final class ModelPropertyExtension implements PropertiesClassReflectionExtension private array $tables = []; public function __construct( - private TypeStringResolver $stringResolver, - private MigrationHelper $migrationHelper, + private TypeStringResolver $stringResolver, + private MigrationHelper $migrationHelper, private SquashedMigrationHelper $squashedMigrationHelper, - private ModelCastHelper $modelCastHelper, + private ModelCastHelper $modelCastHelper, ) { } public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { - if (!$classReflection->isSubclassOf(Model::class)) { + if (! $classReflection->isSubclassOf(Model::class)) { return false; } @@ -52,7 +52,7 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa return false; } - if (!$this->migrationsLoaded()) { + if (! $this->migrationsLoaded()) { $this->loadMigrations(); } @@ -69,7 +69,7 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa $tableName = $modelInstance->getTable(); - if (!array_key_exists($tableName, $this->tables)) { + if (! array_key_exists($tableName, $this->tables)) { return false; } @@ -95,7 +95,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - if (!(new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { + if (! (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { return false; } @@ -107,7 +107,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN private function migrationsLoaded(): bool { - return !empty($this->tables); + return ! empty($this->tables); } private function loadMigrations(): void @@ -121,9 +121,8 @@ private function loadMigrations(): void public function getProperty( ClassReflection $classReflection, - string $propertyName - ): PropertyReflection - { + string $propertyName + ): PropertyReflection { try { /** @var Model $modelInstance */ $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); @@ -135,7 +134,7 @@ public function getProperty( if ( $propertyName === $modelInstance->getKeyName() - && (!array_key_exists($tableName, $this->tables) || !array_key_exists($propertyName, $this->tables[$tableName]->columns)) + && (! array_key_exists($tableName, $this->tables) || ! array_key_exists($propertyName, $this->tables[$tableName]->columns)) ) { return new ModelProperty( declaringClass: $classReflection, From 80e529fe03d1516bfb319d2c482d7ae566b37ae8 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Sat, 3 Sep 2022 11:45:19 +0200 Subject: [PATCH 06/13] Rename DTO namespace to ValueObjects to match Laravel documentation https://laravel.com/docs/9.x/eloquent-mutators#value-object-casting --- tests/Application/app/Casts/Favorites.php | 6 +++--- tests/Application/app/{DTO => ValueObjects}/Favorites.php | 2 +- tests/Features/Properties/ModelPropertyExtension.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename tests/Application/app/{DTO => ValueObjects}/Favorites.php (50%) diff --git a/tests/Application/app/Casts/Favorites.php b/tests/Application/app/Casts/Favorites.php index c6a7498b3..9fe990eb5 100644 --- a/tests/Application/app/Casts/Favorites.php +++ b/tests/Application/app/Casts/Favorites.php @@ -10,11 +10,11 @@ class Favorites implements CastsAttributes /** * Cast the given value. * - * @return \App\DTO\Favorites + * @return \App\ValueObjects\Favorites */ public function get($model, $key, $value, $attributes) { - return new \App\DTO\Favorites(); + return new \App\ValueObjects\Favorites(); } /** @@ -24,7 +24,7 @@ public function get($model, $key, $value, $attributes) */ public function set($model, $key, $value, $attributes) { - if (! $value instanceof \App\DTO\Favorites) { + if (! $value instanceof \App\ValueObjects\Favorites) { throw new InvalidArgumentException('The given value is not a Favorites instance.'); } diff --git a/tests/Application/app/DTO/Favorites.php b/tests/Application/app/ValueObjects/Favorites.php similarity index 50% rename from tests/Application/app/DTO/Favorites.php rename to tests/Application/app/ValueObjects/Favorites.php index 8594f6bd3..be8dd880d 100644 --- a/tests/Application/app/DTO/Favorites.php +++ b/tests/Application/app/ValueObjects/Favorites.php @@ -1,6 +1,6 @@ Date: Sun, 4 Sep 2022 14:23:35 +0200 Subject: [PATCH 07/13] Fix `date` readable type --- src/Properties/ModelCastHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php index 4b6518301..b081bb955 100644 --- a/src/Properties/ModelCastHelper.php +++ b/src/Properties/ModelCastHelper.php @@ -41,7 +41,7 @@ public function getReadableType(string $cast, Type $originalType): Type 'object' => new ObjectType('stdClass'), 'array', 'json' => new ArrayType(new MixedType(), new MixedType()), 'collection' => new ObjectType('Illuminate\Support\Collection'), - 'date', 'datetime' => new ObjectType('Carbon\Carbon'), + 'date', 'datetime' => $this->getDateType(), 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), 'timestamp' => new IntegerType(), default => null, From 84bbbfcbb21e6abc08f51ab337b718572b27213d Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Sun, 4 Sep 2022 14:27:30 +0200 Subject: [PATCH 08/13] Fixes from StyleCI --- tests/Features/Properties/ModelPropertyExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Features/Properties/ModelPropertyExtension.php b/tests/Features/Properties/ModelPropertyExtension.php index e7c5dc753..bbe9c4696 100644 --- a/tests/Features/Properties/ModelPropertyExtension.php +++ b/tests/Features/Properties/ModelPropertyExtension.php @@ -6,13 +6,13 @@ use App\Account; use App\Address; -use App\ValueObjects\Favorites; use App\Group; use App\GuardedModel; use App\Role; use App\Team; use App\Thread; use App\User; +use App\ValueObjects\Favorites; use ArrayObject; use Carbon\Carbon as BaseCarbon; use Illuminate\Support\Carbon; From d23722986b214b2d5abe33cbdc8d3fa186c8ca23 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Sun, 4 Sep 2022 14:28:35 +0200 Subject: [PATCH 09/13] Update Favorites namespace in test --- tests/Type/data/model-properties.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Type/data/model-properties.php b/tests/Type/data/model-properties.php index 6e3232155..96e34fc8e 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -13,4 +13,4 @@ assertType('string', $user->floatButRoundedDecimalString); // CastsAttributes -assertType('App\DTO\Favorites', $user->favorites); +assertType('App\ValueObjects\Favorites', $user->favorites); From 69e9554ec9edca7e6b3581a6bae28ddfb82ebfcf Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 19 Dec 2022 11:28:49 +0100 Subject: [PATCH 10/13] Process feedback --- src/Properties/ModelCastHelper.php | 9 ++++--- src/Properties/ModelPropertyExtension.php | 30 ++++++++++------------- tests/Type/data/model-properties.php | 2 ++ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php index b081bb955..99d95fccf 100644 --- a/src/Properties/ModelCastHelper.php +++ b/src/Properties/ModelCastHelper.php @@ -12,6 +12,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; @@ -35,11 +36,11 @@ public function getReadableType(string $cast, Type $originalType): Type $attributeType = match ($cast) { 'int', 'integer' => new IntegerType(), 'real', 'float', 'double' => new FloatType(), - 'decimal' => new AccessoryNumericStringType(), + 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), 'string' => new StringType(), 'bool', 'boolean' => new BooleanType(), 'object' => new ObjectType('stdClass'), - 'array', 'json' => new ArrayType(new MixedType(), new MixedType()), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType()), 'collection' => new ObjectType('Illuminate\Support\Collection'), 'date', 'datetime' => $this->getDateType(), 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), @@ -84,11 +85,11 @@ public function getWriteableType(string $cast, Type $originalType): Type $attributeType = match ($cast) { 'int', 'integer' => new IntegerType(), 'real', 'float', 'double' => new FloatType(), - 'decimal' => new AccessoryNumericStringType(), + 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), 'string' => new StringType(), 'bool', 'boolean' => TypeCombinator::union(new BooleanType(), new ConstantIntegerType(0), new ConstantIntegerType(1)), 'object' => new ObjectType('stdClass'), - 'array', 'json' => new ArrayType(new MixedType(), new MixedType()), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType()), 'collection' => new ObjectType('Illuminate\Support\Collection'), 'date', 'datetime' => $this->getDateType(), 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 86423b301..197451939 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -80,7 +80,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN { $propertyNameStudlyCase = Str::studly($propertyName); - if ($classReflection->hasNativeMethod("get{$propertyNameStudlyCase}Attribute")) { + if ($classReflection->hasNativeMethod(sprintf("get%sAttribute", $propertyNameStudlyCase))) { return true; } @@ -95,11 +95,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - if (! (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { - return false; - } - - return true; + return (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes(); } return false; @@ -107,7 +103,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN private function migrationsLoaded(): bool { - return ! empty($this->tables); + return count($this->tables) > 0; } private function loadMigrations(): void @@ -137,9 +133,9 @@ public function getProperty( && (! array_key_exists($tableName, $this->tables) || ! array_key_exists($propertyName, $this->tables[$tableName]->columns)) ) { return new ModelProperty( - declaringClass: $classReflection, - readableType: $this->stringResolver->resolve($modelInstance->getKeyType()), - writableType: $this->stringResolver->resolve($modelInstance->getKeyType()), + $classReflection, + $this->stringResolver->resolve($modelInstance->getKeyType()), + $this->stringResolver->resolve($modelInstance->getKeyType()), ); } @@ -152,12 +148,12 @@ public function getProperty( $cast = $modelInstance->getCasts()[$propertyName]; $readableType = $this->modelCastHelper->getReadableType( - cast: $cast, - originalType: $this->stringResolver->resolve($column->readableType), + $cast, + $this->stringResolver->resolve($column->readableType), ); $writeableType = $this->modelCastHelper->getWriteableType( - cast: $cast, - originalType: $this->stringResolver->resolve($column->writeableType), + $cast, + $this->stringResolver->resolve($column->writeableType), ); } else { $readableType = $this->stringResolver->resolve($column->readableType); @@ -170,9 +166,9 @@ public function getProperty( } return new ModelProperty( - declaringClass: $classReflection, - readableType: $readableType, - writableType: $writeableType, + $classReflection, + $readableType, + $writeableType, ); } diff --git a/tests/Type/data/model-properties.php b/tests/Type/data/model-properties.php index 96e34fc8e..a05ec748e 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -3,6 +3,7 @@ namespace ModelProperties; use App\User; +use Carbon\Carbon; use function PHPStan\Testing\assertType; /** @var User $user */ @@ -11,6 +12,7 @@ assertType('string', $user->email); assertType('array', $user->allowed_ips); assertType('string', $user->floatButRoundedDecimalString); +assertType(Carbon::class, $user->email_verified_at); // CastsAttributes assertType('App\ValueObjects\Favorites', $user->favorites); From bdbea4331bc16123d71b148a21711cd2e4cffe76 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 19 Dec 2022 12:19:58 +0100 Subject: [PATCH 11/13] Handle encrypted casts --- src/Properties/ModelCastHelper.php | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php index 99d95fccf..006a9fd49 100644 --- a/src/Properties/ModelCastHelper.php +++ b/src/Properties/ModelCastHelper.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Support\Arr; -use Illuminate\Support\Str; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; @@ -31,9 +30,9 @@ public function __construct(protected ReflectionProvider $reflectionProvider) public function getReadableType(string $cast, Type $originalType): Type { - $cast = Str::before($cast, ':'); + $castType = $this->parseCast($cast); - $attributeType = match ($cast) { + $attributeType = match ($castType) { 'int', 'integer' => new IntegerType(), 'real', 'float', 'double' => new FloatType(), 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), @@ -53,7 +52,7 @@ public function getReadableType(string $cast, Type $originalType): Type } if (! $this->reflectionProvider->hasClass($cast)) { - return new MixedType(); + return $originalType; } $classReflection = $this->reflectionProvider->getClass($cast); @@ -82,7 +81,9 @@ public function getReadableType(string $cast, Type $originalType): Type public function getWriteableType(string $cast, Type $originalType): Type { - $attributeType = match ($cast) { + $castType = $this->parseCast($cast); + + $attributeType = match ($castType) { 'int', 'integer' => new IntegerType(), 'real', 'float', 'double' => new FloatType(), 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), @@ -102,7 +103,7 @@ public function getWriteableType(string $cast, Type $originalType): Type } if (! $this->reflectionProvider->hasClass($cast)) { - return new MixedType(); + return $originalType; } $classReflection = $this->reflectionProvider->getClass($cast); @@ -143,4 +144,20 @@ public function getDateType(): Type return new ObjectType($dateClass); } + + /** + * @param string $cast + * @return string|null + */ + private function parseCast(string $cast): ?string + { + foreach (explode(':', $cast) as $part) { + // If the cast is prefixed with `encrypted:` we need to skip to the next + if ($part === 'encrypted') continue; + + return $part; + } + + return null; + } } From 361102044b36424f89a100b270d1e32855bb3bca Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 19 Dec 2022 12:36:35 +0100 Subject: [PATCH 12/13] Add tests for all castable properties --- tests/Application/app/User.php | 19 ++++++++++++++ .../2020_01_30_000000_create_users_table.php | 21 ++++++++++++++++ tests/Type/data/model-properties.php | 25 +++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/Application/app/User.php b/tests/Application/app/User.php index 483082798..cbedd5a1f 100644 --- a/tests/Application/app/User.php +++ b/tests/Application/app/User.php @@ -54,6 +54,25 @@ class User extends Authenticatable 'properties' => AsCollection::class, 'favorites' => Favorites::class, 'secret' => Hash::class.':sha256', + + 'int' => 'int', + 'integer' => 'integer', + 'real' => 'real', + 'float' => 'float', + 'double' => 'double', + 'decimal' => 'decimal', + 'string' => 'string', + 'bool' => 'bool', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + 'json' => 'json', + 'collection' => 'collection', + 'date' => 'date', + 'datetime' => 'datetime', + 'immutable_date' => 'immutable_date', + 'immutable_datetime' => 'immutable_datetime', + 'timestamp' => 'timestamp', ]; /** diff --git a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php index 6af5657bd..8ddf53577 100644 --- a/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php +++ b/tests/Application/database/migrations/2020_01_30_000000_create_users_table.php @@ -31,6 +31,27 @@ public function up(): void $table->boolean('blocked'); $table->unknownColumnType('unknown_column'); $table->rememberToken(); + + // Testing property casts + $table->integer('int'); + $table->integer('integer'); + $table->float('real'); + $table->float('float'); + $table->double('double'); + $table->decimal('decimal'); + $table->string('string'); + $table->boolean('bool'); + $table->boolean('boolean'); + $table->json('object'); + $table->json('array'); + $table->json('json'); + $table->json('collection'); + $table->date('date'); + $table->dateTime('datetime'); + $table->date('immutable_date'); + $table->dateTime('immutable_datetime'); + $table->timestamp('timestamp'); + $table->timestamps(); $table->softDeletes(); }); diff --git a/tests/Type/data/model-properties.php b/tests/Type/data/model-properties.php index a05ec748e..2d28a26e8 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -4,6 +4,8 @@ use App\User; use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Illuminate\Support\Collection; use function PHPStan\Testing\assertType; /** @var User $user */ @@ -11,8 +13,27 @@ assertType('int', $user->stringButInt); assertType('string', $user->email); assertType('array', $user->allowed_ips); -assertType('string', $user->floatButRoundedDecimalString); -assertType(Carbon::class, $user->email_verified_at); +assertType('numeric-string', $user->floatButRoundedDecimalString); + +// Model Casts +assertType('int', $user->int); +assertType('int', $user->integer); +assertType('float', $user->real); +assertType('float', $user->float); +assertType('float', $user->double); +assertType('numeric-string', $user->decimal); +assertType('string', $user->string); +assertType('bool', $user->bool); +assertType('bool', $user->boolean); +assertType('stdClass', $user->object); +assertType('array', $user->array); +assertType('array', $user->json); +assertType(Collection::class, $user->collection); +assertType(Carbon::class, $user->date); +assertType(Carbon::class, $user->datetime); +assertType(CarbonImmutable::class, $user->immutable_date); +assertType(CarbonImmutable::class, $user->immutable_datetime); +assertType('int', $user->timestamp); // CastsAttributes assertType('App\ValueObjects\Favorites', $user->favorites); From 981f75c628ad8d392e88cc61f14bc0c99c2408bb Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 19 Dec 2022 12:37:54 +0100 Subject: [PATCH 13/13] Apply fixes from StyleCI --- src/Properties/ModelCastHelper.php | 6 ++++-- src/Properties/ModelPropertyExtension.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php index 006a9fd49..74fac3058 100644 --- a/src/Properties/ModelCastHelper.php +++ b/src/Properties/ModelCastHelper.php @@ -146,14 +146,16 @@ public function getDateType(): Type } /** - * @param string $cast + * @param string $cast * @return string|null */ private function parseCast(string $cast): ?string { foreach (explode(':', $cast) as $part) { // If the cast is prefixed with `encrypted:` we need to skip to the next - if ($part === 'encrypted') continue; + if ($part === 'encrypted') { + continue; + } return $part; } diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 197451939..2ebba7046 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -80,7 +80,7 @@ private function hasAccessor(ClassReflection $classReflection, string $propertyN { $propertyNameStudlyCase = Str::studly($propertyName); - if ($classReflection->hasNativeMethod(sprintf("get%sAttribute", $propertyNameStudlyCase))) { + if ($classReflection->hasNativeMethod(sprintf('get%sAttribute', $propertyNameStudlyCase))) { return true; }