From e614f5db13c29ebcdc35109d96e06327cdb3b555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=93=AD=E6=98=95?= <715557344@qq.com> Date: Tue, 11 Jul 2023 08:58:49 +0800 Subject: [PATCH] Allow model attributes to be casted to/from an Enum (#5925) --- CHANGELOG-3.1.md | 1 + .../src/Commands/Ast/ModelUpdateVisitor.php | 4 + .../src/Model/Concerns/HasAttributes.php | 109 ++++++++++++++++++ src/database/tests/GenModelTest.php | 56 +++++++++ src/database/tests/ModelRealBuilderTest.php | 29 +++++ src/database/tests/Stubs/Model/Gender.php | 19 +++ src/database/tests/Stubs/Model/UserEnum.php | 37 ++++++ 7 files changed, 255 insertions(+) create mode 100644 src/database/tests/Stubs/Model/Gender.php create mode 100644 src/database/tests/Stubs/Model/UserEnum.php diff --git a/CHANGELOG-3.1.md b/CHANGELOG-3.1.md index c288d892d..7831c5aea 100644 --- a/CHANGELOG-3.1.md +++ b/CHANGELOG-3.1.md @@ -30,6 +30,7 @@ - [#5915](https://github.com/hyperf/hyperf/pull/5915) Added `data_forget` helper. - [#5914](https://github.com/hyperf/hyperf/pull/5914) Added `Str::isUrl()` and use it from the validator. - [#5918](https://github.com/hyperf/hyperf/pull/5918) Added `Arr::isList()` method. +- [#5925](https://github.com/hyperf/hyperf/pull/5925) Allow model attributes to be casted to/from an Enum. ## Optimized diff --git a/src/database/src/Commands/Ast/ModelUpdateVisitor.php b/src/database/src/Commands/Ast/ModelUpdateVisitor.php index 8d0b4ec75..2cc5349e0 100644 --- a/src/database/src/Commands/Ast/ModelUpdateVisitor.php +++ b/src/database/src/Commands/Ast/ModelUpdateVisitor.php @@ -353,6 +353,10 @@ class ModelUpdateVisitor extends NodeVisitorAbstract $cast = $this->formatDatabaseType($type) ?? 'string'; } + if (enum_exists($cast)) { + return '\\' . $cast; + } + return match ($cast) { 'integer' => 'int', 'date', 'datetime' => '\Carbon\Carbon', diff --git a/src/database/src/Model/Concerns/HasAttributes.php b/src/database/src/Model/Concerns/HasAttributes.php index 795000c7b..3b23c3105 100644 --- a/src/database/src/Model/Concerns/HasAttributes.php +++ b/src/database/src/Model/Concerns/HasAttributes.php @@ -11,6 +11,7 @@ declare(strict_types=1); */ namespace Hyperf\Database\Model\Concerns; +use BackedEnum; use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; @@ -26,6 +27,7 @@ use Hyperf\Database\Model\Relations\Relation; use Hyperf\Stringable\Str; use Hyperf\Stringable\StrCache; use LogicException; +use UnitEnum; use function Hyperf\Collection\collect; use function Hyperf\Tappable\tap; @@ -281,6 +283,12 @@ trait HasAttributes $value = $this->fromDateTime($value); } + if ($this->isEnumCastable($key)) { + $this->setEnumCastableAttribute($key, $value); + + return $this; + } + if ($this->isClassCastable($key)) { $this->setClassCastableAttribute($key, $value); @@ -754,6 +762,10 @@ trait HasAttributes $attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]); } + if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) { + $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($attributes[$key]) : null; + } + if ($attributes[$key] instanceof Arrayable) { $attributes[$key] = $attributes[$key]->toArray(); } @@ -910,6 +922,10 @@ trait HasAttributes return $this->asTimestamp($value); } + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableAttributeValue($key, $value); + } + if ($this->isClassCastable($key)) { return $this->getClassCastableAttributeValue($key, $value); } @@ -940,6 +956,28 @@ trait HasAttributes return $value; } + /** + * Cast the given attribute to an enum. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function getEnumCastableAttributeValue($key, $value) + { + if (is_null($value)) { + return null; + } + + $castType = $this->getCasts()[$key]; + + if ($value instanceof $castType) { + return $value; + } + + return $this->getEnumCaseFromValue($castType, $value); + } + /** * Get the type of cast for a model attribute. */ @@ -1027,6 +1065,54 @@ trait HasAttributes } } + /** + * Set the value of an enum castable attribute. + * + * @param string $key + * @param int|string|UnitEnum $value + */ + protected function setEnumCastableAttribute($key, $value) + { + $enumClass = $this->getCasts()[$key]; + + if (! isset($value)) { + $this->attributes[$key] = null; + } elseif (is_object($value)) { + $this->attributes[$key] = $this->getStorableEnumValue($value); + } else { + $this->attributes[$key] = $this->getStorableEnumValue( + $this->getEnumCaseFromValue($enumClass, $value) + ); + } + } + + /** + * Get an enum case instance from a given class and value. + * + * @param string $enumClass + * @param int|string $value + * @return BackedEnum|UnitEnum + */ + protected function getEnumCaseFromValue($enumClass, $value) + { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass . '::' . $value); + } + + /** + * Get the storable value from the given enum. + * + * @param BackedEnum|UnitEnum $value + * @return int|string + */ + protected function getStorableEnumValue($value) + { + return $value instanceof BackedEnum + ? $value->value + : $value->name; + } + /** * Get an array attribute with the given key and value set. * @@ -1196,6 +1282,29 @@ trait HasAttributes && ! in_array($class, static::$primitiveCastTypes); } + /** + * Determine if the given key is cast using an enum. + * + * @param string $key + * @return bool + */ + protected function isEnumCastable($key) + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $casts[$key]; + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + return enum_exists($castType); + } + /** * Resolve the custom caster class for a given key. */ diff --git a/src/database/tests/GenModelTest.php b/src/database/tests/GenModelTest.php index b249556c0..8510c3ba0 100644 --- a/src/database/tests/GenModelTest.php +++ b/src/database/tests/GenModelTest.php @@ -15,6 +15,8 @@ use Hyperf\Database\Commands\Ast\ModelUpdateVisitor; use Hyperf\Database\ConnectionResolverInterface; use Hyperf\Database\Schema\MySqlBuilder; use HyperfTest\Database\Stubs\ContainerStub; +use HyperfTest\Database\Stubs\Model\Gender; +use HyperfTest\Database\Stubs\Model\UserEnum; use HyperfTest\Database\Stubs\Model\UserExtEmpty; use Mockery; use PhpParser\NodeTraverser; @@ -102,6 +104,60 @@ class UserExtEmpty extends Model }', $code); } + public function testGenModelWithEnum() + { + $container = ContainerStub::getContainer(); + $container->shouldReceive('get')->with(EventDispatcherInterface::class)->andReturnUsing(function () { + $dispatcher = Mockery::mock(EventDispatcherInterface::class); + $dispatcher->shouldReceive('dispatch')->withAnyArgs()->andReturn(null); + return $dispatcher; + }); + $connection = $container->get(ConnectionResolverInterface::class)->connection(); + /** @var MySqlBuilder $builder */ + $builder = $connection->getSchemaBuilder('default'); + $columns = $this->formatColumns($builder->getColumnTypeListing('user')); + foreach ($columns as $i => $column) { + if ($column['column_name'] === 'gender') { + $columns[$i]['cast'] = Gender::class; + } + if ($column['column_name'] === 'created_at' || $column['column_name'] === 'updated_at') { + $columns[$i]['cast'] = 'datetime'; + } + } + $astParser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7); + $stms = $astParser->parse(file_get_contents(__DIR__ . '/Stubs/Model/UserEnum.php')); + $traverser = new NodeTraverser(); + $visitor = new ModelUpdateVisitor(UserEnum::class, $columns, ContainerStub::getModelOption()->setForceCasts(false)); + $traverser->addVisitor($visitor); + $stms = $traverser->traverse($stms); + $code = (new Standard())->prettyPrintFile($stms); + $this->assertEquals($this->license . " +namespace HyperfTest\\Database\\Stubs\\Model; + +/** + * @property int \$id + * @property string \$name + * @property \\HyperfTest\\Database\\Stubs\\Model\\Gender \$gender + * @property \\Carbon\\Carbon \$created_at + * @property \\Carbon\\Carbon \$updated_at + */ +class UserEnum extends Model +{ + /** + * The table associated with the model. + */ + protected ?string \$table = 'user'; + /** + * The attributes that are mass assignable. + */ + protected array \$fillable = ['id', 'name', 'gender', 'created_at', 'updated_at']; + /** + * The attributes that should be cast to native types. + */ + protected array \$casts = ['id' => 'integer', 'gender' => Gender::class, 'created_at' => 'datetime', 'updated_at' => 'datetime']; +}", $code); + } + /** * Format column's key to lower case. */ diff --git a/src/database/tests/ModelRealBuilderTest.php b/src/database/tests/ModelRealBuilderTest.php index 4ca55b4ff..4ede2825d 100644 --- a/src/database/tests/ModelRealBuilderTest.php +++ b/src/database/tests/ModelRealBuilderTest.php @@ -41,10 +41,12 @@ use Hyperf\Paginator\Paginator; use Hyperf\Support\Reflection\ClassInvoker; use HyperfTest\Database\Stubs\ContainerStub; use HyperfTest\Database\Stubs\IntegerStatus; +use HyperfTest\Database\Stubs\Model\Gender; use HyperfTest\Database\Stubs\Model\TestModel; use HyperfTest\Database\Stubs\Model\TestVersionModel; use HyperfTest\Database\Stubs\Model\User; use HyperfTest\Database\Stubs\Model\UserBit; +use HyperfTest\Database\Stubs\Model\UserEnum; use HyperfTest\Database\Stubs\Model\UserExt; use HyperfTest\Database\Stubs\Model\UserExtCamel; use HyperfTest\Database\Stubs\Model\UserRole; @@ -114,6 +116,33 @@ class ModelRealBuilderTest extends TestCase $this->assertTrue($hit); } + public function testModelEnum() + { + $this->getContainer(); + + /** @var UserEnum $user */ + $user = UserEnum::find(1); + $this->assertTrue($user->gender instanceof Gender); + $this->assertSame(Gender::MALE, $user->gender); + + $user->gender = Gender::FEMALE; + $user->save(); + + $sqls = [ + ['select * from `user` where `user`.`id` = ? limit 1', [1]], + ['update `user` set `gender` = ?, `user`.`updated_at` = ? where `id` = ?', [Gender::FEMALE->value, Carbon::now()->toDateTimeString(), 1]], + ]; + + while ($event = $this->channel->pop(0.001)) { + if ($event instanceof QueryExecuted) { + $this->assertSame([$event->sql, $event->bindings], array_shift($sqls)); + } + } + + $user->gender = Gender::MALE; + $user->save(); + } + public function testForPageBeforeId() { $this->getContainer(); diff --git a/src/database/tests/Stubs/Model/Gender.php b/src/database/tests/Stubs/Model/Gender.php new file mode 100644 index 000000000..4a79879d1 --- /dev/null +++ b/src/database/tests/Stubs/Model/Gender.php @@ -0,0 +1,19 @@ + 'integer', 'gender' => Gender::class, 'created_at' => 'datetime', 'updated_at' => 'datetime']; +}