From 60bab8667ce9d41f74ba1c65d045eefdd52ff9ae Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Fri, 2 Sep 2022 23:58:17 +0200 Subject: [PATCH] Rewrite ModelPropertyExtension with ModelCastHelper --- extension.neon | 3 + src/Properties/ModelCastHelper.php | 145 ++++++++++ src/Properties/ModelPropertyExtension.php | 335 +++++++--------------- src/Properties/SchemaColumn.php | 34 +-- src/Properties/SchemaTable.php | 2 +- tests/Type/data/model-properties.php | 3 + 6 files changed, 258 insertions(+), 264 deletions(-) create mode 100644 src/Properties/ModelCastHelper.php diff --git a/extension.neon b/extension.neon index 99d74f5b0..a55cc5496 100644 --- a/extension.neon +++ b/extension.neon @@ -408,6 +408,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 fd90fd7a7..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,272 +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' : '')]; - } - - 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; - } + // 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(); - 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; - } + try { + /** @var Model $modelInstance */ + $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (\ReflectionException $e) { + throw new ShouldNotHappenException(); + } - switch ($type) { - case 'boolean': - case 'bool': - $realType = 'boolean'; - break; - case 'string': - $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; - } + $tableName = $modelInstance->getTable(); - if ($this->tables[$modelInstance->getTable()]->columns[$name]->nullable) { - $realType = $realType instanceof Type ? TypeCombinator::addNull($realType) : $realType.'|null'; - } - - $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 892a96bb5..55a42c486 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -9,3 +9,6 @@ assertType('int', $user->newStyleAttribute); assertType('int', $user->stringButInt); assertType('string', $user->email); + +// CastsAttributes +assertType('App\DTO\Favorites', $user->favorites);