From e152cfbb8ce280661af77ea5a508226b6ff58e6b Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 27 Feb 2026 18:39:49 +0100 Subject: [PATCH] backport stack hydrator --- phpstan-baseline.neon | 12 +- src/CoreExtension.php | 17 + src/Extension.php | 10 + src/Metadata/ClassMetadata.php | 76 ++- src/Metadata/EnrichingMetadataFactory.php | 35 ++ src/Metadata/MetadataEnricher.php | 10 + src/Metadata/PropertyMetadata.php | 31 +- src/Middleware/Middleware.php | 32 + src/Middleware/NoMoreMiddleware.php | 16 + src/Middleware/Stack.php | 29 + src/Middleware/TransformMiddleware.php | 151 +++++ src/StackHydrator.php | 113 ++++ src/StackHydratorBuilder.php | 108 ++++ tests/Benchmark/StackHydratorBench.php | 135 +++++ .../Metadata/EnrichingMetadataFactoryTest.php | 48 ++ .../Metadata/Psr16MetadataFactoryTest.php | 63 ++ .../Unit/Metadata/Psr6MetadataFactoryTest.php | 80 +++ tests/Unit/Middleware/StackTest.php | 34 ++ .../Middleware/TransformerMiddlewareTest.php | 70 +++ tests/Unit/StackHydratorBuilderTest.php | 150 +++++ tests/Unit/StackHydratorTest.php | 545 ++++++++++++++++++ 21 files changed, 1737 insertions(+), 28 deletions(-) create mode 100644 src/CoreExtension.php create mode 100644 src/Extension.php create mode 100644 src/Metadata/EnrichingMetadataFactory.php create mode 100644 src/Metadata/MetadataEnricher.php create mode 100644 src/Middleware/Middleware.php create mode 100644 src/Middleware/NoMoreMiddleware.php create mode 100644 src/Middleware/Stack.php create mode 100644 src/Middleware/TransformMiddleware.php create mode 100644 src/StackHydrator.php create mode 100644 src/StackHydratorBuilder.php create mode 100644 tests/Benchmark/StackHydratorBench.php create mode 100644 tests/Unit/Metadata/EnrichingMetadataFactoryTest.php create mode 100644 tests/Unit/Metadata/Psr16MetadataFactoryTest.php create mode 100644 tests/Unit/Metadata/Psr6MetadataFactoryTest.php create mode 100644 tests/Unit/Middleware/StackTest.php create mode 100644 tests/Unit/Middleware/TransformerMiddlewareTest.php create mode 100644 tests/Unit/StackHydratorBuilderTest.php create mode 100644 tests/Unit/StackHydratorTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e3fe1a1..76fc3b7a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,12 +66,6 @@ parameters: count: 3 path: src/Metadata/AttributeMetadataFactory.php - - - message: '#^Property Patchlevel\\Hydrator\\Metadata\\ClassMetadata\\:\:\$reflection \(ReflectionClass\\) does not accept ReflectionClass\\.$#' - identifier: assign.propertyType - count: 1 - path: src/Metadata/ClassMetadata.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' identifier: assign.propertyType @@ -185,3 +179,9 @@ parameters: identifier: cast.string count: 2 path: tests/Unit/Normalizer/ArrayShapeNormalizerTest.php + + - + message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\StackHydrator\:\:hydrate\(\) expects class\-string\, string given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/StackHydratorTest.php diff --git a/src/CoreExtension.php b/src/CoreExtension.php new file mode 100644 index 00000000..81cce0df --- /dev/null +++ b/src/CoreExtension.php @@ -0,0 +1,17 @@ +addMiddleware(new TransformMiddleware(), -64); + $builder->addGuesser(new BuiltInGuesser(), -64); + } +} diff --git a/src/Extension.php b/src/Extension.php new file mode 100644 index 00000000..723e3052 --- /dev/null +++ b/src/Extension.php @@ -0,0 +1,10 @@ +, + * className: class-string, + * properties: array, * dataSubjectIdField: string|null, * postHydrateCallbacks: list, * preExtractCallbacks: list, * lazy: bool|null, + * extras: array * } * @template T of object = object */ -final readonly class ClassMetadata +final class ClassMetadata { + /** @var class-string */ + public readonly string $className; + + /** @var array */ + public readonly array $properties; + + /** @var array|null */ + private array|null $promotedConstructorDefaults = null; + /** * @param ReflectionClass $reflection * @param list $properties * @param list $postHydrateCallbacks * @param list $preExtractCallbacks + * @param array $extras */ public function __construct( - private ReflectionClass $reflection, - private array $properties = [], - private string|null $dataSubjectIdField = null, - private array $postHydrateCallbacks = [], - private array $preExtractCallbacks = [], - private bool|null $lazy = null, + public readonly ReflectionClass $reflection, + array $properties = [], + public string|null $dataSubjectIdField = null, + public array $postHydrateCallbacks = [], + public array $preExtractCallbacks = [], + public bool|null $lazy = null, + public array $extras = [], ) { + $this->className = $reflection->getName(); + + $map = []; + + foreach ($properties as $property) { + $map[$property->propertyName] = $property; + } + + $this->properties = $map; } /** @return ReflectionClass */ @@ -44,13 +68,13 @@ public function reflection(): ReflectionClass /** @return class-string */ public function className(): string { - return $this->reflection->getName(); + return $this->className; } /** @return list */ public function properties(): array { - return $this->properties; + return array_values($this->properties); } /** @return list */ @@ -92,16 +116,43 @@ public function newInstance(): object return $this->reflection->newInstanceWithoutConstructor(); } + /** @return array */ + public function promotedConstructorDefaults(): array + { + if ($this->promotedConstructorDefaults !== null) { + return $this->promotedConstructorDefaults; + } + + $constructor = $this->reflection->getConstructor(); + + if (!$constructor) { + return $this->promotedConstructorDefaults = []; + } + + $result = []; + + foreach ($constructor->getParameters() as $parameter) { + if (!$parameter->isPromoted() || !$parameter->isDefaultValueAvailable()) { + continue; + } + + $result[$parameter->getName()] = $parameter; + } + + return $this->promotedConstructorDefaults = $result; + } + /** @return serialized */ public function __serialize(): array { return [ - 'className' => $this->reflection->getName(), + 'className' => $this->className, 'properties' => $this->properties, 'dataSubjectIdField' => $this->dataSubjectIdField, 'postHydrateCallbacks' => $this->postHydrateCallbacks, 'preExtractCallbacks' => $this->preExtractCallbacks, 'lazy' => $this->lazy, + 'extras' => $this->extras, ]; } @@ -114,5 +165,6 @@ public function __unserialize(array $data): void $this->postHydrateCallbacks = $data['postHydrateCallbacks']; $this->preExtractCallbacks = $data['preExtractCallbacks']; $this->lazy = $data['lazy']; + $this->extras = $data['extras']; } } diff --git a/src/Metadata/EnrichingMetadataFactory.php b/src/Metadata/EnrichingMetadataFactory.php new file mode 100644 index 00000000..afd8d551 --- /dev/null +++ b/src/Metadata/EnrichingMetadataFactory.php @@ -0,0 +1,35 @@ + $enrichers */ + public function __construct( + private MetadataFactory $factory, + private iterable $enrichers, + ) { + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @throws ClassNotFound if the class does not exist. + * + * @template T of object + */ + public function metadata(string $class): ClassMetadata + { + $metadata = $this->factory->metadata($class); + + foreach ($this->enrichers as $enricher) { + $enricher->enrich($metadata); + } + + return $metadata; + } +} diff --git a/src/Metadata/MetadataEnricher.php b/src/Metadata/MetadataEnricher.php new file mode 100644 index 00000000..5516db15 --- /dev/null +++ b/src/Metadata/MetadataEnricher.php @@ -0,0 +1,10 @@ + * } */ final class PropertyMetadata { private const ENCRYPTED_PREFIX = '!'; - /** @param (callable(string, mixed):mixed)|null $personalDataFallbackCallable */ + public readonly string $propertyName; + + /** + * @param (callable(string, mixed):mixed)|null $personalDataFallbackCallable + * @param array $extras + */ public function __construct( - private readonly ReflectionProperty $reflection, - private readonly string $fieldName, - private readonly Normalizer|null $normalizer = null, - private readonly bool $isPersonalData = false, - private readonly mixed $personalDataFallback = null, - private readonly mixed $personalDataFallbackCallable = null, + public readonly ReflectionProperty $reflection, + public string $fieldName, + public Normalizer|null $normalizer = null, + public readonly bool $isPersonalData = false, + public readonly mixed $personalDataFallback = null, + public readonly mixed $personalDataFallbackCallable = null, + public array $extras = [], ) { + $this->propertyName = $reflection->getName(); + if (str_starts_with($fieldName, self::ENCRYPTED_PREFIX)) { throw new InvalidArgumentException('fieldName must not start with !'); } @@ -46,7 +55,7 @@ public function reflection(): ReflectionProperty public function propertyName(): string { - return $this->reflection->getName(); + return $this->propertyName; } public function fieldName(): string @@ -99,11 +108,12 @@ public function __serialize(): array { return [ 'className' => $this->reflection->getDeclaringClass()->getName(), - 'property' => $this->reflection->getName(), + 'property' => $this->propertyName, 'fieldName' => $this->fieldName, 'normalizer' => $this->normalizer, 'isPersonalData' => $this->isPersonalData, 'personalDataFallback' => $this->personalDataFallback, + 'extras' => $this->extras, ]; } @@ -115,5 +125,6 @@ public function __unserialize(array $data): void $this->normalizer = $data['normalizer']; $this->isPersonalData = $data['isPersonalData']; $this->personalDataFallback = $data['personalDataFallback']; + $this->extras = $data['extras']; } } diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 00000000..106dab61 --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,32 @@ + $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object; + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array; +} diff --git a/src/Middleware/NoMoreMiddleware.php b/src/Middleware/NoMoreMiddleware.php new file mode 100644 index 00000000..8203ab3c --- /dev/null +++ b/src/Middleware/NoMoreMiddleware.php @@ -0,0 +1,16 @@ + $middlewares */ + public function __construct( + private readonly array $middlewares, + ) { + } + + public function next(): Middleware + { + $next = $this->middlewares[$this->index] ?? null; + + if ($next === null) { + throw new NoMoreMiddleware(); + } + + $this->index++; + + return $next; + } +} diff --git a/src/Middleware/TransformMiddleware.php b/src/Middleware/TransformMiddleware.php new file mode 100644 index 00000000..c180d17c --- /dev/null +++ b/src/Middleware/TransformMiddleware.php @@ -0,0 +1,151 @@ + */ + private array $callStack = []; + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $object = $metadata->newInstance(); + + $constructorParameters = null; + + foreach ($metadata->properties() as $propertyMetadata) { + if (!array_key_exists($propertyMetadata->fieldName(), $data)) { + if (!$propertyMetadata->reflection->isPromoted()) { + continue; + } + + $constructorParameters ??= $metadata->promotedConstructorDefaults(); + + if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) { + continue; + } + + $propertyMetadata->setValue( + $object, + $constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(), + ); + + continue; + } + + if ($propertyMetadata->normalizer) { + try { + if ($propertyMetadata->normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); + } else { + /** @psalm-suppress MixedAssignment */ + $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName]); + } + } catch (Throwable $e) { + throw new DenormalizationFailure( + $metadata->className, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $value = $data[$propertyMetadata->fieldName]; + } + + try { + $propertyMetadata->setValue($object, $value); + } catch (TypeError $e) { + throw new TypeMismatch( + $metadata->className, + $propertyMetadata->propertyName, + $e, + ); + } + } + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $objectId = spl_object_id($object); + + if (array_key_exists($objectId, $this->callStack)) { + $references = array_values($this->callStack); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack[$objectId] = $object::class; + + try { + $data = []; + + foreach ($metadata->properties as $propertyMetadata) { + if ($propertyMetadata->normalizer) { + try { + if ($propertyMetadata->normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( + $propertyMetadata->getValue($object), + $context, + ); + } else { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( + $propertyMetadata->getValue($object), + ); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure( + $object::class, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $data[$propertyMetadata->fieldName] = $propertyMetadata->getValue($object); + } + } + } finally { + unset($this->callStack[$objectId]); + } + + return $data; + } +} diff --git a/src/StackHydrator.php b/src/StackHydrator.php new file mode 100644 index 00000000..bfe593bb --- /dev/null +++ b/src/StackHydrator.php @@ -0,0 +1,113 @@ + */ + private array $classMetadata = []; + + /** @param list $middlewares */ + public function __construct( + private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), + private readonly array $middlewares = [new TransformMiddleware()], + private readonly bool $defaultLazy = false, + ) { + } + + /** + * @param class-string $class + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, array $data, array $context = []): object + { + try { + $metadata = $this->metadata($class); + } catch (ClassNotFound $e) { + throw new ClassNotSupported($class, $e); + } + + if (PHP_VERSION_ID < 80400) { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + $lazy = $metadata->lazy ?? $this->defaultLazy; + + if (!$lazy) { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + return (new ReflectionClass($class))->newLazyProxy( + function () use ($metadata, $data, $context): object { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + }, + ); + } + + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array + { + $metadata = $this->metadata($object::class); + + $stack = new Stack($this->middlewares); + + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + public function metadata(string $class): ClassMetadata + { + if (array_key_exists($class, $this->classMetadata)) { + return $this->classMetadata[$class]; + } + + $this->classMetadata[$class] = $metadata = $this->metadataFactory->metadata($class); + + foreach ($metadata->properties() as $property) { + if (!($property->normalizer instanceof HydratorAwareNormalizer)) { + continue; + } + + $property->normalizer->setHydrator($this); + } + + return $metadata; + } +} diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php new file mode 100644 index 00000000..39c5b9b6 --- /dev/null +++ b/src/StackHydratorBuilder.php @@ -0,0 +1,108 @@ +> */ + private array $middlewares = []; + + /** @var array> */ + private array $metadataEnrichers = []; + + /** @var array> */ + private array $guessers = []; + + private CacheItemPoolInterface|CacheInterface|null $cache = null; + + /** @return $this */ + public function addMiddleware(Middleware $middleware, int $priority = 0): static + { + $this->middlewares[$priority][] = $middleware; + + return $this; + } + + /** @return $this */ + public function addMetadataEnricher(MetadataEnricher $enricher, int $priority = 0): static + { + $this->metadataEnrichers[$priority][] = $enricher; + + return $this; + } + + /** @return $this */ + public function addGuesser(Guesser $guesser, int $priority = 0): static + { + $this->guessers[$priority][] = $guesser; + + return $this; + } + + public function enableDefaultLazy(bool $lazy = true): static + { + $this->defaultLazy = $lazy; + + return $this; + } + + public function useExtension(Extension $extension): static + { + $extension->configure($this); + + return $this; + } + + public function setCache(CacheItemPoolInterface|CacheInterface|null $cache): static + { + $this->cache = $cache; + + return $this; + } + + public function build(): StackHydrator + { + krsort($this->guessers); + krsort($this->metadataEnrichers); + krsort($this->middlewares); + + $metadataFactory = new EnrichingMetadataFactory( + new AttributeMetadataFactory( + guesser: new ChainGuesser(array_merge(...$this->guessers)), + ), + array_merge(...$this->metadataEnrichers), + ); + + if ($this->cache instanceof CacheItemPoolInterface) { + $metadataFactory = new Psr6MetadataFactory($metadataFactory, $this->cache); + } + + if ($this->cache instanceof CacheInterface) { + $metadataFactory = new Psr16MetadataFactory($metadataFactory, $this->cache); + } + + return new StackHydrator( + $metadataFactory, + array_merge(...$this->middlewares), + $this->defaultLazy, + ); + } +} diff --git a/tests/Benchmark/StackHydratorBench.php b/tests/Benchmark/StackHydratorBench.php new file mode 100644 index 00000000..4b1f7cf8 --- /dev/null +++ b/tests/Benchmark/StackHydratorBench.php @@ -0,0 +1,135 @@ +hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); + } + + 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 benchExtract1Object(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + $this->hydrator->extract($object); + } + + #[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 benchExtract1000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000; $i++) { + $this->hydrator->extract($object); + } + } + + #[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 benchExtract1000000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->extract($object); + } + } +} diff --git a/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php b/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php new file mode 100644 index 00000000..d82a4e89 --- /dev/null +++ b/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php @@ -0,0 +1,48 @@ +createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $enricher1 = $this->createMock(MetadataEnricher::class); + $enricher1->expects(self::once()) + ->method('enrich') + ->with($classMetadata); + + $enricher2 = $this->createMock(MetadataEnricher::class); + $enricher2->expects(self::once()) + ->method('enrich') + ->with($classMetadata); + + $factory = new EnrichingMetadataFactory( + $innerFactory, + [$enricher1, $enricher2], + ); + + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/Metadata/Psr16MetadataFactoryTest.php b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php new file mode 100644 index 00000000..6f356bc8 --- /dev/null +++ b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php @@ -0,0 +1,63 @@ +createMock(CacheInterface::class); + $cache->expects(self::once()) + ->method('get') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::never()) + ->method('metadata'); + + $factory = new Psr16MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } + + public function testMetadataWithMiss(): void + { + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once()) + ->method('get') + ->with(stdClass::class) + ->willReturn(null); + $cache->expects(self::once()) + ->method('set') + ->with(stdClass::class, $classMetadata); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $factory = new Psr16MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/Metadata/Psr6MetadataFactoryTest.php b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php new file mode 100644 index 00000000..0d6d5f24 --- /dev/null +++ b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php @@ -0,0 +1,80 @@ +createMock(CacheItemInterface::class); + $item->expects(self::once()) + ->method('isHit') + ->willReturn(true); + $item->expects(self::once()) + ->method('get') + ->willReturn($classMetadata); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache->expects(self::once()) + ->method('getItem') + ->with(stdClass::class) + ->willReturn($item); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::never()) + ->method('metadata'); + + $factory = new Psr6MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } + + public function testMetadataWithMiss(): void + { + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once()) + ->method('isHit') + ->willReturn(false); + $item->expects(self::once()) + ->method('set') + ->with($classMetadata); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache->expects(self::once()) + ->method('getItem') + ->with(stdClass::class) + ->willReturn($item); + $cache->expects(self::once()) + ->method('save') + ->with($item); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $factory = new Psr6MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/Middleware/StackTest.php b/tests/Unit/Middleware/StackTest.php new file mode 100644 index 00000000..31f30b23 --- /dev/null +++ b/tests/Unit/Middleware/StackTest.php @@ -0,0 +1,34 @@ +expectException(NoMoreMiddleware::class); + + $stack = new Stack([]); + $stack->next(); + } + + public function testStack(): void + { + $middleware1 = $this->createStub(Middleware::class); + $middleware2 = $this->createStub(Middleware::class); + + $stack = new Stack([$middleware1, $middleware2]); + + self::assertSame($middleware1, $stack->next()); + self::assertSame($middleware2, $stack->next()); + } +} diff --git a/tests/Unit/Middleware/TransformerMiddlewareTest.php b/tests/Unit/Middleware/TransformerMiddlewareTest.php new file mode 100644 index 00000000..b8c4c4ad --- /dev/null +++ b/tests/Unit/Middleware/TransformerMiddlewareTest.php @@ -0,0 +1,70 @@ +hydrate( + $this->classMetadata(ProfileCreated::class), + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + [], + new Stack([]), + ); + + self::assertEquals($expected, $event); + } + + public function testExtract(): void + { + $middleware = new TransformMiddleware(); + + $expected = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $data = $middleware->extract( + $this->classMetadata(ProfileCreated::class), + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + [], + new Stack([]), + ); + + self::assertEquals($expected, $data); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + private function classMetadata(string $class): ClassMetadata + { + return (new AttributeMetadataFactory()) + ->metadata($class); + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php new file mode 100644 index 00000000..774a86dd --- /dev/null +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -0,0 +1,150 @@ +createMock(Middleware::class); + $middleware2 = $this->createMock(Middleware::class); + + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($middleware1, 10); + $builder->addMiddleware($middleware2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'middlewares'); + $middlewares = $reflection->getValue($hydrator); + + self::assertSame([$middleware2, $middleware1], $middlewares); + } + + public function testAddMetadataEnricherWithPriority(): void + { + $enricher1 = $this->createMock(MetadataEnricher::class); + $enricher2 = $this->createMock(MetadataEnricher::class); + + $builder = new StackHydratorBuilder(); + $builder->addMetadataEnricher($enricher1, 10); + $builder->addMetadataEnricher($enricher2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $enrichingMetadataFactory = $reflection->getValue($hydrator); + + self::assertInstanceOf(EnrichingMetadataFactory::class, $enrichingMetadataFactory); + + $reflection = new ReflectionProperty(EnrichingMetadataFactory::class, 'enrichers'); + $enrichers = $reflection->getValue($enrichingMetadataFactory); + + self::assertSame([$enricher2, $enricher1], $enrichers); + } + + public function testAddGuesserWithPriority(): void + { + $guesser1 = $this->createMock(Guesser::class); + $guesser2 = $this->createMock(Guesser::class); + + $builder = new StackHydratorBuilder(); + $builder->addGuesser($guesser1, 10); + $builder->addGuesser($guesser2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $enrichingMetadataFactory = $reflection->getValue($hydrator); + + self::assertInstanceOf(EnrichingMetadataFactory::class, $enrichingMetadataFactory); + + $reflection = new ReflectionProperty(EnrichingMetadataFactory::class, 'factory'); + $metadataFactory = $reflection->getValue($enrichingMetadataFactory); + + self::assertInstanceOf(AttributeMetadataFactory::class, $metadataFactory); + + $reflection = new ReflectionProperty(AttributeMetadataFactory::class, 'guesser'); + $guesser = $reflection->getValue($metadataFactory); + + self::assertInstanceOf(ChainGuesser::class, $guesser); + + $reflection = new ReflectionProperty(ChainGuesser::class, 'guessers'); + $guessers = $reflection->getValue($guesser); + + self::assertSame([$guesser2, $guesser1], $guessers); + } + + public function testEnableDefaultLazy(): void + { + $builder = new StackHydratorBuilder(); + $builder->enableDefaultLazy(); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'defaultLazy'); + self::assertTrue($reflection->getValue($hydrator)); + } + + public function testUseExtension(): void + { + $extension = $this->createMock(Extension::class); + $builder = new StackHydratorBuilder(); + + $extension->expects(self::once()) + ->method('configure') + ->with($builder); + + $builder->useExtension($extension); + } + + public function testCachePsr6(): void + { + $cache = $this->createMock(CacheItemPoolInterface::class); + + $builder = new StackHydratorBuilder(); + $builder->setCache($cache); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $factory = $reflection->getValue($hydrator); + + self::assertInstanceOf(Psr6MetadataFactory::class, $factory); + } + + public function testCachePsr16(): void + { + $cache = $this->createMock(CacheInterface::class); + + $builder = new StackHydratorBuilder(); + $builder->setCache($cache); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $factory = $reflection->getValue($hydrator); + + self::assertInstanceOf(Psr16MetadataFactory::class, $factory); + } +} diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php new file mode 100644 index 00000000..8754e8ac --- /dev/null +++ b/tests/Unit/StackHydratorTest.php @@ -0,0 +1,545 @@ +hydrator = new StackHydrator(); + } + + public function testExtract(): void + { + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $this->hydrator->extract($event), + ); + } + + public function testExtractWithInheritance(): void + { + $event = new ParentDto( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $this->hydrator->extract($event), + ); + } + + public function testExtractWithHydratorAwareNormalizer(): void + { + $event = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + self::assertEquals( + ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']], + $this->hydrator->extract($event), + ); + } + + public function testExtractCircularReference(): void + { + $this->expectException(CircularReference::class); + $this->expectExceptionMessage('Circular reference detected: Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto'); + + $dto1 = new Circle1Dto(); + $dto2 = new Circle2Dto(); + $dto3 = new Circle3Dto(); + + $dto1->to = $dto2; + $dto2->to = $dto3; + $dto3->to = $dto1; + + $this->hydrator->extract($dto1); + } + + public function testExtractWithInferNormalizer(): void + { + $result = $this->hydrator->extract( + new InferNormalizerWithNullableDto( + null, + null, + profileId: ProfileId::fromString('1'), + ), + ); + + self::assertEquals( + [ + 'status' => null, + 'dateTimeImmutable' => null, + 'dateTime' => null, + 'dateTimeZone' => null, + 'profileId' => '1', + ], + $result, + ); + } + + public function testExtractWithContext(): void + { + $object = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $expect = [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ]; + + $middleware = $this->createMock(Middleware::class); + $middleware + ->expects($this->once()) + ->method('extract') + ->with( + $this->isInstanceOf(ClassMetadata::class), + $object, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $data = $hydrator->extract($object, ['context' => '123']); + + self::assertEquals($expect, $data); + } + + #[RequiresPhp('>=8.5')] + public function testExtractWithInlineNormalizer(): void + { + $event = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + self::assertEquals( + ['profileId' => '1', 'valueObject' => 'foo'], + $this->hydrator->extract($event), + ); + } + + public function testHydrate(): void + { + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $event = $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateUnknownClass(): void + { + $this->expectException(ClassNotSupported::class); + $this->expectExceptionCode(0); + + $this->hydrator->hydrate( + 'Unknown', + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + } + + public function testHydrateWithDefaults(): void + { + $object = $this->hydrator->hydrate( + DefaultDto::class, + ['name' => 'test'], + ); + + self::assertEquals('test', $object->name); + self::assertEquals(new Email('info@patchlevel.de'), $object->email); + self::assertEquals(true, $object->admin); + } + + public function testHydrateWithInheritance(): void + { + $expected = new ParentDto( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $event = $this->hydrator->hydrate( + ParentDto::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithHydratorAwareNormalizer(): void + { + $expected = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $event = $this->hydrator->hydrate( + ProfileCreatedWrapper::class, + [ + 'event' => ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithTypeMismatch(): void + { + $this->expectException(TypeMismatch::class); + + $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => null, 'email' => null], + ); + } + + public function testHydrateWithContext(): void + { + $expect = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $data = [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ]; + + $middleware = $this->createMock(Middleware::class); + $middleware + ->expects($this->once()) + ->method('hydrate') + ->with( + $this->isInstanceOf(ClassMetadata::class), + $data, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $object = $hydrator->hydrate(InferNormalizerDto::class, $data, ['context' => '123']); + + self::assertEquals($expect, $object); + } + + #[RequiresPhp('>=8.5')] + public function testHydrateWithInlineNormalizer(): void + { + $expected = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + $event = $this->hydrator->hydrate( + ProfileCreatedWithInlineNormalizer::class, + ['profileId' => '1', 'valueObject' => 'foo'], + ); + + self::assertEquals($expected, $event); + } + + public function testDenormalizationFailure(): void + { + $this->expectException(DenormalizationFailure::class); + + $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => 123, 'email' => 123], + ); + } + + public function testNormalizationFailure(): void + { + $this->expectException(NormalizationFailure::class); + + $this->hydrator->extract( + new WrongNormalizer(true), + ); + } + + public function testHydrateWithNormalizerInBaseClass(): void + { + $expected = new NormalizerInBaseClassDefinedDto( + StatusWithNormalizer::Draft, + new ProfileCreatedWithNormalizer( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + [StatusWithNormalizer::Draft], + [StatusWithNormalizer::Draft], + [StatusWithNormalizer::Draft], + [ + 'foo' => new Skill('php'), + 'bar' => new Skill('symfony'), + ], + [ + 'foo' => 'php', + 'bar' => 15, + 'baz' => ['test'], + ], + ); + + $event = $this->hydrator->hydrate( + NormalizerInBaseClassDefinedDto::class, + [ + 'status' => 'draft', + 'profileCreated' => ['profileId' => '1', 'email' => 'info@patchlevel.de'], + 'defaultArray' => ['draft'], + 'listArray' => ['draft'], + 'iterableArray' => ['draft'], + 'skillsHashMap' => ['foo' => ['name' => 'php'], 'bar' => ['name' => 'symfony']], + 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizer(): void + { + $expected = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $event = $this->hydrator->hydrate( + InferNormalizerDto::class, + [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizerAndNullableProperties(): void + { + $expected = new InferNormalizerWithNullableDto( + null, + null, + null, + null, + ); + + $event = $this->hydrator->hydrate( + InferNormalizerWithNullableDto::class, + [ + 'status' => null, + 'dateTimeImmutable' => null, + 'dateTime' => null, + 'dateTimeZone' => null, + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizerWitIterables(): void + { + $expected = new InferNormalizerWithIterablesDto( + [Status::Draft], + [Status::Draft], + [Status::Draft], + [ + 'foo' => Status::Draft, + 'bar' => Status::Draft, + ], + [ + 'foo' => [Status::Draft], + 'bar' => [Status::Draft], + ], + [ + 'foo' => 'php', + 'bar' => 15, + 'baz' => ['test'], + ], + [ + 'status' => Status::Draft, + 'other' => [Status::Draft], + ], + ); + + $event = $this->hydrator->hydrate( + InferNormalizerWithIterablesDto::class, + [ + 'defaultArray' => ['draft'], + 'listArray' => ['draft'], + 'iterableArray' => ['draft'], + 'hashMap' => ['foo' => 'draft', 'bar' => 'draft'], + 'nested' => ['foo' => ['draft'], 'bar' => ['draft']], + 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']], + 'shapeArray' => ['status' => 'draft', 'other' => ['draft']], + ], + ); + + self::assertEquals($expected, $event); + } + + #[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'), + ); + + $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 testMetadata(): void + { + $metadata = $this->hydrator->metadata(ProfileCreated::class); + + self::assertSame(ProfileCreated::class, $metadata->className); + + $metadata2 = $this->hydrator->metadata(ProfileCreated::class); + self::assertSame($metadata, $metadata2); + } + + public function testMetadataWithHydratorAwareNormalizer(): void + { + $metadata = $this->hydrator->metadata(ProfileCreatedWrapper::class); + + $propertyMetadata = $metadata->propertyForField('event'); + $normalizer = $propertyMetadata->normalizer; + + self::assertInstanceOf(HydratorAwareNormalizer::class, $normalizer); + + $reflection = new ReflectionProperty($normalizer, 'hydrator'); + self::assertSame($this->hydrator, $reflection->getValue($normalizer)); + } +}