From 77490f68ec78b1d481f2b6aa8cb456b35ab40480 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Fri, 3 May 2024 13:29:50 +0400 Subject: [PATCH] feat(serializer): New `Serialized` attribute/accessors/mutator. --- packages/serializer/README.md | 66 +++++++++ .../serializer/docs/Examples/Attribute.php | 36 +++++ .../serializer/docs/Examples/Attribute.run | 3 + packages/serializer/src/Casts/Serialized.php | 27 ++++ .../src/Casts/SerializedAttribute.php | 74 ++++++++++ .../src/Casts/SerializedAttributeTest.php | 137 ++++++++++++++++++ .../src/Exceptions/FailedToCast.php | 2 +- phpstan-baseline.neon | 5 + 8 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 packages/serializer/docs/Examples/Attribute.php create mode 100755 packages/serializer/docs/Examples/Attribute.run create mode 100644 packages/serializer/src/Casts/Serialized.php create mode 100644 packages/serializer/src/Casts/SerializedAttribute.php create mode 100644 packages/serializer/src/Casts/SerializedAttributeTest.php diff --git a/packages/serializer/README.md b/packages/serializer/README.md index 0961234d3..37ecc6c1a 100644 --- a/packages/serializer/README.md +++ b/packages/serializer/README.md @@ -110,6 +110,72 @@ Publish the config and add normalizers/denormalizers if you need more: php artisan vendor:publish --provider=LastDragon_ru\\LaraASP\\Serializer\\Provider --tag=config ``` +# Eloquent Accessor/Mutator[^1] + +You can use the [`LastDragon_ru\LaraASP\Serializer\Casts\Serialized`](./src/Casts/Serialized.php) attribute to populate a model attribute to/from an object: + +[include:example]: ./docs/Examples/Attribute.php +[//]: # (start: 211a5dc77435fa2d79fcb5afce955fdd851bd0be575b7eae03d69d3fe5111b0a) +[//]: # (warning: Generated automatically. Do not edit.) + +```php + + */ + protected function settings(): Attribute { + return app()->make(Serialized::class)->attribute(UserSettings::class); + } +} + +$user = new User(); +$user->settings = new UserSettings(35, false); + +Example::dump($user->settings); +Example::dump($user->getAttributes()); +``` + +The `$user->settings` is: + +```plain +LastDragon_ru\LaraASP\Serializer\Docs\Examples\Attribute\UserSettings { + +perPage: 35 + +showSidebar: false +} +``` + +The `$user->getAttributes()` is: + +```plain +[ + "settings" => "{"perPage":35,"showSidebar":false}", +] +``` + +[//]: # (end: 211a5dc77435fa2d79fcb5afce955fdd851bd0be575b7eae03d69d3fe5111b0a) + # Eloquent Cast[^1] You can use the [`LastDragon_ru\LaraASP\Serializer\Casts\AsSerializable`](./src/Casts/AsSerializable.php) cast class to cast a model string attribute to an object: diff --git a/packages/serializer/docs/Examples/Attribute.php b/packages/serializer/docs/Examples/Attribute.php new file mode 100644 index 000000000..9226d7567 --- /dev/null +++ b/packages/serializer/docs/Examples/Attribute.php @@ -0,0 +1,36 @@ + + */ + protected function settings(): Attribute { + return app()->make(Serialized::class)->attribute(UserSettings::class); + } +} + +$user = new User(); +$user->settings = new UserSettings(35, false); + +Example::dump($user->settings); +Example::dump($user->getAttributes()); diff --git a/packages/serializer/docs/Examples/Attribute.run b/packages/serializer/docs/Examples/Attribute.run new file mode 100755 index 000000000..aefd0de01 --- /dev/null +++ b/packages/serializer/docs/Examples/Attribute.run @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +"${BASH_SOURCE%/*}/../../../../dev/artisan" dev:example "${BASH_SOURCE%.*}.php" diff --git a/packages/serializer/src/Casts/Serialized.php b/packages/serializer/src/Casts/Serialized.php new file mode 100644 index 000000000..a4c0af0f0 --- /dev/null +++ b/packages/serializer/src/Casts/Serialized.php @@ -0,0 +1,27 @@ + $class + * @param array $context + * + * @return Attribute + */ + public function attribute(string $class, string $format = 'json', array $context = []): Attribute { + // @phpstan-ignore-next-line method.unresolvableReturnType I've no idea how to make it work... + return new SerializedAttribute($this->serializer, $class, $format, $context); + } +} diff --git a/packages/serializer/src/Casts/SerializedAttribute.php b/packages/serializer/src/Casts/SerializedAttribute.php new file mode 100644 index 000000000..5c60a7c01 --- /dev/null +++ b/packages/serializer/src/Casts/SerializedAttribute.php @@ -0,0 +1,74 @@ + + */ +class SerializedAttribute extends Attribute { + public function __construct( + protected readonly Serializer $serializer, + /** + * @var class-string + */ + protected readonly string $class, + protected readonly string $format = 'json', + /** + * @var array + */ + protected readonly array $context = [], + ) { + parent::__construct( + get: fn (mixed $value) => $this->deserialize($value), + set: fn (?object $value) => $this->serialize($value), + ); + } + + protected function serialize(?object $value): ?string { + // Null? + if ($value === null) { + return null; + } + + // Expected? + if (!is_a($value, $this->class, true)) { + throw new FailedToCast($this->class, $value); + } + + // Process + try { + return $this->serializer->serialize($value, $this->format, $this->context); + } catch (Exception $exception) { + throw new FailedToCast($this->class, $value, $exception); + } + } + + protected function deserialize(mixed $value): ?object { + // Null? + if ($value === null) { + return null; + } + + // Expected? + if (!is_string($value)) { + throw new FailedToCast($this->class, $value); + } + + // Process + try { + return $this->serializer->deserialize($this->class, $value, $this->format, $this->context); + } catch (Exception $exception) { + throw new FailedToCast($this->class, $value, $exception); + } + } +} diff --git a/packages/serializer/src/Casts/SerializedAttributeTest.php b/packages/serializer/src/Casts/SerializedAttributeTest.php new file mode 100644 index 000000000..305a9a9bb --- /dev/null +++ b/packages/serializer/src/Casts/SerializedAttributeTest.php @@ -0,0 +1,137 @@ +app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + $expected = new SerializedAttributeTest__Serializable('value'); + $actual = ($attribute->get)('{"property":"value"}'); + + self::assertEquals($expected, $actual); + } + + public function testGetNull(): void { + $serializer = $this->app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + $actual = ($attribute->get)(null); + + self::assertNull($actual); + } + + public function testGetTypeNotMatch(): void { + self::expectException(FailedToCast::class); + self::expectExceptionMessage( + sprintf( + 'Failed to cast `%s` to `%s`.', + 'integer', + SerializedAttributeTest__Serializable::class, + ), + ); + + $serializer = $this->app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + + ($attribute->get)(123); + } + + public function testSet(): void { + $serializer = $this->app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + $expected = '{"property":"value"}'; + $actual = ($attribute->set)(new SerializedAttributeTest__Serializable('value')); + + self::assertEquals($expected, $actual); + } + + public function testSetNull(): void { + $serializer = $this->app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + $actual = ($attribute->set)(null); + + self::assertNull($actual); + } + + public function testSetTypeNotMatch(): void { + self::expectException(FailedToCast::class); + self::expectExceptionMessage( + sprintf( + 'Failed to cast `%s` to `%s`.', + stdClass::class, + SerializedAttributeTest__Serializable::class, + ), + ); + + $serializer = $this->app()->make(Serializer::class); + $attribute = new SerializedAttribute($serializer, SerializedAttributeTest__Serializable::class); + + ($attribute->set)(new stdClass()); + } + + public function testModelAttribute(): void { + SerializedAttributeTest__Model::$serializer = $this->app()->make(Serializer::class); + $attributes = [ + 'value' => '{"property":"default"}', + ]; + $model = (new SerializedAttributeTest__Model())->newFromBuilder( + $attributes, + ); + + self::assertEquals( + new SerializedAttributeTest__Serializable('default'), + $model->value, + ); + + $value = new SerializedAttributeTest__Serializable('value'); + $model->value = $value; + + self::assertEquals($value, $model->value); + self::assertEquals('{"property":"value"}', $model->getAttributes()['value'] ?? null); + } +} + +// @phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses +// @phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps + +/** + * @internal + * @noinspection PhpMultipleClassesDeclarationsInOneFile + */ +class SerializedAttributeTest__Serializable implements Serializable { + public function __construct( + public string $property, + ) { + // empty + } +} + +/** + * @internal + * @noinspection PhpMultipleClassesDeclarationsInOneFile + */ +class SerializedAttributeTest__Model extends Model { + public static Serializer $serializer; + + /** + * @return Attribute + */ + protected function value(): Attribute { + return (new Serialized(self::$serializer))->attribute(SerializedAttributeTest__Serializable::class); + } +} diff --git a/packages/serializer/src/Exceptions/FailedToCast.php b/packages/serializer/src/Exceptions/FailedToCast.php index 104ac4f5f..06bc9b91f 100644 --- a/packages/serializer/src/Exceptions/FailedToCast.php +++ b/packages/serializer/src/Exceptions/FailedToCast.php @@ -20,7 +20,7 @@ public function __construct( ) { parent::__construct( sprintf( - 'Failed to cast value into `%1$s`. The `%1$s|string|null` expected, `%2$s` given.', + 'Failed to cast `%2$s` to `%1$s`.', $this->target, is_object($this->value) ? $this->value::class diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d352fa961..c9ae566f7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -45,6 +45,11 @@ parameters: count: 1 path: packages/graphql/src/Testing/Package/Models/Image.php + - + message: "#^Callable callable\\(\\)\\: \\(object\\|null\\) invoked with 1 parameter, 0 required\\.$#" + count: 3 + path: packages/serializer/src/Casts/SerializedAttributeTest.php + - message: "#^Parameter \\#2 \\.\\.\\.\\$configs of method LastDragon_ru\\\\LaraASP\\\\Core\\\\Utils\\\\ConfigMerger\\:\\:merge\\(\\) expects array, mixed given\\.$#" count: 1