Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(refactor): ModelProperty casting #1333

Merged
merged 13 commits into from Dec 20, 2022
3 changes: 3 additions & 0 deletions extension.neon
Expand Up @@ -410,6 +410,9 @@ services:
arguments:
schemaPaths: %squashedMigrationsPath%

-
class: NunoMaduro\Larastan\Properties\ModelCastHelper

-
class: NunoMaduro\Larastan\Rules\ModelProperties\ModelPropertiesRuleHelper

Expand Down
165 changes: 165 additions & 0 deletions src/Properties/ModelCastHelper.php
@@ -0,0 +1,165 @@
<?php

namespace NunoMaduro\Larastan\Properties;

use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Support\Arr;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
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;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;

class ModelCastHelper
{
public function __construct(protected ReflectionProvider $reflectionProvider)
{
}

public function getReadableType(string $cast, Type $originalType): Type
{
$castType = $this->parseCast($cast);

$attributeType = match ($castType) {
'int', 'integer' => new IntegerType(),
'real', 'float', 'double' => new FloatType(),
'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()),
'string' => new StringType(),
'bool', 'boolean' => new BooleanType(),
'object' => new ObjectType('stdClass'),
'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'),
canvural marked this conversation as resolved.
Show resolved Hide resolved
'timestamp' => new IntegerType(),
default => null,
};

if ($attributeType) {
return $attributeType;
}

if (! $this->reflectionProvider->hasClass($cast)) {
return $originalType;
}

$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
{
$castType = $this->parseCast($cast);

$attributeType = match ($castType) {
'int', 'integer' => new IntegerType(),
'real', 'float', 'double' => new FloatType(),
'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 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'),
canvural marked this conversation as resolved.
Show resolved Hide resolved
'timestamp' => new IntegerType(),
default => null,
};

if ($attributeType) {
return $attributeType;
}

if (! $this->reflectionProvider->hasClass($cast)) {
return $originalType;
}

$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::class;

if ($dateClass === \Illuminate\Support\Carbon::class) {
return TypeCombinator::union(new ObjectType($dateClass), new ObjectType(\Carbon\Carbon::class));
}

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;
}
}