diff --git a/clover.xml b/clover.xml index 05321c4..0d51739 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,96 @@ - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1091,22 +1116,23 @@ - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -1529,6 +1555,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; 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..0b91d7e 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(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(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, @@ -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 be763b7..724b82c 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(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/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 7444a1f..fd1c7ae 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 { @@ -55,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(); } /** @@ -91,6 +109,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..08febf3 100644 --- a/tests/Unit/Contexts/ClassContextTest.php +++ b/tests/Unit/Contexts/ClassContextTest.php @@ -2,16 +2,24 @@ 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 ReflectionClass; +use PHPUnit\Framework\Attributes\UsesClass; 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 { /** @@ -20,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); } @@ -32,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); } @@ -45,9 +51,29 @@ 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()); } + + /** + * @throws Throwable + */ + #[Test] + public function will_sync_normalizers_from_attribute(): void + { + $classContext = ClassContext::getInstance(PersonData::class); + + self::assertEquals( + [DummyNormalizer::class, HumanToPersonNormalizer::class], + $classContext->normalizers + ); + + $classContext = ClassContext::getInstance(DummyWithNormalizerData::class); + + self::assertEquals( + [DummyNormalizer::class], + $classContext->normalizers + ); + } }