From eae90897baaa17c80556460f74e782c08b45b87e Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 18 Mar 2024 15:36:41 +0200 Subject: [PATCH 1/7] Determine attribute type from casts --- .../InferExtensions/ModelExtension.php | 32 ++++++++++++++++++- tests/Files/SamplePostModel.php | 1 + tests/InferExtensions/ModelExtensionTest.php | 11 ++++--- ...nferTypesTest__it_infers_model_type__1.yml | 2 ++ .../2016_01_01_000000_create_posts_table.php | 1 + 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 5fe1dd7d..e5932fb4 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,7 @@ 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 @@ -97,7 +99,35 @@ private function getBaseAttributeType(Model $model, string $key, array $value) return new ObjectType($value['cast']); } - return $attributeType; + $castAs = str($value['cast']) + ->before(':') + ->toString(); + + $castedType = match ($castAs) { + '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), + 'date', + 'datetime', + 'custom_datetime' => new ObjectType(Carbon::class), + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime' => new ObjectType(CarbonImmutable::class), + default => null, + }; + + return $castedType ?? $attributeType; } private function getRelationType(array $relation) diff --git a/tests/Files/SamplePostModel.php b/tests/Files/SamplePostModel.php index 6d4e8a4f..c621f1fb 100644 --- a/tests/Files/SamplePostModel.php +++ b/tests/Files/SamplePostModel.php @@ -16,6 +16,7 @@ class SamplePostModel extends Model protected $casts = [ 'status' => Status::class, + 'settings' => 'array' ]; public function getReadTimeAttribute() diff --git a/tests/InferExtensions/ModelExtensionTest.php b/tests/InferExtensions/ModelExtensionTest.php index 5b65b662..d8b3eea7 100644 --- a/tests/InferExtensions/ModelExtensionTest.php +++ b/tests/InferExtensions/ModelExtensionTest.php @@ -3,7 +3,7 @@ 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\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; @@ -14,9 +14,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 +24,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 +32,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 */ ]; 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(); }); From 8d0f47399980bae41fef771a20e1d6f2e8eae7e0 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 18 Mar 2024 16:07:24 +0200 Subject: [PATCH 2/7] Use private method to determine Eloquent cast type --- .../InferExtensions/ModelExtension.php | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index e5932fb4..fdb0655f 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -10,6 +10,7 @@ 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; @@ -53,7 +54,7 @@ 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->getBaseAttributeType($attribute); if ($attribute['nullable']) { return Union::wrap([$baseType, new NullType()]); @@ -69,18 +70,11 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type throw new \LogicException('Should not happen'); } - private function getBaseAttributeType(Model $model, string $key, array $value) + private function getBaseAttributeType(array $attributeInfo) { - $type = explode(' ', $value['type'] ?? ''); + $type = explode(' ', $attributeInfo['type'] ?? ''); $typeName = explode('(', $type[0] ?? '')[0]; - if ( - ($model->getCasts()[$key] ?? null) === 'datetime' - || in_array($key, $model->getDates()) - ) { - return new ObjectType(Carbon::class); - } - // @todo Fix to native types $attributeType = match ($typeName) { 'int', 'integer', 'bigint' => new IntegerType(), @@ -91,19 +85,22 @@ private function getBaseAttributeType(Model $model, string $key, array $value) default => new UnknownType("unimplemented DB column type [$type[0]]"), }; - if ($value['cast'] && function_exists('enum_exists') && enum_exists($value['cast'])) { - if (! isset($value['cast']::cases()[0]->value)) { - return $attributeType; - } + $castedType = $this->getEloquentCastAsType($attributeInfo); - return new ObjectType($value['cast']); + return $castedType ?? $attributeType; + } + + private function getEloquentCastAsType(array $attributeInfo): ?AbstractType + { + if ($attributeInfo['cast'] && enum_exists($attributeInfo['cast'])) { + return new ObjectType($attributeInfo['cast']); } - $castAs = str($value['cast']) + $castAs = str($attributeInfo['cast']) ->before(':') ->toString(); - $castedType = match ($castAs) { + return match ($castAs) { 'array', 'json' => new ArrayType(), 'real', @@ -126,8 +123,6 @@ private function getBaseAttributeType(Model $model, string $key, array $value) 'immutable_custom_datetime' => new ObjectType(CarbonImmutable::class), default => null, }; - - return $castedType ?? $attributeType; } private function getRelationType(array $relation) From fd8874d26a3d21f0b1960f553acfec2faf1a6bf9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 18 Mar 2024 16:29:54 +0200 Subject: [PATCH 3/7] Cast enum collections --- src/Support/InferExtensions/ModelExtension.php | 12 ++++++++---- tests/Files/Role.php | 10 ++++++++++ tests/Files/SampleUserModel.php | 8 ++++++++ tests/InferExtensions/ModelExtensionTest.php | 18 ++++++++++++++++++ .../2016_01_01_000000_create_users_table.php | 1 + 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 tests/Files/Role.php diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index fdb0655f..fb05886d 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -21,6 +21,7 @@ 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; @@ -96,11 +97,11 @@ private function getEloquentCastAsType(array $attributeInfo): ?AbstractType return new ObjectType($attributeInfo['cast']); } - $castAs = str($attributeInfo['cast']) - ->before(':') - ->toString(); + $castAs = str($attributeInfo['cast']); + $castAsType = $castAs->before(':')->toString(); + $castAsParameters = $castAs->after(':')->explode(','); - return match ($castAs) { + return match ($castAsType) { 'array', 'json' => new ArrayType(), 'real', @@ -115,6 +116,9 @@ private function getEloquentCastAsType(array $attributeInfo): ?AbstractType '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), 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 @@ + AsEnumCollection::of(Role::class), + ]; +} } diff --git a/tests/InferExtensions/ModelExtensionTest.php b/tests/InferExtensions/ModelExtensionTest.php index d8b3eea7..71efd22c 100644 --- a/tests/InferExtensions/ModelExtensionTest.php +++ b/tests/InferExtensions/ModelExtensionTest.php @@ -62,3 +62,21 @@ 'updated_at' => 'string|null', ]); }); + +it('casts generic enum collections', function () { + $this->infer->analyzeClass(SampleUserModel::class); + + $object = new ObjectType(SampleUserModel::class); + + $expectedPropertiesTypes = [ + 'roles' => 'Illuminate\Support\Collection' + // other properties omitted for brevity + ]; + + foreach ($expectedPropertiesTypes as $name => $type) { + $propertyType = $object->getPropertyType($name); + + expect(Str::replace('Dedoc\\Scramble\\Tests\\Files\\', '', $propertyType->toString())) + ->toBe($type); + } +}); 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(); }); From e2cbd1a38afde3dc930017944b0aee3927267b55 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 18 Mar 2024 16:43:25 +0200 Subject: [PATCH 4/7] Handle encrypted casts --- src/Support/InferExtensions/ModelExtension.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index fb05886d..7c1c88f5 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -91,15 +91,21 @@ private function getBaseAttributeType(array $attributeInfo) return $castedType ?? $attributeType; } + /** + * @todo Add support for custom castables. + */ private function getEloquentCastAsType(array $attributeInfo): ?AbstractType { if ($attributeInfo['cast'] && enum_exists($attributeInfo['cast'])) { return new ObjectType($attributeInfo['cast']); } - $castAs = str($attributeInfo['cast']); - $castAsType = $castAs->before(':')->toString(); - $castAsParameters = $castAs->after(':')->explode(','); + $castAsType = Str::before($attributeInfo['cast'], ':'); + $castAsParameters = str($attributeInfo['cast'])->after("{$castAsType}:")->explode(','); + + if ($castAsType === 'encrypted') { + $castAsType = $castAsParameters->first(); // array, collection, json, object + } return match ($castAsType) { 'array', From 1e446f016570a95a65e4de3abb3db53a38312624 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 18 Mar 2024 20:19:53 +0200 Subject: [PATCH 5/7] Minor optimization --- .../InferExtensions/ModelExtension.php | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 7c1c88f5..1106942f 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -55,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($attribute); + $baseType = $this->getAttributeTypeFromEloquentCasts($attribute['cast'] ?? '') + ?? $this->getAttributeTypeFromDbColumnType($attribute['type'] ?? ''); if ($attribute['nullable']) { return Union::wrap([$baseType, new NullType()]); @@ -71,10 +72,10 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type throw new \LogicException('Should not happen'); } - private function getBaseAttributeType(array $attributeInfo) + private function getAttributeTypeFromDbColumnType(string $columnType): AbstractType { - $type = explode(' ', $attributeInfo['type'] ?? ''); - $typeName = explode('(', $type[0] ?? '')[0]; + $type = Str::before($columnType, ' '); + $typeName = Str::before($type, '('); // @todo Fix to native types $attributeType = match ($typeName) { @@ -83,27 +84,25 @@ private function getBaseAttributeType(array $attributeInfo) '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]"), }; - $castedType = $this->getEloquentCastAsType($attributeInfo); - - return $castedType ?? $attributeType; + return $attributeType; } /** * @todo Add support for custom castables. */ - private function getEloquentCastAsType(array $attributeInfo): ?AbstractType + private function getAttributeTypeFromEloquentCasts(string $cast): ?AbstractType { - if ($attributeInfo['cast'] && enum_exists($attributeInfo['cast'])) { - return new ObjectType($attributeInfo['cast']); + if ($cast && enum_exists($cast)) { + return new ObjectType($cast); } - $castAsType = Str::before($attributeInfo['cast'], ':'); - $castAsParameters = str($attributeInfo['cast'])->after("{$castAsType}:")->explode(','); + $castAsType = Str::before($cast, ':'); + $castAsParameters = str($cast)->after("{$castAsType}:")->explode(','); - if ($castAsType === 'encrypted') { + if (Str::startsWith($castAsType, 'encrypted:')) { $castAsType = $castAsParameters->first(); // array, collection, json, object } From 53ad8acd5c07894a2e131537d46f3b3ae2bab077 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sat, 23 Mar 2024 22:41:33 +0200 Subject: [PATCH 6/7] formatting --- .../InferExtensions/ModelExtension.php | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 1106942f..2a01635d 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -107,29 +107,18 @@ private function getAttributeTypeFromEloquentCasts(string $cast): ?AbstractType } 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(), + '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), + 'date', 'datetime', 'custom_datetime' => new ObjectType(Carbon::class), + 'immutable_date', 'immutable_datetime', 'immutable_custom_datetime' => new ObjectType(CarbonImmutable::class), default => null, }; } From 8260a6de55b7f2f77c33990293f97efdbaed63f7 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sat, 23 Mar 2024 22:48:09 +0200 Subject: [PATCH 7/7] fix tests on l10 and formatting --- tests/Files/SampleUserModel.php | 10 ++++---- tests/InferExtensions/ModelExtensionTest.php | 25 ++++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/tests/Files/SampleUserModel.php b/tests/Files/SampleUserModel.php index 5f7240ac..c1ef63cf 100644 --- a/tests/Files/SampleUserModel.php +++ b/tests/Files/SampleUserModel.php @@ -14,9 +14,9 @@ class SampleUserModel extends Model protected $table = 'users'; protected function casts(): array -{ - return [ - 'roles' => AsEnumCollection::of(Role::class), - ]; -} + { + return [ + 'roles' => AsEnumCollection::of(Role::class), + ]; + } } diff --git a/tests/InferExtensions/ModelExtensionTest.php b/tests/InferExtensions/ModelExtensionTest.php index 71efd22c..c61c7b3a 100644 --- a/tests/InferExtensions/ModelExtensionTest.php +++ b/tests/InferExtensions/ModelExtensionTest.php @@ -5,6 +5,7 @@ use Dedoc\Scramble\Support\Type\ObjectType; 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; @@ -63,20 +64,18 @@ ]); }); -it('casts generic enum collections', function () { - $this->infer->analyzeClass(SampleUserModel::class); +/* + * `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); + $object = new ObjectType(SampleUserModel::class); - $expectedPropertiesTypes = [ - 'roles' => 'Illuminate\Support\Collection' - // other properties omitted for brevity - ]; - - foreach ($expectedPropertiesTypes as $name => $type) { - $propertyType = $object->getPropertyType($name); + $propertyType = $object->getPropertyType('roles'); expect(Str::replace('Dedoc\\Scramble\\Tests\\Files\\', '', $propertyType->toString())) - ->toBe($type); - } -}); + ->toBe('Illuminate\Support\Collection'); + }); +}