diff --git a/composer.json b/composer.json index 0f796930..eb5eacb0 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,9 @@ "psr/simple-cache": "^2.0.0 || ^3.0.0", "symfony/type-info": "^7.3.0 || ^8.0.0" }, + "suggest": { + "symfony/stopwatch": "To trace the hydrator with the StopwatchTracer of the TracingExtension" + }, "require-dev": { "infection/infection": "^0.32.4", "patchlevel/coding-standard": "^1.3.0", @@ -38,6 +41,7 @@ "phpstan/phpstan-phpunit": "^2.0.15", "phpunit/phpunit": "^11.5.53", "symfony/cache": "^6.4.0 || ^7.0.0 || ^8.0.0", + "symfony/stopwatch": "^6.4.0 || ^7.0.0 || ^8.0.0", "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", "wnx/commonmark-markdown-renderer": "^1.6" }, diff --git a/composer.lock b/composer.lock index 28ace722..45f73a7c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8325c0e87a9d0da748dae8c076093197", + "content-hash": "3f5d6f0b3983f5a6dc13733b31c962b9", "packages": [ { "name": "psr/cache", @@ -5875,6 +5875,72 @@ ], "time": "2026-07-27T15:39:01+00:00" }, + { + "name": "symfony/stopwatch", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "21c07b026905d596e8379caeb115d87aa479499d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/string", "version": "v8.1.2", diff --git a/docs/caching.md b/docs/caching.md index 9ebb04b5..3eb794c4 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -20,7 +20,7 @@ use Symfony\Component\Cache\Adapter\FilesystemAdapter; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->setCache(new FilesystemAdapter()) - ->build(); + ->buildHydrator(); ``` :::note Internally the builder wraps the metadata factory in a `Psr6MetadataFactory` or diff --git a/docs/cryptography.md b/docs/cryptography.md index 20187dac..ea475ef5 100644 --- a/docs/cryptography.md +++ b/docs/cryptography.md @@ -27,7 +27,7 @@ $cipherKeyStore = new InMemoryCipherKeyStore(); $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) - ->build(); + ->buildHydrator(); ``` ## DataSubjectId diff --git a/docs/extensions.md b/docs/extensions.md index 9cd0f7f1..5e4af2e8 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -19,11 +19,11 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new LifecycleExtension()) - ->build(); + ->buildHydrator(); ``` ## Built-in extensions -The library ships with four extensions out of the box: +The library ships with five extensions out of the box: | Extension | Purpose | | --- | --- | @@ -31,6 +31,7 @@ The library ships with four extensions out of the box: | `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | | `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | | `UpcastExtension` | [Upcasting](upcasting.md), reshape outdated stored data while it is hydrated. | +| `TracingExtension` | [Tracing](tracing.md), measure every hydrate and extract call. | ## Middleware @@ -152,6 +153,45 @@ extras) can be [cached](caching.md), everything you store in `extras` must be serializable. ::: +## Decorators + +A decorator wraps the whole hydrator instead of a single step inside the +stack. Use it for things which concern the call as a whole, like +[tracing](tracing.md) or logging. A decorator implements `HydratorDecorator` +and returns a new `Hydrator` around the given one. It also receives the +innermost `StackHydrator`, in case it needs its metadata. + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorDecorator; +use Patchlevel\Hydrator\StackHydrator; + +final class AuditDecorator implements HydratorDecorator +{ + public function __construct( + private readonly AuditLog $auditLog, + ) { + } + + public function decorate(Hydrator $hydrator, StackHydrator $stack): Hydrator + { + return new AuditHydrator($hydrator, $this->auditLog); + } +} +``` +```php +$builder->addDecorator(new AuditDecorator($auditLog)); +``` +Decorators also accept a priority, a decorator with a higher priority is +wrapped around the ones with a lower priority. The default is `0`, the +decorator of the [tracing](tracing.md) extension has priority `32`, so it also +measures the decorators below it. + +:::warning +Decorators are only applied by `buildHydrator()`. The deprecated `build()` +throws a `DecoratorsNotApplied` exception as soon as a decorator is registered. +::: + ## Writing your own extension An extension implements the `Extension` interface and configures the builder. @@ -175,4 +215,4 @@ final class AuditExtension implements Extension * [How to run code before extract and after hydrate](lifecycle-hooks.md) * [How to encrypt sensitive data](cryptography.md) * [How to reshape outdated stored data](upcasting.md) -* [How to cache the metadata](caching.md) +* [How to trace the hydrator](tracing.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 4b427840..46fd510e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,7 +57,7 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->build(); + ->buildHydrator(); ``` :::note The builder is also the place to add [extensions](extensions.md), custom diff --git a/docs/guesser.md b/docs/guesser.md index af488674..948c5a60 100644 --- a/docs/guesser.md +++ b/docs/guesser.md @@ -42,7 +42,7 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->addGuesser(new NameGuesser()) - ->build(); + ->buildHydrator(); ``` :::note The guessers are queried in order of their priority, and the first match wins. @@ -66,7 +66,7 @@ $hydrator = (new StackHydratorBuilder()) Name::class => NameNormalizer::class, Email::class => EmailNormalizer::class, ])) - ->build(); + ->buildHydrator(); ``` :::note The `MappedGuesser` instantiates the normalizer class without arguments, so the diff --git a/docs/hydrator.md b/docs/hydrator.md index 8cf62e48..59b1f43f 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -18,8 +18,15 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->build(); + ->buildHydrator(); ``` +:::note +`buildHydrator()` returns a `Hydrator`. Extensions can wrap the +`StackHydrator` in [decorators](extensions.md#decorators), so type hint +against the `Hydrator` interface. The older `build()` is deprecated, it only +works as long as no decorator is registered. +::: + If you don't need any extensions, you can also instantiate the `StackHydrator` directly, it defaults to the same middleware and guesser: diff --git a/docs/introduction.md b/docs/introduction.md index 4a9e7fb8..a7a5b85e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -20,6 +20,7 @@ And if customization is required, it can be done easily using attributes. * [Lifecycle hooks](lifecycle-hooks.md) before extracting and after hydrating. * Encrypt and decrypt sensitive data with the [cryptography](cryptography.md) extension (crypto-shredding). * [Upcast](upcasting.md) outdated stored data while it is hydrated. +* [Trace](tracing.md) every hydrate and extract call, for example in the Symfony profiler. * [Cache](caching.md) the metadata with any PSR-6 or PSR-16 cache. ## Installation diff --git a/docs/lazy.md b/docs/lazy.md index 154e4a1d..13e6f7cb 100644 --- a/docs/lazy.md +++ b/docs/lazy.md @@ -40,7 +40,7 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->enableDefaultLazy() - ->build(); + ->buildHydrator(); ``` Single classes can then opt out again with the attribute: diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md index 91029091..7ee0182f 100644 --- a/docs/lifecycle-hooks.md +++ b/docs/lifecycle-hooks.md @@ -17,7 +17,7 @@ use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new LifecycleExtension()) - ->build(); + ->buildHydrator(); ``` ## Hooks diff --git a/docs/project.json b/docs/project.json index 5fae563a..3dbe41b7 100644 --- a/docs/project.json +++ b/docs/project.json @@ -18,7 +18,8 @@ "subEntries": [ { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, { "title": "Cryptography", "file": "cryptography.md" }, - { "title": "Upcasting", "file": "upcasting.md" } + { "title": "Upcasting", "file": "upcasting.md" }, + { "title": "Tracing", "file": "tracing.md" } ] }, { diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 00000000..e32dd511 --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,90 @@ +# Tracing + +The tracing extension measures every `hydrate` and `extract` call on the +hydrator. It wraps the hydrator in a [decorator](extensions.md#decorators) and +passes each call to a `Tracer`, which decides what to record: an event in the +Symfony profiler, a log entry or a span in your observability tool. + +## Setup + +Register the `TracingExtension` on the builder and pass it a `Tracer`. The +library ships with the `StopwatchTracer`, which records every call as an event +of the Symfony Stopwatch. It needs the `symfony/stopwatch` package. + +```bash +composer require symfony/stopwatch +``` +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Tracing\StopwatchTracer; +use Patchlevel\Hydrator\Extension\Tracing\TracingExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; +use Symfony\Component\Stopwatch\Stopwatch; + +$stopwatch = new Stopwatch(); + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new TracingExtension(new StopwatchTracer($stopwatch))) + ->buildHydrator(); +``` +Each call shows up as an event named after the operation and the class, for +example `hydrate App\Profile\Event\ProfileCreated`, in the category +`hydrator`. You can pass another category as the second argument of the +`StopwatchTracer`. + +:::warning +The decorators are only applied by `buildHydrator()`. The deprecated `build()` +throws a `DecoratorsNotApplied` exception as soon as an extension registers a +decorator, like the `TracingExtension` does. +::: + +## Write your own tracer + +A tracer implements the `Tracer` interface. It receives the operation, the +class and a callback which runs the actual call. The tracer has to call it, +return its result unchanged and let exceptions pass. + +```php +use Patchlevel\Hydrator\Extension\Tracing\Operation; +use Patchlevel\Hydrator\Extension\Tracing\Tracer; +use Psr\Log\LoggerInterface; + +final class SlowCallTracer implements Tracer +{ + public function __construct( + private readonly LoggerInterface $logger, + private readonly float $thresholdInMs = 5.0, + ) { + } + + public function trace(Operation $operation, string $class, Closure $callback): mixed + { + $start = hrtime(true); + + try { + return $callback(); + } finally { + $duration = (hrtime(true) - $start) / 1_000_000; + + if ($duration >= $this->thresholdInMs) { + $this->logger->warning('Slow {operation} of {class} took {duration} ms', [ + 'operation' => $operation->value, + 'class' => $class, + 'duration' => $duration, + ]); + } + } + } +} +``` +:::note +Nested objects are traced as well. Their trace runs inside the trace of their +parent, so the parent's time includes the time of its nested objects. +::: + +## Learn more + +* [How to write your own decorator](extensions.md#decorators) +* [How to create the hydrator](hydrator.md) +* [How to cache the metadata](caching.md) diff --git a/docs/upcasting.md b/docs/upcasting.md index 8e384cdf..41ee541b 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -31,7 +31,7 @@ $hydrator = (new StackHydratorBuilder()) ), ], )) - ->build(); + ->buildHydrator(); ``` :::note Upcasting only runs during [hydration](hydrator.md). Extraction always writes diff --git a/src/DecoratorsNotApplied.php b/src/DecoratorsNotApplied.php new file mode 100644 index 00000000..90dafc69 --- /dev/null +++ b/src/DecoratorsNotApplied.php @@ -0,0 +1,15 @@ +stopwatch->start(sprintf('%s %s', $operation->value, $class), $this->category); + + try { + return $callback(); + } finally { + $event->stop(); + } + } +} diff --git a/src/Extension/Tracing/Tracer.php b/src/Extension/Tracing/Tracer.php new file mode 100644 index 00000000..4e55eff1 --- /dev/null +++ b/src/Extension/Tracing/Tracer.php @@ -0,0 +1,23 @@ +tracer); + } +} diff --git a/src/Extension/Tracing/TracingExtension.php b/src/Extension/Tracing/TracingExtension.php new file mode 100644 index 00000000..a8b2a86a --- /dev/null +++ b/src/Extension/Tracing/TracingExtension.php @@ -0,0 +1,21 @@ +addDecorator(new TracingDecorator($this->tracer), 32); + } +} diff --git a/src/Extension/Tracing/TracingHydrator.php b/src/Extension/Tracing/TracingHydrator.php new file mode 100644 index 00000000..ac0ec6cc --- /dev/null +++ b/src/Extension/Tracing/TracingHydrator.php @@ -0,0 +1,46 @@ + $class + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, mixed $data, array $context = []): object + { + return $this->tracer->trace( + Operation::Hydrate, + $class, + fn (): object => $this->hydrator->hydrate($class, $data, $context), + ); + } + + /** @param array $context */ + public function extract(object $object, array $context = []): mixed + { + return $this->tracer->trace( + Operation::Extract, + $object::class, + fn (): mixed => $this->hydrator->extract($object, $context), + ); + } +} diff --git a/src/HydratorDecorator.php b/src/HydratorDecorator.php new file mode 100644 index 00000000..f4390488 --- /dev/null +++ b/src/HydratorDecorator.php @@ -0,0 +1,17 @@ + $middlewares */ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), @@ -56,6 +59,21 @@ public function __construct( } $this->hasSkippableMiddlewares = $hasSkippableMiddlewares; + $this->rootHydrator = $this; + } + + /** + * Sets the hydrator which wraps this one, so nested objects also go through its decorators. + * + * @internal + */ + public function setRootHydrator(Hydrator $hydrator): void + { + $this->rootHydrator = $hydrator; + + foreach ($this->classMetadata as $metadata) { + $this->injectHydrator($metadata); + } } /** @@ -191,14 +209,19 @@ public function metadata(string $class): ClassMetadata $this->classMetadata[$class] = $metadata = $this->metadataFactory->metadata($class); + $this->injectHydrator($metadata); + + return $metadata; + } + + private function injectHydrator(ClassMetadata $metadata): void + { foreach ($metadata->properties as $property) { if (!($property->normalizer instanceof HydratorAwareNormalizer)) { continue; } - $property->normalizer->setHydrator($this); + $property->normalizer->setHydrator($this->rootHydrator); } - - return $metadata; } } diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php index 92c251cd..42223032 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -17,6 +17,7 @@ use function array_merge; use function krsort; +use function ksort; final class StackHydratorBuilder { @@ -31,6 +32,9 @@ final class StackHydratorBuilder /** @var array> */ private array $guessers = []; + /** @var array> */ + private array $decorators = []; + private CacheItemPoolInterface|CacheInterface|null $cache = null; /** @return $this */ @@ -57,6 +61,18 @@ public function addGuesser(Guesser $guesser, int $priority = 0): static return $this; } + /** + * Decorators with a higher priority are wrapped around the ones with a lower priority. + * + * @return $this + */ + public function addDecorator(HydratorDecorator $decorator, int $priority = 0): static + { + $this->decorators[$priority][] = $decorator; + + return $this; + } + public function enableDefaultLazy(bool $lazy = true): static { $this->defaultLazy = $lazy; @@ -78,7 +94,38 @@ public function setCache(CacheItemPoolInterface|CacheInterface|null $cache): sta return $this; } + /** Builds the hydrator with all registered decorators. */ + public function buildHydrator(): Hydrator + { + $stack = $this->buildStackHydrator(); + $hydrator = $stack; + + foreach ($this->decorators() as $decorator) { + $hydrator = $decorator->decorate($hydrator, $stack); + } + + $stack->setRootHydrator($hydrator); + + return $hydrator; + } + + /** + * Builds the plain stack hydrator. Fails if decorators are registered, since they can not be applied here. + * + * @deprecated use buildHydrator() instead, which also applies the decorators + * + * @throws DecoratorsNotApplied + */ public function build(): StackHydrator + { + if ($this->decorators !== []) { + throw new DecoratorsNotApplied(); + } + + return $this->buildStackHydrator(); + } + + private function buildStackHydrator(): StackHydrator { $metadataFactory = new EnrichingMetadataFactory( new AttributeMetadataFactory( @@ -130,4 +177,12 @@ public function metadataEnrichers(): array return array_merge(...$this->metadataEnrichers); } + + /** @return list in the order they are applied, the innermost first */ + public function decorators(): array + { + ksort($this->decorators); + + return array_merge(...$this->decorators); + } } diff --git a/tests/Unit/Extension/Tracing/Fixture/RecordingTracer.php b/tests/Unit/Extension/Tracing/Fixture/RecordingTracer.php new file mode 100644 index 00000000..1fa3c7c5 --- /dev/null +++ b/tests/Unit/Extension/Tracing/Fixture/RecordingTracer.php @@ -0,0 +1,30 @@ + */ + public array $traces = []; + + /** + * @param class-string $class + * @param Closure(): T $callback + * + * @return T + * + * @template T + */ + public function trace(Operation $operation, string $class, Closure $callback): mixed + { + $this->traces[] = $operation->value . ' ' . $class; + + return $callback(); + } +} diff --git a/tests/Unit/Extension/Tracing/StopwatchTracerTest.php b/tests/Unit/Extension/Tracing/StopwatchTracerTest.php new file mode 100644 index 00000000..b94f7dda --- /dev/null +++ b/tests/Unit/Extension/Tracing/StopwatchTracerTest.php @@ -0,0 +1,56 @@ +trace(Operation::Hydrate, ProfileCreated::class, static fn (): object => $object); + + self::assertSame($object, $result); + + $event = $stopwatch->getEvent('hydrate ' . ProfileCreated::class); + self::assertSame('hydrator', $event->getCategory()); + self::assertFalse($event->isStarted()); + self::assertCount(1, $event->getPeriods()); + } + + public function testEventIsStoppedOnException(): void + { + $stopwatch = new Stopwatch(); + $tracer = new StopwatchTracer($stopwatch, 'custom'); + + $exception = null; + + try { + $tracer->trace(Operation::Extract, ProfileCreated::class, static function (): string { + throw new RuntimeException('failed'); + }); + } catch (RuntimeException $e) { + $exception = $e; + } + + self::assertSame('failed', $exception->getMessage()); + + $event = $stopwatch->getEvent('extract ' . ProfileCreated::class); + self::assertSame('custom', $event->getCategory()); + self::assertFalse($event->isStarted()); + } +} diff --git a/tests/Unit/Extension/Tracing/TracingExtensionTest.php b/tests/Unit/Extension/Tracing/TracingExtensionTest.php new file mode 100644 index 00000000..14c49493 --- /dev/null +++ b/tests/Unit/Extension/Tracing/TracingExtensionTest.php @@ -0,0 +1,98 @@ +useExtension(new CoreExtension()) + ->useExtension(new TracingExtension($tracer)) + ->buildHydrator(); + + self::assertInstanceOf(TracingHydrator::class, $hydrator); + + $data = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + $event = $hydrator->hydrate(ProfileCreated::class, $data); + + self::assertEquals(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), $event); + self::assertSame($data, $hydrator->extract($event)); + + self::assertSame( + ['hydrate ' . ProfileCreated::class, 'extract ' . ProfileCreated::class], + $tracer->traces, + ); + } + + public function testNestedObjectsAreTraced(): void + { + $tracer = new RecordingTracer(); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new TracingExtension($tracer)) + ->buildHydrator(); + + $data = ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']]; + $wrapper = $hydrator->hydrate(ProfileCreatedWrapper::class, $data); + + self::assertEquals( + new ProfileCreatedWrapper(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'))), + $wrapper, + ); + self::assertSame($data, $hydrator->extract($wrapper)); + + self::assertSame( + [ + 'hydrate ' . ProfileCreatedWrapper::class, + 'hydrate ' . ProfileCreated::class, + 'extract ' . ProfileCreatedWrapper::class, + 'extract ' . ProfileCreated::class, + ], + $tracer->traces, + ); + } + + public function testWrapsDecoratorsWithDefaultPriority(): void + { + $inner = $this->createMock(Hydrator::class); + + $decorator = $this->createMock(HydratorDecorator::class); + $decorator->method('decorate')->willReturn($inner); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new TracingExtension(new RecordingTracer())) + ->addDecorator($decorator) + ->buildHydrator(); + + self::assertInstanceOf(TracingHydrator::class, $hydrator); + + $reflection = new ReflectionProperty(TracingHydrator::class, 'hydrator'); + self::assertSame($inner, $reflection->getValue($hydrator)); + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 274d8dbe..577645a3 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -4,9 +4,12 @@ namespace Patchlevel\Hydrator\Tests\Unit; +use Patchlevel\Hydrator\DecoratorsNotApplied; use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorDecorator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\EnrichingMetadataFactory; use Patchlevel\Hydrator\Metadata\MetadataEnricher; @@ -159,4 +162,64 @@ public function testCachePsr16(): void self::assertInstanceOf(Psr16MetadataFactory::class, $factory); } + + public function testBuildHydratorWithoutDecorators(): void + { + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($this->createMock(Middleware::class)); + + self::assertInstanceOf(StackHydrator::class, $builder->buildHydrator()); + } + + public function testBuildHydratorAppliesDecoratorsByPriority(): void + { + $inner = $this->createMock(Hydrator::class); + $outer = $this->createMock(Hydrator::class); + $stack = null; + + $innerDecorator = $this->createMock(HydratorDecorator::class); + $innerDecorator + ->expects($this->once()) + ->method('decorate') + ->willReturnCallback(static function (Hydrator $hydrator, StackHydrator $stackHydrator) use ($inner, &$stack): Hydrator { + self::assertSame($stackHydrator, $hydrator); + $stack = $stackHydrator; + + return $inner; + }); + + $outerDecorator = $this->createMock(HydratorDecorator::class); + $outerDecorator + ->expects($this->once()) + ->method('decorate') + ->willReturnCallback(static function (Hydrator $hydrator, StackHydrator $stackHydrator) use ($inner, $outer, &$stack): Hydrator { + self::assertSame($inner, $hydrator); + self::assertSame($stack, $stackHydrator); + + return $outer; + }); + + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($this->createMock(Middleware::class)); + $builder->addDecorator($outerDecorator, 10); + $builder->addDecorator($innerDecorator, -10); + + self::assertSame([$innerDecorator, $outerDecorator], $builder->decorators()); + self::assertSame($outer, $builder->buildHydrator()); + } + + public function testBuildFailsWithDecorators(): void + { + $decorator = $this->createMock(HydratorDecorator::class); + $decorator->expects($this->never())->method('decorate'); + + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($this->createMock(Middleware::class)); + $builder->addDecorator($decorator); + + $this->expectException(DecoratorsNotApplied::class); + $this->expectExceptionMessage('Decorators are registered but build() can not apply them, use buildHydrator() instead.'); + + $builder->build(); + } } diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 8b8e3195..30ad164d 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\DenormalizationFailure; +use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\AllMiddlewaresSkipped; @@ -771,4 +772,44 @@ public function testAllMiddlewaresSkipped(): void $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']); } + + public function testNestedObjectsUseRootHydrator(): void + { + $nested = new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')); + + $root = $this->createMock(Hydrator::class); + $root->expects($this->once()) + ->method('hydrate') + ->with(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']) + ->willReturn($nested); + $root->expects($this->once()) + ->method('extract') + ->with($nested) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); + + $this->hydrator->setRootHydrator($root); + + $data = ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']]; + $wrapper = $this->hydrator->hydrate(ProfileCreatedWrapper::class, $data); + + self::assertSame($nested, $wrapper->profileCreated); + self::assertSame($data, $this->hydrator->extract($wrapper)); + } + + public function testRootHydratorIsInjectedIntoLoadedMetadata(): void + { + $nested = new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')); + $wrapper = new ProfileCreatedWrapper($nested); + + $this->hydrator->extract($wrapper); + + $root = $this->createMock(Hydrator::class); + $root->expects($this->once()) + ->method('extract') + ->with($nested) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); + + $this->hydrator->setRootHydrator($root); + $this->hydrator->extract($wrapper); + } }