From 060fb9d786de46c7594b41cb8b3ee37b67baaf6c Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 6 Oct 2026 11:09:44 +0200 Subject: [PATCH 1/4] Add hydrator decorators and a tracing extension Extensions can now register decorators on the builder which wrap the built hydrator. They are applied by the new buildHydrator() method, build() is deprecated since it can only return the plain StackHydrator. The TracingExtension is the first decorator: it passes every hydrate and extract call to a Tracer, a StopwatchTracer for the Symfony Stopwatch is included. --- composer.json | 4 + composer.lock | 68 +++++++++++++- docs/caching.md | 2 +- docs/cryptography.md | 2 +- docs/extensions.md | 44 ++++++++- docs/getting-started.md | 2 +- docs/guesser.md | 4 +- docs/hydrator.md | 9 +- docs/introduction.md | 1 + docs/lazy.md | 2 +- docs/lifecycle-hooks.md | 2 +- docs/project.json | 3 +- docs/tracing.md | 90 +++++++++++++++++++ docs/upcasting.md | 2 +- src/Extension/Tracing/Operation.php | 11 +++ src/Extension/Tracing/StopwatchTracer.php | 39 ++++++++ src/Extension/Tracing/Tracer.php | 23 +++++ src/Extension/Tracing/TracingDecorator.php | 22 +++++ src/Extension/Tracing/TracingExtension.php | 21 +++++ src/Extension/Tracing/TracingHydrator.php | 47 ++++++++++ src/HydratorDecorator.php | 17 ++++ src/StackHydratorBuilder.php | 47 ++++++++++ .../Tracing/Fixture/RecordingTracer.php | 30 +++++++ .../Extension/Tracing/StopwatchTracerTest.php | 56 ++++++++++++ .../Tracing/TracingExtensionTest.php | 47 ++++++++++ tests/Unit/StackHydratorBuilderTest.php | 59 ++++++++++++ 26 files changed, 640 insertions(+), 14 deletions(-) create mode 100644 docs/tracing.md create mode 100644 src/Extension/Tracing/Operation.php create mode 100644 src/Extension/Tracing/StopwatchTracer.php create mode 100644 src/Extension/Tracing/Tracer.php create mode 100644 src/Extension/Tracing/TracingDecorator.php create mode 100644 src/Extension/Tracing/TracingExtension.php create mode 100644 src/Extension/Tracing/TracingHydrator.php create mode 100644 src/HydratorDecorator.php create mode 100644 tests/Unit/Extension/Tracing/Fixture/RecordingTracer.php create mode 100644 tests/Unit/Extension/Tracing/StopwatchTracerTest.php create mode 100644 tests/Unit/Extension/Tracing/TracingExtensionTest.php 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..4482719b 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,43 @@ 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. + +:::warning +Decorators are only applied by `buildHydrator()`. The deprecated `build()` +returns the plain `StackHydrator`. +::: + ## Writing your own extension An extension implements the `Extension` interface and configures the builder. @@ -175,4 +213,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..4cdc1a45 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 +returns the plain `StackHydrator` without decorators. +::: + 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..4cb19031 --- /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()` +returns the plain `StackHydrator` without tracing. +::: + +## 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 +Only the calls on the hydrator itself are traced. Nested objects are hydrated +and extracted inside the call of their parent, so their time is part of the +parent's trace. +::: + +## 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/Extension/Tracing/Operation.php b/src/Extension/Tracing/Operation.php new file mode 100644 index 00000000..2c83fcd9 --- /dev/null +++ b/src/Extension/Tracing/Operation.php @@ -0,0 +1,11 @@ +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..be32a957 --- /dev/null +++ b/src/Extension/Tracing/TracingExtension.php @@ -0,0 +1,21 @@ +addDecorator(new TracingDecorator($this->tracer)); + } +} diff --git a/src/Extension/Tracing/TracingHydrator.php b/src/Extension/Tracing/TracingHydrator.php new file mode 100644 index 00000000..b27011ad --- /dev/null +++ b/src/Extension/Tracing/TracingHydrator.php @@ -0,0 +1,47 @@ + $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 @@ +> */ 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,30 @@ 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); + } + + return $hydrator; + } + + /** + * Builds the plain stack hydrator, registered decorators are not applied. + * + * @deprecated use buildHydrator() instead, which also applies the decorators + */ public function build(): StackHydrator + { + return $this->buildStackHydrator(); + } + + private function buildStackHydrator(): StackHydrator { $metadataFactory = new EnrichingMetadataFactory( new AttributeMetadataFactory( @@ -130,4 +169,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..eab4ca1a --- /dev/null +++ b/tests/Unit/Extension/Tracing/TracingExtensionTest.php @@ -0,0 +1,47 @@ +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)); + + // only the calls on the hydrator itself are traced + self::assertSame( + ['hydrate ' . ProfileCreated::class, 'extract ' . ProfileCreated::class], + $tracer->traces, + ); + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 274d8dbe..c5220778 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -7,6 +7,8 @@ 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 +161,61 @@ 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 testBuildIgnoresDecorators(): void + { + $decorator = $this->createMock(HydratorDecorator::class); + $decorator->expects($this->never())->method('decorate'); + + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($this->createMock(Middleware::class)); + $builder->addDecorator($decorator); + + $builder->build(); + } } From 18f6b4c04b856eeed5ae0093f1da82ddadd703d1 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 6 Oct 2026 11:12:13 +0200 Subject: [PATCH 2/4] Throw in build() when decorators are registered build() can only return the plain StackHydrator, so silently dropping the decorators would hide that e.g. tracing is not active. It now throws a DecoratorsNotApplied exception pointing to buildHydrator(). --- docs/extensions.md | 2 +- docs/hydrator.md | 4 ++-- docs/tracing.md | 3 ++- src/DecoratorsNotApplied.php | 15 +++++++++++++++ src/StackHydratorBuilder.php | 8 +++++++- tests/Unit/StackHydratorBuilderTest.php | 6 +++++- 6 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 src/DecoratorsNotApplied.php diff --git a/docs/extensions.md b/docs/extensions.md index 4482719b..f9f99520 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -187,7 +187,7 @@ wrapped around the ones with a lower priority. :::warning Decorators are only applied by `buildHydrator()`. The deprecated `build()` -returns the plain `StackHydrator`. +throws a `DecoratorsNotApplied` exception as soon as a decorator is registered. ::: ## Writing your own extension diff --git a/docs/hydrator.md b/docs/hydrator.md index 4cdc1a45..59b1f43f 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -23,8 +23,8 @@ $hydrator = (new StackHydratorBuilder()) :::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 -returns the plain `StackHydrator` without decorators. +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` diff --git a/docs/tracing.md b/docs/tracing.md index 4cb19031..274a0db4 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -35,7 +35,8 @@ example `hydrate App\Profile\Event\ProfileCreated`, in the category :::warning The decorators are only applied by `buildHydrator()`. The deprecated `build()` -returns the plain `StackHydrator` without tracing. +throws a `DecoratorsNotApplied` exception as soon as an extension registers a +decorator, like the `TracingExtension` does. ::: ## Write your own tracer 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 @@ +decorators !== []) { + throw new DecoratorsNotApplied(); + } + return $this->buildStackHydrator(); } diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index c5220778..577645a3 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Unit; +use Patchlevel\Hydrator\DecoratorsNotApplied; use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\Guesser\ChainGuesser; use Patchlevel\Hydrator\Guesser\Guesser; @@ -207,7 +208,7 @@ public function testBuildHydratorAppliesDecoratorsByPriority(): void self::assertSame($outer, $builder->buildHydrator()); } - public function testBuildIgnoresDecorators(): void + public function testBuildFailsWithDecorators(): void { $decorator = $this->createMock(HydratorDecorator::class); $decorator->expects($this->never())->method('decorate'); @@ -216,6 +217,9 @@ public function testBuildIgnoresDecorators(): void $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(); } } From 08a104959dea0203f4a0dcb2d2ed3b2c6c856d84 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 6 Oct 2026 11:56:08 +0200 Subject: [PATCH 3/4] Pass the decorated hydrator to normalizers for nested objects The StackHydrator injected itself into the hydrator aware normalizers, so nested objects bypassed all decorators and were e.g. not traced. buildHydrator() now sets the outermost hydrator as root hydrator on the stack, which is then passed to the normalizers instead. --- docs/tracing.md | 5 +-- src/Extension/Tracing/TracingHydrator.php | 3 +- src/StackHydrator.php | 29 +++++++++++-- src/StackHydratorBuilder.php | 2 + .../Tracing/TracingExtensionTest.php | 31 +++++++++++++- tests/Unit/StackHydratorTest.php | 41 +++++++++++++++++++ 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index 274a0db4..e32dd511 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -79,9 +79,8 @@ final class SlowCallTracer implements Tracer } ``` :::note -Only the calls on the hydrator itself are traced. Nested objects are hydrated -and extracted inside the call of their parent, so their time is part of the -parent's trace. +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 diff --git a/src/Extension/Tracing/TracingHydrator.php b/src/Extension/Tracing/TracingHydrator.php index b27011ad..ac0ec6cc 100644 --- a/src/Extension/Tracing/TracingHydrator.php +++ b/src/Extension/Tracing/TracingHydrator.php @@ -7,8 +7,7 @@ use Patchlevel\Hydrator\Hydrator; /** - * Traces every call on the hydrator. Nested objects are hydrated and extracted inside the call of their parent, - * so they are part of the parent's trace. + * Traces every call on the hydrator. Nested objects are traced as well, inside the trace of their parent. */ final class TracingHydrator implements Hydrator { diff --git a/src/StackHydrator.php b/src/StackHydrator.php index 568b0cc5..837cb7b2 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -35,6 +35,9 @@ final class StackHydrator implements Hydrator private readonly bool $hasSkippableMiddlewares; + /** The hydrator which is passed to the normalizers to hydrate and extract nested objects. */ + private Hydrator $rootHydrator; + /** @param list $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 c6633c8e..42223032 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -104,6 +104,8 @@ public function buildHydrator(): Hydrator $hydrator = $decorator->decorate($hydrator, $stack); } + $stack->setRootHydrator($hydrator); + return $hydrator; } diff --git a/tests/Unit/Extension/Tracing/TracingExtensionTest.php b/tests/Unit/Extension/Tracing/TracingExtensionTest.php index eab4ca1a..dae6d852 100644 --- a/tests/Unit/Extension/Tracing/TracingExtensionTest.php +++ b/tests/Unit/Extension/Tracing/TracingExtensionTest.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\Tests\Unit\Extension\Tracing\Fixture\RecordingTracer; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -38,10 +39,38 @@ public function testIntegration(): void self::assertEquals(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), $event); self::assertSame($data, $hydrator->extract($event)); - // only the calls on the hydrator itself are traced 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, + ); + } } 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); + } } From 8feec99ccf67960503c0265d5efc9a8341c0d173 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 6 Oct 2026 12:05:22 +0200 Subject: [PATCH 4/4] Register the tracing decorator with priority 32 This way the tracing is wrapped around decorators with the default priority and also measures them. --- docs/extensions.md | 4 +++- src/Extension/Tracing/TracingExtension.php | 2 +- .../Tracing/TracingExtensionTest.php | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index f9f99520..5e4af2e8 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -183,7 +183,9 @@ final class AuditDecorator implements HydratorDecorator $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. +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()` diff --git a/src/Extension/Tracing/TracingExtension.php b/src/Extension/Tracing/TracingExtension.php index be32a957..a8b2a86a 100644 --- a/src/Extension/Tracing/TracingExtension.php +++ b/src/Extension/Tracing/TracingExtension.php @@ -16,6 +16,6 @@ public function __construct( public function configure(StackHydratorBuilder $builder): void { - $builder->addDecorator(new TracingDecorator($this->tracer)); + $builder->addDecorator(new TracingDecorator($this->tracer), 32); } } diff --git a/tests/Unit/Extension/Tracing/TracingExtensionTest.php b/tests/Unit/Extension/Tracing/TracingExtensionTest.php index dae6d852..14c49493 100644 --- a/tests/Unit/Extension/Tracing/TracingExtensionTest.php +++ b/tests/Unit/Extension/Tracing/TracingExtensionTest.php @@ -8,6 +8,8 @@ use Patchlevel\Hydrator\Extension\Tracing\TracingDecorator; use Patchlevel\Hydrator\Extension\Tracing\TracingExtension; use Patchlevel\Hydrator\Extension\Tracing\TracingHydrator; +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorDecorator; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\Hydrator\Tests\Unit\Extension\Tracing\Fixture\RecordingTracer; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; @@ -16,6 +18,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use ReflectionProperty; #[CoversClass(TracingExtension::class)] #[CoversClass(TracingDecorator::class)] @@ -73,4 +76,23 @@ public function testNestedObjectsAreTraced(): void $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)); + } }