From 0145bae210b48ea95824d34be19e75c7c5433d7e Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 6 Jun 2025 16:24:14 +0200 Subject: [PATCH] add lazy with attribute --- README.md | 27 ++++ src/Attribute/Lazy.php | 16 +++ src/Metadata/AttributeMetadataFactory.php | 15 ++ src/Metadata/ClassMetadata.php | 9 ++ src/MetadataHydrator.php | 37 ++++- tests/Benchmark/HydratorBench.php | 8 +- .../HydratorWithCryptographyBench.php | 8 +- tests/Benchmark/HydratorWithLazyBench.php | 130 ++++++++++++++++++ tests/Unit/Fixture/LazyProfileCreated.php | 19 +++ .../Metadata/AttributeMetadataFactoryTest.php | 24 ++++ tests/Unit/MetadataHydratorTest.php | 56 ++++++++ 11 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 src/Attribute/Lazy.php create mode 100644 tests/Benchmark/HydratorWithLazyBench.php create mode 100644 tests/Unit/Fixture/LazyProfileCreated.php diff --git a/README.md b/README.md index 4d83fa5..a8e7de7 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,29 @@ readonly class ProfileCreated } ``` +### Lazy + +Since PHP 8.4, it's been possible to lazy-hydrate objects. +That is, the actual hydration process occurs when the object is accessed. +You can define for each class whether you want it to be lazy by using the `Lazy` attribute. + +```php +use Patchlevel\Hydrator\Attribute\Lazy; + +#[Lazy] +readonly class ProfileCreated +{ + public function __construct( + public string $id, + public string $name, + ) { + } +} +``` + +> [!NOTE] +> If you are using a PHP version older than 8.4, the attribute will be ignored. + ### Hooks Sometimes you need to do something before extract or after hydrate process. @@ -592,6 +615,10 @@ final class ProfileCreated } ``` +> [!TIP] +> Cryptography is very expensive in terms of performance, +> you can combine it with lazy to improve performance and only decrypt when you actually access the object. + #### Configure Cryptography Here we show you how to configure the cryptography. diff --git a/src/Attribute/Lazy.php b/src/Attribute/Lazy.php new file mode 100644 index 0000000..1407229 --- /dev/null +++ b/src/Attribute/Lazy.php @@ -0,0 +1,16 @@ +getSubjectIdField($reflectionClass), $this->getPostHydrateCallbacks($reflectionClass), $this->getPreExtractCallbacks($reflectionClass), + $this->getLazy($reflectionClass), ); $parentMetadataClass = $reflectionClass->getParentClass(); @@ -212,6 +214,18 @@ private function getPreExtractCallbacks(ReflectionClass $reflection): array return $methods; } + /** @param ReflectionClass $reflection */ + private function getLazy(ReflectionClass $reflection): bool|null + { + $attributeReflectionList = $reflection->getAttributes(Lazy::class); + + if ($attributeReflectionList === []) { + return null; + } + + return $attributeReflectionList[0]->newInstance()->enabled; + } + private function getFieldName(ReflectionProperty $reflectionProperty): string { $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); @@ -271,6 +285,7 @@ private function mergeMetadata(ClassMetadata $parent, ClassMetadata $child): Cla $parentDataSubjectIdField ?? $childDataSubjectIdField, array_merge($parent->postHydrateCallbacks(), $child->postHydrateCallbacks()), array_merge($parent->preExtractCallbacks(), $child->preExtractCallbacks()), + $child->lazy() ?? $parent->lazy(), ); } diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 89ba44c..c3ead0a 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -13,6 +13,7 @@ * dataSubjectIdField: string|null, * postHydrateCallbacks: list, * preExtractCallbacks: list, + * lazy: bool|null, * } * @template T of object = object */ @@ -30,6 +31,7 @@ public function __construct( private readonly string|null $dataSubjectIdField = null, private readonly array $postHydrateCallbacks = [], private readonly array $preExtractCallbacks = [], + private readonly bool|null $lazy = null, ) { } @@ -63,6 +65,11 @@ public function preExtractCallbacks(): array return $this->preExtractCallbacks; } + public function lazy(): bool|null + { + return $this->lazy; + } + public function dataSubjectIdField(): string|null { return $this->dataSubjectIdField; @@ -94,6 +101,7 @@ public function __serialize(): array 'dataSubjectIdField' => $this->dataSubjectIdField, 'postHydrateCallbacks' => $this->postHydrateCallbacks, 'preExtractCallbacks' => $this->preExtractCallbacks, + 'lazy' => $this->lazy, ]; } @@ -105,5 +113,6 @@ public function __unserialize(array $data): void $this->dataSubjectIdField = $data['dataSubjectIdField']; $this->postHydrateCallbacks = $data['postHydrateCallbacks']; $this->preExtractCallbacks = $data['preExtractCallbacks']; + $this->lazy = $data['lazy']; } } diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 2e00b28..e1a37a7 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -16,6 +16,7 @@ use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; +use ReflectionClass; use ReflectionParameter; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -27,6 +28,8 @@ use function is_object; use function spl_object_id; +use const PHP_VERSION_ID; + final class MetadataHydrator implements Hydrator { /** @var array */ @@ -36,6 +39,7 @@ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), PayloadCryptographer|null $cryptographer = null, private EventDispatcherInterface|null $eventDispatcher = null, + private readonly bool $defaultLazy = false, ) { if (!$cryptographer) { return; @@ -66,6 +70,33 @@ public function hydrate(string $class, array $data): object throw new ClassNotSupported($class, $e); } + if (PHP_VERSION_ID < 80400) { + return $this->doHydrate($metadata, $data); + } + + $lazy = $metadata->lazy() ?? $this->defaultLazy; + + if (!$lazy) { + return $this->doHydrate($metadata, $data); + } + + return (new ReflectionClass($class))->newLazyProxy( + function () use ($metadata, $data): object { + return $this->doHydrate($metadata, $data); + }, + ); + } + + /** + * @param ClassMetadata $metadata + * @param array $data + * + * @return T + * + * @template T of object + */ + private function doHydrate(ClassMetadata $metadata, array $data): object + { if ($this->eventDispatcher) { $data = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata))->data; } @@ -110,7 +141,7 @@ public function hydrate(string $class, array $data): object $value = $normalizer->denormalize($value); } catch (Throwable $e) { throw new DenormalizationFailure( - $class, + $metadata->className(), $propertyMetadata->propertyName(), $normalizer::class, $e, @@ -122,7 +153,7 @@ public function hydrate(string $class, array $data): object $propertyMetadata->setValue($object, $value); } catch (TypeError $e) { throw new TypeMismatch( - $class, + $metadata->className(), $propertyMetadata->propertyName(), $e, ); @@ -234,6 +265,7 @@ private function promotedConstructorParametersWithDefaultValue(ClassMetadata $me public static function create( iterable $guessers = [], EventDispatcherInterface|null $eventDispatcher = null, + bool $defaultLazy = false, ): self { $guesser = new BuiltInGuesser(); @@ -250,6 +282,7 @@ public static function create( ), null, $eventDispatcher, + $defaultLazy, ); } } diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/HydratorBench.php index 7c0c35b..529b980 100644 --- a/tests/Benchmark/HydratorBench.php +++ b/tests/Benchmark/HydratorBench.php @@ -66,7 +66,7 @@ public function benchExtract1Object(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000Objects(): void { for ($i = 0; $i < 1_000; $i++) { @@ -81,7 +81,7 @@ public function benchHydrate1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000Objects(): void { $object = new ProfileCreated( @@ -98,7 +98,7 @@ public function benchExtract1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000000Objects(): void { for ($i = 0; $i < 1_000_000; $i++) { @@ -113,7 +113,7 @@ public function benchHydrate1000000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000000Objects(): void { $object = new ProfileCreated( diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index 5b8ebb2..da3ab2b 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -84,7 +84,7 @@ public function benchExtract1Object(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000Objects(): void { for ($i = 0; $i < 1_000; $i++) { @@ -102,7 +102,7 @@ public function benchHydrate1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000Objects(): void { $object = new ProfileCreated( @@ -119,7 +119,7 @@ public function benchExtract1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000000Objects(): void { for ($i = 0; $i < 1_000_000; $i++) { @@ -137,7 +137,7 @@ public function benchHydrate1000000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000000Objects(): void { $object = new ProfileCreated( diff --git a/tests/Benchmark/HydratorWithLazyBench.php b/tests/Benchmark/HydratorWithLazyBench.php new file mode 100644 index 0000000..fe9c98d --- /dev/null +++ b/tests/Benchmark/HydratorWithLazyBench.php @@ -0,0 +1,130 @@ +hydrator = MetadataHydrator::create(defaultLazy: true); + } + + public function setUp(): void + { + $object = $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); + + $this->hydrator->extract($object); + } + + #[Bench\Revs(5)] + public function benchHydrate1Object(): void + { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + + #[Bench\Revs(5)] + public function benchHydrate1ObjectTriggerInit(): void + { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $name = $object->name; + } + + #[Bench\Revs(3)] + public function benchHydrate1000Objects(): void + { + for ($i = 0; $i < 1_000; $i++) { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000ObjectsTriggerInit(): void + { + for ($i = 0; $i < 1_000; $i++) { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $name = $object->name; + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000000Objects(): void + { + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000000ObjectsTriggerInit(): void + { + for ($i = 0; $i < 1_000_000; $i++) { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $object = $object->name; + } + } +} diff --git a/tests/Unit/Fixture/LazyProfileCreated.php b/tests/Unit/Fixture/LazyProfileCreated.php new file mode 100644 index 0000000..18a148d --- /dev/null +++ b/tests/Unit/Fixture/LazyProfileCreated.php @@ -0,0 +1,19 @@ +preExtractCallbacks()); self::assertCount(0, $metadata->postHydrateCallbacks()); } + + public function testNoLazy(): void + { + $object = new class { + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + self::assertNull($metadata->lazy()); + } + + public function testLazy(): void + { + $object = new #[Lazy] + class { + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + self::assertTrue($metadata->lazy()); + } } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index df9800c..239c98e 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -29,6 +29,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; @@ -40,7 +41,9 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer; use Patchlevel\Hydrator\TypeMismatch; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; +use ReflectionClass; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -512,6 +515,59 @@ public function testHydrateWithHooks(): void self::assertEquals(false, $object->preExtractCalled); } + #[RequiresPhp('>=8.4')] + public function testLazyHydrate(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $expected = new LazyProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertInstanceOf(LazyProfileCreated::class, $event); + + $reflection = new ReflectionClass(LazyProfileCreated::class); + + self::assertTrue($reflection->isUninitializedLazyObject($event)); + + $reflection->initializeLazyObject($event); + + self::assertEquals($expected, $event); + } + + #[RequiresPhp('<8.4')] + public function testLazyNotSupported(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $expected = new LazyProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals($expected, $event); + } + + #[RequiresPhp('>=8.4')] + public function testLazyExtract(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $data = $this->hydrator->extract($event); + + self::assertEquals(['profileId' => '1', 'email' => 'info@patchlevel.de'], $data); + } + public function testCreate(): void { $eventDispatcher = $this->createMock(EventDispatcherInterface::class);