diff --git a/docs/UPGRADE-3.0.md b/docs/UPGRADE-3.0.md index 5e26767f..8c92e3b3 100644 --- a/docs/UPGRADE-3.0.md +++ b/docs/UPGRADE-3.0.md @@ -186,13 +186,14 @@ final class LoggingHydrator implements Hydrator ``` ## Middleware -### Next instead of Stack +### Mapper instead of Stack -`Patchlevel\Hydrator\Middleware\Stack` has been removed in favor of -`Patchlevel\Hydrator\Middleware\Next`. A middleware no longer asks the stack for -the next middleware and calls it with the stack again, it calls `$next` -directly with the metadata, the data and the context. Change the last parameter -of `hydrate()` and `extract()` in your middlewares to `Next $next`. +`Patchlevel\Hydrator\Middleware\Stack` has been removed. The hydrator builds +the chain of a class once: the mapper of the class, wrapped in the middlewares +which run for it. A middleware receives the rest of this chain as a +`Patchlevel\Hydrator\Mapper\Mapper` and calls it with the data or the object +and the context, the metadata is no longer passed on. Change the last parameter +of `hydrate()` and `extract()` in your middlewares to `Mapper $next`. before: @@ -217,25 +218,25 @@ final class AuditMiddleware implements Middleware after: ```php +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; final class AuditMiddleware implements Middleware { - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - return $next->extract($metadata, $object, $context); + return $next->extract($object, $context); } } ``` -In tests, where a middleware is called on its own, pass a `Next` with the -following middlewares and the transformer instead of a `Stack`. +In tests, where a middleware is called on its own, pass a `ReflectionMapper` +of the class instead of a `Stack`. before: @@ -248,16 +249,15 @@ $object = $middleware->hydrate($metadata, $data, [], new Stack([new TransformMid after: ```php -use Patchlevel\Hydrator\Middleware\Next; -use Patchlevel\Hydrator\Transformer\ReflectionTransformer; +use Patchlevel\Hydrator\Mapper\ReflectionMapper; -$object = $middleware->hydrate($metadata, $data, [], new Next([], new ReflectionTransformer($metadata))); +$object = $middleware->hydrate($metadata, $data, [], new ReflectionMapper($metadata)); ``` ### TransformMiddleware -The `TransformMiddleware` has been removed. The hydrator maps the data with a -`ClassTransformer` after all middlewares, the `ReflectionTransformer` by -default. Remove the `TransformMiddleware` if you pass the middlewares to the +The `TransformMiddleware` has been removed. The hydrator maps the data with the +mapper of the class after all middlewares, the `ReflectionMapper` by default. +Remove the `TransformMiddleware` if you pass the middlewares to the `StackHydrator` yourself. `Extension::PRIORITY_TRANSFORM` has been removed as well. @@ -277,14 +277,78 @@ use Patchlevel\Hydrator\StackHydrator; $hydrator = new StackHydrator(middlewares: [new AuditMiddleware()]); ``` If you replaced the `TransformMiddleware` with your own mapping, implement a -`ClassTransformerFactory` instead and register it with -`StackHydratorBuilder::addTransformerFactory()`. +`Patchlevel\Hydrator\Mapper\MapperFactory` instead and register it with +`StackHydratorBuilder::addMapperFactory()`. +```php +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\MapperFactory; +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\StackHydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; + +final class MoneyMapperFactory implements MapperFactory +{ + public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null + { + if ($metadata->className !== Money::class) { + return null; + } + + return new MoneyMapper(); + } +} + +$hydrator = (new StackHydratorBuilder()) + ->addMapperFactory(new MoneyMapperFactory()) + ->build(); +``` +### PRIORITY_BEFORE_TRANSFORM + +`Extension::PRIORITY_BEFORE_TRANSFORM` has been renamed to +`Extension::PRIORITY_BEFORE_MAPPING`, the value stays `0`. + +before: + +```php +use Patchlevel\Hydrator\Extension; + +$builder->addMiddleware(new AuditMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM); +``` +after: + +```php +use Patchlevel\Hydrator\Extension; + +$builder->addMiddleware(new AuditMiddleware(), Extension::PRIORITY_BEFORE_MAPPING); +``` ### Removed exceptions `MissingMiddlewares`, `AllMiddlewaresSkipped` and `NoMoreMiddleware` have been removed. A hydrator without middlewares is valid, and a class which every -middleware skips is only transformed. Remove the `catch` blocks for them. +middleware skips is only mapped. Remove the `catch` blocks for them. + +## Upcasting + +### beforeTransform + +The `beforeTransform` argument of the `UpcastExtension` has been renamed to +`beforeMapping`. + +before: + +```php +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; + +$extension = new UpcastExtension(beforeTransform: [$mergeNameUpcaster]); +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; + +$extension = new UpcastExtension(beforeMapping: [$mergeNameUpcaster]); +``` ## Caching The serialized form of the normalizers changed. Clear the diff --git a/docs/extensions.md b/docs/extensions.md index 4c47de30..900a906a 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -2,7 +2,7 @@ The `StackHydrator` is assembled from small building blocks: middlewares that wrap the hydration process, [guessers](guesser.md) that resolve normalizers, -metadata enrichers that add information to the class metadata and transformer +metadata enrichers that add information to the class metadata and mapper factories that map the data to the object. An extension bundles such building blocks so they can be registered with a single call. @@ -33,34 +33,34 @@ The library ships with three extensions out of the box: ## Middleware -A middleware wraps the hydration and extraction process, similar to HTTP +A middleware wraps the [mapper](#mapper-factories) of a class, similar to HTTP middlewares. It can modify the incoming data, the outgoing array or the object -itself, and passes the call on to the rest of the stack with `$next`. After the last -middleware the [transformer](#transformer-factories) does the actual property -mapping. +itself, and passes the call on with `$next`. `$next` is a `Mapper` too: the +following middlewares and at the end the mapper of the class, which does the +actual property mapping. The hydrator builds this chain once per class. ```php +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; final class RemoveNullValuesMiddleware implements Middleware { - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - $data = $next->extract($metadata, $object, $context); + $data = $next->extract($object, $context); return array_filter($data, static fn (mixed $value) => $value !== null); } } ``` Middlewares are added with a priority, higher priorities run first (outermost). -The transformation always runs last, after all middlewares. +The mapper of the class always runs last, after all middlewares. ```php $builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); @@ -68,7 +68,7 @@ $builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); ## Skipping middlewares A middleware usually only has something to do for a few classes. Instead of -walking through the whole stack every time, a middleware can implement +being part of the chain of every class, a middleware can implement `SkippableMiddleware` and tell the hydrator that it is not needed for a class. The decision is made once per class and then reused, so it must only depend on the metadata. @@ -78,21 +78,21 @@ The `skip` method returns a `Skip` case: `Skip::None` to always run, `Skip::Both` to be left out completely. ```php +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; final class RemoveNullValuesMiddleware implements SkippableMiddleware { - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - $data = $next->extract($metadata, $object, $context); + $data = $next->extract($object, $context); return array_filter($data, static fn (mixed $value) => $value !== null); } @@ -113,8 +113,8 @@ upcaster is a `CallbackUpcaster` or carries an out while hydrating classes none of them target. :::tip -If every middleware skips a class, the hydrator calls the transformer directly -without building the middleware stack. +If every middleware skips a class, the mapper of the class is the whole chain +and the hydrator calls it directly. ::: ## Metadata enricher @@ -151,38 +151,63 @@ extras) can be [cached](caching.md), everything you store in `extras` must be serializable. ::: -## Transformer factories +## Mapper factories -At the end of the stack a `ClassTransformer` turns the array into the object -and back. By default this is the `ReflectionTransformer`, which sets and reads -the properties with reflection. A transformer factory can provide another -transformer for a class, for example generated code. +At the end of the chain the `Mapper` of the class turns the array into the +object and back. By default this is the `ReflectionMapper`, which sets and +reads the properties with reflection. A mapper factory can provide another +mapper for a class, for example generated code. ```php +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\MapperFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Transformer\ClassTransformer; -use Patchlevel\Hydrator\Transformer\ClassTransformerFactory; +use Patchlevel\Hydrator\StackHydrator; -final class MoneyTransformerFactory implements ClassTransformerFactory +final class MoneyMapperFactory implements MapperFactory { - public function create(ClassMetadata $metadata): ClassTransformer|null + public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null { if ($metadata->className !== Money::class) { return null; } - return new MoneyTransformer(); + return new MoneyMapper(); } } ``` ```php -$builder->addTransformerFactory(new MoneyTransformerFactory()); +$builder->addMapperFactory(new MoneyMapperFactory()); ``` -The factories are asked once per class, the first transformer wins and is -cached by the hydrator. Factories also accept a priority, a factory with a -higher priority is asked first. The `ReflectionTransformerFactory` is always -asked last, so every class without its own transformer is transformed with -reflection. +The factories are asked once per class, the first mapper wins and is cached by +the hydrator together with its middlewares. Factories also accept a priority, a +factory with a higher priority is asked first. Every class without a mapper of +a factory is mapped with the `ReflectionMapper`. + +### Nested objects + +`StackHydrator::mapper()` returns the chain the hydrator runs for a class: its +mapper, wrapped in the middlewares and the lazy proxy which run for it. A class +with a class normalizer has no chain, then it returns `null`. A mapper can call +the chain of a nested class directly instead of going through the normalizer +and the hydrator again, the `ReflectionMapper` does this for properties with an +`ObjectNormalizer`. + +Only do this if the context contains the hydrator which created the mapper: +a hydrator which wraps it has to see the nested objects. + +```php +use Patchlevel\Hydrator\Hydrator; + +if (($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator) { + $address = $this->hydrator->mapper(Address::class)?->hydrate($data['address'], $context); +} +``` +:::warning +Ask for the mappers of nested classes once the mapper is used, not inside +`create()`. The hydrator caches the mapper only after `create()` returned, so +two classes which reference each other would create each other endlessly. +::: ## Writing your own extension diff --git a/docs/upcasting.md b/docs/upcasting.md index 548d11d6..507333b8 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -17,7 +17,7 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new UpcastExtension( - beforeTransform: [ + beforeMapping: [ CallbackUpcaster::forClass( ProfileCreated::class, static function (array $data): array { @@ -97,23 +97,23 @@ you pass your upcasters to the matching argument. | Argument | Runs | Works on | | --- | --- | --- | | `beforeEncoding` | before the values are decoded | the raw stored values (strings, ints, ...) | -| `beforeTransform` | after decoding, right before the object is built | the decoded values (enums, dates, value objects, ...) | +| `beforeMapping` | after decoding, right before the object is built | the decoded values (enums, dates, value objects, ...) | Use `beforeEncoding` when you rename or restructure fields whose raw form is -enough, and `beforeTransform` when you need the already decoded values. +enough, and `beforeMapping` when you need the already decoded values. ```php use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; $extension = new UpcastExtension( beforeEncoding: [$renameFieldUpcaster], - beforeTransform: [$mergeNameUpcaster], + beforeMapping: [$mergeNameUpcaster], ); ``` :::warning The [cryptography](cryptography.md) extension decrypts values during the decoding stage. A `beforeEncoding` upcaster therefore still sees the encrypted -values, while a `beforeTransform` upcaster sees the decrypted ones. Pick the +values, while a `beforeMapping` upcaster sees the decrypted ones. Pick the stage that matches the data you need. ::: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a7f097ea..1a36fdfb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -78,6 +78,30 @@ parameters: count: 1 path: src/Guesser/Guesser.php + - + message: '#^Parameter \#1 \$data of method Patchlevel\\Hydrator\\Mapper\\Mapper\\:\:hydrate\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 2 + path: src/Mapper/ReflectionMapper.php + + - + message: '#^Parameter \#1 \$object of method Patchlevel\\Hydrator\\Mapper\\ReflectionMapper\\:\:hydrateProperties\(\) expects T of object, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Mapper/ReflectionMapper.php + + - + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Mapper\\ReflectionMapper\\:\:hydrateProperties\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Mapper/ReflectionMapper.php + + - + message: '#^Parameter \#4 \$value of method Patchlevel\\Hydrator\\Mapper\\ReflectionMapper\\:\:hydrateWithReflection\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Mapper/ReflectionMapper.php + - message: '#^Dead catch \- ReflectionException is never thrown in the try block\.$#' identifier: catch.neverThrown @@ -96,18 +120,6 @@ parameters: count: 1 path: src/Metadata/ClassMetadata.php - - - message: '#^Method Patchlevel\\Hydrator\\Transformer\\ReflectionTransformer\:\:hydrate\(\) should return T of object but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Transformer/ReflectionTransformer.php - - - - message: '#^Parameter \#1 \$object of method Patchlevel\\Hydrator\\Metadata\\PropertyMetadata\:\:setValue\(\) expects object, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Transformer/ReflectionTransformer.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' identifier: assign.propertyType @@ -121,15 +133,9 @@ parameters: path: src/Normalizer/ObjectNormalizer.php - - message: '#^Method Patchlevel\\Hydrator\\StackHydrator\:\:middlewaresFor\(\) should return non\-empty\-list\ but returns list\\.$#' - identifier: return.type - count: 1 - path: src/StackHydrator.php - - - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\StackHydrator\:\:hydrateWithMiddlewares\(\) expects array\, array\ given\.$#' + message: '#^Parameter \#1 \$data of method Patchlevel\\Hydrator\\Mapper\\Mapper\\:\:hydrate\(\) expects array\, array\ given\.$#' identifier: argument.type - count: 2 + count: 1 path: src/StackHydrator.php - diff --git a/src/Extension.php b/src/Extension.php index a827500c..60275ff6 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -13,7 +13,7 @@ interface Extension public const PRIORITY_ENCODING = 32; /** Last structural step before the array becomes an object. */ - public const PRIORITY_BEFORE_TRANSFORM = 0; + public const PRIORITY_BEFORE_MAPPING = 0; public function configure(StackHydratorBuilder $builder): void; } diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index 58e5591f..a2aa64b6 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -7,8 +7,8 @@ use Closure; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Stringable; @@ -30,12 +30,13 @@ public function __construct( * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { $context[SubjectIds::class] = $subjectIds = $this->resolveSubjectIds($metadata, $data, $context); @@ -73,27 +74,24 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ne } } - return $next->hydrate( - $metadata, - $data, - $context, - ); + return $next->hydrate($data, $context); } /** * @param ClassMetadata $metadata * @param T $object * @param array $context + * @param Mapper $next * * @return array * * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { $context[SubjectIds::class] = $subjectIds = $this->resolveSubjectIds($metadata, $object, $context); - $data = $next->extract($metadata, $object, $context); + $data = $next->extract($object, $context); foreach ($metadata->properties as $propertyMetadata) { $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; diff --git a/src/Extension/Lifecycle/LifecycleExtension.php b/src/Extension/Lifecycle/LifecycleExtension.php index be7bd70b..321cdc53 100644 --- a/src/Extension/Lifecycle/LifecycleExtension.php +++ b/src/Extension/Lifecycle/LifecycleExtension.php @@ -11,7 +11,7 @@ { public function configure(StackHydratorBuilder $builder): void { - $builder->addMiddleware(new LifecycleMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM); + $builder->addMiddleware(new LifecycleMiddleware(), Extension::PRIORITY_BEFORE_MAPPING); $builder->addMetadataEnricher(new LifecycleMetadataEnricher()); } } diff --git a/src/Extension/Lifecycle/LifecycleMiddleware.php b/src/Extension/Lifecycle/LifecycleMiddleware.php index ccf9a766..db51f7b1 100644 --- a/src/Extension/Lifecycle/LifecycleMiddleware.php +++ b/src/Extension/Lifecycle/LifecycleMiddleware.php @@ -4,8 +4,8 @@ namespace Patchlevel\Hydrator\Extension\Lifecycle; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; @@ -17,12 +17,13 @@ final class LifecycleMiddleware implements SkippableMiddleware * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { $lifecycle = $metadata->extras[Lifecycle::class] ?? null; assert($lifecycle instanceof Lifecycle || $lifecycle === null); @@ -32,7 +33,7 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ne /** @var array $data */ } - $object = $next->hydrate($metadata, $data, $context); + $object = $next->hydrate($data, $context); if ($lifecycle?->postHydrate) { $metadata->reflection->getMethod($lifecycle->postHydrate)->invoke(null, $object, $context); @@ -45,12 +46,13 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ne * @param ClassMetadata $metadata * @param T $object * @param array $context + * @param Mapper $next * * @return array * * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { $lifecycle = $metadata->extras[Lifecycle::class] ?? null; assert($lifecycle instanceof Lifecycle || $lifecycle === null); @@ -59,7 +61,7 @@ public function extract(ClassMetadata $metadata, object $object, array $context, $metadata->reflection->getMethod($lifecycle->preExtract)->invoke(null, $object, $context); } - $data = $next->extract($metadata, $object, $context); + $data = $next->extract($object, $context); if ($lifecycle?->postExtract) { $data = $metadata->reflection->getMethod($lifecycle->postExtract)->invoke(null, $data, $context); diff --git a/src/Extension/Upcast/UpcastExtension.php b/src/Extension/Upcast/UpcastExtension.php index 0e99d0d8..956f7bfa 100644 --- a/src/Extension/Upcast/UpcastExtension.php +++ b/src/Extension/Upcast/UpcastExtension.php @@ -10,12 +10,12 @@ final readonly class UpcastExtension implements Extension { /** - * @param list $beforeEncoding upcasters that reshape the raw stored payload before its values are decoded - * @param list $beforeTransform upcasters that run after value decoding, right before the object is built + * @param list $beforeEncoding upcasters that reshape the raw stored payload before its values are decoded + * @param list $beforeMapping upcasters that run after value decoding, right before the object is built */ public function __construct( private array $beforeEncoding = [], - private array $beforeTransform = [], + private array $beforeMapping = [], ) { } @@ -28,13 +28,13 @@ public function configure(StackHydratorBuilder $builder): void ); } - if ($this->beforeTransform === []) { + if ($this->beforeMapping === []) { return; } $builder->addMiddleware( - new UpcastMiddleware($this->beforeTransform), - Extension::PRIORITY_BEFORE_TRANSFORM, + new UpcastMiddleware($this->beforeMapping), + Extension::PRIORITY_BEFORE_MAPPING, ); } } diff --git a/src/Extension/Upcast/UpcastMiddleware.php b/src/Extension/Upcast/UpcastMiddleware.php index 01b24935..1a2f7571 100644 --- a/src/Extension/Upcast/UpcastMiddleware.php +++ b/src/Extension/Upcast/UpcastMiddleware.php @@ -5,8 +5,8 @@ namespace Patchlevel\Hydrator\Extension\Upcast; use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use ReflectionClass; @@ -29,12 +29,13 @@ public function __construct( * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { foreach ($this->upcasters as $index => $upcaster) { $target = $this->targets[$index]; @@ -46,21 +47,22 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ne $data = $upcaster->upcast($metadata, $data, $context); } - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } /** * @param ClassMetadata $metadata * @param T $object * @param array $context + * @param Mapper $next * * @return array * * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - return $next->extract($metadata, $object, $context); + return $next->extract($object, $context); } /** diff --git a/src/Mapper/CallStack.php b/src/Mapper/CallStack.php new file mode 100644 index 00000000..8dc8560e --- /dev/null +++ b/src/Mapper/CallStack.php @@ -0,0 +1,17 @@ + object id => class */ + public array $objects = []; +} diff --git a/src/Mapper/LazyMapper.php b/src/Mapper/LazyMapper.php new file mode 100644 index 00000000..b999d418 --- /dev/null +++ b/src/Mapper/LazyMapper.php @@ -0,0 +1,54 @@ + + */ +final class LazyMapper implements Mapper +{ + /** + * @param ReflectionClass $reflection + * @param Mapper $mapper + */ + public function __construct( + private readonly ReflectionClass $reflection, + private readonly Mapper $mapper, + ) { + } + + /** + * @param array $data + * @param array $context + * + * @return T + */ + public function hydrate(array $data, array $context): object + { + $mapper = $this->mapper; + + return $this->reflection->newLazyProxy( + static fn (): object => $mapper->hydrate($data, $context), + ); + } + + /** + * @param T $object + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context): array + { + return $this->mapper->extract($object, $context); + } +} diff --git a/src/Transformer/ClassTransformer.php b/src/Mapper/Mapper.php similarity index 52% rename from src/Transformer/ClassTransformer.php rename to src/Mapper/Mapper.php index e15577f7..7b1a590a 100644 --- a/src/Transformer/ClassTransformer.php +++ b/src/Mapper/Mapper.php @@ -2,21 +2,28 @@ declare(strict_types=1); -namespace Patchlevel\Hydrator\Transformer; +namespace Patchlevel\Hydrator\Mapper; /** - * Maps the data of one class to an object and back. It runs at the end of the middleware stack, or directly if no - * middleware has to run for the class. + * Maps the data of one class to an object and back. + * + * The hydrator builds one mapper per class, once: the mapper of the class, wrapped in the middlewares which run for the + * class and, if the class is lazy, the lazy proxy. A middleware receives the rest of this chain as its next mapper. + * + * @template T of object */ -interface ClassTransformer +interface Mapper { /** * @param array $data * @param array $context may contain an object to populate, see {@see \Patchlevel\Hydrator\Hydrator::OBJECT_TO_POPULATE} + * + * @return T */ public function hydrate(array $data, array $context): object; /** + * @param T $object * @param array $context * * @return array diff --git a/src/Mapper/MapperFactory.php b/src/Mapper/MapperFactory.php new file mode 100644 index 00000000..8347630a --- /dev/null +++ b/src/Mapper/MapperFactory.php @@ -0,0 +1,30 @@ + $metadata + * + * @return Mapper|null null if this factory is not responsible for the class + * + * @template T of object + */ + public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null; +} diff --git a/src/Mapper/ReflectionMapper.php b/src/Mapper/ReflectionMapper.php new file mode 100644 index 00000000..92ef4e21 --- /dev/null +++ b/src/Mapper/ReflectionMapper.php @@ -0,0 +1,449 @@ + + */ +final class ReflectionMapper implements Mapper +{ + /** @var array|null property name => [nested class, is a list], resolved on the first call */ + private array|null $nested = null; + + /** @var array|null> property name => mapper of the nested class */ + private array $nestedMappers = []; + + /** @param ClassMetadata $metadata */ + public function __construct( + private readonly ClassMetadata $metadata, + private readonly StackHydrator|null $hydrator = null, + private readonly CallStack $callStack = new CallStack(), + ) { + } + + /** + * @param array $data + * @param array $context + * + * @return T + */ + public function hydrate(array $data, array $context): object + { + $metadata = $this->metadata; + + if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { + $object = $context[Hydrator::OBJECT_TO_POPULATE]; + // only unset if it is there, unset() copies the context even if the key does not exist + unset($context[Hydrator::OBJECT_TO_POPULATE]); + } else { + $object = $metadata->newInstance(); + } + + return $this->hydrateProperties($object, $data, $context, $this->nested($context)); + } + + /** + * @param T $object + * @param array $data + * @param array $context + * @param array $nested + * + * @return T + */ + private function hydrateProperties(object $object, array $data, array $context, array $nested): object + { + $metadata = $this->metadata; + $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->reflection->setValue( + $object, + $constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(), + ); + + continue; + } + + if ($propertyMetadata->normalizer) { + try { + /** @psalm-suppress MixedAssignment */ + $value = isset($nested[$propertyMetadata->propertyName]) + ? $this->hydrateNested( + $propertyMetadata->propertyName, + $nested[$propertyMetadata->propertyName], + $propertyMetadata->normalizer, + $data[$propertyMetadata->fieldName], + $context, + ) + : $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure( + $metadata->className, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $value = $data[$propertyMetadata->fieldName]; + } + + try { + $propertyMetadata->reflection->setValue($object, $value); + } catch (TypeError $e) { + throw new TypeMismatch( + $metadata->className, + $propertyMetadata->propertyName, + $e, + ); + } + } + + return $object; + } + + /** + * @param T $object + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context): array + { + return $this->extractProperties($object, $context, $this->nested($context)); + } + + /** + * @param T $object + * @param array $context + * @param array $nested + * + * @return array + */ + private function extractProperties(object $object, array $context, array $nested): array + { + $objectId = spl_object_id($object); + + if (array_key_exists($objectId, $this->callStack->objects)) { + $references = array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$objectId] = $object::class; + + try { + $data = []; + + foreach ($this->metadata->properties as $propertyMetadata) { + if ($propertyMetadata->normalizer) { + try { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = isset($nested[$propertyMetadata->propertyName]) + ? $this->extractNested( + $propertyMetadata->propertyName, + $nested[$propertyMetadata->propertyName], + $propertyMetadata->normalizer, + $propertyMetadata->reflection->getValue($object), + $context, + ) + : $propertyMetadata->normalizer->normalize($propertyMetadata->reflection->getValue($object), $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure( + $object::class, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $data[$propertyMetadata->fieldName] = $propertyMetadata->reflection->getValue($object); + } + } + } finally { + unset($this->callStack->objects[$objectId]); + } + + return $data; + } + + /** + * The nested objects which are mapped with the mappers of their classes right away, only for calls of the hydrator + * which created this mapper. + * + * @param array $context + * + * @return array + */ + private function nested(array $context): array + { + if (($context[Hydrator::HYDRATOR] ?? null) !== $this->hydrator) { + return []; + } + + return $this->nested ??= $this->nestedProperties(); + } + + /** + * Mapping nested objects right away is only a shortcut, the normalizer leads to the same mapper through the + * hydrator. Mutations here keep the result and only take the longer way. + * + * @return array + * + * @infection-ignore-all + */ + private function nestedProperties(): array + { + $nested = []; + + foreach ($this->metadata->properties as $property) { + if ($property->normalizer === null) { + continue; + } + + $class = self::nestedClass($property->normalizer); + + if ($class === null) { + continue; + } + + $nested[$property->propertyName] = $class; + } + + return $nested; + } + + /** + * @return array{class-string, bool}|null [nested class, is a list] + * + * @infection-ignore-all see {@see self::nestedProperties()} + */ + private static function nestedClass(Normalizer $normalizer): array|null + { + $list = false; + + if ($normalizer instanceof ArrayNormalizer) { + $list = true; + $normalizer = $normalizer->innerNormalizer(); + } + + if (!$normalizer instanceof ObjectNormalizer) { + return null; + } + + try { + $className = $normalizer->className(); + } catch (InvalidType) { + return null; + } + + if (!class_exists($className)) { + return null; + } + + return [$className, $list]; + } + + /** + * @param array{class-string, bool} $nested + * @param array $context + * + * @infection-ignore-all see {@see self::nestedProperties()} + */ + private function hydrateNested(string $property, array $nested, Normalizer $normalizer, mixed $value, array $context): mixed + { + // null and wrong types are handled by the normalizer + if (!is_array($value)) { + return $normalizer->denormalize($value, $context); + } + + $mapper = $this->nestedMapper($property, $nested[0]); + + // the class normalizer of the nested class is called by the hydrator + if ($mapper === null) { + return $normalizer->denormalize($value, $context); + } + + if ($mapper instanceof self && $mapper->hydrator === $this->hydrator) { + return $this->hydrateWithReflection($mapper, $nested[1], $normalizer, $value, $context); + } + + if (!$nested[1]) { + return $mapper->hydrate($value, $context); + } + + if (!$normalizer instanceof ArrayNormalizer) { + return $normalizer->denormalize($value, $context); + } + + $inner = $normalizer->innerNormalizer(); + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = is_array($item) ? $mapper->hydrate($item, $context) : $inner->denormalize($item, $context); + } + + return $items; + } + + /** + * @param array{class-string, bool} $nested + * @param array $context + * + * @infection-ignore-all see {@see self::nestedProperties()} + */ + private function extractNested(string $property, array $nested, Normalizer $normalizer, mixed $value, array $context): mixed + { + [$className, $list] = $nested; + + if (!$list) { + // subclasses are extracted with the mapper of their own class by the hydrator + if (!is_object($value) || $value::class !== $className) { + return $normalizer->normalize($value, $context); + } + + $mapper = $this->nestedMapper($property, $className); + + if ($mapper instanceof self && $mapper->hydrator === $this->hydrator) { + return $mapper->extractProperties($value, $context, $mapper->nested ??= $mapper->nestedProperties()); + } + + // the class normalizer of the nested class is called by the hydrator + return $mapper === null + ? $normalizer->normalize($value, $context) + : $mapper->extract($value, $context); + } + + if (!is_array($value) || !$normalizer instanceof ArrayNormalizer) { + return $normalizer->normalize($value, $context); + } + + $inner = $normalizer->innerNormalizer(); + $items = []; + $mapper = $this->nestedMapper($property, $className); + + if ($mapper instanceof self && $mapper->hydrator === $this->hydrator) { + $properties = $mapper->nested ??= $mapper->nestedProperties(); + + foreach ($value as $key => $item) { + $items[$key] = is_object($item) && $item::class === $className + ? $mapper->extractProperties($item, $context, $properties) + : $inner->normalize($item, $context); + } + + return $items; + } + + foreach ($value as $key => $item) { + if (is_object($item) && $item::class === $className) { + if ($mapper !== null) { + $items[$key] = $mapper->extract($item, $context); + + continue; + } + } + + $items[$key] = $inner->normalize($item, $context); + } + + return $items; + } + + /** + * The nested class is mapped with reflection as well: its properties are hydrated right away, without the checks + * of hydrate() this mapper already did. + * + * @param ReflectionMapper $mapper + * @param array $value + * @param array $context + * + * @infection-ignore-all see {@see self::nestedProperties()} + */ + private function hydrateWithReflection(self $mapper, bool $list, Normalizer $normalizer, array $value, array $context): mixed + { + $properties = $mapper->nested ??= $mapper->nestedProperties(); + + if (!$list) { + return $mapper->hydrateProperties($mapper->metadata->newInstance(), $value, $context, $properties); + } + + if (!$normalizer instanceof ArrayNormalizer) { + return $normalizer->denormalize($value, $context); + } + + $inner = $normalizer->innerNormalizer(); + $items = []; + + foreach ($value as $key => $item) { + $items[$key] = is_array($item) + ? $mapper->hydrateProperties($mapper->metadata->newInstance(), $item, $context, $properties) + : $inner->denormalize($item, $context); + } + + return $items; + } + + /** + * @param class-string $className + * + * @return Mapper|null + * + * @infection-ignore-all see {@see self::nestedProperties()} + */ + private function nestedMapper(string $property, string $className): Mapper|null + { + if (array_key_exists($property, $this->nestedMappers)) { + return $this->nestedMappers[$property]; + } + + if ($this->hydrator === null) { + return null; + } + + return $this->nestedMappers[$property] = $this->hydrator->mapper($className); + } +} diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php index 38447707..234e3cf2 100644 --- a/src/Middleware/Middleware.php +++ b/src/Middleware/Middleware.php @@ -4,29 +4,36 @@ namespace Patchlevel\Hydrator\Middleware; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; +/** + * Wraps the mapper of a class. $next is the rest of the chain: the following middlewares and the mapper of the class + * at the end. The chain is built once per class and can be called any number of times. + */ interface Middleware { /** * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object; + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object; /** * @param ClassMetadata $metadata * @param T $object * @param array $context + * @param Mapper $next * * @return array * * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array; + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array; } diff --git a/src/Middleware/MiddlewareMapper.php b/src/Middleware/MiddlewareMapper.php new file mode 100644 index 00000000..234d03ef --- /dev/null +++ b/src/Middleware/MiddlewareMapper.php @@ -0,0 +1,68 @@ + + */ +final class MiddlewareMapper implements Mapper +{ + private readonly bool $hydrate; + + private readonly bool $extract; + + /** + * @param ClassMetadata $metadata + * @param Mapper $next + */ + public function __construct( + private readonly Middleware $middleware, + private readonly ClassMetadata $metadata, + private readonly Mapper $next, + Skip $skip = Skip::None, + ) { + $this->hydrate = $skip !== Skip::Hydrate && $skip !== Skip::Both; + $this->extract = $skip !== Skip::Extract && $skip !== Skip::Both; + } + + /** + * @param array $data + * @param array $context + * + * @return T + */ + public function hydrate(array $data, array $context): object + { + if (!$this->hydrate) { + return $this->next->hydrate($data, $context); + } + + return $this->middleware->hydrate($this->metadata, $data, $context, $this->next); + } + + /** + * @param T $object + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context): array + { + if (!$this->extract) { + return $this->next->extract($object, $context); + } + + return $this->middleware->extract($this->metadata, $object, $context, $this->next); + } +} diff --git a/src/Middleware/Next.php b/src/Middleware/Next.php deleted file mode 100644 index da06ce87..00000000 --- a/src/Middleware/Next.php +++ /dev/null @@ -1,82 +0,0 @@ - $middlewares */ - public function __construct( - private readonly array $middlewares, - private readonly ClassTransformer $transformer, - ) { - } - - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context): object - { - $middleware = $this->middlewares[$this->index] ?? null; - - if ($middleware === null) { - $object = $this->transformer->hydrate($data, $context); - assert($object instanceof $metadata->className); - - return $object; - } - - $this->index++; - - try { - return $middleware->hydrate($metadata, $data, $context, $this); - } finally { - // the position is restored, so a middleware can call the rest of the stack more than once - $this->index--; - } - } - - /** - * @param ClassMetadata $metadata - * @param T $object - * @param array $context - * - * @return array - * - * @template T of object - */ - public function extract(ClassMetadata $metadata, object $object, array $context): array - { - $middleware = $this->middlewares[$this->index] ?? null; - - if ($middleware === null) { - return $this->transformer->extract($object, $context); - } - - $this->index++; - - try { - return $middleware->extract($metadata, $object, $context, $this); - } finally { - $this->index--; - } - } -} diff --git a/src/StackHydrator.php b/src/StackHydrator.php index 0da41ff5..eff80564 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -4,62 +4,62 @@ namespace Patchlevel\Hydrator; +use Patchlevel\Hydrator\Mapper\CallStack; +use Patchlevel\Hydrator\Mapper\LazyMapper; +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\MapperFactory; +use Patchlevel\Hydrator\Mapper\ReflectionMapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; +use Patchlevel\Hydrator\Middleware\MiddlewareMapper; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; -use Patchlevel\Hydrator\Transformer\ClassTransformer; -use Patchlevel\Hydrator\Transformer\ClassTransformerFactory; -use Patchlevel\Hydrator\Transformer\ReflectionTransformerFactory; -use ReflectionClass; use function array_key_exists; +use function array_reverse; use function assert; use function is_array; use const PHP_VERSION_ID; +/** + * Maps every class with a mapper, built once per class: the mapper of a {@see MapperFactory} or the + * {@see ReflectionMapper}, wrapped in the middlewares which run for the class and, if the class is lazy, the lazy + * proxy. A class with a class normalizer has no mapper, the normalizer is called instead. + */ final class StackHydrator implements Hydrator { /** @var array */ private array $classMetadata = []; - /** @var array> */ - private array $hydrateMiddlewares = []; + /** @var array> */ + private array $mappers = []; - /** @var array> */ - private array $extractMiddlewares = []; + private readonly CallStack $callStack; - /** @var array */ - private array $transformers = []; - - private readonly bool $hasSkippableMiddlewares; + /** + * The context of calls without a context, built once: adding the hydrator to an empty context would allocate a new + * array on every call. + * + * @var array + */ + private readonly array $context; /** - * @param list $middlewares run before the data is transformed, in this order - * @param ClassTransformerFactory $transformerFactory provides the transformer which maps the data of a class + * @param list $middlewares wrap the mapper of every class, in this order + * @param list $mapperFactories asked in this order for the mapper of a class, the first mapper wins */ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), private readonly array $middlewares = [], private readonly bool $defaultLazy = false, - private readonly ClassTransformerFactory $transformerFactory = new ReflectionTransformerFactory(), + private readonly array $mapperFactories = [], ) { - $hasSkippableMiddlewares = false; - - foreach ($middlewares as $middleware) { - if ($middleware instanceof SkippableMiddleware) { - $hasSkippableMiddlewares = true; - - break; - } - } - - $this->hasSkippableMiddlewares = $hasSkippableMiddlewares; + $this->callStack = new CallStack(); + $this->context = [self::HYDRATOR => $this]; } /** @@ -72,37 +72,26 @@ public function __construct( */ public function hydrate(string $class, mixed $data, array $context = []): object { - $context[self::HYDRATOR] ??= $this; - - try { - $metadata = $this->metadata($class); - } catch (ClassNotFound $e) { - throw new ClassNotSupported($class, $e); + if ($context === []) { + $context = $this->context; + } else { + $context[self::HYDRATOR] ??= $this; } - if ($metadata->normalizer) { - $return = $metadata->normalizer->denormalize($data, $context); + $mapper = $this->mappers[$class] ?? $this->mapper($class); - if (!$return instanceof $class) { - throw new ObjectRequired($class, $metadata->normalizer::class); - } - - return $return; + if ($mapper === null) { + return $this->denormalize($class, $data, $context); } if (!is_array($data)) { throw new ArrayDataRequired($class); } - $lazy = PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->defaultLazy); - - if (!$lazy) { - return $this->hydrateWithMiddlewares($metadata, $data, $context); - } + $object = $mapper->hydrate($data, $context); + assert($object instanceof $class); - return (new ReflectionClass($class))->newLazyProxy( - fn (): object => $this->hydrateWithMiddlewares($metadata, $data, $context), - ); + return $object; } /** @@ -112,116 +101,137 @@ public function hydrate(string $class, mixed $data, array $context = []): object */ public function extract(object $object, array $context = []): mixed { - $context[self::HYDRATOR] ??= $this; + if ($context === []) { + $context = $this->context; + } else { + $context[self::HYDRATOR] ??= $this; + } - $metadata = $this->metadata($object::class); + $mapper = $this->mappers[$object::class] ?? $this->mapper($object::class); - if ($metadata->normalizer) { - return $metadata->normalizer->normalize($object, $context); + if ($mapper === null) { + return $this->normalize($object, $context); } - $middlewares = $this->middlewaresFor($metadata, Skip::Extract); - $transformer = $this->transformer($metadata); - - // without middlewares the transformer is called without building the stack - return $middlewares === [] - ? $transformer->extract($object, $context) - : (new Next($middlewares, $transformer))->extract($metadata, $object, $context); + return $mapper->extract($object, $context); } /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context + * The mapper the hydrator runs for the class. Calling it behaves exactly like calling the hydrator, as long as the + * context contains this hydrator, see {@see Hydrator::HYDRATOR}. * - * @return T + * @param class-string $class + * + * @return Mapper|null null if the class has a class normalizer, the hydrator calls the normalizer instead + * + * @throws ClassNotSupported if the class is not found. * * @template T of object */ - private function hydrateWithMiddlewares(ClassMetadata $metadata, array $data, array $context): object + public function mapper(string $class): Mapper|null { - $middlewares = $this->middlewaresFor($metadata, Skip::Hydrate); - $transformer = $this->transformer($metadata); + if (array_key_exists($class, $this->mappers)) { + /** @var Mapper $mapper */ + $mapper = $this->mappers[$class]; + + return $mapper; + } + + try { + $metadata = $this->metadata($class); + } catch (ClassNotFound $e) { + throw new ClassNotSupported($class, $e); + } + + if ($metadata->normalizer !== null) { + return null; + } + + $mapper = $this->createMapper($metadata); + + foreach (array_reverse($this->middlewares) as $middleware) { + $skip = $middleware instanceof SkippableMiddleware ? $middleware->skip($metadata) : Skip::None; + + if ($skip === Skip::Both) { + continue; + } - if ($middlewares === []) { - // no middleware runs for this class, the transformer is called without building the stack - $object = $transformer->hydrate($data, $context); - assert($object instanceof $metadata->className); + $mapper = new MiddlewareMapper($middleware, $metadata, $mapper, $skip); + } - return $object; + if (PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->defaultLazy)) { + $mapper = new LazyMapper($metadata->reflection, $mapper); } - return (new Next($middlewares, $transformer))->hydrate($metadata, $data, $context); + return $this->mappers[$class] = $mapper; } /** - * @param ClassMetadata $metadata + * @param class-string $class + * + * @return ClassMetadata + * + * @throws ClassNotFound * * @template T of object */ - private function transformer(ClassMetadata $metadata): ClassTransformer + public function metadata(string $class): ClassMetadata { - return $this->transformers[$metadata->className] - ??= $this->transformerFactory->create($metadata) - ?? throw new ClassNotSupported($metadata->className); + if (array_key_exists($class, $this->classMetadata)) { + return $this->classMetadata[$class]; + } + + return $this->classMetadata[$class] = $this->metadataFactory->metadata($class); } /** - * @param ClassMetadata $metadata - * @param Skip::Hydrate|Skip::Extract $direction + * @param ClassMetadata $metadata * - * @return list + * @return Mapper * * @template T of object */ - private function middlewaresFor(ClassMetadata $metadata, Skip $direction): array + private function createMapper(ClassMetadata $metadata): Mapper { - if (!$this->hasSkippableMiddlewares) { - return $this->middlewares; - } - - $cached = $direction === Skip::Hydrate - ? $this->hydrateMiddlewares[$metadata->className] ?? null - : $this->extractMiddlewares[$metadata->className] ?? null; - - if ($cached !== null) { - return $cached; - } - - $middlewares = []; + foreach ($this->mapperFactories as $factory) { + $mapper = $factory->create($metadata, $this); - foreach ($this->middlewares as $middleware) { - if ($middleware instanceof SkippableMiddleware) { - $skip = $middleware->skip($metadata); - - if ($skip === $direction || $skip === Skip::Both) { - continue; - } + if ($mapper !== null) { + return $mapper; } - - $middlewares[] = $middleware; } - if ($direction === Skip::Hydrate) { - return $this->hydrateMiddlewares[$metadata->className] = $middlewares; - } - - return $this->extractMiddlewares[$metadata->className] = $middlewares; + return new ReflectionMapper($metadata, $this, $this->callStack); } /** - * @param class-string $class + * @param class-string $class + * @param array $context * - * @return ClassMetadata + * @return T * * @template T of object */ - public function metadata(string $class): ClassMetadata + private function denormalize(string $class, mixed $data, array $context): object { - if (array_key_exists($class, $this->classMetadata)) { - return $this->classMetadata[$class]; + $normalizer = $this->metadata($class)->normalizer; + assert($normalizer !== null); + + $object = $normalizer->denormalize($data, $context); + + if (!$object instanceof $class) { + throw new ObjectRequired($class, $normalizer::class); } - return $this->classMetadata[$class] = $this->metadataFactory->metadata($class); + return $object; + } + + /** @param array $context */ + private function normalize(object $object, array $context): mixed + { + $normalizer = $this->metadata($object::class)->normalizer; + assert($normalizer !== null); + + return $normalizer->normalize($object, $context); } } diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php index 4d3240d5..41816756 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -7,15 +7,13 @@ use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; +use Patchlevel\Hydrator\Mapper\MapperFactory; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\EnrichingMetadataFactory; use Patchlevel\Hydrator\Metadata\MetadataEnricher; use Patchlevel\Hydrator\Metadata\Psr16MetadataFactory; use Patchlevel\Hydrator\Metadata\Psr6MetadataFactory; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Transformer\ChainTransformerFactory; -use Patchlevel\Hydrator\Transformer\ClassTransformerFactory; -use Patchlevel\Hydrator\Transformer\ReflectionTransformerFactory; use Psr\Cache\CacheItemPoolInterface; use Psr\SimpleCache\CacheInterface; @@ -35,13 +33,13 @@ final class StackHydratorBuilder /** @var array> */ private array $guessers = []; - /** @var array> */ - private array $transformerFactories = []; + /** @var array> */ + private array $mapperFactories = []; private CacheItemPoolInterface|CacheInterface|null $cache = null; /** @return $this */ - public function addMiddleware(Middleware $middleware, int $priority = Extension::PRIORITY_BEFORE_TRANSFORM): static + public function addMiddleware(Middleware $middleware, int $priority = Extension::PRIORITY_BEFORE_MAPPING): static { $this->middlewares[$priority][] = $middleware; @@ -69,13 +67,14 @@ public function addGuesser(Guesser $guesser, int $priority = 0): static } /** - * Factories with a higher priority are asked first, the {@see ReflectionTransformerFactory} is always asked last. + * Factories with a higher priority are asked first, classes without a mapper of a factory are mapped with the + * {@see ReflectionMapper}. * * @return $this */ - public function addTransformerFactory(ClassTransformerFactory $factory, int $priority = 0): static + public function addMapperFactory(MapperFactory $factory, int $priority = 0): static { - $this->transformerFactories[$priority][] = $factory; + $this->mapperFactories[$priority][] = $factory; return $this; } @@ -118,15 +117,11 @@ public function build(): StackHydrator $metadataFactory = new Psr16MetadataFactory($metadataFactory, $this->cache); } - $transformerFactories = $this->transformerFactories(); - return new StackHydrator( $metadataFactory, $this->middlewares(), $this->defaultLazy, - $transformerFactories === [] - ? new ReflectionTransformerFactory() - : new ChainTransformerFactory([...$transformerFactories, new ReflectionTransformerFactory()]), + $this->mapperFactories(), ); } @@ -151,12 +146,12 @@ public function guessers(): array return array_merge(...$this->guessers); } - /** @return list without the reflection fallback */ - public function transformerFactories(): array + /** @return list */ + public function mapperFactories(): array { - krsort($this->transformerFactories); + krsort($this->mapperFactories); - return array_merge(...$this->transformerFactories); + return array_merge(...$this->mapperFactories); } /** @return list */ diff --git a/src/Transformer/CallStack.php b/src/Transformer/CallStack.php deleted file mode 100644 index b32c3fd8..00000000 --- a/src/Transformer/CallStack.php +++ /dev/null @@ -1,17 +0,0 @@ - object id => class */ - public array $objects = []; -} diff --git a/src/Transformer/ChainTransformerFactory.php b/src/Transformer/ChainTransformerFactory.php deleted file mode 100644 index 2b6d1071..00000000 --- a/src/Transformer/ChainTransformerFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - $factories asked in this order, the first transformer wins */ - public function __construct( - private iterable $factories, - ) { - } - - public function create(ClassMetadata $metadata): ClassTransformer|null - { - foreach ($this->factories as $factory) { - $transformer = $factory->create($metadata); - - if ($transformer !== null) { - return $transformer; - } - } - - return null; - } -} diff --git a/src/Transformer/ClassTransformerFactory.php b/src/Transformer/ClassTransformerFactory.php deleted file mode 100644 index 32c49faf..00000000 --- a/src/Transformer/ClassTransformerFactory.php +++ /dev/null @@ -1,26 +0,0 @@ - $metadata - * - * @return ClassTransformer|null null if this factory is not responsible for the class - * - * @template T of object - */ - public function create(ClassMetadata $metadata): ClassTransformer|null; -} diff --git a/src/Transformer/ReflectionTransformer.php b/src/Transformer/ReflectionTransformer.php deleted file mode 100644 index 9abc5f90..00000000 --- a/src/Transformer/ReflectionTransformer.php +++ /dev/null @@ -1,153 +0,0 @@ - $metadata */ - public function __construct( - private readonly ClassMetadata $metadata, - private readonly CallStack $callStack = new CallStack(), - ) { - } - - /** - * @param array $data - * @param array $context - * - * @return T - */ - public function hydrate(array $data, array $context): object - { - $metadata = $this->metadata; - - if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { - $object = $context[Hydrator::OBJECT_TO_POPULATE]; - // only unset if it is there, unset() copies the context even if the key does not exist - unset($context[Hydrator::OBJECT_TO_POPULATE]); - } else { - $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 { - /** @psalm-suppress MixedAssignment */ - $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); - } 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(object $object, array $context): array - { - $objectId = spl_object_id($object); - - if (array_key_exists($objectId, $this->callStack->objects)) { - $references = array_values($this->callStack->objects); - $references[] = $object::class; - - throw new CircularReference($references); - } - - $this->callStack->objects[$objectId] = $object::class; - - try { - $data = []; - - foreach ($this->metadata->properties as $propertyMetadata) { - if ($propertyMetadata->normalizer) { - try { - /** @psalm-suppress MixedAssignment */ - $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( - $propertyMetadata->getValue($object), - $context, - ); - } 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->objects[$objectId]); - } - - return $data; - } -} diff --git a/src/Transformer/ReflectionTransformerFactory.php b/src/Transformer/ReflectionTransformerFactory.php deleted file mode 100644 index 9d748147..00000000 --- a/src/Transformer/ReflectionTransformerFactory.php +++ /dev/null @@ -1,30 +0,0 @@ -callStack = new CallStack(); - } - - /** - * @param ClassMetadata $metadata - * - * @return ReflectionTransformer - * - * @template T of object - */ - public function create(ClassMetadata $metadata): ReflectionTransformer - { - return new ReflectionTransformer($metadata, $this->callStack); - } -} diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php index 3f9d3105..d18155df 100644 --- a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -12,18 +12,16 @@ use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Extension\Cryptography\SubjectIds; use Patchlevel\Hydrator\Extension\Cryptography\UnsupportedSubjectId; +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\ReflectionMapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreatedFallbackCallback; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; -use Patchlevel\Hydrator\Transformer\ClassTransformer; -use Patchlevel\Hydrator\Transformer\ReflectionTransformer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -42,7 +40,7 @@ public function testUnsupportedSubjectId(): void $this->metadata(SensitiveDataProfileCreated::class), ['id' => null, 'email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreated::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreated::class)), ); } @@ -58,7 +56,7 @@ public function testMissingSubjectId(): void $this->metadata(SensitiveDataProfileCreated::class), ['email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreated::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreated::class)), ); } @@ -77,13 +75,11 @@ public function testSkipEncrypt(): void $metadata = $this->metadata(ProfileCreated::class); - $otherMiddleware = $this->createMock(Middleware::class); - $next = new Next([$otherMiddleware], self::createStub(ClassTransformer::class)); - - $otherMiddleware + $next = $this->createMock(Mapper::class); + $next ->expects($this->once()) ->method('extract') - ->with($metadata, $object, [SubjectIds::class => new SubjectIds()], $next) + ->with($object, [SubjectIds::class => new SubjectIds()]) ->willReturn($expected); $result = $middleware->extract( @@ -105,13 +101,11 @@ public function testEncrypt(): void $metadata = $this->metadata(SensitiveDataProfileCreated::class); - $otherMiddleware = $this->createMock(Middleware::class); - $next = new Next([$otherMiddleware], self::createStub(ClassTransformer::class)); - - $otherMiddleware + $next = $this->createMock(Mapper::class); + $next ->expects($this->once()) ->method('extract') - ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $next) + ->with($object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])]) ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); $cryptographer = $this->createMock(Cryptographer::class); @@ -143,13 +137,11 @@ public function testSkipDecrypt(): void $metadata = $this->metadata(ProfileCreated::class); - $otherMiddleware = $this->createMock(Middleware::class); - $next = new Next([$otherMiddleware], self::createStub(ClassTransformer::class)); - - $otherMiddleware + $next = $this->createMock(Mapper::class); + $next ->expects($this->once()) ->method('hydrate') - ->with($metadata, $data, [SubjectIds::class => new SubjectIds()], $next) + ->with($data, [SubjectIds::class => new SubjectIds()]) ->willReturn($expected); $middleware = new CryptographyMiddleware($cryptographer); @@ -176,7 +168,7 @@ public function testDecryptWithCipherKeyNotExists(): void $this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreated::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreated::class)), ); self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); @@ -196,7 +188,7 @@ public function testDecryptWithDecryptionFailed(): void $this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreated::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreated::class)), ); self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); @@ -216,7 +208,7 @@ public function testDecryptWithFallbackCallback(): void $this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), ['id' => 'foo', 'email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreatedFallbackCallback::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreatedFallbackCallback::class)), ); self::assertInstanceOf(SensitiveDataProfileCreatedFallbackCallback::class, $result); @@ -236,7 +228,7 @@ public function testDecrypt(): void $this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted'], [], - new Next([], new ReflectionTransformer($this->metadata(SensitiveDataProfileCreated::class))), + new ReflectionMapper($this->metadata(SensitiveDataProfileCreated::class)), ); self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); diff --git a/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php index 93746c60..5a8505c5 100644 --- a/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php +++ b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php @@ -6,16 +6,13 @@ use Patchlevel\Hydrator\Extension\Lifecycle\Lifecycle; use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleMiddleware; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle\Fixture\LifecycleFixture; -use Patchlevel\Hydrator\Transformer\ClassTransformer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use stdClass; use function assert; use function is_string; @@ -32,40 +29,16 @@ public function testHydrate(): void postHydrate: 'postHydrate', ); - $innerMiddleware = new class implements Middleware { - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object - { + $next = $this->createMock(Mapper::class); + $next + ->expects($this->once()) + ->method('hydrate') + ->willReturnCallback(static function (array $data): LifecycleFixture { $name = $data['name'] ?? ''; assert(is_string($name)); - $object = new LifecycleFixture($name); - - assert($object instanceof $metadata->className); - - return $object; - } - - /** - * @param array $context - * - * @return array - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array - { - return []; - } - }; - - $next = new Next([$innerMiddleware], self::createStub(ClassTransformer::class)); + return new LifecycleFixture($name); + }); $object = $middleware->hydrate($metadata, ['name' => 'foo'], [], $next); @@ -82,41 +55,15 @@ public function testExtract(): void postExtract: 'postExtract', ); - $innerMiddleware = new class implements Middleware { - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object - { - $object = new stdClass(); - - assert($object instanceof $metadata->className); - - return $object; - } - - /** - * @param array $context - * - * @return array - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array - { - if ($object instanceof LifecycleFixture) { - return ['name' => $object->name]; - } - - return []; - } - }; - - $next = new Next([$innerMiddleware], self::createStub(ClassTransformer::class)); + $next = $this->createMock(Mapper::class); + $next + ->expects($this->once()) + ->method('extract') + ->willReturnCallback(static function (object $object): array { + assert($object instanceof LifecycleFixture); + + return ['name' => $object->name]; + }); $object = new LifecycleFixture('foo'); $data = $middleware->extract($metadata, $object, [], $next); diff --git a/tests/Unit/Extension/Upcast/UpcastExtensionTest.php b/tests/Unit/Extension/Upcast/UpcastExtensionTest.php index 2be67cfb..0037417c 100644 --- a/tests/Unit/Extension/Upcast/UpcastExtensionTest.php +++ b/tests/Unit/Extension/Upcast/UpcastExtensionTest.php @@ -25,7 +25,7 @@ public function testIntegration(): void { $hydrator = (new StackHydratorBuilder()) ->useExtension(new UpcastExtension( - beforeTransform: [ + beforeMapping: [ CallbackUpcaster::forClass( UpcastFixture::class, static function (array $data): array { @@ -54,7 +54,7 @@ public function testConfigureAroundCryptography(): void UpcastFixture::class, static fn (array $data): array => $data, ); - $beforeTransformUpcaster = CallbackUpcaster::forClass( + $beforeMappingUpcaster = CallbackUpcaster::forClass( UpcastFixture::class, static fn (array $data): array => $data, ); @@ -62,7 +62,7 @@ public function testConfigureAroundCryptography(): void $builder = new StackHydratorBuilder(); $builder->useExtension(new UpcastExtension( beforeEncoding: [$beforeEncodingUpcaster], - beforeTransform: [$beforeTransformUpcaster], + beforeMapping: [$beforeMappingUpcaster], )); $builder->useExtension(new CryptographyExtension($this->createMock(Cryptographer::class))); diff --git a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php index 46bf0ed1..177e3a73 100644 --- a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php +++ b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php @@ -8,15 +8,14 @@ use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\Upcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; -use Patchlevel\Hydrator\Transformer\ClassTransformer; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; #[CoversClass(UpcastMiddleware::class)] @@ -48,14 +47,13 @@ static function (array $data): array { $expectedObject = new UpcastFixture('Jane Doe'); - $nextMiddleware = $this->createMock(Middleware::class); - $nextMiddleware->expects(self::once()) + /** @var Mapper&MockObject $next */ + $next = $this->createMock(Mapper::class); + $next->expects(self::once()) ->method('hydrate') - ->with($metadata, ['firstName' => 'Jane', 'lastName' => 'Doe', 'name' => 'Jane Doe'], [], self::isInstanceOf(Next::class)) + ->with(['firstName' => 'Jane', 'lastName' => 'Doe', 'name' => 'Jane Doe'], []) ->willReturn($expectedObject); - $next = new Next([$nextMiddleware], self::createStub(ClassTransformer::class)); - $object = $middleware->hydrate($metadata, ['firstName' => 'Jane', 'lastName' => 'Doe'], [], $next); self::assertSame($expectedObject, $object); @@ -67,14 +65,13 @@ public function testExtract(): void $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); $object = new UpcastFixture('Jane Doe'); - $nextMiddleware = $this->createMock(Middleware::class); - $nextMiddleware->expects(self::once()) + /** @var Mapper&MockObject $next */ + $next = $this->createMock(Mapper::class); + $next->expects(self::once()) ->method('extract') - ->with($metadata, $object, [], self::isInstanceOf(Next::class)) + ->with($object, []) ->willReturn(['name' => 'Jane Doe']); - $next = new Next([$nextMiddleware], self::createStub(ClassTransformer::class)); - self::assertSame(['name' => 'Jane Doe'], $middleware->extract($metadata, $object, [], $next)); } @@ -174,14 +171,13 @@ public function upcast(ClassMetadata $metadata, array $data, array $context): ar $expectedObject = new UpcastFixture('Jane Doe'); - $nextMiddleware = $this->createMock(Middleware::class); - $nextMiddleware->expects(self::once()) + /** @var Mapper&MockObject $next */ + $next = $this->createMock(Mapper::class); + $next->expects(self::once()) ->method('hydrate') - ->with($metadata, ['name' => 'Jane Doe', 'matching' => true], [], self::isInstanceOf(Next::class)) + ->with(['name' => 'Jane Doe', 'matching' => true], []) ->willReturn($expectedObject); - $next = new Next([$nextMiddleware], self::createStub(ClassTransformer::class)); - $object = $middleware->hydrate($metadata, ['name' => 'Jane Doe'], [], $next); self::assertSame($expectedObject, $object); diff --git a/tests/Unit/Fixture/DummyMiddleware.php b/tests/Unit/Fixture/DummyMiddleware.php index 729c7fb9..c8c29d5b 100644 --- a/tests/Unit/Fixture/DummyMiddleware.php +++ b/tests/Unit/Fixture/DummyMiddleware.php @@ -4,9 +4,9 @@ namespace Patchlevel\Hydrator\Tests\Unit\Fixture; +use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; final class DummyMiddleware implements Middleware { @@ -14,23 +14,29 @@ final class DummyMiddleware implements Middleware * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } /** + * @param ClassMetadata $metadata + * @param T $object * @param array $context + * @param Mapper $next * * @return array + * + * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - return $next->extract($metadata, $object, $context); + return $next->extract($object, $context); } } diff --git a/tests/Unit/Fixture/NestedObjectsDto.php b/tests/Unit/Fixture/NestedObjectsDto.php new file mode 100644 index 00000000..ed3230ad --- /dev/null +++ b/tests/Unit/Fixture/NestedObjectsDto.php @@ -0,0 +1,23 @@ +|null $skills */ + public function __construct( + public string $name, + #[ObjectNormalizer(Skill::class)] + public Skill|null $skill = null, + #[ArrayNormalizer(new ObjectNormalizer(Skill::class))] + public array|null $skills = null, + #[ObjectNormalizer(ChildDto::class)] + public ChildDto|null $child = null, + ) { + } +} diff --git a/tests/Unit/Fixture/RecordingMiddleware.php b/tests/Unit/Fixture/RecordingMiddleware.php new file mode 100644 index 00000000..2d86a0ed --- /dev/null +++ b/tests/Unit/Fixture/RecordingMiddleware.php @@ -0,0 +1,54 @@ + $calls */ + public function __construct( + private readonly string $name, + private readonly ArrayObject $calls, + ) { + } + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * @param Mapper $next + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object + { + $this->calls[] = 'hydrate ' . $this->name; + + return $next->hydrate($data, $context); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * @param Mapper $next + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array + { + $this->calls[] = 'extract ' . $this->name; + + return $next->extract($object, $context); + } +} diff --git a/tests/Unit/Fixture/UpperCaseSkillTransformerFactory.php b/tests/Unit/Fixture/UpperCaseSkillMapperFactory.php similarity index 57% rename from tests/Unit/Fixture/UpperCaseSkillTransformerFactory.php rename to tests/Unit/Fixture/UpperCaseSkillMapperFactory.php index 37825b7e..42a03059 100644 --- a/tests/Unit/Fixture/UpperCaseSkillTransformerFactory.php +++ b/tests/Unit/Fixture/UpperCaseSkillMapperFactory.php @@ -4,28 +4,48 @@ namespace Patchlevel\Hydrator\Tests\Unit\Fixture; +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\MapperFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Transformer\ClassTransformer; -use Patchlevel\Hydrator\Transformer\ClassTransformerFactory; +use Patchlevel\Hydrator\StackHydrator; use function assert; use function is_string; use function strtoupper; /** Only responsible for Skill, which it writes in upper case. */ -final class UpperCaseSkillTransformerFactory implements ClassTransformerFactory +final class UpperCaseSkillMapperFactory implements MapperFactory { public int $created = 0; - public function create(ClassMetadata $metadata): ClassTransformer|null + public StackHydrator|null $hydrator = null; + + /** + * @param ClassMetadata $metadata + * + * @return Mapper|null + * + * @template T of object + */ + public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null { if ($metadata->className !== Skill::class) { return null; } $this->created++; + $this->hydrator = $hydrator; + + /** @var Mapper $mapper */ + $mapper = self::mapper(); + + return $mapper; + } - return new class implements ClassTransformer { + /** @return Mapper */ + private static function mapper(): Mapper + { + return new /** @implements Mapper */ class implements Mapper { /** * @param array $data * @param array $context @@ -38,14 +58,13 @@ public function hydrate(array $data, array $context): Skill } /** + * @param Skill $object * @param array $context * * @return array */ public function extract(object $object, array $context): array { - assert($object instanceof Skill); - return ['name' => strtoupper($object->name)]; } }; diff --git a/tests/Unit/Mapper/ReflectionMapperTest.php b/tests/Unit/Mapper/ReflectionMapperTest.php new file mode 100644 index 00000000..1b7cc266 --- /dev/null +++ b/tests/Unit/Mapper/ReflectionMapperTest.php @@ -0,0 +1,276 @@ +metadata(Skill::class)); + + $skill = $mapper->hydrate(['name' => 'php'], []); + + self::assertEquals(new Skill('php'), $skill); + self::assertSame(['name' => 'php'], $mapper->extract($skill, [])); + } + + public function testHydrateObjectToPopulate(): void + { + $mapper = new ReflectionMapper((new AttributeMetadataFactory())->metadata(Skill::class)); + $skill = new Skill('php'); + + $result = $mapper->hydrate(['name' => 'symfony'], [Hydrator::OBJECT_TO_POPULATE => $skill]); + + self::assertSame($skill, $result); + self::assertSame('symfony', $skill->name); + } + + public function testHydrateNestedObjectsWithTheMapperOfTheirClass(): void + { + $skill = new Skill('php'); + + $skillMapper = $this->createMock(Mapper::class); + $skillMapper->expects($this->exactly(4)) + ->method('hydrate') + ->with(['name' => 'php'], $this->anything()) + ->willReturn($skill); + + $hydrator = self::hydrator($skillMapper); + $mapper = self::mapper($hydrator); + $context = [Hydrator::HYDRATOR => $hydrator]; + $data = ['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => [['name' => 'php'], null]]; + + self::assertEquals(new NestedObjectsDto('foo', $skill, [$skill, null]), $mapper->hydrate($data, $context)); + self::assertEquals(new NestedObjectsDto('foo', $skill, [$skill, null]), $mapper->hydrate($data, $context)); + } + + public function testHydrateNullWithTheNormalizer(): void + { + $skillMapper = $this->createMock(Mapper::class); + $skillMapper->expects($this->never())->method('hydrate'); + + $hydrator = self::hydrator($skillMapper); + + $object = self::mapper($hydrator)->hydrate( + ['name' => 'foo', 'skill' => null, 'skills' => null], + [Hydrator::HYDRATOR => $hydrator], + ); + + self::assertEquals(new NestedObjectsDto('foo'), $object); + } + + public function testExtractNestedObjectsWithTheMapperOfTheirClass(): void + { + $skill = new Skill('php'); + + $skillMapper = $this->createMock(Mapper::class); + $skillMapper->expects($this->exactly(4)) + ->method('extract') + ->with($skill, $this->anything()) + ->willReturn(['name' => 'PHP']); + + $hydrator = self::hydrator($skillMapper); + $mapper = self::mapper($hydrator); + $context = [Hydrator::HYDRATOR => $hydrator]; + $object = new NestedObjectsDto('foo', $skill, [$skill, null]); + $expected = ['name' => 'foo', 'skill' => ['name' => 'PHP'], 'skills' => [['name' => 'PHP'], null], 'child' => null]; + + self::assertSame($expected, $mapper->extract($object, $context)); + self::assertSame($expected, $mapper->extract($object, $context)); + } + + public function testExtractSubclassesWithTheHydrator(): void + { + $hydrator = new StackHydrator(); + $child = new ParentDto(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')); + + self::assertSame( + [ + 'name' => 'foo', + 'skill' => null, + 'skills' => null, + 'child' => ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ], + self::mapper($hydrator)->extract(new NestedObjectsDto('foo', child: $child), [Hydrator::HYDRATOR => $hydrator]), + ); + } + + public function testNestedObjectsOfAnotherHydratorUseTheNormalizer(): void + { + $skill = new Skill('php'); + $other = $this->createMock(Hydrator::class); + $context = [Hydrator::HYDRATOR => $other]; + $other->expects($this->exactly(2)) + ->method('hydrate') + ->with(Skill::class, ['name' => 'php'], $context) + ->willReturn($skill); + $other->expects($this->exactly(2)) + ->method('extract') + ->with($skill, $context) + ->willReturn(['name' => 'php']); + + $skillMapper = $this->createMock(Mapper::class); + $skillMapper->expects($this->never())->method('hydrate'); + $skillMapper->expects($this->never())->method('extract'); + + $mapper = self::mapper(self::hydrator($skillMapper)); + $object = $mapper->hydrate(['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => [['name' => 'php']]], $context); + + self::assertEquals(new NestedObjectsDto('foo', $skill, [$skill]), $object); + self::assertSame( + ['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => [['name' => 'php']], 'child' => null], + $mapper->extract($object, $context), + ); + } + + public function testNestedClassesWithAClassNormalizerUseTheNormalizer(): void + { + $classNormalizer = $this->createMock(Normalizer::class); + $classNormalizer->expects($this->exactly(2)) + ->method('denormalize') + ->willReturnCallback(static fn (array $data): Skill => new Skill('normalized')); + $classNormalizer->expects($this->exactly(2)) + ->method('normalize') + ->willReturn(['name' => 'normalized']); + + $metadataFactory = new class ($classNormalizer) implements MetadataFactory { + public function __construct( + private readonly Normalizer $classNormalizer, + ) { + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + public function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + + if ($class === Skill::class) { + $metadata->normalizer = $this->classNormalizer; + } + + return $metadata; + } + }; + + $hydrator = new StackHydrator($metadataFactory); + $mapper = self::mapper($hydrator); + $context = [Hydrator::HYDRATOR => $hydrator]; + $object = $mapper->hydrate(['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => [['name' => 'php']]], $context); + + self::assertEquals(new NestedObjectsDto('foo', new Skill('normalized'), [new Skill('normalized')]), $object); + self::assertSame( + ['name' => 'foo', 'skill' => ['name' => 'normalized'], 'skills' => [['name' => 'normalized']], 'child' => null], + $mapper->extract($object, $context), + ); + } + + public function testWithoutHydratorTheNormalizerIsUsed(): void + { + $skill = new Skill('php'); + $hydrator = $this->createMock(Hydrator::class); + $context = [Hydrator::HYDRATOR => $hydrator]; + $hydrator->expects($this->once()) + ->method('hydrate') + ->with(Skill::class, ['name' => 'php'], $context) + ->willReturn($skill); + $hydrator->expects($this->once()) + ->method('extract') + ->with($skill, $context) + ->willReturn(['name' => 'php']); + + $mapper = new ReflectionMapper((new AttributeMetadataFactory())->metadata(NestedObjectsDto::class)); + $object = $mapper->hydrate(['name' => 'foo', 'skill' => ['name' => 'php']], $context); + + self::assertEquals(new NestedObjectsDto('foo', $skill), $object); + self::assertSame( + ['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => null, 'child' => null], + $mapper->extract($object, $context), + ); + } + + public function testObjectCanBeExtractedAgainAfterAFailure(): void + { + $hydrator = $this->createStub(Hydrator::class); + $hydrator->method('extract')->willThrowException(new RuntimeException('failed')); + $context = [Hydrator::HYDRATOR => $hydrator]; + + $mapper = new ReflectionMapper((new AttributeMetadataFactory())->metadata(NestedObjectsDto::class)); + $object = new NestedObjectsDto('foo', new Skill('php')); + + try { + $mapper->extract($object, $context); + self::fail('the extraction has to fail'); + } catch (NormalizationFailure) { + } + + // the object is no longer on the call stack, so it is no circular reference + $this->expectException(NormalizationFailure::class); + $mapper->extract($object, $context); + } + + /** @return ReflectionMapper */ + private static function mapper(StackHydrator $hydrator): ReflectionMapper + { + return new ReflectionMapper( + (new AttributeMetadataFactory())->metadata(NestedObjectsDto::class), + $hydrator, + ); + } + + /** @param Mapper $skillMapper */ + private static function hydrator(Mapper $skillMapper): StackHydrator + { + return new StackHydrator(mapperFactories: [ + new class ($skillMapper) implements MapperFactory { + /** @param Mapper $skillMapper */ + public function __construct( + private readonly Mapper $skillMapper, + ) { + } + + /** + * @param ClassMetadata $metadata + * + * @return Mapper|null + * + * @template T of object + */ + public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null + { + /** @var Mapper|null $mapper */ + $mapper = $metadata->className === Skill::class ? $this->skillMapper : null; + + return $mapper; + } + }, + ]); + } +} diff --git a/tests/Unit/Middleware/MiddlewareMapperTest.php b/tests/Unit/Middleware/MiddlewareMapperTest.php new file mode 100644 index 00000000..3fbdd3ce --- /dev/null +++ b/tests/Unit/Middleware/MiddlewareMapperTest.php @@ -0,0 +1,152 @@ +hydrate(['name' => 'php'], []); + $data = $mapper->extract($skill, []); + + self::assertEquals(new Skill('php'), $skill); + self::assertSame(['name' => 'php'], $data); + self::assertSame(['hydrate first', 'hydrate second', 'extract first', 'extract second'], $calls->getArrayCopy()); + } + + public function testMiddlewareGetsTheMetadataAndTheNextMapper(): void + { + $metadata = self::metadata(); + $next = self::mapper(); + $skill = new Skill('php'); + + $middleware = $this->createMock(Middleware::class); + $middleware->expects($this->once())->method('hydrate') + ->with($metadata, ['name' => 'php'], ['foo' => 'bar'], $next) + ->willReturn($skill); + $middleware->expects($this->once())->method('extract') + ->with($metadata, $skill, ['foo' => 'bar'], $next) + ->willReturn(['name' => 'php']); + + $mapper = new MiddlewareMapper($middleware, $metadata, $next); + + self::assertSame($skill, $mapper->hydrate(['name' => 'php'], ['foo' => 'bar'])); + self::assertSame(['name' => 'php'], $mapper->extract($skill, ['foo' => 'bar'])); + } + + public function testRestOfTheChainCanBeCalledTwice(): void + { + $calls = self::calls(); + $twice = new class implements Middleware { + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * @param Mapper $next + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object + { + $next->hydrate($data, $context); + + return $next->hydrate($data, $context); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * @param Mapper $next + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array + { + $next->extract($object, $context); + + return $next->extract($object, $context); + } + }; + + $mapper = new MiddlewareMapper( + $twice, + self::metadata(), + new MiddlewareMapper(new RecordingMiddleware('inner', $calls), self::metadata(), self::mapper()), + ); + + $skill = $mapper->hydrate(['name' => 'php'], []); + $mapper->extract($skill, []); + + self::assertSame(['hydrate inner', 'hydrate inner', 'extract inner', 'extract inner'], $calls->getArrayCopy()); + } + + /** @param list $expected */ + #[DataProvider('skipProvider')] + public function testSkippedDirectionCallsTheNextMapperDirectly(Skip $skip, array $expected): void + { + $calls = self::calls(); + $mapper = new MiddlewareMapper(new RecordingMiddleware('middleware', $calls), self::metadata(), self::mapper(), $skip); + + $skill = $mapper->hydrate(['name' => 'php'], []); + + self::assertEquals(new Skill('php'), $skill); + self::assertSame(['name' => 'php'], $mapper->extract($skill, [])); + self::assertSame($expected, $calls->getArrayCopy()); + } + + /** @return iterable}> */ + public static function skipProvider(): iterable + { + yield 'none' => [Skip::None, ['hydrate middleware', 'extract middleware']]; + yield 'hydrate' => [Skip::Hydrate, ['extract middleware']]; + yield 'extract' => [Skip::Extract, ['hydrate middleware']]; + yield 'both' => [Skip::Both, []]; + } + + /** @return ReflectionMapper */ + private static function mapper(): ReflectionMapper + { + return new ReflectionMapper(self::metadata()); + } + + /** @return ArrayObject */ + private static function calls(): ArrayObject + { + return new ArrayObject(); + } + + /** @return ClassMetadata */ + private static function metadata(): ClassMetadata + { + return (new AttributeMetadataFactory())->metadata(Skill::class); + } +} diff --git a/tests/Unit/Middleware/NextTest.php b/tests/Unit/Middleware/NextTest.php deleted file mode 100644 index 41120c07..00000000 --- a/tests/Unit/Middleware/NextTest.php +++ /dev/null @@ -1,209 +0,0 @@ -hydrate(self::metadata(), ['name' => 'php'], []); - $data = $next->extract(self::metadata(), $skill, []); - - self::assertEquals(new Skill('php'), $skill); - self::assertSame(['name' => 'php'], $data); - self::assertSame(['hydrate first', 'hydrate second', 'extract first', 'extract second'], $calls->getArrayCopy()); - } - - public function testRestOfTheStackCanBeCalledTwice(): void - { - $calls = self::calls(); - $twice = new class implements Middleware { - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object - { - $next->hydrate($metadata, $data, $context); - - return $next->hydrate($metadata, $data, $context); - } - - /** - * @param array $context - * - * @return array - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array - { - $next->extract($metadata, $object, $context); - - return $next->extract($metadata, $object, $context); - } - }; - - $next = new Next([$twice, self::recording('inner', $calls)], self::transformer()); - - $skill = $next->hydrate(self::metadata(), ['name' => 'php'], []); - $next->extract(self::metadata(), $skill, []); - - self::assertSame(['hydrate inner', 'hydrate inner', 'extract inner', 'extract inner'], $calls->getArrayCopy()); - } - - public function testPositionIsRestoredAfterAnException(): void - { - $calls = self::calls(); - $failing = new class implements Middleware { - public bool $fail = true; - - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object - { - if ($this->fail) { - throw new RuntimeException('failed'); - } - - return $next->hydrate($metadata, $data, $context); - } - - /** - * @param array $context - * - * @return array - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array - { - if ($this->fail) { - throw new RuntimeException('failed'); - } - - return $next->extract($metadata, $object, $context); - } - }; - - $next = new Next([self::recording('outer', $calls), $failing], self::transformer()); - - try { - $next->hydrate(self::metadata(), ['name' => 'php'], []); - self::fail('the middleware has to fail'); - } catch (RuntimeException) { - } - - try { - $next->extract(self::metadata(), new Skill('php'), []); - self::fail('the middleware has to fail'); - } catch (RuntimeException) { - } - - $failing->fail = false; - - self::assertEquals(new Skill('php'), $next->hydrate(self::metadata(), ['name' => 'php'], [])); - self::assertSame(['name' => 'php'], $next->extract(self::metadata(), new Skill('php'), [])); - self::assertSame(['hydrate outer', 'extract outer', 'hydrate outer', 'extract outer'], $calls->getArrayCopy()); - } - - public function testWithoutMiddlewaresOnlyTheTransformerRuns(): void - { - $next = new Next([], self::transformer()); - - $skill = $next->hydrate(self::metadata(), ['name' => 'php'], []); - - self::assertEquals(new Skill('php'), $skill); - self::assertSame(['name' => 'php'], $next->extract(self::metadata(), $skill, [])); - } - - /** @return ReflectionTransformer */ - private static function transformer(): ReflectionTransformer - { - return new ReflectionTransformer(self::metadata()); - } - - /** @return ArrayObject */ - private static function calls(): ArrayObject - { - return new ArrayObject(); - } - - /** @return ClassMetadata */ - private static function metadata(): ClassMetadata - { - return (new AttributeMetadataFactory())->metadata(Skill::class); - } - - /** @param ArrayObject $calls */ - private static function recording(string $name, ArrayObject $calls): Middleware - { - return new class ($name, $calls) implements Middleware { - /** @param ArrayObject $calls */ - public function __construct( - private readonly string $name, - private readonly ArrayObject $calls, - ) { - } - - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object - { - $this->calls[] = 'hydrate ' . $this->name; - - return $next->hydrate($metadata, $data, $context); - } - - /** - * @param array $context - * - * @return array - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array - { - $this->calls[] = 'extract ' . $this->name; - - return $next->extract($metadata, $object, $context); - } - }; - } -} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 473c9506..99077e34 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -8,6 +8,7 @@ use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; +use Patchlevel\Hydrator\Mapper\MapperFactory; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\EnrichingMetadataFactory; use Patchlevel\Hydrator\Metadata\MetadataEnricher; @@ -18,9 +19,6 @@ use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; -use Patchlevel\Hydrator\Transformer\ChainTransformerFactory; -use Patchlevel\Hydrator\Transformer\ClassTransformerFactory; -use Patchlevel\Hydrator\Transformer\ReflectionTransformerFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemPoolInterface; @@ -166,29 +164,26 @@ public function testCachePsr16(): void self::assertInstanceOf(Psr16MetadataFactory::class, $factory); } - public function testAddTransformerFactoryWithPriority(): void + public function testAddMapperFactoryWithPriority(): void { - $factory1 = $this->createMock(ClassTransformerFactory::class); - $factory2 = $this->createMock(ClassTransformerFactory::class); + $factory1 = $this->createMock(MapperFactory::class); + $factory2 = $this->createMock(MapperFactory::class); $builder = new StackHydratorBuilder(); - $builder->addTransformerFactory($factory1, 10); - $builder->addTransformerFactory($factory2, 20); + $builder->addMapperFactory($factory1, 10); + $builder->addMapperFactory($factory2, 20); - $reflection = new ReflectionProperty(StackHydrator::class, 'transformerFactory'); + $reflection = new ReflectionProperty(StackHydrator::class, 'mapperFactories'); - self::assertSame([$factory2, $factory1], $builder->transformerFactories()); - self::assertEquals( - new ChainTransformerFactory([$factory2, $factory1, new ReflectionTransformerFactory()]), - $reflection->getValue($builder->build()), - ); + self::assertSame([$factory2, $factory1], $builder->mapperFactories()); + self::assertSame([$factory2, $factory1], $reflection->getValue($builder->build())); } - public function testReflectionWithoutTransformerFactories(): void + public function testWithoutMapperFactories(): void { - $reflection = new ReflectionProperty(StackHydrator::class, 'transformerFactory'); + $reflection = new ReflectionProperty(StackHydrator::class, 'mapperFactories'); - self::assertInstanceOf(ReflectionTransformerFactory::class, $reflection->getValue((new StackHydratorBuilder())->build())); + self::assertSame([], $reflection->getValue((new StackHydratorBuilder())->build())); } public function testBuiltInGuesserWithoutRegistration(): void @@ -206,17 +201,17 @@ public function testBuiltInGuesserWithoutRegistration(): void self::assertSame(Status::Draft, $object->status); } - public function testTransformerFactoryDefaultPriority(): void + public function testMapperFactoryDefaultPriority(): void { - $low = $this->createMock(ClassTransformerFactory::class); - $default = $this->createMock(ClassTransformerFactory::class); - $high = $this->createMock(ClassTransformerFactory::class); + $low = $this->createMock(MapperFactory::class); + $default = $this->createMock(MapperFactory::class); + $high = $this->createMock(MapperFactory::class); $builder = new StackHydratorBuilder(); - $builder->addTransformerFactory($low, -1); - $builder->addTransformerFactory($default); - $builder->addTransformerFactory($high, 1); + $builder->addMapperFactory($low, -1); + $builder->addMapperFactory($default); + $builder->addMapperFactory($high, 1); - self::assertSame([$high, $default, $low], $builder->transformerFactories()); + self::assertSame([$high, $default, $low], $builder->mapperFactories()); } } diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 74903b16..318dbed5 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Unit; +use ArrayObject; use DateTime; use DateTimeImmutable; use DateTimeZone; @@ -12,10 +13,13 @@ use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Mapper\LazyMapper; +use Patchlevel\Hydrator\Mapper\Mapper; +use Patchlevel\Hydrator\Mapper\ReflectionMapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Next; +use Patchlevel\Hydrator\Middleware\MiddlewareMapper; use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\NormalizationFailure; @@ -32,6 +36,7 @@ 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\NestedObjectsDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; @@ -39,15 +44,13 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; +use Patchlevel\Hydrator\Tests\Unit\Fixture\RecordingMiddleware; use Patchlevel\Hydrator\Tests\Unit\Fixture\Skill; use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; -use Patchlevel\Hydrator\Tests\Unit\Fixture\UpperCaseSkillTransformerFactory; +use Patchlevel\Hydrator\Tests\Unit\Fixture\UpperCaseSkillMapperFactory; use Patchlevel\Hydrator\Tests\Unit\Fixture\ValueObject; use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer; -use Patchlevel\Hydrator\Transformer\ChainTransformerFactory; -use Patchlevel\Hydrator\Transformer\ReflectionTransformer; -use Patchlevel\Hydrator\Transformer\ReflectionTransformerFactory; use Patchlevel\Hydrator\TypeMismatch; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -55,9 +58,9 @@ use ReflectionClass; #[CoversClass(StackHydrator::class)] -#[CoversClass(ReflectionTransformer::class)] -#[CoversClass(ReflectionTransformerFactory::class)] -#[CoversClass(ChainTransformerFactory::class)] +#[CoversClass(ReflectionMapper::class)] +#[CoversClass(MiddlewareMapper::class)] +#[CoversClass(LazyMapper::class)] final class StackHydratorTest extends TestCase { private StackHydrator $hydrator; @@ -202,7 +205,7 @@ public function testExtractWithContext(): void $object, $this->callback(static fn (array $context): bool => $context['context'] === '123' && $context[Hydrator::HYDRATOR] instanceof StackHydrator), - $this->isInstanceOf(Next::class), + $this->isInstanceOf(Mapper::class), )->willReturn($expect); $hydrator = (new StackHydratorBuilder()) @@ -368,7 +371,7 @@ public function testHydrateWithContext(): void $data, $this->callback(static fn (array $context): bool => $context['context'] === '123' && $context[Hydrator::HYDRATOR] instanceof StackHydrator), - $this->isInstanceOf(Next::class), + $this->isInstanceOf(Mapper::class), )->willReturn($expect); $hydrator = (new StackHydratorBuilder()) @@ -635,26 +638,32 @@ public function testHydratorIsPassedInContext(): void * @param ClassMetadata $metadata * @param array $data * @param array $context + * @param Mapper $next * * @return T * * @template T of object */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object + public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { $this->contexts[] = $context; - return $next->hydrate($metadata, $data, $context); + return $next->hydrate($data, $context); } /** + * @param ClassMetadata $metadata + * @param T $object * @param array $context + * @param Mapper $next * * @return array + * + * @template T of object */ - public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array + public function extract(ClassMetadata $metadata, object $object, array $context, Mapper $next): array { - return $next->extract($metadata, $object, $context); + return $next->extract($object, $context); } }; @@ -855,30 +864,143 @@ public function testAllMiddlewaresSkipped(): void self::assertSame(['profileId' => '1', 'email' => 'info@patchlevel.de'], $hydrator->extract($event)); } - public function testTransformerFactory(): void + public function testMapperFactory(): void { - $factory = new UpperCaseSkillTransformerFactory(); - $hydrator = new StackHydrator( - transformerFactory: new ChainTransformerFactory([$factory, new ReflectionTransformerFactory()]), - ); + $factory = new UpperCaseSkillMapperFactory(); + $hydrator = new StackHydrator(mapperFactories: [$factory]); self::assertEquals(new Skill('PHP'), $hydrator->hydrate(Skill::class, ['name' => 'php'])); self::assertSame(['name' => 'PHP'], $hydrator->extract(new Skill('php'))); self::assertSame(1, $factory->created); + self::assertSame($hydrator, $factory->hydrator); - // other classes are left to the next factory + // other classes are mapped with reflection self::assertEquals( new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']), ); } - public function testNoTransformerForClass(): void + public function testFirstMapperFactoryWins(): void + { + $first = new UpperCaseSkillMapperFactory(); + $second = new UpperCaseSkillMapperFactory(); + $hydrator = new StackHydrator(mapperFactories: [$first, $second]); + + $hydrator->hydrate(Skill::class, ['name' => 'php']); + + self::assertSame(1, $first->created); + self::assertSame(0, $second->created); + } + + public function testNestedObjectsAreMappedWithTheMapperOfTheirClass(): void + { + $hydrator = new StackHydrator(mapperFactories: [new UpperCaseSkillMapperFactory()]); + + $object = $hydrator->hydrate( + NestedObjectsDto::class, + ['name' => 'foo', 'skill' => ['name' => 'php'], 'skills' => [['name' => 'symfony']]], + ); + + self::assertEquals(new NestedObjectsDto('foo', new Skill('PHP'), [new Skill('SYMFONY')]), $object); + self::assertSame( + ['name' => 'foo', 'skill' => ['name' => 'PHP'], 'skills' => [['name' => 'SYMFONY']], 'child' => null], + $hydrator->extract($object), + ); + } + + public function testMapper(): void + { + $hydrator = new StackHydrator(); + + $mapper = $hydrator->mapper(Skill::class); + + self::assertInstanceOf(ReflectionMapper::class, $mapper); + self::assertSame($mapper, $hydrator->mapper(Skill::class)); + self::assertEquals(new Skill('php'), $mapper->hydrate(['name' => 'php'], [])); + } + + public function testMapperIsWrappedInTheMiddlewares(): void + { + $hydrator = new StackHydrator(middlewares: [new DummyMiddleware()]); + + self::assertInstanceOf(MiddlewareMapper::class, $hydrator->mapper(Skill::class)); + } + + #[RequiresPhp('>=8.4')] + public function testMapperOfALazyClass(): void { - $hydrator = new StackHydrator(transformerFactory: new UpperCaseSkillTransformerFactory()); + $hydrator = new StackHydrator(middlewares: [new DummyMiddleware()]); + self::assertInstanceOf(LazyMapper::class, $hydrator->mapper(LazyProfileCreated::class)); + } + + public function testNoMapperForAClassWithAClassNormalizer(): void + { + self::assertNull((new StackHydrator())->mapper(ProfileId::class)); + } + + public function testMapperOfAnUnknownClass(): void + { $this->expectException(ClassNotSupported::class); - $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']); + /** @phpstan-ignore argument.type */ + (new StackHydrator())->mapper('Unknown'); + } + + public function testMapperIsBuiltOncePerClass(): void + { + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware->expects($this->once())->method('skip')->willReturn(Skip::Both); + + $factory = new UpperCaseSkillMapperFactory(); + $hydrator = new StackHydrator(middlewares: [$middleware], mapperFactories: [$factory]); + + $skill = $hydrator->hydrate(Skill::class, ['name' => 'php']); + $hydrator->hydrate(Skill::class, ['name' => 'php']); + $hydrator->extract($skill); + $hydrator->extract($skill); + + self::assertSame(1, $factory->created); + } + + public function testHydrateWithArrayDataRequiredAndMiddlewares(): void + { + $middleware = $this->createMock(Middleware::class); + $middleware->expects($this->never())->method('hydrate'); + + $hydrator = new StackHydrator(middlewares: [$middleware]); + + $this->expectException(ArrayDataRequired::class); + + $hydrator->hydrate(ProfileCreated::class, 'foo'); + } + + public function testMiddlewaresRunInTheirOrder(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $hydrator = new StackHydrator(middlewares: [ + new RecordingMiddleware('first', $calls), + new RecordingMiddleware('second', $calls), + ]); + + $hydrator->extract($hydrator->hydrate(Skill::class, ['name' => 'php'])); + + self::assertSame(['hydrate first', 'hydrate second', 'extract first', 'extract second'], $calls->getArrayCopy()); + } + + public function testMiddlewareAfterASkippedOneStillRuns(): void + { + $skipped = $this->createMock(SkippableMiddleware::class); + $skipped->method('skip')->willReturn(Skip::Both); + + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $hydrator = new StackHydrator(middlewares: [new RecordingMiddleware('first', $calls), $skipped]); + + $hydrator->extract($hydrator->hydrate(Skill::class, ['name' => 'php'])); + + self::assertSame(['hydrate first', 'extract first'], $calls->getArrayCopy()); } } diff --git a/tests/Unit/Transformer/ReflectionTransformerTest.php b/tests/Unit/Transformer/ReflectionTransformerTest.php deleted file mode 100644 index 80fd77f1..00000000 --- a/tests/Unit/Transformer/ReflectionTransformerTest.php +++ /dev/null @@ -1,47 +0,0 @@ -metadata(Skill::class)); - - $skill = $transformer->hydrate(['name' => 'php'], []); - - self::assertEquals(new Skill('php'), $skill); - self::assertSame(['name' => 'php'], $transformer->extract($skill, [])); - } - - public function testHydrateObjectToPopulate(): void - { - $transformer = new ReflectionTransformer((new AttributeMetadataFactory())->metadata(Skill::class)); - $skill = new Skill('php'); - - $result = $transformer->hydrate(['name' => 'symfony'], [Hydrator::OBJECT_TO_POPULATE => $skill]); - - self::assertSame($skill, $result); - self::assertSame('symfony', $skill->name); - } - - public function testFactoryCreatesReflectionTransformer(): void - { - $factory = new ReflectionTransformerFactory(); - $transformer = $factory->create((new AttributeMetadataFactory())->metadata(Skill::class)); - - self::assertEquals(new Skill('php'), $transformer->hydrate(['name' => 'php'], [])); - } -}