Skip to content

Commit

Permalink
Merge pull request #345 from kburton-dev/determine-eloquent-attribute…
Browse files Browse the repository at this point in the history
…-type-from-casts

Determine eloquent attribute type from casts
  • Loading branch information
romalytvynenko authored Mar 23, 2024
2 parents 46a4d36 + 8260a6d commit 1ca0198
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 23 deletions.
59 changes: 41 additions & 18 deletions src/Support/InferExtensions/ModelExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
namespace Dedoc\Scramble\Support\InferExtensions;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent;
use Dedoc\Scramble\Infer\Extensions\Event\PropertyFetchEvent;
use Dedoc\Scramble\Infer\Extensions\MethodReturnTypeExtension;
use Dedoc\Scramble\Infer\Extensions\PropertyTypeExtension;
use Dedoc\Scramble\Support\ResponseExtractor\ModelInfo;
use Dedoc\Scramble\Support\Type\AbstractType;
use Dedoc\Scramble\Support\Type\ArrayItemType_;
use Dedoc\Scramble\Support\Type\ArrayType;
use Dedoc\Scramble\Support\Type\BooleanType;
Expand All @@ -19,11 +21,13 @@
use Dedoc\Scramble\Support\Type\NullType;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\StringType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\Union;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

class ModelExtension implements MethodReturnTypeExtension, PropertyTypeExtension
Expand Down Expand Up @@ -51,7 +55,8 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type
$info = $this->getModelInfo($event->getInstance());

if ($attribute = $info->get('attributes')->get($event->getName())) {
$baseType = $this->getBaseAttributeType($info->get('instance'), $event->getName(), $attribute);
$baseType = $this->getAttributeTypeFromEloquentCasts($attribute['cast'] ?? '')
?? $this->getAttributeTypeFromDbColumnType($attribute['type'] ?? '');

if ($attribute['nullable']) {
return Union::wrap([$baseType, new NullType()]);
Expand All @@ -67,17 +72,10 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type
throw new \LogicException('Should not happen');
}

private function getBaseAttributeType(Model $model, string $key, array $value)
private function getAttributeTypeFromDbColumnType(string $columnType): AbstractType
{
$type = explode(' ', $value['type'] ?? '');
$typeName = explode('(', $type[0] ?? '')[0];

if (
($model->getCasts()[$key] ?? null) === 'datetime'
|| in_array($key, $model->getDates())
) {
return new ObjectType(Carbon::class);
}
$type = Str::before($columnType, ' ');
$typeName = Str::before($type, '(');

// @todo Fix to native types
$attributeType = match ($typeName) {
Expand All @@ -86,18 +84,43 @@ private function getBaseAttributeType(Model $model, string $key, array $value)
'varchar', 'string', 'text', 'datetime' => new StringType(), // string, text - needed?
'tinyint', 'bool', 'boolean' => new BooleanType(), // bool, boolean - needed?
'json', 'array' => new ArrayType(),
default => new UnknownType("unimplemented DB column type [$type[0]]"),
default => new UnknownType("unimplemented DB column type [$type]"),
};

if ($value['cast'] && function_exists('enum_exists') && enum_exists($value['cast'])) {
if (! isset($value['cast']::cases()[0]->value)) {
return $attributeType;
}
return $attributeType;
}

/**
* @todo Add support for custom castables.
*/
private function getAttributeTypeFromEloquentCasts(string $cast): ?AbstractType
{
if ($cast && enum_exists($cast)) {
return new ObjectType($cast);
}

$castAsType = Str::before($cast, ':');
$castAsParameters = str($cast)->after("{$castAsType}:")->explode(',');

return new ObjectType($value['cast']);
if (Str::startsWith($castAsType, 'encrypted:')) {
$castAsType = $castAsParameters->first(); // array, collection, json, object
}

return $attributeType;
return match ($castAsType) {
'array', 'json' => new ArrayType(),
'real', 'float', 'double' => new FloatType(),
'int', 'integer', 'timestamp' => new IntegerType(),
'bool', 'boolean' => new BooleanType(),
'string', 'decimal' => new StringType(),
'object' => new ObjectType('\stdClass'),
'collection' => new ObjectType(Collection::class),
'Illuminate\Database\Eloquent\Casts\AsEnumCollection' => new Generic(Collection::class, [
new TemplateType($castAsParameters->first())
]),
'date', 'datetime', 'custom_datetime' => new ObjectType(Carbon::class),
'immutable_date', 'immutable_datetime', 'immutable_custom_datetime' => new ObjectType(CarbonImmutable::class),
default => null,
};
}

private function getRelationType(array $relation)
Expand Down
10 changes: 10 additions & 0 deletions tests/Files/Role.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Dedoc\Scramble\Tests\Files;

enum Role: string
{
case Admin = 'admin';
case TeamLead = 'team_lead';
case Developer = 'developer';
}
1 change: 1 addition & 0 deletions tests/Files/SamplePostModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class SamplePostModel extends Model

protected $casts = [
'status' => Status::class,
'settings' => 'array'
];

public function getReadTimeAttribute()
Expand Down
8 changes: 8 additions & 0 deletions tests/Files/SampleUserModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Dedoc\Scramble\Tests\Files;

use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;

class SampleUserModel extends Model
Expand All @@ -11,4 +12,11 @@ class SampleUserModel extends Model
protected $guarded = [];

protected $table = 'users';

protected function casts(): array
{
return [
'roles' => AsEnumCollection::of(Role::class),
];
}
}
28 changes: 23 additions & 5 deletions tests/InferExtensions/ModelExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Type\ArrayItemType_;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Tests\Files\SamplePostModelWithToArray;
use Dedoc\Scramble\Tests\Files\SamplePostModel;
use Dedoc\Scramble\Tests\Files\SampleUserModel;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;

Expand All @@ -14,25 +15,26 @@
});

it('adds models attributes to the model class definition as properties', function () {
$this->infer->analyzeClass(SamplePostModelWithToArray::class);
$this->infer->analyzeClass(SamplePostModel::class);

$object = new ObjectType(SamplePostModelWithToArray::class);
$object = new ObjectType(SamplePostModel::class);

$expectedPropertiesTypes = [
/* Attributes from the DB */
'id' => 'int',
'status' => 'Status',
'user_id' => 'int',
'title' => 'string',
'settings' => 'array<mixed>|null',
'body' => 'string',
'created_at' => 'Carbon\Carbon|null',
'updated_at' => 'Carbon\Carbon|null',
/* Appended attributes */
'read_time' => 'unknown',
/* Relations */
'user' => 'SampleUserModel',
'parent' => 'SamplePostModelWithToArray',
'children' => 'Illuminate\Database\Eloquent\Collection<SamplePostModelWithToArray>',
'parent' => 'SamplePostModel',
'children' => 'Illuminate\Database\Eloquent\Collection<SamplePostModel>',
/* other properties from model class are ommited here but exist on type */
];

Expand Down Expand Up @@ -61,3 +63,19 @@
'updated_at' => 'string|null',
]);
});

/*
* `AsEnumCollection::of` is added in Laravel 11, hence this check so tests are passing with Laravel 10.
*/
if (method_exists(AsEnumCollection::class, 'of')) {
it('casts generic enum collections', function () {
$this->infer->analyzeClass(SampleUserModel::class);

$object = new ObjectType(SampleUserModel::class);

$propertyType = $object->getPropertyType('roles');

expect(Str::replace('Dedoc\\Scramble\\Tests\\Files\\', '', $propertyType->toString()))
->toBe('Illuminate\Support\Collection<Role>');
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ properties:
status: { type: object }
user_id: { type: integer }
title: { type: string }
settings: { type: [array, 'null'], items: { type: string } }
body: { type: string }
created_at: { type: [string, 'null'], format: date-time }
updated_at: { type: [string, 'null'], format: date-time }
Expand All @@ -15,6 +16,7 @@ required:
- status
- user_id
- title
- settings
- body
- created_at
- updated_at
1 change: 1 addition & 0 deletions tests/migrations/2016_01_01_000000_create_posts_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function up()
$table->enum('status', ['draft', 'published']);
$table->integer('user_id');
$table->string('title');
$table->json('settings')->nullable();
$table->text('body');
$table->timestamps();
});
Expand Down
1 change: 1 addition & 0 deletions tests/migrations/2016_01_01_000000_create_users_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function up()
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->json('roles');
$table->rememberToken();
$table->timestamps();
});
Expand Down

0 comments on commit 1ca0198

Please sign in to comment.