Skip to content

Commit

Permalink
feat: support Laravel 11 casts method
Browse files Browse the repository at this point in the history
closes #1877
  • Loading branch information
canvural committed Apr 20, 2024
1 parent 1a05ac6 commit 66db15f
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 13 deletions.
74 changes: 74 additions & 0 deletions src/Properties/ModelCastHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon as IlluminateCarbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Stringable as IlluminateStringable;
use PHPStan\Analyser\OutOfClassScope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MissingMethodFromReflectionException;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Accessory\AccessoryNumericStringType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\BenevolentUnionType;
Expand All @@ -36,14 +41,24 @@
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use ReflectionException;
use stdClass;
use Stringable;

use function array_combine;
use function array_key_exists;
use function array_map;
use function array_merge;
use function class_exists;
use function explode;
use function str_replace;
use function version_compare;

class ModelCastHelper
{
/** @var array<string, array<string, string>> */
private array $modelCasts = [];

public function __construct(
protected ReflectionProvider $reflectionProvider,
) {
Expand Down Expand Up @@ -193,4 +208,63 @@ private function parseCast(string $cast): string

return $cast;
}

public function hasCastForProperty(ClassReflection $modelClassReflection, string $propertyName): bool
{
if (! array_key_exists($modelClassReflection->getName(), $this->modelCasts)) {
$modelCasts = $this->getModelCasts($modelClassReflection);
} else {
$modelCasts = $this->modelCasts[$modelClassReflection->getName()];
}

return array_key_exists($propertyName, $modelCasts);
}

public function getCastForProperty(ClassReflection $modelClassReflection, string $propertyName): string|null
{
if (! array_key_exists($modelClassReflection->getName(), $this->modelCasts)) {
$modelCasts = $this->getModelCasts($modelClassReflection);
} else {
$modelCasts = $this->modelCasts[$modelClassReflection->getName()];
}

return $modelCasts[$propertyName] ?? null;
}

/**
* @return array<string, string>
*
* @throws ShouldNotHappenException
* @throws MissingMethodFromReflectionException
*/
private function getModelCasts(ClassReflection $modelClassReflection): array
{
try {
/** @var Model $modelInstance */
$modelInstance = $modelClassReflection->getNativeReflection()->newInstanceWithoutConstructor();
} catch (ReflectionException) {
throw new ShouldNotHappenException();
}

$modelCasts = $modelInstance->getCasts();

if (version_compare(LARAVEL_VERSION, '11.0.0', '>=')) { // @phpstan-ignore-line
$castsMethodReturnType = ParametersAcceptorSelector::selectSingle($modelClassReflection->getMethod(
'casts',
new OutOfClassScope(),
)->getVariants())->getReturnType();

if ($castsMethodReturnType->isConstantArray()->yes()) {
$modelCasts = array_merge(
$modelCasts,
array_combine(
array_map(static fn ($key) => $key->getValue(), $castsMethodReturnType->getKeyTypes()), // @phpstan-ignore-line
array_map(static fn ($value) => str_replace('\\\\', '\\', $value->getValue()), $castsMethodReturnType->getValueTypes()), // @phpstan-ignore-line
),
);
}
}

return $modelCasts;
}
}
26 changes: 13 additions & 13 deletions src/Properties/ModelPropertyHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,19 +106,19 @@ public function getDatabaseProperty(ClassReflection $classReflection, string $pr
if ($this->hasDate($modelInstance, $propertyName)) {
$readableType = $this->modelCastHelper->getDateType();
$writableType = TypeCombinator::union($this->modelCastHelper->getDateType(), new StringType());
} elseif ($modelInstance->hasCast($propertyName)) {
$cast = $modelInstance->getCasts()[$propertyName];

$readableType = $this->modelCastHelper->getReadableType(
$cast,
$this->stringResolver->resolve($column->readableType),
);
$writableType = $this->modelCastHelper->getWriteableType(
$cast,
$this->stringResolver->resolve($column->writeableType),
);
} else {
if (in_array($column->readableType, ['enum', 'set'], true)) {
$cast = $this->modelCastHelper->getCastForProperty($classReflection, $propertyName);

if ($cast !== null) {
$readableType = $this->modelCastHelper->getReadableType(
$cast,
$this->stringResolver->resolve($column->readableType),
);
$writableType = $this->modelCastHelper->getWriteableType(
$cast,
$this->stringResolver->resolve($column->writeableType),
);
} elseif (in_array($column->readableType, ['enum', 'set'], true)) {
if ($column->options === null || count($column->options) < 1) {
$readableType = $writableType = new StringType();
} else {
Expand Down Expand Up @@ -227,6 +227,6 @@ private function hasDate(Model $modelInstance, string $propertyName): bool
$dates[] = $modelInstance->getDeletedAtColumn();
}

return in_array($propertyName, $dates);
return in_array($propertyName, $dates, true);
}
}
4 changes: 4 additions & 0 deletions tests/Type/GeneralTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ public static function dataFileAsserts(): iterable
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1819.php');
}

if (version_compare(LARAVEL_VERSION, '11.0.0', '>=')) {
yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l11.php');
}

//##############################################################################################################

// Console Commands
Expand Down
34 changes: 34 additions & 0 deletions tests/Type/data/model-properties-l11.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace ModelPropertiesL11;

use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Stringable;
use function PHPStan\Testing\assertType;

function test(ModelWithCasts $modelWithCasts): void
{
assertType('bool', $modelWithCasts->integer);
assertType(Stringable::class, $modelWithCasts->string);
}

class ModelWithCasts extends Model
{
protected $casts = [
'integer' => 'int',
];

/**
* @return array{integer: 'bool', string: 'Illuminate\\Database\\Eloquent\\Casts\\AsStringable:argument'}
*/
public function casts(): array
{
$argument = 'argument';

return [
'integer' => 'bool', // overrides the cast from the property
'string' => AsStringable::class.':'.$argument,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Database\Migrations;

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateModelWithCastsTable extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('model_with_casts', static function (Blueprint $table) {
$table->bigIncrements('id');

// 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->json('nullable_collection')->nullable();
$table->date('date');
$table->dateTime('datetime');
$table->date('immutable_date');
$table->dateTime('immutable_datetime');
$table->timestamp('timestamp');

$table->timestamps();
$table->softDeletes();
});
}
}

4 comments on commit 66db15f

@szepeviktor
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@canvural Could L11 cast support be improved not to need the array shape?

@canvural
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szepeviktor No it cannot. Not easily at least.

@calebdw
Copy link
Contributor

@calebdw calebdw commented on 66db15f May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My implementation would not have needed the duplicate array shape---and it was simply/easy.

@canvural
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@calebdw I explained in detail why we could not go with your implementation.

Please sign in to comment.