From 39b59fecbdd3498883f9432f7a33021d0adeb885 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 25 Dec 2024 10:31:28 -0500 Subject: [PATCH 1/5] Added support for refiners --- src/Attributes/Property/WithRefiner.php | 28 +++ src/Concerns/BaseData.php | 16 +- src/Contexts/ClassContext.php | 12 +- src/Contexts/PropertyContext.php | 23 ++- src/Contexts/TypeContext.php | 7 +- src/DataRefiners/DataRefiner.php | 15 ++ src/DataRefiners/DateTimeRefiner.php | 58 ++++++ src/Exceptions/InvalidRefiner.php | 23 +++ .../DeserializePipelinePassable.php | 30 ++++ .../DeserializePipeline/RefineDataPipe.php | 37 ++++ src/Serializers/DateTimeSerializer.php | 2 + src/Support/Passable.php | 7 + src/Support/Pipe.php | 18 ++ src/Support/Pipeline.php | 46 +++++ src/Support/Traits/HasTypes.php | 2 +- tests/Unit/Concerns/BaseDataTest.php | 18 +- tests/Unit/Contexts/PropertyContextTest.php | 25 +++ .../Unit/DataRefiners/DateTimeRefinerTest.php | 165 ++++++++++++++++++ 18 files changed, 506 insertions(+), 26 deletions(-) create mode 100644 src/Attributes/Property/WithRefiner.php create mode 100644 src/DataRefiners/DataRefiner.php create mode 100644 src/DataRefiners/DateTimeRefiner.php create mode 100644 src/Exceptions/InvalidRefiner.php create mode 100644 src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php create mode 100644 src/Pipelines/DeserializePipeline/RefineDataPipe.php create mode 100644 src/Support/Passable.php create mode 100644 src/Support/Pipe.php create mode 100644 src/Support/Pipeline.php create mode 100644 tests/Unit/DataRefiners/DateTimeRefinerTest.php diff --git a/src/Attributes/Property/WithRefiner.php b/src/Attributes/Property/WithRefiner.php new file mode 100644 index 0000000..d89fdea --- /dev/null +++ b/src/Attributes/Property/WithRefiner.php @@ -0,0 +1,28 @@ + */ + private array $refinerArgs; + + /** + * @param class-string $refinerClass + */ + public function __construct( + private readonly string $refinerClass, + mixed ...$refinerArgs + ) { + $this->refinerArgs = $refinerArgs; + } + + public function getRefiner(): DataRefiner + { + return new $this->refinerClass(...$this->refinerArgs); + } +} diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 24328df..c3a4338 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -3,8 +3,12 @@ namespace Nuxtifyts\PhpDto\Concerns; use Nuxtifyts\PhpDto\Contexts\ClassContext; +use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; +use Nuxtifyts\PhpDto\Support\Pipeline; use Nuxtifyts\PhpDto\Support\Traits\HasNormalizers; use ReflectionClass; use Throwable; @@ -30,9 +34,17 @@ final public static function from(mixed $value): static /** @var ClassContext $context */ $context = ClassContext::getInstance(new ReflectionClass(static::class)); + $data = new Pipeline(DeserializePipelinePassable::class) + ->through(RefineDataPipe::class) + ->sendThenReturn(new DeserializePipelinePassable( + classContext: $context, + data: $value + )) + ->data; + return $context->hasComputedProperties - ? static::instanceWithConstructorCallFrom($context, $value) - : static::instanceWithoutConstructorFrom($context, $value); + ? static::instanceWithConstructorCallFrom($context, $data) + : static::instanceWithoutConstructorFrom($context, $data); } catch (Throwable $e) { throw new DeserializeException($e->getMessage(), $e->getCode(), $e); } diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index 9d79ef0..7f82f1f 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -29,17 +29,17 @@ class ClassContext public readonly array $constructorParams; /** - * @param ReflectionClass $_reflectionClass + * @param ReflectionClass $reflection * * @throws UnsupportedTypeException */ final private function __construct( - protected readonly ReflectionClass $_reflectionClass + protected readonly ReflectionClass $reflection ) { - $this->properties = self::getPropertyContexts($this->_reflectionClass); + $this->properties = self::getPropertyContexts($this->reflection); $this->constructorParams = array_map( static fn (ReflectionParameter $param) => $param->getName(), - $this->_reflectionClass->getConstructor()?->getParameters() ?? [], + $this->reflection->getConstructor()?->getParameters() ?? [], ); } @@ -96,7 +96,7 @@ private static function getPropertyContexts(ReflectionClass $reflectionClass): a */ public function newInstanceWithoutConstructor(): mixed { - return $this->_reflectionClass->newInstanceWithoutConstructor(); + return $this->reflection->newInstanceWithoutConstructor(); } /** @@ -104,6 +104,6 @@ public function newInstanceWithoutConstructor(): mixed */ public function newInstanceWithConstructorCall(mixed ...$args): mixed { - return $this->_reflectionClass->newInstance(...$args); + return $this->reflection->newInstance(...$args); } } diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index 0cdf6c0..038811d 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -3,6 +3,8 @@ namespace Nuxtifyts\PhpDto\Contexts; use Nuxtifyts\PhpDto\Attributes\Property\Computed; +use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; +use Nuxtifyts\PhpDto\DataRefiners\DataRefiner; use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; @@ -12,6 +14,7 @@ use Nuxtifyts\PhpDto\Support\Traits\HasSerializers; use Nuxtifyts\PhpDto\Support\Traits\HasTypes; use ReflectionProperty; +use ReflectionAttribute; class PropertyContext { @@ -28,22 +31,25 @@ class PropertyContext private(set) bool $isComputed = false; + /** @var list */ + private(set) array $dataRefiners = []; + /** * @throws UnsupportedTypeException */ final private function __construct( - protected readonly ReflectionProperty $_reflectionProperty + protected(set) readonly ReflectionProperty $reflection ) { - $this->syncTypesFromReflectionProperty($this->_reflectionProperty); + $this->syncTypesFromReflectionProperty($this->reflection); $this->syncPropertyAttributes(); } public string $propertyName { - get => $this->_reflectionProperty->getName(); + get => $this->reflection->getName(); } public string $className { - get => $this->_reflectionProperty->getDeclaringClass()->getName(); + get => $this->reflection->getDeclaringClass()->getName(); } /** @var list> $arrayTypeContexts */ @@ -73,12 +79,17 @@ private static function getKey(ReflectionProperty $property): string private function syncPropertyAttributes(): void { - $this->isComputed = !empty($this->_reflectionProperty->getAttributes(Computed::class)); + $this->isComputed = !empty($this->reflection->getAttributes(Computed::class)); + + foreach ($this->reflection->getAttributes(WithRefiner::class) as $withRefinerAttribute) { + /** @var ReflectionAttribute $withRefinerAttribute */ + $this->dataRefiners[] = $withRefinerAttribute->newInstance()->getRefiner(); + } } public function getValue(object $object): mixed { - return $this->_reflectionProperty->getValue($object); + return $this->reflection->getValue($object); } /** diff --git a/src/Contexts/TypeContext.php b/src/Contexts/TypeContext.php index bf8147c..338ec09 100644 --- a/src/Contexts/TypeContext.php +++ b/src/Contexts/TypeContext.php @@ -5,7 +5,6 @@ use DateTimeInterface; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Enums\Property\Type; -use Nuxtifyts\PhpDto\Exceptions\UnknownTypeException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; use Nuxtifyts\PhpDto\Serializers\Serializer; use Nuxtifyts\PhpDto\Support\Traits\HasSerializers; @@ -64,9 +63,9 @@ final protected function __construct( * * @throws UnsupportedTypeException */ - public static function getInstances(ReflectionProperty $property): array + public static function getInstances(PropertyContext $property): array { - $reflectionTypes = self::getPropertyStringTypes($property); + $reflectionTypes = self::getPropertyStringTypes($property->reflection); $instances = []; foreach ($reflectionTypes as $type) { @@ -106,7 +105,7 @@ public static function getInstances(ReflectionProperty $property): array case $type === 'array': $instances[] = new static( Type::ARRAY, - subTypeContexts: self::resolveSubContextsForArray($property) + subTypeContexts: self::resolveSubContextsForArray($property->reflection) ); break; default: diff --git a/src/DataRefiners/DataRefiner.php b/src/DataRefiners/DataRefiner.php new file mode 100644 index 0000000..5664ad1 --- /dev/null +++ b/src/DataRefiners/DataRefiner.php @@ -0,0 +1,15 @@ + */ + protected(set) array $formats = [ + DateTimeInterface::ATOM, + 'Y-m-d H:i:s', + 'Y-m-d' + ]; + + /** + * @param string|list|null $formats + */ + public function __construct( + string|array|null $formats = null + ) { + if (!is_null($formats)) { + $this->formats = is_string($formats) ? [$formats] : $formats; + } + } + + public function refine(mixed $value, PropertyContext $property): mixed + { + if (is_null($value)) { + return null; + } + + if (is_string($value)) { + $typeContexts = $property->getFilteredTypeContexts(Type::DATETIME); + + if (empty($typeContexts)) { + throw InvalidRefiner::from($this, $property); + } + + $refinedValue = false; + + if (array_any( + $this->formats, + static function(string $format) use (&$refinedValue, $value): bool { + return (bool)($refinedValue = DateTimeImmutable::createFromFormat($format, $value)); + } + )) { + return $refinedValue; + } + } + + return $value; + } +} diff --git a/src/Exceptions/InvalidRefiner.php b/src/Exceptions/InvalidRefiner.php new file mode 100644 index 0000000..e947b8d --- /dev/null +++ b/src/Exceptions/InvalidRefiner.php @@ -0,0 +1,23 @@ +propertyName + ) + ); + } +} diff --git a/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php b/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php new file mode 100644 index 0000000..36d3e63 --- /dev/null +++ b/src/Pipelines/DeserializePipeline/DeserializePipelinePassable.php @@ -0,0 +1,30 @@ + $classContext + * @param array $data + */ + public function __construct( + protected(set) ClassContext $classContext, + protected(set) array $data + ) { + } + + /** + * @param array $data + */ + public function with(array $data): DeserializePipelinePassable + { + return new self($this->classContext, $data); + } +} diff --git a/src/Pipelines/DeserializePipeline/RefineDataPipe.php b/src/Pipelines/DeserializePipeline/RefineDataPipe.php new file mode 100644 index 0000000..33b0d5b --- /dev/null +++ b/src/Pipelines/DeserializePipeline/RefineDataPipe.php @@ -0,0 +1,37 @@ + + */ +readonly class RefineDataPipe extends Pipe +{ + public function handle(Passable $passable): DeserializePipelinePassable + { + $data = $passable->data; + + foreach ($passable->classContext->properties as $propertyContext) { + $propertyName = $propertyContext->propertyName; + + if (!array_key_exists($propertyName, $data)) { + continue; + } + + foreach ($propertyContext->dataRefiners as $dataRefiner) { + try { + $data[$propertyName] = $dataRefiner->refine( + value: $data[$propertyName], + property: $propertyContext + ); + } catch (Exception) {} + } + } + + return $passable->with($data); + } +} diff --git a/src/Serializers/DateTimeSerializer.php b/src/Serializers/DateTimeSerializer.php index c77244b..d50a3c3 100644 --- a/src/Serializers/DateTimeSerializer.php +++ b/src/Serializers/DateTimeSerializer.php @@ -70,6 +70,8 @@ protected function deserializeItem(mixed $item, PropertyContext $property): ?Dat } // @codeCoverageIgnoreEnd } + } elseif ($item instanceof DateTimeInterface) { + return $item; } return is_null($item) && $property->isNullable diff --git a/src/Support/Passable.php b/src/Support/Passable.php new file mode 100644 index 0000000..b353dd8 --- /dev/null +++ b/src/Support/Passable.php @@ -0,0 +1,7 @@ +>> */ + protected array $pipes = []; + + /** + * @param class-string $passableClass + * + * @phpstan-return self + */ + public function __construct( + protected readonly string $passableClass + ) { + } + + /** + * @param class-string> $pipe + */ + public function through(string $pipe): static + { + $this->pipes[] = $pipe; + + return $this; + } + + /** + * @param T $passable + * + * @return T + */ + public function sendThenReturn(Passable $passable): Passable + { + foreach ($this->pipes as $pipe) { + $passable = new $pipe()->handle($passable); + } + + return $passable; + } +} diff --git a/src/Support/Traits/HasTypes.php b/src/Support/Traits/HasTypes.php index a39c6d8..7ae90a0 100644 --- a/src/Support/Traits/HasTypes.php +++ b/src/Support/Traits/HasTypes.php @@ -28,6 +28,6 @@ trait HasTypes protected function syncTypesFromReflectionProperty(ReflectionProperty $property): void { $this->isNullable = $property->getType()?->allowsNull() ?? false; - $this->typeContexts = TypeContext::getInstances($property); + $this->typeContexts = TypeContext::getInstances($this); } } diff --git a/tests/Unit/Concerns/BaseDataTest.php b/tests/Unit/Concerns/BaseDataTest.php index caa7635..0415430 100644 --- a/tests/Unit/Concerns/BaseDataTest.php +++ b/tests/Unit/Concerns/BaseDataTest.php @@ -6,6 +6,11 @@ use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; +use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe; +use Nuxtifyts\PhpDto\Support\Passable; +use Nuxtifyts\PhpDto\Support\Pipe; +use Nuxtifyts\PhpDto\Support\Pipeline; use Nuxtifyts\PhpDto\Tests\Dummies\AddressData; use Nuxtifyts\PhpDto\Tests\Dummies\ComputedPropertiesData; use Nuxtifyts\PhpDto\Tests\Dummies\CoordinatesData; @@ -31,6 +36,11 @@ #[CoversClass(Data::class)] #[CoversClass(DeserializeException::class)] #[CoversClass(SerializeException::class)] +#[CoversClass(Pipeline::class)] +#[CoversClass(Pipe::class)] +#[CoversClass(DeserializePipelinePassable::class)] +#[CoversClass(RefineDataPipe::class)] +#[CoversClass(Passable::class)] #[UsesClass(PersonData::class)] #[UsesClass(UnionTypedData::class)] #[UsesClass(YesOrNoData::class)] @@ -152,16 +162,10 @@ public function will_perform_serialization_and_deserialization_data( } /** - * @return array, - * data: array, - * expectedProperties: array, - * expectedSerializedData: array - * }> + * @return array */ public static function will_perform_serialization_and_deserialization_data_provider(): array { - // @phpstan-ignore-next-line return [ 'Person data' => [ 'dtoClass' => PersonData::class, diff --git a/tests/Unit/Contexts/PropertyContextTest.php b/tests/Unit/Contexts/PropertyContextTest.php index 581deeb..015ec45 100644 --- a/tests/Unit/Contexts/PropertyContextTest.php +++ b/tests/Unit/Contexts/PropertyContextTest.php @@ -3,10 +3,12 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Contexts; use Nuxtifyts\PhpDto\Attributes\Property\Computed; +use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Contexts\TypeContext; use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Serializers\ScalarTypeSerializer; use Nuxtifyts\PhpDto\Tests\Dummies\ComputedPropertiesData; @@ -28,6 +30,7 @@ #[CoversClass(PropertyContext::class)] #[CoversClass(TypeContext::class)] #[CoversClass(Computed::class)] +#[CoversClass(WithRefiner::class)] #[UsesClass(ComputedPropertiesData::class)] #[UsesClass(ScalarTypeSerializer::class)] #[UsesClass(PersonData::class)] @@ -140,6 +143,28 @@ public function resolves_computed_properties(): void self::assertFalse($aPropertyContext->isComputed); } + /** + * @throws Throwable + */ + #[Test] + public function resolves_data_refiners(): void + { + $object = new readonly class (new DateTimeImmutable()) extends Data { + public function __construct( + #[WithRefiner(DateTimeRefiner::class, formats: ['Y-m-d'])] + public DateTimeImmutable $value + ) { + } + }; + + $reflectionProperty = new ReflectionProperty($object::class, 'value'); + + $propertyContext = PropertyContext::getInstance($reflectionProperty); + + self::assertCount(1, $propertyContext->dataRefiners); + self::assertInstanceOf(DateTimeRefiner::class, $propertyContext->dataRefiners[0]); + } + /** * @throws Throwable */ diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php new file mode 100644 index 0000000..07457fd --- /dev/null +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -0,0 +1,165 @@ +|null $formats + * + * @throws Throwable + */ + #[Test] + #[DataProvider('will_refine_a_string_to_a_dateTime_immutable_data_provider')] + public function will_refine_a_string_to_a_datetime_immutable( + object $object, + string $propertyName, + string|array|null $formats, + mixed $value, + mixed $expectedRefinedValue + ): void { + $propertyContext = PropertyContext::getInstance( + new ReflectionProperty($object, $propertyName) + ); + + $refinedValue = new DateTimeRefiner($formats)->refine($value, $propertyContext); + + self::assertEquals( + $expectedRefinedValue, + $refinedValue instanceof DateTimeImmutable + ? $refinedValue->format(DateTimeInterface::ATOM) + : $refinedValue + ); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_an_exception_if_refiner_is_used_on_a_non_datetime_property(): void + { + $object = new readonly class ('2025-06-17') extends Data { + public function __construct( + #[WithRefiner(DateTimeRefiner::class)] + public string $date + ) { + } + }; + + $propertyContext = PropertyContext::getInstance( + new ReflectionProperty($object, 'date') + ); + + self::expectException(InvalidRefiner::class); + $refinedValue = new DateTimeRefiner()->refine('2025-06-17', $propertyContext); + } + + /** + * @return array + */ + public static function will_refine_a_string_to_a_dateTime_immutable_data_provider(): array + { + $now = new DateTimeImmutable(); + + $object = new readonly class ($now) extends Data { + public function __construct( + #[WithRefiner(DateTimeRefiner::class)] + public DateTimeImmutable $date + ) { + } + }; + + return [ + 'Default formats Y-m-d' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => null, + 'value' => $now->format('Y-m-d'), + 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + ], + 'Default formats Y-m-d H:i:s' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => null, + 'value' => $now->format('Y-m-d H:i:s'), + 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + ], + 'Default formats ATOM' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => null, + 'value' => $now->format(DateTimeInterface::ATOM), + 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + ], + 'Ignores null' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => null, + 'value' => null, + 'expectedRefinedValue' => null + ], + 'Returns original value if not a string' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => null, + 'value' => 123, + 'expectedRefinedValue' => 123 + ], + 'Returns original value if it fails to refine' => [ + 'object' => $object, + 'propertyName' => 'date', + 'formats' => [DateTimeInterface::ATOM], + 'value' => $now->format('Y-m-d'), + 'expectedRefinedValue' => $now->format('Y-m-d') + ] + ]; + } + + /** + * @throws Throwable + */ + #[Test] + public function will_refine_data_to_helper_dto_deserialization(): void + { + $object = new readonly class (null) extends Data { + public function __construct( + #[WithRefiner(DateTimeRefiner::class, formats: 'Y/m-d')] + public ?DateTimeImmutable $date + ) { + } + }; + + $now = new DateTimeImmutable(); + + $object2 = $object::from([ + 'date' => $now->format('Y/m-d') + ]); + + self::assertInstanceOf(DateTimeImmutable::class, $object2->date); + self::assertEquals($now->format('Y-m-d'), $object2->date->format('Y-m-d')); + } +} From 66fb1e5e263b1e9a9887986e40b76215010d8a7b Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 25 Dec 2024 10:40:40 -0500 Subject: [PATCH 2/5] Added ability to deserialize backed enum from backed enum --- src/Serializers/BackedEnumSerializer.php | 14 ++++++++++++- .../Serializers/BackedEnumSerializerTest.php | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Serializers/BackedEnumSerializer.php b/src/Serializers/BackedEnumSerializer.php index 33804ed..99fd631 100644 --- a/src/Serializers/BackedEnumSerializer.php +++ b/src/Serializers/BackedEnumSerializer.php @@ -45,7 +45,11 @@ protected function deserializeItem(mixed $item, PropertyContext $property): ?Bac : throw new DeserializeException('Property is not nullable'); } - if (!is_string($item) && !is_integer($item)) { + if ( + !is_string($item) + && !is_integer($item) + && !$item instanceof BackedEnum + ) { throw new DeserializeException('Value is not a string or integer'); } @@ -58,6 +62,14 @@ protected function deserializeItem(mixed $item, PropertyContext $property): ?Bac continue; } + if ($item instanceof BackedEnum) { + if ($item instanceof ($typeContext->reflection->getName())) { + return $item; + } else { + continue; + } + } + $enumValue = call_user_func( // @phpstan-ignore-next-line [$typeContext->reflection->getName(), 'tryFrom'], diff --git a/tests/Unit/Serializers/BackedEnumSerializerTest.php b/tests/Unit/Serializers/BackedEnumSerializerTest.php index b28672a..7cefb98 100644 --- a/tests/Unit/Serializers/BackedEnumSerializerTest.php +++ b/tests/Unit/Serializers/BackedEnumSerializerTest.php @@ -106,4 +106,25 @@ public function will_resolve_null_if_property_is_nullable(): void $backedEnumSerializer->deserialize(PropertyContext::getInstance($property), $serializedData) ); } + + /** + * @throws Throwable + */ + #[Test] + public function will_resolve_backed_enum_from_backed_enum(): void + { + $yesNoNullableData = new YesOrNoNullableData(null); + + $reflectionClass = new ReflectionClass($yesNoNullableData); + $property = $reflectionClass->getProperty('yesNo'); + + $backedEnumSerializer = new BackedEnumSerializer(); + + $serializedData = ['yesNo' => YesNoBackedEnum::YES]; + + self::assertEquals( + YesNoBackedEnum::YES, + $backedEnumSerializer->deserialize(PropertyContext::getInstance($property), $serializedData) + ); + } } From 99daa4e2897af41af7c3d5a8b2ea19d0284b5d86 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 25 Dec 2024 11:02:40 -0500 Subject: [PATCH 3/5] Added documentation --- docs/DataRefiners.md | 119 ++++++++++++++++++ docs/Quickstart.md | 1 + src/DataRefiners/DateTimeRefiner.php | 9 +- .../Unit/DataRefiners/DateTimeRefinerTest.php | 4 +- 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 docs/DataRefiners.md diff --git a/docs/DataRefiners.md b/docs/DataRefiners.md new file mode 100644 index 0000000..ed63b4b --- /dev/null +++ b/docs/DataRefiners.md @@ -0,0 +1,119 @@ +Data Refiners += + +In the deserialization process, sometimes we may need to refine the data before it is passed to the deserializer. +This is where Data Refiners come in. + +A pretty good example would be DateTimes. When attempting to create an instanceof DateTime, we may need to +be aware of specific formats that the DateTime can be created from. + +By default, these are the DataRefiners that are available in the library: +- [DateTimeRefiner](#DateTimeRefiner) - Refines the data to a DateTimeImmutable instance depending on the format provided. + +DateTimeRefiner +- + +```php +use Nuxtifyts\PhpDto\Data; +use DateTimeImmutable; + +final readonly class DateRangeData extends Data +{ + public function __construct( + public ?DateTimeImmutable $start, + public ?DateTimeImmutable $end + ) {} +} +``` + +With this DTO, if we try to hydrate it with a custom format `'Y/m-d'`, it will fail. + +```php +DateRangeData::from([ + 'start' => '2023/01-12', + 'end' => '2023/01-14' +]); +``` + +To resolve this, we may need to specify a Data Refiner that will help deserialize the data. + +```php +use Nuxtifyts\PhpDto\Data; +use DateTimeImmutable; +use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; +use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; + +final readonly class DateRangeData extends Data +{ + public function __construct( + #[WithRefiner(DateTimeRefiner::class, formats: 'Y/m-d')] + public ?DateTimeImmutable $start, + #[WithRefiner(DateTimeRefiner::class, formats: 'Y/m-d')] + public ?DateTimeImmutable $end + ) {} +} +``` + +With this, hydrating the DTO will be possible. + +Creating a Custom Data Refiner += + +To create a custom Data Refiner, you need to implement the `DataRefiner` interface. for example suppose we +want to create a Data Refiner that will refine an object of class `CustomDate`: + +```php +class CustomDate { + public function __construct( + private(set) int $year, + private(set) int $month, + private(set) int $day + ) {} + + // ... +} +``` + +We can add the ability to hydrate a `DateTime` property from this class using a custom refiner like so: + +```php +use Nuxtifyts\PhpDto\Contexts\PropertyContext; +use Nuxtifyts\PhpDto\DataRefiners\DataRefiner; + + +class CustomDateRefiner implements DataRefiner +{ + public function refine(mixed $value, PropertyContext $property) : mixed + { + if ($value instanceof CustomDate) { + return DateTimeImmutable::createFromFormat( + format: 'Y-m-d', + datetime: sprintf('%d-%d-%d', $value->year, $value->month, $value->day) + ); + } + + return $value; + } +} +``` + +Now we can use this refiner in our previous DTO: + +```php +use Nuxtifyts\PhpDto\Data; +use DateTimeImmutable; +use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; +use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; + +final readonly class DateRangeData extends Data +{ + public function __construct( + #[WithRefiner(DateTimeRefiner::class, formats: 'Y/m-d')] + #[WithRefiner(CustomDateRefiner::class)] + public ?DateTimeImmutable $start, + #[WithRefiner(CustomDateRefiner::class)] + #[WithRefiner(DateTimeRefiner::class, formats: 'Y/m-d')] + public ?DateTimeImmutable $end + ) {} +} +``` diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 8b6ad55..ad50d79 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -79,3 +79,4 @@ can be found here: - [Supported Types](https://github.com/nuxtifyts/php-dto/blob/main/docs/SupportedTypes.md) - [Normalizers](https://github.com/nuxtifyts/php-dto/blob/main/docs/Normalizers.md) - [Property Attributes](https://github.com/nuxtifyts/php-dto/blob/main/docs/PropertyAttributes.md) +- [Data Refiners](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataRefiners.md) diff --git a/src/DataRefiners/DateTimeRefiner.php b/src/DataRefiners/DateTimeRefiner.php index 77408bf..9b1c170 100644 --- a/src/DataRefiners/DateTimeRefiner.php +++ b/src/DataRefiners/DateTimeRefiner.php @@ -24,7 +24,14 @@ public function __construct( string|array|null $formats = null ) { if (!is_null($formats)) { - $this->formats = is_string($formats) ? [$formats] : $formats; + if (is_string($formats)) { + $this->formats[] = $formats; + } else { + $this->formats = array_values(array_unique([ + ...$this->formats, + ...$formats + ])); + } } } diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php index 07457fd..e8b318d 100644 --- a/tests/Unit/DataRefiners/DateTimeRefinerTest.php +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -3,7 +3,6 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\DataRefiners; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; -use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\DataRefiners\DateTimeRefiner; @@ -15,7 +14,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; -use ReflectionClass; use ReflectionProperty; use DateTimeInterface; use DateTimeImmutable; @@ -75,7 +73,7 @@ public function __construct( ); self::expectException(InvalidRefiner::class); - $refinedValue = new DateTimeRefiner()->refine('2025-06-17', $propertyContext); + new DateTimeRefiner()->refine('2025-06-17', $propertyContext); } /** From 4e0ef4a55be81f6ec0d8c9727ca2ada4d2e5c7a0 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 25 Dec 2024 11:05:17 -0500 Subject: [PATCH 4/5] Fixed unit tests --- tests/Unit/DataRefiners/DateTimeRefinerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php index e8b318d..3bc0960 100644 --- a/tests/Unit/DataRefiners/DateTimeRefinerTest.php +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -131,8 +131,8 @@ public function __construct( 'object' => $object, 'propertyName' => 'date', 'formats' => [DateTimeInterface::ATOM], - 'value' => $now->format('Y-m-d'), - 'expectedRefinedValue' => $now->format('Y-m-d') + 'value' => $now->format('Y/m-d'), + 'expectedRefinedValue' => $now->format('Y/m-d') ] ]; } From 3e30e091213afb82921ce3a4fb2d1579149405f5 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 25 Dec 2024 11:07:24 -0500 Subject: [PATCH 5/5] Fixed unit tests --- tests/Unit/DataRefiners/DateTimeRefinerTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/DataRefiners/DateTimeRefinerTest.php b/tests/Unit/DataRefiners/DateTimeRefinerTest.php index 3bc0960..6e3ea6c 100644 --- a/tests/Unit/DataRefiners/DateTimeRefinerTest.php +++ b/tests/Unit/DataRefiners/DateTimeRefinerTest.php @@ -49,7 +49,7 @@ public function will_refine_a_string_to_a_datetime_immutable( self::assertEquals( $expectedRefinedValue, $refinedValue instanceof DateTimeImmutable - ? $refinedValue->format(DateTimeInterface::ATOM) + ? $refinedValue->format('Y-m-d H:i') : $refinedValue ); } @@ -97,21 +97,21 @@ public function __construct( 'propertyName' => 'date', 'formats' => null, 'value' => $now->format('Y-m-d'), - 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + 'expectedRefinedValue' => $now->format('Y-m-d H:i') ], 'Default formats Y-m-d H:i:s' => [ 'object' => $object, 'propertyName' => 'date', 'formats' => null, 'value' => $now->format('Y-m-d H:i:s'), - 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + 'expectedRefinedValue' => $now->format('Y-m-d H:i') ], 'Default formats ATOM' => [ 'object' => $object, 'propertyName' => 'date', 'formats' => null, 'value' => $now->format(DateTimeInterface::ATOM), - 'expectedRefinedValue' => $now->format(DateTimeInterface::ATOM) + 'expectedRefinedValue' => $now->format('Y-m-d H:i') ], 'Ignores null' => [ 'object' => $object,