From fd8cf3416e229877be2d3802b368a4a382f36c1f Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 13:35:08 -0500 Subject: [PATCH 1/3] Added default to attribute to specify default values --- docs/PropertyAttributes.md | 46 ++++++++++++ src/Attributes/Property/DefaultsTo.php | 38 ++++++++++ src/Concerns/BaseData.php | 4 +- src/Contexts/PropertyContext.php | 14 ++++ src/Exceptions/FallbackResolverException.php | 27 +++++++ src/FallbackResolver/FallbackConfig.php | 17 +++++ src/FallbackResolver/FallbackResolver.php | 16 ++++ src/Normalizers/ArrayNormalizer.php | 5 +- .../ResolveDefaultDataPipe.php | 35 +++++++++ src/Support/Traits/HasNormalizers.php | 2 +- ...iasesAttributeTest.php => AliasesTest.php} | 2 +- tests/Unit/Attributes/DefaultsToTest.php | 74 +++++++++++++++++++ 12 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 src/Attributes/Property/DefaultsTo.php create mode 100644 src/Exceptions/FallbackResolverException.php create mode 100644 src/FallbackResolver/FallbackConfig.php create mode 100644 src/FallbackResolver/FallbackResolver.php create mode 100644 src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php rename tests/Unit/Attributes/{AliasesAttributeTest.php => AliasesTest.php} (96%) create mode 100644 tests/Unit/Attributes/DefaultsToTest.php diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index 305e276..769bb22 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -4,6 +4,7 @@ Property Attributes In order to provide more functionality to your DTOs, you can use the following attributes: - [Computed](#Computed) - To define a property that is computed from other properties. - [Aliases](#Aliases) - To define aliases for a property. +- [DefaultsUsing](#DefaultsUsing) - To define a default value for a property using a fallback resolver. - [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted. Computed @@ -109,3 +110,48 @@ public function __construct( ) {} ``` +DefaultsUsing +- + +Sometimes, we may need to specify that a property has a default value, +we can achieve that using plain PHP for some property types but not all of them. + +```php +use Nuxtifyts\PhpDto\Data; + +final readonly class User extends Data +{ + public function __construct( + public string $firstName, + public string $lastName, + public string $email, + public UserType $type = UserType::DEFAULT, + public UserConfigData $config, + ) {} +} +``` + +On the other hand, if we want to specify, for example, a default value for UserType depending +on the provided email address, or if you want to provide a default value for complex data such as +`UserConfigData` which is another DTO, there is no way to do it using plain PHP, +that's where `DefaultsUsing` attribute comes in. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Property\DefaultsUsing; + +final readonly class User extends Data +{ + public function __construct( + public string $firstName, + public string $lastName, + public string $email, + #[DefaultsUsing(UserTypeFallbackResolver::class)] + public UserType $type, + #[DefaultsUsing(UserConfigDataFallbackResolver::class)] + public UserConfigData $config, + ) {} +} +``` + +TODO - Add example of fall back resolver code diff --git a/src/Attributes/Property/DefaultsTo.php b/src/Attributes/Property/DefaultsTo.php new file mode 100644 index 0000000..57b90c5 --- /dev/null +++ b/src/Attributes/Property/DefaultsTo.php @@ -0,0 +1,38 @@ +> */ + protected static array $_resolverReflections = []; + + /** @var ?class-string */ + protected(set) ?string $fallbackResolverClass = null; + + /** + * @throws FallbackResolverException + */ + public function __construct( + protected(set) BackedEnum|int|string|float|bool|null $value + ) { + if (is_string($value) && class_exists($value)) { + /** @var ReflectionClass $reflection */ + $reflection = self::$_resolverReflections[$value] ??= new ReflectionClass($value); + + if (!$reflection->implementsInterface(FallbackResolver::class)) { + throw FallbackResolverException::unableToFindResolverClass($value); + } else { + /** @var class-string $value */ + $this->fallbackResolverClass = $value; + } + } + } +} diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 5199df0..d4f3543 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -8,6 +8,7 @@ use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DecipherDataPipe; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveDefaultDataPipe; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveValuesFromAliasesPipe; use Nuxtifyts\PhpDto\Support\Pipeline; use Nuxtifyts\PhpDto\Support\Traits\HasNormalizers; @@ -26,7 +27,7 @@ final public static function from(mixed $value): static try { $value = static::normalizeValue($value, static::class); - if (empty($value)) { + if ($value === false) { throw new DeserializeException( code: DeserializeException::INVALID_VALUE_ERROR_CODE ); @@ -39,6 +40,7 @@ final public static function from(mixed $value): static ->through(ResolveValuesFromAliasesPipe::class) ->through(RefineDataPipe::class) ->through(DecipherDataPipe::class) + ->through(ResolveDefaultDataPipe::class) ->sendThenReturn(new DeserializePipelinePassable( classContext: $context, data: $value diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index 7d1976e..54200c1 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -5,6 +5,7 @@ use Nuxtifyts\PhpDto\Attributes\Property\Aliases; use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget; use Nuxtifyts\PhpDto\Attributes\Property\Computed; +use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; use Nuxtifyts\PhpDto\DataCiphers\CipherConfig; use Nuxtifyts\PhpDto\DataRefiners\DataRefiner; @@ -13,6 +14,7 @@ use Nuxtifyts\PhpDto\Exceptions\SerializeException; use Nuxtifyts\PhpDto\Exceptions\UnknownTypeException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; +use Nuxtifyts\PhpDto\FallbackResolver\FallbackConfig; use Nuxtifyts\PhpDto\Serializers\Serializer; use Nuxtifyts\PhpDto\Support\Traits\HasSerializers; use Nuxtifyts\PhpDto\Support\Traits\HasTypes; @@ -42,6 +44,8 @@ class PropertyContext private(set) ?CipherConfig $cipherConfig = null; + private(set) ?FallbackConfig $fallbackConfig = null; + /** @var list */ private(set) array $dataRefiners = []; @@ -112,6 +116,16 @@ private function syncPropertyAttributes(): void encoded: $instance->encoded ); } + + if ($defaultsToAttribute = $this->reflection->getAttributes(DefaultsTo::class)[0] ?? null) { + /** @var ReflectionAttribute $defaultsToAttribute */ + $instance = $defaultsToAttribute->newInstance(); + + $this->fallbackConfig = new FallbackConfig( + value: $instance->value, + resolverClass: $instance->fallbackResolverClass + ); + } } public function getValue(object $object): mixed diff --git a/src/Exceptions/FallbackResolverException.php b/src/Exceptions/FallbackResolverException.php new file mode 100644 index 0000000..a91c322 --- /dev/null +++ b/src/Exceptions/FallbackResolverException.php @@ -0,0 +1,27 @@ + $resolverClass + */ + public function __construct( + public BackedEnum|int|string|float|bool|null $value, + public ?string $resolverClass = null + ) { + } +} diff --git a/src/FallbackResolver/FallbackResolver.php b/src/FallbackResolver/FallbackResolver.php new file mode 100644 index 0000000..04b57c4 --- /dev/null +++ b/src/FallbackResolver/FallbackResolver.php @@ -0,0 +1,16 @@ + $rawData + * + * @throws FallbackResolverException + */ + public static function resolve(array $rawData, PropertyContext $property): mixed; +} diff --git a/src/Normalizers/ArrayNormalizer.php b/src/Normalizers/ArrayNormalizer.php index 1dc0c84..952c109 100644 --- a/src/Normalizers/ArrayNormalizer.php +++ b/src/Normalizers/ArrayNormalizer.php @@ -8,7 +8,10 @@ public function normalize(): array|false { if ( !is_array($this->value) - || array_is_list($this->value) + || ( + array_is_list($this->value) + && !empty($this->value) + ) ) { return false; } diff --git a/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php b/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php new file mode 100644 index 0000000..6cac97f --- /dev/null +++ b/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php @@ -0,0 +1,35 @@ + + */ +readonly class ResolveDefaultDataPipe extends Pipe +{ + public function handle(Passable $passable): DeserializePipelinePassable + { + $data = $passable->data; + + foreach ($passable->classContext->properties as $propertyContext) { + if (array_key_exists($propertyContext->propertyName, $data)) { + continue; + } + + if ($propertyContext->fallbackConfig) { + $data[$propertyContext->propertyName] = $propertyContext->fallbackConfig->resolverClass + ? $propertyContext->fallbackConfig->resolverClass::resolve($data, $propertyContext) + : $propertyContext->fallbackConfig->value; + } + + if ($propertyContext->reflection->hasDefaultValue()) { + $data[$propertyContext->propertyName] = $propertyContext->reflection->getDefaultValue(); + } + } + + return $passable->with(data: $data); + } +} diff --git a/src/Support/Traits/HasNormalizers.php b/src/Support/Traits/HasNormalizers.php index fec6c0b..519e5d3 100644 --- a/src/Support/Traits/HasNormalizers.php +++ b/src/Support/Traits/HasNormalizers.php @@ -21,7 +21,7 @@ protected static function normalizeValue(mixed $value, string $class): array|fal foreach (static::allNormalizer() as $normalizer) { $normalized = new $normalizer($value, $class)->normalize(); - if (!empty($normalized)) { + if ($normalized !== false) { return $normalized; } } diff --git a/tests/Unit/Attributes/AliasesAttributeTest.php b/tests/Unit/Attributes/AliasesTest.php similarity index 96% rename from tests/Unit/Attributes/AliasesAttributeTest.php rename to tests/Unit/Attributes/AliasesTest.php index bc15c68..2fb0aa6 100644 --- a/tests/Unit/Attributes/AliasesAttributeTest.php +++ b/tests/Unit/Attributes/AliasesTest.php @@ -16,7 +16,7 @@ #[CoversClass(ResolveValuesFromAliasesPipe::class)] #[CoversClass(Aliases::class)] #[UsesClass(PersonData::class)] -final class AliasesAttributeTest extends UnitCase +final class AliasesTest extends UnitCase { /** * @throws Throwable diff --git a/tests/Unit/Attributes/DefaultsToTest.php b/tests/Unit/Attributes/DefaultsToTest.php new file mode 100644 index 0000000..022a835 --- /dev/null +++ b/tests/Unit/Attributes/DefaultsToTest.php @@ -0,0 +1,74 @@ + $arrayData + * @param array $expectedSerializedData + * + * @throws Throwable + */ + #[Test] + #[DataProvider('should_be_able_to_resolve_default_values_data_provider')] + public function should_be_able_to_resolve_default_values( + Data $object, + array $arrayData, + array $expectedSerializedData + ): void { + self::assertEquals( + $expectedSerializedData, + $object::from($arrayData)->toArray() + ); + } + + /** + * @return array + */ + public static function should_be_able_to_resolve_default_values_data_provider(): array + { + return [ + 'Resolves default scalar type value' => [ + 'object' => new readonly class ('') extends Data { + public function __construct( + #[DefaultsTo('John')] + public string $name + ) { + } + }, + 'arrayData' => [], + 'expectedSerializedData' => [ + 'name' => 'John' + ] + ] + ]; + } +} From f02b3c57ebe09924a15d37f03751c65a332c1669 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 19:27:03 -0500 Subject: [PATCH 2/3] Enhance default value handling with `DefaultsTo` improvements --- README.md | 3 +- docs/PropertyAttributes.md | 40 ++++++++-- src/Attributes/Property/DefaultsTo.php | 4 +- src/FallbackResolver/FallbackConfig.php | 3 +- .../ResolveDefaultDataPipe.php | 18 ++++- .../DummyUserFallbackResolver.php | 22 ++++++ tests/Unit/Attributes/DefaultsToTest.php | 73 ++++++++++++++++++- .../Unit/DataRefiners/DateTimeRefinerTest.php | 24 ++++++ 8 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 tests/Dummies/FallbackResolvers/DummyUserFallbackResolver.php diff --git a/README.md b/README.md index fb8624f..3a64b60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # PHP Pure Data objects -![Packagist Version](https://img.shields.io/packagist/v/nuxtifyts/php-dto) +![Packagist Version](https://img.shields.io/packagist/v/nuxtifyts/php-dto?style=for-the-badge&cacheSeconds=3600) +![PhpStan Level](https://img.shields.io/badge/PHPStan-level%2010-brightgreen.svg?style=for-the-badge) This package enabled the creation of data objects which can be used in various ways. Using modern PHP syntax, it allows you to hydrate data from arrays, objects, and other data sources. diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index 769bb22..6d463ec 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -4,7 +4,7 @@ Property Attributes In order to provide more functionality to your DTOs, you can use the following attributes: - [Computed](#Computed) - To define a property that is computed from other properties. - [Aliases](#Aliases) - To define aliases for a property. -- [DefaultsUsing](#DefaultsUsing) - To define a default value for a property using a fallback resolver. +- [DefaultsTo](#DefaultsTo) - To define a default value for a property using a fallback resolver. - [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted. Computed @@ -110,7 +110,7 @@ public function __construct( ) {} ``` -DefaultsUsing +DefaultsTo - Sometimes, we may need to specify that a property has a default value, @@ -134,11 +134,11 @@ final readonly class User extends Data On the other hand, if we want to specify, for example, a default value for UserType depending on the provided email address, or if you want to provide a default value for complex data such as `UserConfigData` which is another DTO, there is no way to do it using plain PHP, -that's where `DefaultsUsing` attribute comes in. +that's where `DefaultsTo` attribute comes in. ```php use Nuxtifyts\PhpDto\Data; -use Nuxtifyts\PhpDto\Attributes\Property\DefaultsUsing; +use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; final readonly class User extends Data { @@ -146,12 +146,38 @@ final readonly class User extends Data public string $firstName, public string $lastName, public string $email, - #[DefaultsUsing(UserTypeFallbackResolver::class)] + #[DefaultsTo(UserType::DEFAULT)] public UserType $type, - #[DefaultsUsing(UserConfigDataFallbackResolver::class)] + #[DefaultsTo(UserConfigDataFallbackResolver::class)] public UserConfigData $config, ) {} } ``` -TODO - Add example of fall back resolver code +The `DefaultsTo` attribute provides the ability to specify default values for complex types, +such as DateTimes and DTOs. + +In this example, the `UserConfigDataFallbackResolver` would look like this: + +```php +use Nuxtifyts\PhpDto\Contexts\PropertyContext; +use Nuxtifyts\PhpDto\FallbackResolver\FallbackResolver; + +class UserConfigDataFallbackResolver implements FallbackResolver +{ + /** + * @param array $rawData + */ + public static function resolve(array $rawData, PropertyContext $property) : mixed{ + $email = $rawData['email'] ?? null; + + return match(true) { + str_contains($email, 'example.com') => new UserConfigData(/** Admin configs */), + default => new UserConfigData(/** User configs */) + } + } +} +``` + +>! When using `DefaultsTo` attribute, priority is given to the attribute instead of the parameter's default value. + diff --git a/src/Attributes/Property/DefaultsTo.php b/src/Attributes/Property/DefaultsTo.php index 57b90c5..5ed2fb4 100644 --- a/src/Attributes/Property/DefaultsTo.php +++ b/src/Attributes/Property/DefaultsTo.php @@ -18,10 +18,12 @@ class DefaultsTo protected(set) ?string $fallbackResolverClass = null; /** + * @param BackedEnum|array|int|string|float|bool|null $value + * * @throws FallbackResolverException */ public function __construct( - protected(set) BackedEnum|int|string|float|bool|null $value + protected(set) BackedEnum|array|int|string|float|bool|null $value ) { if (is_string($value) && class_exists($value)) { /** @var ReflectionClass $reflection */ diff --git a/src/FallbackResolver/FallbackConfig.php b/src/FallbackResolver/FallbackConfig.php index f005187..ab6aa45 100644 --- a/src/FallbackResolver/FallbackConfig.php +++ b/src/FallbackResolver/FallbackConfig.php @@ -7,10 +7,11 @@ readonly class FallbackConfig { /** + * @param BackedEnum|array|int|string|float|bool|null $value * @param ?class-string $resolverClass */ public function __construct( - public BackedEnum|int|string|float|bool|null $value, + public BackedEnum|array|int|string|float|bool|null $value, public ?string $resolverClass = null ) { } diff --git a/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php b/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php index 6cac97f..cd85eed 100644 --- a/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php +++ b/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php @@ -4,6 +4,7 @@ use Nuxtifyts\PhpDto\Support\Passable; use Nuxtifyts\PhpDto\Support\Pipe; +use ReflectionParameter; /** * @extends Pipe @@ -25,8 +26,21 @@ public function handle(Passable $passable): DeserializePipelinePassable : $propertyContext->fallbackConfig->value; } - if ($propertyContext->reflection->hasDefaultValue()) { - $data[$propertyContext->propertyName] = $propertyContext->reflection->getDefaultValue(); + $constructorParameters = $propertyContext->reflection + ->getDeclaringClass() + ->getConstructor() + ?->getParameters(); + + if ( + $propertyParameter = array_find( + $constructorParameters ?? [], + fn (ReflectionParameter $parameter) => $parameter->getName() === $propertyContext->propertyName + ) + ) { + /** @var ReflectionParameter $propertyParameter */ + if ($propertyParameter->isDefaultValueAvailable()) { + $data[$propertyContext->propertyName] = $propertyParameter->getDefaultValue(); + } } } diff --git a/tests/Dummies/FallbackResolvers/DummyUserFallbackResolver.php b/tests/Dummies/FallbackResolvers/DummyUserFallbackResolver.php new file mode 100644 index 0000000..1552307 --- /dev/null +++ b/tests/Dummies/FallbackResolvers/DummyUserFallbackResolver.php @@ -0,0 +1,22 @@ +propertyName, $rawData)) { + return $rawData[$property->propertyName]; + } + + return new UserData( + 'John', + 'Doe' + ); + } +} diff --git a/tests/Unit/Attributes/DefaultsToTest.php b/tests/Unit/Attributes/DefaultsToTest.php index 022a835..81e6a35 100644 --- a/tests/Unit/Attributes/DefaultsToTest.php +++ b/tests/Unit/Attributes/DefaultsToTest.php @@ -3,21 +3,29 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Attributes; use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; +use Nuxtifyts\PhpDto\Attributes\Property\Types\ArrayOfScalarTypes; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Exceptions\FallbackResolverException; use Nuxtifyts\PhpDto\FallbackResolver\FallbackConfig; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveDefaultDataPipe; +use Nuxtifyts\PhpDto\Tests\Dummies\FallbackResolvers\DummyUserFallbackResolver; use Nuxtifyts\PhpDto\Tests\Dummies\UserData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use Throwable; #[CoversClass(DefaultsTo::class)] #[CoversClass(FallbackResolverException::class)] #[CoversClass(FallbackConfig::class)] #[CoversClass(PropertyContext::class)] +#[CoversClass(ResolveDefaultDataPipe::class)] +#[UsesClass(DummyUserFallbackResolver::class)] +#[UsesClass(UserData::class)] final class DefaultsToTest extends UnitCase { /** @@ -68,7 +76,70 @@ public function __construct( 'expectedSerializedData' => [ 'name' => 'John' ] - ] + ], + 'Resolves default complex type value' => [ + 'object' => new readonly class ( + new UserData('John', 'Doe') + ) extends Data { + public function __construct( + #[DefaultsTo(DummyUserFallbackResolver::class)] + public UserData $userData + ) { + } + }, + 'arrayData' => [], + 'expectedSerializedData' => [ + 'userData' => [ + 'firstName' => 'John', + 'lastName' => 'Doe' + ] + ] + ], + 'Resolves array of scalar type values' => [ + 'object' => new readonly class([]) extends Data { + /** + * @param list $names + */ + public function __construct( + #[ArrayOfScalarTypes(Type::STRING)] + #[DefaultsTo(['John', 'Jane'])] + public array $names + ) { + } + }, + 'arrayData' => [], + 'expectedSerializedData' => [ + 'names' => ['John', 'Jane'] + ] + ], + 'Allows pure php way of defaulting 1' => [ + 'object' => new readonly class ('') extends Data { + public function __construct( + public string $name = 'John' + ) { + } + }, + 'arrayData' => [], + 'expectedSerializedData' => [ + 'name' => 'John' + ] + ], + 'Allows pure php way of defaulting 2' => [ + 'object' => new readonly class([]) extends Data { + /** + * @param list $names + */ + public function __construct( + #[ArrayOfScalarTypes(Type::STRING)] + public array $names = ['John', 'Jane'] + ) { + } + }, + 'arrayData' => [], + 'expectedSerializedData' => [ + 'names' => ['John', 'Jane'] + ] + ], ]; } } diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php index 6e3ea6c..9f56c6c 100644 --- a/tests/Unit/DataRefiners/DateTimeRefinerTest.php +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -6,6 +6,7 @@ use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; +use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\InvalidRefiner; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; use Nuxtifyts\PhpDto\Serializers\DateTimeSerializer; @@ -160,4 +161,27 @@ public function __construct( self::assertInstanceOf(DateTimeImmutable::class, $object2->date); self::assertEquals($now->format('Y-m-d'), $object2->date->format('Y-m-d')); } + + /** + * @throws Throwable + */ + #[Test] + public function will_fail_to_refine_value_if_wrong_value_is_used(): void + { + $object = new readonly class (null) extends Data { + public function __construct( + #[WithRefiner(DateTimeRefiner::class)] + public ?DateTimeImmutable $date + ) { + } + }; + + $now = new DateTimeImmutable(); + + self::expectException(DeserializeException::class); + + $object::from([ + 'date' => $now->format('Y/m-d') + ]); + } } From e3a305a12de0cc5482402643b1d63f336de898d6 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Thu, 26 Dec 2024 20:42:18 -0500 Subject: [PATCH 3/3] Add support for creating DTO instances using a `create` method Introduces a `create` method to support default values for complex types, leveraging a new streamlined `DeserializePipeline`. Adjustments include a new `DataCreationException`, documentation updates, fallback resolver enhancements, and pipeline refactoring for better organization and maintainability. --- docs/DefaultValues.md | 87 +++++++++++++++++++ docs/PropertyAttributes.md | 26 +----- src/Concerns/BaseData.php | 47 +++++++--- src/Contracts/BaseData.php | 6 ++ src/Exceptions/DataCreationException.php | 31 +++++++ .../DeserializePipeline.php | 36 ++++++++ .../{ => Pipes}/DecipherDataPipe.php | 3 +- .../{ => Pipes}/RefineDataPipe.php | 5 +- .../{ => Pipes}/ResolveDefaultDataPipe.php | 3 +- .../ResolveValuesFromAliasesPipe.php | 3 +- .../DummyPointsFallbackResolver.php | 22 +++++ tests/Dummies/PointData.php | 14 +++ tests/Dummies/PointGroupData.php | 24 +++++ tests/Unit/Attributes/AliasesTest.php | 2 +- tests/Unit/Attributes/CipherTargetTest.php | 2 +- tests/Unit/Attributes/DefaultsToTest.php | 2 +- tests/Unit/Concerns/BaseDataTest.php | 45 +++++++++- .../Unit/DataRefiners/DateTimeRefinerTest.php | 6 +- 18 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 docs/DefaultValues.md create mode 100644 src/Exceptions/DataCreationException.php create mode 100644 src/Pipelines/DeserializePipeline/DeserializePipeline.php rename src/Pipelines/DeserializePipeline/{ => Pipes}/DecipherDataPipe.php (87%) rename src/Pipelines/DeserializePipeline/{ => Pipes}/RefineDataPipe.php (86%) rename src/Pipelines/DeserializePipeline/{ => Pipes}/ResolveDefaultDataPipe.php (91%) rename src/Pipelines/DeserializePipeline/{ => Pipes}/ResolveValuesFromAliasesPipe.php (85%) create mode 100644 tests/Dummies/FallbackResolvers/DummyPointsFallbackResolver.php create mode 100644 tests/Dummies/PointData.php create mode 100644 tests/Dummies/PointGroupData.php diff --git a/docs/DefaultValues.md b/docs/DefaultValues.md new file mode 100644 index 0000000..760f0d8 --- /dev/null +++ b/docs/DefaultValues.md @@ -0,0 +1,87 @@ +Default Values += + +Sometimes, we may need to specify that a property has a default value, +we can achieve that using plain PHP for some property types but not all of them. + +```php +use Nuxtifyts\PhpDto\Data; + +final readonly class User extends Data +{ + public function __construct( + public string $firstName, + public string $lastName, + public string $email, + public UserType $type = UserType::DEFAULT, + public UserConfigData $config, + ) {} +} +``` + +On the other hand, if we want to specify, for example, a default value for UserType depending +on the provided email address, or if you want to provide a default value for complex data such as +`UserConfigData` which is another DTO, there is no way to do it using plain PHP, +that's where `DefaultsTo` attribute comes in. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; + +final readonly class User extends Data +{ + public function __construct( + public string $firstName, + public string $lastName, + public string $email, + #[DefaultsTo(UserType::DEFAULT)] + public UserType $type, + #[DefaultsTo(UserConfigDataFallbackResolver::class)] + public UserConfigData $config, + ) {} +} +``` + +The `DefaultsTo` attribute provides the ability to specify default values for complex types, +such as DateTimes and DTOs. + +For more details checkout the [DefaultValues](https://github.com/nuxtifyts/php-dto/blob/main/docs/DefaultValues.md) +guide. + +In this example, the `UserConfigDataFallbackResolver` would look like this: + +```php +use Nuxtifyts\PhpDto\Contexts\PropertyContext; +use Nuxtifyts\PhpDto\FallbackResolver\FallbackResolver; + +class UserConfigDataFallbackResolver implements FallbackResolver +{ + /** + * @param array $rawData + */ + public static function resolve(array $rawData, PropertyContext $property) : mixed{ + $email = $rawData['email'] ?? null; + + return match(true) { + str_contains($email, 'example.com') => new UserConfigData(/** Admin configs */), + default => new UserConfigData(/** User configs */) + } + } +} +``` + +>! When using `DefaultsTo` attribute, priority is given to the attribute instead of the parameter's default value. + +If ever needed to create a new instance of a DTO with complex default value, +using the constructor is no longer possible, instead, you can make use of the +`create` function provided by the DTO class. + +Using the same example above, we can create a new instance of `User` with the default value for `config`: + +```php +$user = User::create( + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com' +); +``` diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index 6d463ec..c7cd562 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -157,27 +157,5 @@ final readonly class User extends Data The `DefaultsTo` attribute provides the ability to specify default values for complex types, such as DateTimes and DTOs. -In this example, the `UserConfigDataFallbackResolver` would look like this: - -```php -use Nuxtifyts\PhpDto\Contexts\PropertyContext; -use Nuxtifyts\PhpDto\FallbackResolver\FallbackResolver; - -class UserConfigDataFallbackResolver implements FallbackResolver -{ - /** - * @param array $rawData - */ - public static function resolve(array $rawData, PropertyContext $property) : mixed{ - $email = $rawData['email'] ?? null; - - return match(true) { - str_contains($email, 'example.com') => new UserConfigData(/** Admin configs */), - default => new UserConfigData(/** User configs */) - } - } -} -``` - ->! When using `DefaultsTo` attribute, priority is given to the attribute instead of the parameter's default value. - +For more details checkout the [DefaultValues](https://github.com/nuxtifyts/php-dto/blob/main/docs/DefaultValues.md) +guide. diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index d4f3543..634ebb8 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -3,14 +3,11 @@ namespace Nuxtifyts\PhpDto\Concerns; use Nuxtifyts\PhpDto\Contexts\ClassContext; +use Nuxtifyts\PhpDto\Exceptions\DataCreationException; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DecipherDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipeline; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveDefaultDataPipe; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveValuesFromAliasesPipe; -use Nuxtifyts\PhpDto\Support\Pipeline; use Nuxtifyts\PhpDto\Support\Traits\HasNormalizers; use ReflectionClass; use Throwable; @@ -19,6 +16,40 @@ trait BaseData { use HasNormalizers; + final public static function create(mixed ...$args): static + { + if (array_any( + array_keys($args), + static fn (string|int $arg) => is_numeric($arg) + )) { + throw DataCreationException::invalidProperty(); + } + + try { + $value = static::normalizeValue($args, static::class); + + if ($value === false) { + throw new DeserializeException( + code: DeserializeException::INVALID_VALUE_ERROR_CODE + ); + } + + /** @var ClassContext $context */ + $context = ClassContext::getInstance(new ReflectionClass(static::class)); + + $data = DeserializePipeline::createFromArray() + ->sendThenReturn(new DeserializePipelinePassable( + classContext: $context, + data: $value + )) + ->data; + + return static::instanceWithConstructorCallFrom($context, $data); + } catch (Throwable $e) { + throw DataCreationException::unableToCreateInstance(static::class, $e); + } + } + /** * @throws DeserializeException */ @@ -36,11 +67,7 @@ final public static function from(mixed $value): static /** @var ClassContext $context */ $context = ClassContext::getInstance(new ReflectionClass(static::class)); - $data = new Pipeline(DeserializePipelinePassable::class) - ->through(ResolveValuesFromAliasesPipe::class) - ->through(RefineDataPipe::class) - ->through(DecipherDataPipe::class) - ->through(ResolveDefaultDataPipe::class) + $data = DeserializePipeline::hydrateFromArray() ->sendThenReturn(new DeserializePipelinePassable( classContext: $context, data: $value diff --git a/src/Contracts/BaseData.php b/src/Contracts/BaseData.php index 9f05178..896a834 100644 --- a/src/Contracts/BaseData.php +++ b/src/Contracts/BaseData.php @@ -2,12 +2,18 @@ namespace Nuxtifyts\PhpDto\Contracts; +use Nuxtifyts\PhpDto\Exceptions\DataCreationException; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; use JsonSerializable; interface BaseData extends JsonSerializable { + /** + * @throws DataCreationException + */ + public static function create(mixed ...$args): static; + /** * @return array * diff --git a/src/Exceptions/DataCreationException.php b/src/Exceptions/DataCreationException.php new file mode 100644 index 0000000..e383ccd --- /dev/null +++ b/src/Exceptions/DataCreationException.php @@ -0,0 +1,31 @@ + + */ +class DeserializePipeline extends Pipeline +{ + public static function hydrateFromArray(): self + { + return new DeserializePipeline(DeserializePipelinePassable::class) + ->through(ResolveValuesFromAliasesPipe::class) + ->through(RefineDataPipe::class) + ->through(DecipherDataPipe::class) + ->through(ResolveDefaultDataPipe::class); + } + + /** + * @desc Basically the same as hydrateFromArray, but without deciphering data. + * This is used when create a new instance using the `create` static method from `BaseData`. + */ + public static function createFromArray(): self + { + return new DeserializePipeline(DeserializePipelinePassable::class) + ->through(ResolveValuesFromAliasesPipe::class) + ->through(RefineDataPipe::class) + ->through(ResolveDefaultDataPipe::class); + } +} diff --git a/src/Pipelines/DeserializePipeline/DecipherDataPipe.php b/src/Pipelines/DeserializePipeline/Pipes/DecipherDataPipe.php similarity index 87% rename from src/Pipelines/DeserializePipeline/DecipherDataPipe.php rename to src/Pipelines/DeserializePipeline/Pipes/DecipherDataPipe.php index 7661231..7e26bbc 100644 --- a/src/Pipelines/DeserializePipeline/DecipherDataPipe.php +++ b/src/Pipelines/DeserializePipeline/Pipes/DecipherDataPipe.php @@ -1,7 +1,8 @@ diff --git a/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php b/src/Pipelines/DeserializePipeline/Pipes/ResolveDefaultDataPipe.php similarity index 91% rename from src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php rename to src/Pipelines/DeserializePipeline/Pipes/ResolveDefaultDataPipe.php index cd85eed..27f0eed 100644 --- a/src/Pipelines/DeserializePipeline/ResolveDefaultDataPipe.php +++ b/src/Pipelines/DeserializePipeline/Pipes/ResolveDefaultDataPipe.php @@ -1,7 +1,8 @@ + */ + public static function resolve(array $rawData, PropertyContext $property): array + { + return [ + new PointData(1, 2), + new PointData(3, 4), + new PointData(5, 6), + ]; + } +} diff --git a/tests/Dummies/PointData.php b/tests/Dummies/PointData.php new file mode 100644 index 0000000..6468ebb --- /dev/null +++ b/tests/Dummies/PointData.php @@ -0,0 +1,14 @@ + $points + */ + public function __construct( + #[CipherTarget] + public string $key, + #[ArrayOfData(PointData::class)] + #[DefaultsTo(DummyPointsFallbackResolver::class)] + public array $points + ) { + } +} diff --git a/tests/Unit/Attributes/AliasesTest.php b/tests/Unit/Attributes/AliasesTest.php index 2fb0aa6..fff56e3 100644 --- a/tests/Unit/Attributes/AliasesTest.php +++ b/tests/Unit/Attributes/AliasesTest.php @@ -4,7 +4,7 @@ use Nuxtifyts\PhpDto\Attributes\Property\Aliases; use Nuxtifyts\PhpDto\Contexts\PropertyContext; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveValuesFromAliasesPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveValuesFromAliasesPipe; use Nuxtifyts\PhpDto\Tests\Dummies\PersonData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Unit/Attributes/CipherTargetTest.php b/tests/Unit/Attributes/CipherTargetTest.php index cb62ed8..cb320c0 100644 --- a/tests/Unit/Attributes/CipherTargetTest.php +++ b/tests/Unit/Attributes/CipherTargetTest.php @@ -11,7 +11,7 @@ use Nuxtifyts\PhpDto\DataCiphers\DefaultDataCipher; use Nuxtifyts\PhpDto\Exceptions\DataCipherException; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DecipherDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\DecipherDataPipe; use Nuxtifyts\PhpDto\Tests\Dummies\PersonData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Unit/Attributes/DefaultsToTest.php b/tests/Unit/Attributes/DefaultsToTest.php index 81e6a35..c6c7ead 100644 --- a/tests/Unit/Attributes/DefaultsToTest.php +++ b/tests/Unit/Attributes/DefaultsToTest.php @@ -9,7 +9,7 @@ use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Exceptions\FallbackResolverException; use Nuxtifyts\PhpDto\FallbackResolver\FallbackConfig; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveDefaultDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveDefaultDataPipe; use Nuxtifyts\PhpDto\Tests\Dummies\FallbackResolvers\DummyUserFallbackResolver; use Nuxtifyts\PhpDto\Tests\Dummies\UserData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; diff --git a/tests/Unit/Concerns/BaseDataTest.php b/tests/Unit/Concerns/BaseDataTest.php index 26fb957..06f45e3 100644 --- a/tests/Unit/Concerns/BaseDataTest.php +++ b/tests/Unit/Concerns/BaseDataTest.php @@ -3,11 +3,13 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Concerns; use DateTimeImmutable; +use DateTimeInterface; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipeline; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\RefineDataPipe; use Nuxtifyts\PhpDto\Serializers\BackedEnumSerializer; use Nuxtifyts\PhpDto\Serializers\DataSerializer; use Nuxtifyts\PhpDto\Serializers\DateTimeSerializer; @@ -19,23 +21,25 @@ use Nuxtifyts\PhpDto\Tests\Dummies\CoordinatesData; use Nuxtifyts\PhpDto\Tests\Dummies\CountryData; use Nuxtifyts\PhpDto\Tests\Dummies\Enums\YesNoBackedEnum; +use Nuxtifyts\PhpDto\Tests\Dummies\FallbackResolvers\DummyPointsFallbackResolver; use Nuxtifyts\PhpDto\Tests\Dummies\InvitationData; +use Nuxtifyts\PhpDto\Tests\Dummies\PersonData; +use Nuxtifyts\PhpDto\Tests\Dummies\PointData; +use Nuxtifyts\PhpDto\Tests\Dummies\PointGroupData; use Nuxtifyts\PhpDto\Tests\Dummies\RefundableItemData; use Nuxtifyts\PhpDto\Tests\Dummies\UnionMultipleComplexData; use Nuxtifyts\PhpDto\Tests\Dummies\UnionMultipleTypeData; +use Nuxtifyts\PhpDto\Tests\Dummies\UnionTypedData; use Nuxtifyts\PhpDto\Tests\Dummies\UserData; use Nuxtifyts\PhpDto\Tests\Dummies\UserGroupData; use Nuxtifyts\PhpDto\Tests\Dummies\UserLocationData; use Nuxtifyts\PhpDto\Tests\Dummies\YesOrNoData; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; -use Nuxtifyts\PhpDto\Tests\Dummies\PersonData; -use Nuxtifyts\PhpDto\Tests\Dummies\UnionTypedData; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use Throwable; -use DateTimeInterface; #[CoversClass(Data::class)] #[CoversClass(DeserializeException::class)] @@ -43,6 +47,7 @@ #[CoversClass(Pipeline::class)] #[CoversClass(Pipe::class)] #[CoversClass(DeserializePipelinePassable::class)] +#[CoversClass(DeserializePipeline::class)] #[CoversClass(RefineDataPipe::class)] #[CoversClass(Passable::class)] #[CoversClass(DateTimeSerializer::class)] @@ -61,6 +66,9 @@ #[UsesClass(UserLocationData::class)] #[UsesClass(UserGroupData::class)] #[UsesClass(ComputedPropertiesData::class)] +#[UsesClass(PointGroupData::class)] +#[UsesClass(PointData::class)] +#[UsesClass(DummyPointsFallbackResolver::class)] final class BaseDataTest extends UnitCase { /** @@ -556,4 +564,33 @@ public static function will_perform_serialization_and_deserialization_data_provi ] ]; } + + /** + * @throws Throwable + */ + #[Test] + public function will_be_able_to_create_an_instance_using_create(): void + { + $point = PointData::create( + x: 1, + y: 2 + ); + + self::assertInstanceOf(PointData::class, $point); + self::assertEquals(1, $point->x); + self::assertEquals(2, $point->y); + + // Make sure we skip deciphering the key + $pointGroup = PointGroupData::create( + key: 'random-key' + ); + + self::assertInstanceOf(PointGroupData::class, $pointGroup); + self::assertEquals('random-key', $pointGroup->key); + self::assertEquals([ + new PointData(1, 2), + new PointData(3, 4), + new PointData(5, 6) + ], $pointGroup->points); + } } diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php index 9f56c6c..f655e01 100644 --- a/tests/Unit/DataRefiners/DateTimeRefinerTest.php +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -2,13 +2,15 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\DataRefiners; +use DateTimeImmutable; +use DateTimeInterface; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\InvalidRefiner; -use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\RefineDataPipe; use Nuxtifyts\PhpDto\Serializers\DateTimeSerializer; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -16,8 +18,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use ReflectionProperty; -use DateTimeInterface; -use DateTimeImmutable; use Throwable; #[CoversClass(DateTimeRefiner::class)]