From 73827b9337d15e9c65e031c21f2ab0fa0e5bdba5 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Tue, 31 Dec 2024 19:12:42 -0500 Subject: [PATCH 1/3] Add support for class-level normalizers via attributes Introduces `WithNormalizer` attribute for class-level normalizers, enhancing flexibility in data transformation. Updates relevant tests and normalizer logic to accommodate and validate class-level normalizers alongside existing ones. --- clover.xml | 213 ++++++++++-------- src/Attributes/Class/WithNormalizer.php | 30 +++ src/Concerns/BaseData.php | 18 +- src/Concerns/CloneableData.php | 10 +- src/Contexts/ClassContext.php | 18 ++ src/Normalizers/Concerns/HasNormalizers.php | 17 +- tests/Dummies/DummyWithNormalizerData.php | 14 ++ tests/Dummies/NonData/Human.php | 12 + .../Normalizers/HumanToPersonNormalizer.php | 22 ++ tests/Dummies/PersonData.php | 5 + tests/Unit/Attributes/WithNormalizerTest.php | 58 +++++ tests/Unit/Contexts/ClassContextTest.php | 34 ++- 12 files changed, 336 insertions(+), 115 deletions(-) create mode 100644 src/Attributes/Class/WithNormalizer.php create mode 100644 tests/Dummies/DummyWithNormalizerData.php create mode 100644 tests/Dummies/NonData/Human.php create mode 100644 tests/Dummies/Normalizers/HumanToPersonNormalizer.php create mode 100644 tests/Unit/Attributes/WithNormalizerTest.php diff --git a/clover.xml b/clover.xml index 05321c4..6b6bce9 100644 --- a/clover.xml +++ b/clover.xml @@ -1,6 +1,19 @@ - - + + + + + + + + + + + + + + + @@ -151,11 +164,11 @@ - - - + + + @@ -166,10 +179,10 @@ - - - - + + + + @@ -219,11 +232,11 @@ - - + - - + + + @@ -288,84 +301,91 @@ - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1091,22 +1111,23 @@ - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -1529,6 +1550,6 @@ - + diff --git a/src/Attributes/Class/WithNormalizer.php b/src/Attributes/Class/WithNormalizer.php new file mode 100644 index 0000000..804dc40 --- /dev/null +++ b/src/Attributes/Class/WithNormalizer.php @@ -0,0 +1,30 @@ +> */ + public array $classStrings; + + /** + * @param class-string $classString + * @param class-string ...$classStrings + */ + public function __construct(string $classString, string ...$classStrings) + { + $arrOfClassStrings = [$classString, ...$classStrings]; + + if (!Arr::isArrayOfClassStrings($arrOfClassStrings, Normalizer::class)) { + throw new InvalidArgumentException('expects a list of class strings of normalizers'); + } + + $this->classStrings = $arrOfClassStrings; + } +} diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index f9cc34a..8f8b3f2 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -22,16 +22,16 @@ trait BaseData final public static function create(mixed ...$args): static { try { - $value = static::normalizeValue($args, static::class) - ?: static::normalizeValue($args[0] ?? [], static::class); + /** @var ClassContext $context */ + $context = ClassContext::getInstance(new ReflectionClass(static::class)); + + $value = static::normalizeValue($args, static::class, $context->normalizers) + ?: static::normalizeValue($args[0] ?? [], static::class, $context->normalizers); if ($value === false) { throw DataCreationException::invalidParamsPassed(static::class); } - /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); - $data = DeserializePipeline::createFromArray() ->sendThenReturn(new DeserializePipelinePassable( classContext: $context, @@ -51,15 +51,15 @@ classContext: $context, final public static function from(mixed $value): static { try { - $value = static::normalizeValue($value, static::class); + /** @var ClassContext $context */ + $context = ClassContext::getInstance(new ReflectionClass(static::class)); + + $value = static::normalizeValue($value, static::class, $context->normalizers); if ($value === false) { throw DeserializeException::invalidValue(); } - /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); - $data = DeserializePipeline::hydrateFromArray() ->sendThenReturn(new DeserializePipelinePassable( classContext: $context, diff --git a/src/Concerns/CloneableData.php b/src/Concerns/CloneableData.php index be763b7..252733c 100644 --- a/src/Concerns/CloneableData.php +++ b/src/Concerns/CloneableData.php @@ -22,16 +22,16 @@ public function with(mixed ...$args): static throw DataCreationException::invalidParamsPassed(static::class); } - $value = static::normalizeValue($args, static::class) - ?: static::normalizeValue($args[0], static::class); + /** @var ClassContext $context */ + $context = ClassContext::getInstance(new ReflectionClass(static::class)); + + $value = static::normalizeValue($args, static::class, $context->normalizers) + ?: static::normalizeValue($args[0], static::class, $context->normalizers); if ($value === false) { throw DataCreationException::invalidParamsPassed(static::class); } - /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); - return $context->hasComputedProperties ? $this->cloneInstanceWithConstructorCall($context, $value) : $this->cloneInstanceWithoutConstructorCall($context, $value); diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index 7444a1f..87561c0 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -2,9 +2,12 @@ namespace Nuxtifyts\PhpDto\Contexts; +use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DataCreationException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; +use Nuxtifyts\PhpDto\Normalizers\Normalizer; +use ReflectionAttribute; use ReflectionException; use ReflectionParameter; use ReflectionClass; @@ -30,6 +33,9 @@ class ClassContext /** @var list List of param names */ public readonly array $constructorParams; + /** @var array> */ + private(set) array $normalizers = []; + /** * @param ReflectionClass $reflection * @@ -43,6 +49,7 @@ final private function __construct( static fn (ReflectionParameter $param) => $param->getName(), $this->reflection->getConstructor()?->getParameters() ?? [], ); + $this->syncClassAttributes(); } public bool $hasComputedProperties { @@ -91,6 +98,17 @@ private static function getPropertyContexts(ReflectionClass $reflectionClass): a return $properties; } + private function syncClassAttributes(): void + { + foreach ($this->reflection->getAttributes(WithNormalizer::class) as $withNormalizerAttribute) { + /** @var ReflectionAttribute $withNormalizerAttribute */ + $this->normalizers = array_values([ + ...$this->normalizers, + ...$withNormalizerAttribute->newInstance()->classStrings + ]); + } + } + /** * @throws ReflectionException * diff --git a/src/Normalizers/Concerns/HasNormalizers.php b/src/Normalizers/Concerns/HasNormalizers.php index 9486b63..ad52e19 100644 --- a/src/Normalizers/Concerns/HasNormalizers.php +++ b/src/Normalizers/Concerns/HasNormalizers.php @@ -3,22 +3,28 @@ namespace Nuxtifyts\PhpDto\Normalizers\Concerns; use Nuxtifyts\PhpDto\Configuration\DataConfiguration; +use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DataConfigurationException; use Nuxtifyts\PhpDto\Normalizers\Normalizer; +use ReflectionClass; trait HasNormalizers { /** * @param class-string $class + * @param array> $classNormalizers * * @return array|false * * @throws DataConfigurationException */ - protected static function normalizeValue(mixed $value, string $class): array|false - { - foreach (static::allNormalizer() as $normalizer) { + protected static function normalizeValue( + mixed $value, + string $class, + array $classNormalizers = [] + ): array|false { + foreach (static::allNormalizer($classNormalizers) as $normalizer) { $normalized = new $normalizer($value, $class)->normalize(); if ($normalized !== false) { @@ -30,13 +36,16 @@ protected static function normalizeValue(mixed $value, string $class): array|fal } /** + * @param array> $classNormalizers + * * @return list> * * @throws DataConfigurationException */ - final protected static function allNormalizer(): array + final protected static function allNormalizer(array $classNormalizers = []): array { return array_values(array_unique([ + ...$classNormalizers, ...static::normalizers(), ...DataConfiguration::getInstance()->normalizers->baseNormalizers, ])); diff --git a/tests/Dummies/DummyWithNormalizerData.php b/tests/Dummies/DummyWithNormalizerData.php new file mode 100644 index 0000000..5029d66 --- /dev/null +++ b/tests/Dummies/DummyWithNormalizerData.php @@ -0,0 +1,14 @@ +|false + */ + public function normalize(): array|false + { + return $this->value instanceof Human + ? [ + 'firstName' => $this->value->name, + 'lastName' => $this->value->surname + ] + : false; + } +} diff --git a/tests/Dummies/PersonData.php b/tests/Dummies/PersonData.php index ecbd8ad..0327fb6 100644 --- a/tests/Dummies/PersonData.php +++ b/tests/Dummies/PersonData.php @@ -2,10 +2,15 @@ namespace Nuxtifyts\PhpDto\Tests\Dummies; +use Nuxtifyts\PhpDto\Tests\Dummies\Normalizers\DummyNormalizer; +use Nuxtifyts\PhpDto\Tests\Dummies\Normalizers\HumanToPersonNormalizer; +use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; use Nuxtifyts\PhpDto\Attributes\Property\Aliases; use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Data; +#[WithNormalizer(DummyNormalizer::class)] +#[WithNormalizer(HumanToPersonNormalizer::class)] final readonly class PersonData extends Data { #[Computed] diff --git a/tests/Unit/Attributes/WithNormalizerTest.php b/tests/Unit/Attributes/WithNormalizerTest.php new file mode 100644 index 0000000..62b29e8 --- /dev/null +++ b/tests/Unit/Attributes/WithNormalizerTest.php @@ -0,0 +1,58 @@ +classStrings); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_use_normalizers_from_attribute(): void + { + $person = PersonData::from(new Human('John', 'Doe')); + + self::assertEquals('John', $person->firstName); + self::assertEquals('Doe', $person->lastName); + } +} diff --git a/tests/Unit/Contexts/ClassContextTest.php b/tests/Unit/Contexts/ClassContextTest.php index 4982187..c5acbee 100644 --- a/tests/Unit/Contexts/ClassContextTest.php +++ b/tests/Unit/Contexts/ClassContextTest.php @@ -2,16 +2,25 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Contexts; +use Nuxtifyts\PhpDto\Tests\Dummies\DummyWithNormalizerData; +use Nuxtifyts\PhpDto\Tests\Dummies\Normalizers\DummyNormalizer; +use Nuxtifyts\PhpDto\Tests\Dummies\Normalizers\HumanToPersonNormalizer; +use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; use Nuxtifyts\PhpDto\Tests\Dummies\PersonData; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\CoversClass; use Nuxtifyts\PhpDto\Contexts\ClassContext; +use PHPUnit\Framework\Attributes\UsesClass; use ReflectionClass; use Throwable; - use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; #[CoversClass(ClassContext::class)] +#[CoversClass(WithNormalizer::class)] +#[UsesClass(HumanToPersonNormalizer::class)] +#[UsesClass(DummyNormalizer::class)] +#[UsesClass(PersonData::class)] +#[UsesClass(DummyWithNormalizerData::class)] final class ClassContextTest extends UnitCase { /** @@ -50,4 +59,27 @@ public function can_create_an_instance_from_the_reflection_class(): void self::assertInstanceOf(PersonData::class, $classContext->newInstanceWithoutConstructor()); } + + /** + * @throws Throwable + */ + #[Test] + public function will_sync_normalizers_from_attribute(): void + { + $reflectionClass = new ReflectionClass(PersonData::class); + $classContext = ClassContext::getInstance($reflectionClass); + + self::assertEquals( + [DummyNormalizer::class, HumanToPersonNormalizer::class], + $classContext->normalizers + ); + + $reflectionClass = new ReflectionClass(DummyWithNormalizerData::class); + $classContext = ClassContext::getInstance($reflectionClass); + + self::assertEquals( + [DummyNormalizer::class], + $classContext->normalizers + ); + } } From cbe6ae152343103615f0a66b9d94fbfa047c91b4 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Tue, 31 Dec 2024 19:16:21 -0500 Subject: [PATCH 2/3] Add normalizer integration examples and update coverage metrics Added examples for integrating normalizers via attributes or methods in `Normalizers.md`. Updated code coverage metrics in `clover.xml` to reflect improved test coverage and minor adjustments to method execution counts. --- clover.xml | 52 ++++++++++++++++++++++----------------------- docs/Normalizers.md | 16 +++++++++++++- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/clover.xml b/clover.xml index 6b6bce9..afbe255 100644 --- a/clover.xml +++ b/clover.xml @@ -1,16 +1,16 @@ - - + + - - - + + + - + @@ -348,31 +348,31 @@ - + - - - - - - - + + + + + + + - - - + + + - - - - - - - + + + + + + + @@ -385,7 +385,7 @@ - + @@ -1550,6 +1550,6 @@ - + diff --git a/docs/Normalizers.md b/docs/Normalizers.md index 7d83418..4228abd 100644 --- a/docs/Normalizers.md +++ b/docs/Normalizers.md @@ -77,7 +77,21 @@ final readonly class GoalTodoNormalizer extends Normalizer } ``` -Next step is to add this new normalizer to the todo class: +Next step is to add this new normalizer to the todo class, either using +an attribute: + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; + +#[WithNormalizer(GoalTodoNormalizer::class)] +final readonly class TodoData extends Data +{ + // ... +} +``` + +Or using a method: ```php use Nuxtifyts\PhpDto\Data; From 65168cbf9e0fdd4556bc3295ca815a2fcab0eb43 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Tue, 31 Dec 2024 19:27:57 -0500 Subject: [PATCH 3/3] Refactor ClassContext to accept class-string directly. Simplified ClassContext instantiation by allowing class-string input in addition to ReflectionClass. Updated relevant tests and methods to use the class-string approach, removing unnecessary ReflectionClass instances. This enhances readability and reduces boilerplate code. --- clover.xml | 67 +++++++++++++----------- src/Concerns/BaseData.php | 6 +-- src/Concerns/CloneableData.php | 2 +- src/Concerns/EmptyData.php | 2 +- src/Contexts/ClassContext.php | 23 +++++--- tests/Unit/Contexts/ClassContextTest.php | 18 +++---- 6 files changed, 64 insertions(+), 54 deletions(-) diff --git a/clover.xml b/clover.xml index afbe255..0d51739 100644 --- a/clover.xml +++ b/clover.xml @@ -1,6 +1,6 @@ - - + + @@ -348,7 +348,7 @@ - + @@ -357,35 +357,40 @@ - - + - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -1550,6 +1555,6 @@ - + diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 8f8b3f2..0b91d7e 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -23,7 +23,7 @@ final public static function create(mixed ...$args): static { try { /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); + $context = ClassContext::getInstance(static::class); $value = static::normalizeValue($args, static::class, $context->normalizers) ?: static::normalizeValue($args[0] ?? [], static::class, $context->normalizers); @@ -52,7 +52,7 @@ final public static function from(mixed $value): static { try { /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); + $context = ClassContext::getInstance(static::class); $value = static::normalizeValue($value, static::class, $context->normalizers); @@ -126,7 +126,7 @@ protected static function instanceWithConstructorCallFrom(ClassContext $context, final public function jsonSerialize(): array { try { - $context = ClassContext::getInstance(new ReflectionClass($this)); + $context = ClassContext::getInstance($this::class); $serializedData = []; foreach ($context->properties as $propertyContext) { diff --git a/src/Concerns/CloneableData.php b/src/Concerns/CloneableData.php index 252733c..724b82c 100644 --- a/src/Concerns/CloneableData.php +++ b/src/Concerns/CloneableData.php @@ -23,7 +23,7 @@ public function with(mixed ...$args): static } /** @var ClassContext $context */ - $context = ClassContext::getInstance(new ReflectionClass(static::class)); + $context = ClassContext::getInstance(static::class); $value = static::normalizeValue($args, static::class, $context->normalizers) ?: static::normalizeValue($args[0], static::class, $context->normalizers); diff --git a/src/Concerns/EmptyData.php b/src/Concerns/EmptyData.php index a50036c..791e162 100644 --- a/src/Concerns/EmptyData.php +++ b/src/Concerns/EmptyData.php @@ -16,7 +16,7 @@ public static function empty(): static { try { /** @var ClassContext $classContext */ - $classContext = ClassContext::getInstance(new ReflectionClass(static::class)); + $classContext = ClassContext::getInstance(static::class); return $classContext->emptyValue(); } catch (Throwable $t) { diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index 87561c0..fd1c7ae 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -62,22 +62,33 @@ final private function __construct( } /** - * @param ReflectionClass $reflectionClass + * @param ReflectionClass|class-string $reflectionClass * * @throws UnsupportedTypeException + * @throws ReflectionException */ - final public static function getInstance(ReflectionClass $reflectionClass): static + final public static function getInstance(string|ReflectionClass $reflectionClass): static { + $instance = self::$_instances[self::getKey($reflectionClass)] ?? null; + + if ($instance) { + return $instance; + } + + if (is_string($reflectionClass)) { + $reflectionClass = new ReflectionClass($reflectionClass); + } + return self::$_instances[self::getKey($reflectionClass)] - ??= new static($reflectionClass); + = new static($reflectionClass); } /** - * @param ReflectionClass $reflectionClass + * @param ReflectionClass|class-string $reflectionClass */ - private static function getKey(ReflectionClass $reflectionClass): string + private static function getKey(string|ReflectionClass $reflectionClass): string { - return $reflectionClass->getName(); + return is_string($reflectionClass) ? $reflectionClass : $reflectionClass->getName(); } /** diff --git a/tests/Unit/Contexts/ClassContextTest.php b/tests/Unit/Contexts/ClassContextTest.php index c5acbee..08febf3 100644 --- a/tests/Unit/Contexts/ClassContextTest.php +++ b/tests/Unit/Contexts/ClassContextTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use Nuxtifyts\PhpDto\Contexts\ClassContext; use PHPUnit\Framework\Attributes\UsesClass; -use ReflectionClass; use Throwable; use Nuxtifyts\PhpDto\Tests\Unit\UnitCase; @@ -29,8 +28,7 @@ final class ClassContextTest extends UnitCase #[Test] public function can_create_an_instance_from_reflection_class(): void { - $reflectionClass = new ReflectionClass(PersonData::class); - $classContext = ClassContext::getInstance($reflectionClass); + $classContext = ClassContext::getInstance(PersonData::class); self::assertInstanceOf(ClassContext::class, $classContext); } @@ -41,9 +39,8 @@ public function can_create_an_instance_from_reflection_class(): void #[Test] public function can_retrieve_same_instance_of_class(): void { - $reflectionClass = new ReflectionClass(PersonData::class); - $classContext = ClassContext::getInstance($reflectionClass); - $classContext2 = ClassContext::getInstance($reflectionClass); + $classContext = ClassContext::getInstance(PersonData::class); + $classContext2 = ClassContext::getInstance(PersonData::class); self::assertSame($classContext, $classContext2); } @@ -54,8 +51,7 @@ public function can_retrieve_same_instance_of_class(): void #[Test] public function can_create_an_instance_from_the_reflection_class(): void { - $reflectionClass = new ReflectionClass(PersonData::class); - $classContext = ClassContext::getInstance($reflectionClass); + $classContext = ClassContext::getInstance(PersonData::class); self::assertInstanceOf(PersonData::class, $classContext->newInstanceWithoutConstructor()); } @@ -66,16 +62,14 @@ public function can_create_an_instance_from_the_reflection_class(): void #[Test] public function will_sync_normalizers_from_attribute(): void { - $reflectionClass = new ReflectionClass(PersonData::class); - $classContext = ClassContext::getInstance($reflectionClass); + $classContext = ClassContext::getInstance(PersonData::class); self::assertEquals( [DummyNormalizer::class, HumanToPersonNormalizer::class], $classContext->normalizers ); - $reflectionClass = new ReflectionClass(DummyWithNormalizerData::class); - $classContext = ClassContext::getInstance($reflectionClass); + $classContext = ClassContext::getInstance(DummyWithNormalizerData::class); self::assertEquals( [DummyNormalizer::class],