diff --git a/README.md b/README.md index cb56fce..4070546 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,31 @@ readonly class ProfileCreated } ``` +### Hooks + +Sometimes you need to do something before extract or after hydrate process. +For this we have the `PreExtract` and `PostHydrate` attributes. + +```php +use Patchlevel\Hydrator\Attribute\PostHydrate; +use Patchlevel\Hydrator\Attribute\PreExtract; + +readonly class Dto +{ + #[PostHydrate] + private function postHydrate(): void + { + // do something + } + + #[PreExtract] + private function preExtract(): void + { + // do something + } +} +``` + ### Cryptography The library also offers the possibility to encrypt and decrypt personal data. diff --git a/src/Attribute/PostHydrate.php b/src/Attribute/PostHydrate.php new file mode 100644 index 0000000..c7a8f62 --- /dev/null +++ b/src/Attribute/PostHydrate.php @@ -0,0 +1,12 @@ +getPropertyMetadataList($reflectionClass), $this->getSubjectIdField($reflectionClass), + $this->getPostHydrateCallbacks($reflectionClass), + $this->getPreExtractCallbacks($reflectionClass), ); $parentMetadataClass = $reflectionClass->getParentClass(); @@ -135,6 +140,50 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra return array_values($properties); } + /** @return list */ + private function getPostHydrateCallbacks(ReflectionClass $reflection): array + { + $methods = []; + + foreach ($reflection->getMethods() as $reflectionMethod) { + if ($reflectionMethod->isStatic()) { + continue; + } + + $attributeReflectionList = $reflectionMethod->getAttributes(PostHydrate::class); + + if ($attributeReflectionList === []) { + continue; + } + + $methods[] = new CallbackMetadata($reflectionMethod); + } + + return $methods; + } + + /** @return list */ + private function getPreExtractCallbacks(ReflectionClass $reflection): array + { + $methods = []; + + foreach ($reflection->getMethods() as $reflectionMethod) { + if ($reflectionMethod->isStatic()) { + continue; + } + + $attributeReflectionList = $reflectionMethod->getAttributes(PreExtract::class); + + if ($attributeReflectionList === []) { + continue; + } + + $methods[] = new CallbackMetadata($reflectionMethod); + } + + return $methods; + } + private function getFieldName(ReflectionProperty $reflectionProperty): string { $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); @@ -236,6 +285,8 @@ private function mergeMetadata(ClassMetadata $parent, ClassMetadata $child): Cla $parent->reflection(), array_values($properties), $parentDataSubjectIdField ?? $childDataSubjectIdField, + array_merge($parent->postHydrateCallbacks(), $child->postHydrateCallbacks()), + array_merge($parent->preExtractCallbacks(), $child->preExtractCallbacks()), ); } diff --git a/src/Metadata/CallbackMetadata.php b/src/Metadata/CallbackMetadata.php new file mode 100644 index 0000000..cd0e29e --- /dev/null +++ b/src/Metadata/CallbackMetadata.php @@ -0,0 +1,51 @@ +reflection; + } + + public function methodName(): string + { + return $this->reflection->getName(); + } + + public function invoke(object $object): void + { + $this->reflection->invoke($object); + } + + /** @return serialized */ + public function __serialize(): array + { + return [ + 'className' => $this->reflection->getDeclaringClass()->getName(), + 'method' => $this->reflection->getName(), + ]; + } + + /** @param serialized $data */ + public function __unserialize(array $data): void + { + $this->reflection = new ReflectionMethod($data['className'], $data['method']); + } +} diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 0d4ded3..36e07c8 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -10,7 +10,9 @@ * @psalm-type serialized array{ * className: class-string, * properties: list, - * dataSubjectIdField: string|null + * dataSubjectIdField: string|null, + * postHydrateCallbacks: list, + * preExtractCallbacks: list, * } * @template T of object */ @@ -19,11 +21,15 @@ final class ClassMetadata /** * @param ReflectionClass $reflection * @param list $properties + * @param list $postHydrateCallbacks + * @param list $preExtractCallbacks */ public function __construct( private readonly ReflectionClass $reflection, private readonly array $properties = [], private readonly string|null $dataSubjectIdField = null, + private readonly array $postHydrateCallbacks = [], + private readonly array $preExtractCallbacks = [], ) { } @@ -45,6 +51,18 @@ public function properties(): array return $this->properties; } + /** @return list */ + public function postHydrateCallbacks(): array + { + return $this->postHydrateCallbacks; + } + + /** @return list */ + public function preExtractCallbacks(): array + { + return $this->preExtractCallbacks; + } + public function dataSubjectIdField(): string|null { return $this->dataSubjectIdField; @@ -74,6 +92,8 @@ public function __serialize(): array 'className' => $this->reflection->getName(), 'properties' => $this->properties, 'dataSubjectIdField' => $this->dataSubjectIdField, + 'postHydrateCallbacks' => $this->postHydrateCallbacks, + 'preExtractCallbacks' => $this->preExtractCallbacks, ]; } @@ -83,5 +103,7 @@ public function __unserialize(array $data): void $this->reflection = new ReflectionClass($data['className']); $this->properties = $data['properties']; $this->dataSubjectIdField = $data['dataSubjectIdField']; + $this->postHydrateCallbacks = $data['postHydrateCallbacks']; + $this->preExtractCallbacks = $data['preExtractCallbacks']; } } diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 11326c0..357d00a 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -104,6 +104,10 @@ public function hydrate(string $class, array $data): object } } + foreach ($metadata->postHydrateCallbacks() as $callback) { + $callback->invoke($object); + } + return $object; } @@ -124,6 +128,10 @@ public function extract(object $object): array try { $metadata = $this->metadataFactory->metadata($object::class); + foreach ($metadata->preExtractCallbacks() as $callback) { + $callback->invoke($object); + } + $data = []; foreach ($metadata->properties() as $propertyMetadata) { diff --git a/tests/Unit/Fixture/DtoWithHooks.php b/tests/Unit/Fixture/DtoWithHooks.php new file mode 100644 index 0000000..f5ec909 --- /dev/null +++ b/tests/Unit/Fixture/DtoWithHooks.php @@ -0,0 +1,27 @@ +postHydrateCalled = true; + } + + #[PreExtract] + private function preExtract(): void + { + $this->preExtractCalled = true; + } +} diff --git a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php index 36d48f2..475092a 100644 --- a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php +++ b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php @@ -7,6 +7,8 @@ use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\NormalizedName; use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Attribute\PostHydrate; +use Patchlevel\Hydrator\Attribute\PreExtract; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\DuplicatedFieldNameInMetadata; use Patchlevel\Hydrator\Metadata\MissingDataSubjectId; @@ -38,6 +40,8 @@ public function testEmptyObject(): void $metadata = $metadataFactory->metadata($object::class); self::assertCount(0, $metadata->properties()); + self::assertCount(0, $metadata->preExtractCallbacks()); + self::assertCount(0, $metadata->postHydrateCallbacks()); } public function testNotFoundProperty(): void @@ -358,4 +362,53 @@ public function testExtendsWithPersonalData(): void self::assertTrue($emailPropertyMetadata->isPersonalData()); self::assertInstanceOf(EmailNormalizer::class, $emailPropertyMetadata->normalizer()); } + + public function testHooks(): void + { + $object = new class { + #[PreExtract] + private function preExtract(): void + { + } + + #[PostHydrate] + private function postHydrate(): void + { + } + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + $preExtract = $metadata->preExtractCallbacks(); + + self::assertCount(1, $preExtract); + self::assertSame('preExtract', $preExtract[0]->methodName()); + + $postHydrate = $metadata->postHydrateCallbacks(); + + self::assertCount(1, $postHydrate); + self::assertSame('postHydrate', $postHydrate[0]->methodName()); + } + + public function testSkipStaticHook(): void + { + $object = new class { + #[PreExtract] + private static function preExtract(): void + { + } + + #[PostHydrate] + private static function postHydrate(): void + { + } + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + self::assertCount(0, $metadata->preExtractCallbacks()); + self::assertCount(0, $metadata->postHydrateCallbacks()); + } } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index d9aa451..784dffb 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -18,6 +18,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\DtoWithHooks; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerBrokenDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto; @@ -116,6 +117,13 @@ public function testExtractWithInferNormalizerFailed(): void ); } + public function testExtractWithHooks(): void + { + $data = $this->hydrator->extract(new DtoWithHooks()); + + self::assertEquals(['postHydrateCalled' => false, 'preExtractCalled' => true], $data); + } + public function testHydrate(): void { $expected = new ProfileCreated( @@ -335,4 +343,12 @@ public function testHydrateWithInferNormalizerFailed(): void ], ); } + + public function testHydrateWithHooks(): void + { + $object = $this->hydrator->hydrate(DtoWithHooks::class, ['postHydrateCalled' => false, 'preExtractCalled' => false]); + + self::assertEquals(true, $object->postHydrateCalled); + self::assertEquals(false, $object->preExtractCalled); + } }