diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 5fe1dd7d..2a01635d 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -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; @@ -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 @@ -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()]); @@ -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) { @@ -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) diff --git a/tests/Files/Role.php b/tests/Files/Role.php new file mode 100644 index 00000000..7014018a --- /dev/null +++ b/tests/Files/Role.php @@ -0,0 +1,10 @@ + Status::class, + 'settings' => 'array' ]; public function getReadTimeAttribute() diff --git a/tests/Files/SampleUserModel.php b/tests/Files/SampleUserModel.php index 8b0feee0..c1ef63cf 100644 --- a/tests/Files/SampleUserModel.php +++ b/tests/Files/SampleUserModel.php @@ -2,6 +2,7 @@ namespace Dedoc\Scramble\Tests\Files; +use Illuminate\Database\Eloquent\Casts\AsEnumCollection; use Illuminate\Database\Eloquent\Model; class SampleUserModel extends Model @@ -11,4 +12,11 @@ class SampleUserModel extends Model protected $guarded = []; protected $table = 'users'; + + protected function casts(): array + { + return [ + 'roles' => AsEnumCollection::of(Role::class), + ]; + } } diff --git a/tests/InferExtensions/ModelExtensionTest.php b/tests/InferExtensions/ModelExtensionTest.php index 5b65b662..c61c7b3a 100644 --- a/tests/InferExtensions/ModelExtensionTest.php +++ b/tests/InferExtensions/ModelExtensionTest.php @@ -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; @@ -14,9 +15,9 @@ }); 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 */ @@ -24,6 +25,7 @@ 'status' => 'Status', 'user_id' => 'int', 'title' => 'string', + 'settings' => 'array|null', 'body' => 'string', 'created_at' => 'Carbon\Carbon|null', 'updated_at' => 'Carbon\Carbon|null', @@ -31,8 +33,8 @@ 'read_time' => 'unknown', /* Relations */ 'user' => 'SampleUserModel', - 'parent' => 'SamplePostModelWithToArray', - 'children' => 'Illuminate\Database\Eloquent\Collection', + 'parent' => 'SamplePostModel', + 'children' => 'Illuminate\Database\Eloquent\Collection', /* other properties from model class are ommited here but exist on type */ ]; @@ -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'); + }); +} diff --git a/tests/__snapshots__/InferTypesTest__it_infers_model_type__1.yml b/tests/__snapshots__/InferTypesTest__it_infers_model_type__1.yml index a7776463..e656f7f6 100644 --- a/tests/__snapshots__/InferTypesTest__it_infers_model_type__1.yml +++ b/tests/__snapshots__/InferTypesTest__it_infers_model_type__1.yml @@ -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 } @@ -15,6 +16,7 @@ required: - status - user_id - title + - settings - body - created_at - updated_at diff --git a/tests/migrations/2016_01_01_000000_create_posts_table.php b/tests/migrations/2016_01_01_000000_create_posts_table.php index 5c38ef5f..b439eb8a 100644 --- a/tests/migrations/2016_01_01_000000_create_posts_table.php +++ b/tests/migrations/2016_01_01_000000_create_posts_table.php @@ -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(); }); diff --git a/tests/migrations/2016_01_01_000000_create_users_table.php b/tests/migrations/2016_01_01_000000_create_users_table.php index 056ed1cf..93f4b083 100644 --- a/tests/migrations/2016_01_01_000000_create_users_table.php +++ b/tests/migrations/2016_01_01_000000_create_users_table.php @@ -18,6 +18,7 @@ public function up() $table->string('name'); $table->string('email')->unique(); $table->string('password'); + $table->json('roles'); $table->rememberToken(); $table->timestamps(); });