diff --git a/.gitignore b/.gitignore
index a8264bba..d2bc37a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ infection.log
infection.html
.phpbench/
docs_php/
+var
diff --git a/Makefile b/Makefile
index faf64b56..303231f9 100644
--- a/Makefile
+++ b/Makefile
@@ -34,6 +34,10 @@ static: phpstan cs ## run stat
test: phpunit ## run tests
+.PHONY: snapshot
+snapshot: vendor ## regenerate the snapshots of the generated mappers
+ UPDATE_SNAPSHOTS=1 vendor/bin/phpunit --no-coverage --filter testGeneratedCodeMatchesSnapshot
+
.PHONY: benchmark
benchmark: vendor ## run benchmarks
vendor/bin/phpbench run tests/Benchmark --report=default
diff --git a/docs/extensions.md b/docs/extensions.md
index 900a906a..8bedc563 100644
--- a/docs/extensions.md
+++ b/docs/extensions.md
@@ -23,13 +23,14 @@ $hydrator = (new StackHydratorBuilder())
```
## Built-in extensions
-The library ships with three extensions out of the box:
+The library ships with four extensions out of the box:
| Extension | Purpose |
| --- | --- |
| `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. |
+| `GeneratedMapperExtension` | [Generated code](generated-code.md), use generated mapping code instead of reflection. |
## Middleware
@@ -232,4 +233,5 @@ 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 speed up hydration with generated code](generated-code.md)
* [How to cache the metadata](caching.md)
diff --git a/docs/generated-code.md b/docs/generated-code.md
new file mode 100644
index 00000000..6d003b11
--- /dev/null
+++ b/docs/generated-code.md
@@ -0,0 +1,108 @@
+# Generated Code
+
+By default the hydrator maps arrays to objects with reflection: the
+`ReflectionMapper` iterates over the metadata of a class, sets each
+property through a `ReflectionProperty` and calls the [normalizers](normalizer.md)
+dynamically. This is fast enough for most applications, but when you hydrate
+millions of objects the reflection overhead adds up. The
+`GeneratedMapperExtension` uses plain PHP code generated for your classes
+instead.
+
+The generated mappers plug in as a [mapper factory](extensions.md#mapper-factories).
+The generation and the runtime are strictly separated: a warmup step generates
+the code ahead of time, and the hydrator only loads it.
+
+## Enable the extension
+
+Register the `GeneratedMapperExtension` with the directory which contains
+the generated code.
+
+```php
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension;
+use Patchlevel\Hydrator\StackHydratorBuilder;
+
+$hydrator = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedMapperExtension(__DIR__ . '/var/cache/hydrator'))
+ ->build();
+```
+For every class the hydrator looks for a generated mapper in this
+directory. Classes without one are mapped with reflection as usual.
+
+:::success
+The generated code behaves exactly like the `ReflectionMapper`: objects
+are created without calling the constructor, missing fields keep their promoted
+default value, private and readonly properties are supported and the same
+exceptions are thrown. You can switch between both without changing your
+classes.
+:::
+
+## Warm up
+
+The `GeneratedMapperWarmer` generates the mappers and writes them to
+the directory. Run it in a cache warmer or a deployment step, with the metadata
+factory of the builder, so the code matches the metadata of the hydrator,
+including the guessers and enrichers of all registered extensions.
+
+```php
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperWarmer;
+use Patchlevel\Hydrator\StackHydratorBuilder;
+
+$cachePath = __DIR__ . '/var/cache/hydrator';
+
+$builder = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedMapperExtension($cachePath));
+
+$warmer = new GeneratedMapperWarmer($builder->metadataFactory(), $cachePath);
+$warmer->warmup([
+ ProfileCreated::class,
+ NameChanged::class,
+]);
+```
+Classes referenced by these classes, such as nested value objects, get a
+mapper too. The warmup throws a `ClassNotGeneratable` exception for a
+class which can not be generated, for example an abstract class or a class with
+a class normalizer.
+
+:::note
+Only the warmer knows how to generate code. At runtime the extension only loads
+existing files, the generator is never loaded.
+:::
+
+## Changed classes
+
+The name of a generated file contains a fingerprint of everything the code
+depends on: the properties with their field names, normalizers, visibility and
+default values. If a class changes after the warmup, its generated mapper
+is simply not found and the class is mapped with reflection until you warm
+up again.
+
+:::warning
+A missing warmup does not fail, it only makes the hydration slower. Warm up the
+mappers on every deployment, like you do for other generated files.
+:::
+
+## Nested objects
+
+The biggest speedup comes from nested objects: instead of going through the
+normalizer and the hydrator again for every nested object, the generated code
+calls the [mapper](extensions.md#mapper-factories) the hydrator built for the
+nested class directly. That mapper already contains the
+[middlewares](extensions.md) and the [lazy](lazy.md) proxy of the nested class,
+so nothing changes in behaviour. With the [cryptography](cryptography.md)
+extension for example, value objects without sensitive data are mapped by their
+generated mapper right away, while classes with sensitive data still go
+through the middleware, without the detour through the hydrator.
+
+Only classes with a class normalizer are left to the normalizer.
+
+:::note
+If a hydrator wraps the `StackHydrator`, nested objects take the way through
+the normalizer for its calls, so it sees every nested object.
+:::
+
+## Learn more
+
+* [How to build the hydrator](hydrator.md)
+* [How to cache the metadata](caching.md)
+* [How to write your own extension](extensions.md)
diff --git a/docs/hydrator.md b/docs/hydrator.md
index 409fb697..4df70d54 100644
--- a/docs/hydrator.md
+++ b/docs/hydrator.md
@@ -180,3 +180,4 @@ value does not fit the property type.
* [How normalizers convert complex types](normalizer.md)
* [How to hydrate objects lazily](lazy.md)
* [How to hook into the hydration process](extensions.md)
+* [How to speed up hydration with generated code](generated-code.md)
diff --git a/docs/project.json b/docs/project.json
index 1181fb49..44c77b88 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": "Generated Code", "file": "generated-code.md" }
]
},
{
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 6998ee63..75142e1f 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -10,6 +10,8 @@
src
tests
+
+ tests/Unit/Extension/Generated/Snapshot/*
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 81e9ded9..7a209d3d 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -8,6 +8,8 @@ parameters:
paths:
- src
- tests
+ excludePaths:
+ - tests/Unit/Extension/Generated/Snapshot
reportUnmatchedIgnoredErrors: false
services:
@@ -19,3 +21,7 @@ services:
class: Patchlevel\Hydrator\Tests\Architecture\ExceptionImplementsHydratorExceptionTest
tags:
- phpat.test
+ -
+ class: Patchlevel\Hydrator\Tests\Architecture\NoCodeGenerationAtRuntimeTest
+ tags:
+ - phpat.test
diff --git a/src/Extension/Generated/ChainNestedMapper.php b/src/Extension/Generated/ChainNestedMapper.php
new file mode 100644
index 00000000..4b8dc98d
--- /dev/null
+++ b/src/Extension/Generated/ChainNestedMapper.php
@@ -0,0 +1,41 @@
+ $mapper */
+ public function __construct(
+ private readonly Mapper $mapper,
+ ) {
+ }
+
+ /**
+ * @param array $data
+ * @param array $context
+ */
+ public function hydrateNested(array $data, array $context): object
+ {
+ return $this->mapper->hydrate($data, $context);
+ }
+
+ /**
+ * @param array $context
+ *
+ * @return array
+ */
+ public function extractNested(object $object, array $context): array
+ {
+ return $this->mapper->extract($object, $context);
+ }
+}
diff --git a/src/Extension/Generated/ClassFingerprint.php b/src/Extension/Generated/ClassFingerprint.php
new file mode 100644
index 00000000..24d4def1
--- /dev/null
+++ b/src/Extension/Generated/ClassFingerprint.php
@@ -0,0 +1,104 @@
+ $metadata
+ *
+ * @template T of object
+ */
+ public static function of(ClassMetadata $metadata): string
+ {
+ $defaults = $metadata->promotedConstructorDefaults();
+ $shape = [$metadata->reflection->isFinal()];
+
+ foreach ($metadata->properties as $property) {
+ $reflection = $property->reflection;
+ $default = null;
+
+ if ($reflection->isPromoted() && isset($defaults[$property->propertyName])) {
+ // defaults which can not be inlined are resolved at runtime, their value does not matter here
+ $default = Literal::export($defaults[$property->propertyName]->getDefaultValue()) ?? 'runtime';
+ }
+
+ $declaringClass = $reflection->getDeclaringClass()->getName();
+ $nested = null;
+
+ if ($property->normalizer !== null) {
+ $nested = self::nested($property->normalizer);
+ }
+
+ $shape[] = [
+ $property->propertyName,
+ $property->fieldName,
+ $property->normalizer !== null,
+ $reflection->getModifiers(),
+ // inherited properties may need the scope of their declaring class
+ $declaringClass === $metadata->className ? null : $declaringClass,
+ $default,
+ $nested,
+ ];
+ }
+
+ return substr(hash('sha256', serialize($shape)), 0, 16);
+ }
+
+ /**
+ * Nested objects are mapped in place if they are normalized with an object normalizer, directly or in an array.
+ *
+ * @return array{class: class-string, array: bool, final: bool}|null
+ */
+ public static function nested(Normalizer $normalizer): array|null
+ {
+ $array = false;
+
+ if ($normalizer instanceof ArrayNormalizer) {
+ $array = true;
+ $normalizer = $normalizer->innerNormalizer();
+ }
+
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return null;
+ }
+
+ try {
+ $class = $normalizer->className();
+ } catch (InvalidType) {
+ return null;
+ }
+
+ if (!class_exists($class)) {
+ return null;
+ }
+
+ return [
+ 'class' => $class,
+ 'array' => $array,
+ // the generated code checks the exact class of nested objects, which is cheaper for final classes
+ 'final' => (new ReflectionClass($class))->isFinal(),
+ ];
+ }
+}
diff --git a/src/Extension/Generated/ClassNotGeneratable.php b/src/Extension/Generated/ClassNotGeneratable.php
new file mode 100644
index 00000000..72ca8299
--- /dev/null
+++ b/src/Extension/Generated/ClassNotGeneratable.php
@@ -0,0 +1,18 @@
+ $properties
+ * @param bool $scopedHydrate hydrate code must run in the scope of the class
+ * @param bool $scopedExtract extract code must run in the scope of the class
+ */
+ public function __construct(
+ public readonly ClassMetadata $metadata,
+ public readonly array $properties,
+ public readonly bool $scopedHydrate,
+ public readonly bool $scopedExtract,
+ ) {
+ }
+
+ /** @return class-string */
+ public function className(): string
+ {
+ return $this->metadata->className;
+ }
+
+ /** Classes without normalizers can never be part of a circular reference. */
+ public function leaf(): bool
+ {
+ foreach ($this->properties as $property) {
+ if ($property->kind !== ValueKind::Raw) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** @return array slot => nested class whose objects may be mapped in place */
+ public function nested(): array
+ {
+ $nested = [];
+
+ foreach ($this->properties as $property) {
+ if ($property->nested === null) {
+ continue;
+ }
+
+ $nested[$property->nested->slot] = $property->nested;
+ }
+
+ return $nested;
+ }
+}
diff --git a/src/Extension/Generated/ClassPlanner.php b/src/Extension/Generated/ClassPlanner.php
new file mode 100644
index 00000000..66cdd7b8
--- /dev/null
+++ b/src/Extension/Generated/ClassPlanner.php
@@ -0,0 +1,170 @@
+ nested class => slot of its mapper */
+ private array $nestedSlots = [];
+
+ /** @param ClassMetadata $metadata */
+ public function plan(ClassMetadata $metadata): ClassPlan
+ {
+ $properties = [];
+
+ foreach ($metadata->properties as $property) {
+ $properties[] = $this->property($metadata, $property);
+ }
+
+ [$scopedHydrate, $scopedExtract] = self::scoped($metadata, $properties);
+
+ return new ClassPlan($metadata, $properties, $scopedHydrate, $scopedExtract);
+ }
+
+ /**
+ * Constructor visibility does not matter, the generated code runs in the scope of the class. Anonymous classes
+ * have no stable name the generated code could refer to.
+ *
+ * @param ClassMetadata $metadata
+ */
+ public static function generatable(ClassMetadata $metadata): bool
+ {
+ $reflection = $metadata->reflection;
+
+ return !$reflection->isInternal()
+ && !$reflection->isAbstract()
+ && !$reflection->isInterface()
+ && !$reflection->isEnum()
+ && !$reflection->isAnonymous();
+ }
+
+ /** @param ClassMetadata $metadata */
+ private function property(ClassMetadata $metadata, PropertyMetadata $property): PropertyPlan
+ {
+ $reflection = $property->reflection;
+ $declaringClass = $reflection->getDeclaringClass()->getName();
+ $inherited = $declaringClass !== $metadata->className;
+
+ $kind = ValueKind::Raw;
+ $slot = null;
+ $inner = null;
+ $flag = null;
+ $nested = null;
+ $default = null;
+ $defaultSlot = null;
+
+ $normalizer = $property->normalizer;
+
+ if ($normalizer !== null) {
+ $kind = ValueKind::Normalizer;
+ $slot = $this->normalizerSlots++;
+ $nested = ClassFingerprint::nested($normalizer);
+
+ if ($nested !== null) {
+ $kind = $nested['array'] ? ValueKind::NestedArray : ValueKind::NestedObject;
+ $flag = $this->flagSlots++;
+ $this->nestedSlots[$nested['class']] ??= count($this->nestedSlots);
+
+ if ($normalizer instanceof ArrayNormalizer) {
+ $inner = $this->normalizerSlots++;
+ }
+ }
+ }
+
+ if ($reflection->isPromoted()) {
+ $parameter = $metadata->promotedConstructorDefaults()[$property->propertyName] ?? null;
+
+ if ($parameter !== null) {
+ // scalar defaults are inlined, everything else is resolved via reflection at runtime
+ $default = Literal::export($parameter->getDefaultValue());
+
+ if ($default === null) {
+ $defaultSlot = $this->defaultSlots++;
+ }
+ }
+ }
+
+ return new PropertyPlan(
+ name: $property->propertyName,
+ field: $property->fieldName,
+ kind: $kind,
+ slot: $slot,
+ inner: $inner,
+ flag: $flag,
+ nested: $nested === null ? null : new NestedPlan(
+ $nested['class'],
+ $this->nestedSlots[$nested['class']],
+ $nested['final'],
+ ),
+ default: $default,
+ defaultSlot: $defaultSlot,
+ hydrateScope: $inherited && ($reflection->isPrivate() || $reflection->isReadOnly() || self::privateSet($reflection)) ? $declaringClass : null,
+ extractScope: $inherited && $reflection->isPrivate() ? $declaringClass : null,
+ );
+ }
+
+ /**
+ * Code runs in the scope of the class (a bound closure) only if needed, otherwise in the methods of the mapper.
+ *
+ * @param ClassMetadata $metadata
+ * @param list $properties
+ *
+ * @return array{bool, bool} [hydrate needs the class scope, extract needs the class scope]
+ */
+ private static function scoped(ClassMetadata $metadata, array $properties): array
+ {
+ $hydrate = false;
+ $extract = false;
+
+ foreach ($properties as $property) {
+ if ($property->hydrateScope !== null || $property->extractScope !== null) {
+ // handled by helper closures bound to the declaring class
+ continue;
+ }
+
+ $reflection = $metadata->properties[$property->name]->reflection;
+
+ if (!$reflection->isPublic()) {
+ $hydrate = true;
+ $extract = true;
+ } elseif (self::restrictedSet($reflection)) {
+ $hydrate = true;
+ }
+ }
+
+ return [$hydrate, $extract];
+ }
+
+ /** Readonly and asymmetric visibility restrict writes to the class scope. */
+ private static function restrictedSet(ReflectionProperty $reflection): bool
+ {
+ return $reflection->isReadOnly() || self::privateSet($reflection) || (PHP_VERSION_ID >= 80400 && $reflection->isProtectedSet());
+ }
+
+ private static function privateSet(ReflectionProperty $reflection): bool
+ {
+ return PHP_VERSION_ID >= 80400 && $reflection->isPrivateSet();
+ }
+}
diff --git a/src/Extension/Generated/CodeEmitter.php b/src/Extension/Generated/CodeEmitter.php
new file mode 100644
index 00000000..93bbe392
--- /dev/null
+++ b/src/Extension/Generated/CodeEmitter.php
@@ -0,0 +1,406 @@
+ property declarations of the generated class */
+ private array $properties = [];
+
+ private int $helperSlots = 0;
+
+ public function __construct(
+ private readonly ClassPlan $plan,
+ ) {
+ }
+
+ public function emit(string $namespace, string $className): string
+ {
+ $plan = $this->plan;
+ $initialize = [];
+
+ foreach ($plan->properties as $property) {
+ if ($property->defaultSlot !== null) {
+ $this->properties[] = sprintf('public ReflectionParameter $d%d;', $property->defaultSlot);
+ $initialize[] = sprintf('$this->d%d = $this->promotedDefault(%s);', $property->defaultSlot, var_export($property->name, true));
+ }
+
+ if ($property->slot === null) {
+ continue;
+ }
+
+ $this->properties[] = sprintf('public Normalizer $n%d;', $property->slot);
+ $initialize[] = sprintf('$this->n%d = $this->normalizer(%s);', $property->slot, var_export($property->name, true));
+
+ if ($property->nested === null || $property->flag === null) {
+ continue;
+ }
+
+ $flag = $property->flag;
+ $nested = $property->nested;
+
+ $this->properties[] = sprintf('public bool $i%d = false;', $flag);
+ $initialize[] = sprintf('$m%d = $this->nested($this->n%d, \\%s::class);', $flag, $property->slot, $nested->class);
+ $initialize[] = sprintf('$this->i%1$d = $m%1$d !== null;', $flag);
+ $initialize[] = sprintf('if ($m%1$d !== null) { $this->x%2$d = $m%1$d; }', $flag, $nested->slot);
+
+ if ($property->inner === null) {
+ continue;
+ }
+
+ $this->properties[] = sprintf('public ObjectNormalizer $n%d;', $property->inner);
+ $initialize[] = sprintf('if ($this->i%1$d) { $this->n%2$d = $this->n%3$d->innerNormalizer(); }', $flag, $property->inner, $property->slot);
+ }
+
+ foreach ($plan->nested() as $slot => $nested) {
+ $this->properties[] = sprintf('public NestedMapper $x%d;', $slot);
+ }
+
+ [$hydrate, $hydrateHelpers] = $this->hydrateBody();
+ [$extract, $extractHelpers] = $this->extractBody();
+
+ foreach ([...$hydrateHelpers, ...$extractHelpers] as $helper) {
+ $initialize[] = $helper;
+ }
+
+ if ($plan->scopedHydrate) {
+ $this->properties[] = 'public Closure $h;';
+ $initialize[] = Templates::render(Templates::CLOSURE, [
+ 'slot' => 'h',
+ 'signature' => 'object $object, array $data, array $context, bool $inline',
+ 'return' => 'object',
+ 'body' => $hydrate,
+ 'class' => $plan->className(),
+ ]);
+ $hydrate = 'return ($this->h)($object, $data, $context, $inline);';
+ }
+
+ if ($plan->scopedExtract) {
+ $this->properties[] = 'public Closure $e;';
+ $initialize[] = Templates::render(Templates::CLOSURE, [
+ 'slot' => 'e',
+ 'signature' => 'object $object, array $context, bool $inline',
+ 'return' => 'array',
+ 'body' => $extract,
+ 'class' => $plan->className(),
+ ]);
+ $extract = 'return ($this->e)($object, $context, $inline);';
+ }
+
+ foreach ($plan->nested() as $slot => $nested) {
+ // the nested mappers are initialized last, they may lead back to this one
+ $initialize[] = sprintf('self::initNested($this->x%d ?? null);', $slot);
+ }
+
+ $code = Templates::render(Templates::MAPPER, [
+ 'namespace' => $namespace,
+ 'className' => $className,
+ 'class' => $plan->className(),
+ 'version' => (string)MapperFiles::VERSION,
+ 'properties' => implode("\n", $this->properties),
+ // nested objects are only mapped right away for calls of the hydrator which created the mapper
+ 'hydrate' => self::withInline($hydrate, $plan->nested() === [] ? 'false' : Templates::OWNER),
+ 'extract' => self::withInline($extract, $plan->nested() === [] ? 'false' : Templates::OWNER),
+ // the nested entry points are only called by a parent mapper which already did these checks
+ 'hydrateNested' => self::withInline($hydrate, 'true'),
+ 'extractNested' => self::withInline($extract, 'true'),
+ 'initialize' => $initialize === [] ? '// nothing to resolve' : implode("\n", $initialize),
+ 'recursive' => $this->recursive(),
+ ]);
+
+ // empty placeholders leave blank lines behind
+ $code = preg_replace(["/\n{3,}/", "/{\n\n/"], ["\n\n", "{\n"], $code);
+ assert($code !== null);
+
+ return $code;
+ }
+
+ /** Code without nested objects does not use $inline, the code of scoped classes gets it as argument. */
+ private static function withInline(string $code, string $inline): string
+ {
+ if (!str_contains($code, '$inline')) {
+ return $code;
+ }
+
+ return sprintf("\$inline = %s;\n\n%s", $inline, $code);
+ }
+
+ /**
+ * Circular references are only possible through normalizers which extract objects with the hydrator. A class
+ * whose normalizers can not reach the class itself again does not need the (expensive) call stack tracking.
+ */
+ private function recursive(): string
+ {
+ $checks = [];
+
+ foreach ($this->plan->properties as $property) {
+ if ($property->kind === ValueKind::Raw) {
+ continue;
+ }
+
+ if ($property->nested === null) {
+ $checks[] = sprintf('self::mayRecurse($this->n%d)', $property->slot);
+
+ continue;
+ }
+
+ $checks[] = sprintf(
+ '($this->i%d ? self::reaches($this->x%d) : self::mayRecurse($this->n%d))',
+ $property->flag,
+ $property->nested->slot,
+ $property->slot,
+ );
+ }
+
+ return $checks === [] ? 'false' : implode("\n || ", $checks);
+ }
+
+ /**
+ * Generates the hydrate code, properties which need another scope are assigned in a helper closure.
+ *
+ * @return array{string, list} body and init code of the helper closures
+ */
+ private function hydrateBody(): array
+ {
+ $main = [];
+ $scoped = [];
+
+ foreach ($this->plan->properties as $property) {
+ if ($property->hydrateScope !== null) {
+ $scoped[$property->hydrateScope][] = $this->assignment($property);
+
+ continue;
+ }
+
+ $main[] = $this->assignment($property);
+ }
+
+ $helpers = [];
+
+ foreach ($scoped as $scope => $code) {
+ $slot = $this->helperSlots++;
+ $this->properties[] = sprintf('public Closure $hp%d;', $slot);
+ $helpers[] = Templates::render(Templates::HYDRATE_HELPER, [
+ 'slot' => (string)$slot,
+ 'scope' => $scope,
+ 'body' => implode("\n", $code),
+ ]);
+ $main[] = sprintf('($this->hp%d)($object, $data, $context, $inline);', $slot);
+ }
+
+ $main[] = 'return $object;';
+
+ return [implode("\n\n", $main), $helpers];
+ }
+
+ private function assignment(PropertyPlan $property): string
+ {
+ $template = $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN;
+
+ return Templates::render($template, [
+ 'field' => var_export($property->field, true),
+ 'name' => $property->name,
+ 'property' => var_export($property->name, true),
+ 'class' => $this->plan->className(),
+ 'value' => $this->denormalize($property, '$value'),
+ 'default' => $property->default ?? sprintf('$this->d%d->getDefaultValue()', $property->defaultSlot),
+ ]);
+ }
+
+ /** Code which denormalizes the field into the given variable. */
+ private function denormalize(PropertyPlan $property, string $variable): string
+ {
+ $field = var_export($property->field, true);
+
+ if ($property->kind === ValueKind::Raw) {
+ return sprintf('%s = $data[%s];', $variable, $field);
+ }
+
+ $inner = match ($property->kind) {
+ ValueKind::Normalizer => sprintf('%s = $this->n%d->denormalize($data[%s], $context);', $variable, $property->slot, $field),
+ ValueKind::NestedObject => Templates::render(Templates::DENORMALIZE_OBJECT, [
+ 'variable' => $variable,
+ 'field' => $field,
+ 'flag' => (string)$property->flag,
+ 'slot' => (string)$property->slot,
+ 'nested' => $this->nestedCall($property, 'hydrate', $variable),
+ ]),
+ ValueKind::NestedArray => Templates::render(Templates::DENORMALIZE_ARRAY, [
+ 'variable' => $variable,
+ 'field' => $field,
+ 'flag' => (string)$property->flag,
+ 'slot' => (string)$property->slot,
+ 'inner' => (string)$property->inner,
+ 'nested' => $this->nestedCall($property, 'hydrate', '$item'),
+ ]),
+ };
+
+ return Templates::render(Templates::DENORMALIZE, [
+ 'inner' => $inner,
+ 'class' => $this->plan->className(),
+ 'property' => var_export($property->name, true),
+ 'slot' => (string)$property->slot,
+ ]);
+ }
+
+ /**
+ * Generates the extract code, properties which need another scope are read in a helper closure.
+ *
+ * @return array{string, list} body and init code of the helper closures
+ */
+ private function extractBody(): array
+ {
+ $pre = [];
+ $scoped = [];
+ $fields = [];
+
+ foreach ($this->plan->properties as $property) {
+ $field = var_export($property->field, true);
+ [$code, $expression] = $this->normalize($property);
+
+ if ($property->extractScope !== null) {
+ $scoped[$property->extractScope][] = [$property->name, $code, $expression];
+ $fields[] = sprintf('%s => $v_%s,', $field, $property->name);
+
+ continue;
+ }
+
+ if ($code !== '') {
+ $pre[] = $code;
+ }
+
+ $fields[] = sprintf('%s => %s,', $field, $expression);
+ }
+
+ $helpers = [];
+
+ foreach ($scoped as $scope => $items) {
+ $slot = $this->helperSlots++;
+ $this->properties[] = sprintf('public Closure $ep%d;', $slot);
+
+ $body = [];
+ $values = [];
+ $variables = [];
+
+ foreach ($items as [$name, $code, $expression]) {
+ if ($code !== '') {
+ $body[] = $code;
+ }
+
+ $values[] = $expression;
+ $variables[] = sprintf('$v_%s', $name);
+ }
+
+ $body[] = sprintf('return [%s];', implode(', ', $values));
+
+ $helpers[] = Templates::render(Templates::EXTRACT_HELPER, [
+ 'slot' => (string)$slot,
+ 'scope' => $scope,
+ 'body' => implode("\n", $body),
+ ]);
+
+ array_unshift($pre, sprintf('[%s] = ($this->ep%d)($object, $context, $inline);', implode(', ', $variables), $slot));
+ }
+
+ $variables = [
+ 'class' => $this->plan->className(),
+ 'pre' => implode("\n\n", $pre),
+ 'fields' => implode("\n", $fields),
+ ];
+
+ if ($this->plan->leaf()) {
+ return [Templates::render(Templates::EXTRACT_PLAIN, $variables), $helpers];
+ }
+
+ return [
+ Templates::render(Templates::EXTRACT_TRACKED, $variables) . "\n\n"
+ . Templates::render(Templates::EXTRACT_PLAIN, $variables),
+ $helpers,
+ ];
+ }
+
+ /** @return array{string, string} preparing code and the expression for the normalized value */
+ private function normalize(PropertyPlan $property): array
+ {
+ $name = $property->name;
+
+ if ($property->kind === ValueKind::Raw) {
+ return ['', sprintf('$object->%s', $name)];
+ }
+
+ $variable = sprintf('$v_%s', $name);
+
+ $inner = match ($property->kind) {
+ ValueKind::Normalizer => sprintf('%s = $this->n%d->normalize($object->%s, $context);', $variable, $property->slot, $name),
+ ValueKind::NestedObject => Templates::render(Templates::NORMALIZE_OBJECT, [
+ 'variable' => $variable,
+ 'name' => $name,
+ 'flag' => (string)$property->flag,
+ 'slot' => (string)$property->slot,
+ 'nested' => $this->nestedCall($property, 'extract', '$value'),
+ 'check' => self::instanceCheck('$value', $property),
+ ]),
+ ValueKind::NestedArray => Templates::render(Templates::NORMALIZE_ARRAY, [
+ 'variable' => $variable,
+ 'name' => $name,
+ 'flag' => (string)$property->flag,
+ 'slot' => (string)$property->slot,
+ 'inner' => (string)$property->inner,
+ 'nested' => $this->nestedCall($property, 'extract', '$item'),
+ 'check' => self::instanceCheck('$item', $property),
+ ]),
+ };
+
+ $code = Templates::render(Templates::NORMALIZE, [
+ 'inner' => $inner,
+ 'class' => $this->plan->className(),
+ 'property' => var_export($name, true),
+ 'slot' => (string)$property->slot,
+ ]);
+
+ return [$code, $variable];
+ }
+
+ /** Only exact instances are mapped in place, subclasses take the generic path through the hydrator. */
+ private static function instanceCheck(string $variable, PropertyPlan $property): string
+ {
+ $nested = $property->nested;
+ assert($nested !== null);
+
+ if ($nested->final) {
+ return sprintf('%s instanceof \\%s', $variable, $nested->class);
+ }
+
+ return sprintf('%1$s instanceof \\%2$s && %1$s::class === \\%2$s::class', $variable, $nested->class);
+ }
+
+ /** The call of the nested entry point, it skips the checks this mapper already did. */
+ private function nestedCall(PropertyPlan $property, string $direction, string $value): string
+ {
+ assert($property->nested !== null);
+
+ return sprintf('$this->x%d->%sNested(%s, $context)', $property->nested->slot, $direction, $value);
+ }
+}
diff --git a/src/Extension/Generated/GeneratedCode.php b/src/Extension/Generated/GeneratedCode.php
new file mode 100644
index 00000000..fef1caa4
--- /dev/null
+++ b/src/Extension/Generated/GeneratedCode.php
@@ -0,0 +1,19 @@
+ $nested classes whose objects may be mapped in place, they need a mapper too
+ */
+ public function __construct(
+ public readonly string $code,
+ public readonly array $nested,
+ ) {
+ }
+}
diff --git a/src/Extension/Generated/GeneratedMapper.php b/src/Extension/Generated/GeneratedMapper.php
new file mode 100644
index 00000000..6af6e333
--- /dev/null
+++ b/src/Extension/Generated/GeneratedMapper.php
@@ -0,0 +1,175 @@
+
+ */
+abstract class GeneratedMapper implements Mapper, NestedMapper
+{
+ public bool $ready = false;
+ public bool $initializing = false;
+ public bool|null $recursive = null;
+
+ /** Whether extracted objects are tracked to detect circular references. */
+ public bool $tracked = false;
+
+ /** @var ReflectionClass */
+ public ReflectionClass $reflection;
+
+ /** @param ClassMetadata $metadata */
+ public function __construct(
+ public readonly ClassMetadata $metadata,
+ public readonly StackHydrator $hydrator,
+ public readonly CallStack $callStack,
+ ) {
+ }
+
+ final public function init(): void
+ {
+ if ($this->ready || $this->initializing) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized mapper behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing = true;
+
+ try {
+ $this->reflection = $this->metadata->reflection;
+ $this->initialize();
+ $this->tracked = $this->recursive();
+ $this->ready = true;
+ } finally {
+ $this->initializing = false;
+ }
+ }
+
+ /** Whether an extracted object can lead back to an object of this class, see {@see self::$tracked}. */
+ final public function recursive(): bool
+ {
+ if ($this->recursive !== null) {
+ return $this->recursive;
+ }
+
+ // cycles are treated as recursive
+ $this->recursive = true;
+
+ return $this->recursive = $this->mayReachItself();
+ }
+
+ /** Resolves the normalizers and the mappers of the nested classes. */
+ abstract protected function initialize(): void;
+
+ abstract protected function mayReachItself(): bool;
+
+ final protected function normalizer(string $property): Normalizer
+ {
+ return $this->metadata->properties[$property]->normalizer
+ ?? throw new OutdatedGeneratedMapper($this->metadata->className);
+ }
+
+ final protected function promotedDefault(string $property): ReflectionParameter
+ {
+ return $this->metadata->promotedConstructorDefaults()[$property]
+ ?? throw new OutdatedGeneratedMapper($this->metadata->className);
+ }
+
+ /**
+ * The mapper the hydrator runs for the nested class, if the objects of the property can be mapped with it right
+ * away: the property still has the object normalizer of the class the code was generated for and the nested
+ * class has no class normalizer. A generated mapper is called directly, any other chain, for example with
+ * middlewares, through a {@see ChainNestedMapper}.
+ *
+ * @param class-string $class
+ */
+ final protected function nested(Normalizer $normalizer, string $class): NestedMapper|null
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ $normalizer = $normalizer->innerNormalizer();
+ }
+
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return null;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return null;
+ }
+
+ $mapper = $this->hydrator->mapper($class);
+ } catch (Throwable) {
+ return null;
+ }
+
+ if ($mapper === null || $mapper instanceof self) {
+ return $mapper;
+ }
+
+ return new ChainNestedMapper($mapper);
+ }
+
+ /** Initializes a nested generated mapper, the nested class may lead back to this one. */
+ final protected static function initNested(NestedMapper|null $mapper): void
+ {
+ if (!$mapper instanceof self) {
+ return;
+ }
+
+ $mapper->init();
+ }
+
+ /** Whether objects extracted with the mapper can lead back to the same object. */
+ final protected static function reaches(NestedMapper $mapper): bool
+ {
+ // the middlewares and the lazy proxy of a chain are not known, they might
+ return !$mapper instanceof self || $mapper->recursive();
+ }
+
+ /** Whether the normalizer extracts objects with the hydrator, which can lead back to the same object. */
+ final protected static function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return self::mayRecurse($normalizer->innerNormalizer());
+ }
+
+ if ($normalizer instanceof ArrayShapeNormalizer) {
+ foreach ($normalizer->innerNormalizers() as $inner) {
+ if (self::mayRecurse($inner)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $normalizer instanceof ObjectNormalizer || $normalizer instanceof ObjectMapNormalizer;
+ }
+}
diff --git a/src/Extension/Generated/GeneratedMapperExtension.php b/src/Extension/Generated/GeneratedMapperExtension.php
new file mode 100644
index 00000000..05f3cb32
--- /dev/null
+++ b/src/Extension/Generated/GeneratedMapperExtension.php
@@ -0,0 +1,26 @@
+addMapperFactory(new GeneratedMapperFactory(new MapperFiles($this->cachePath)));
+ }
+}
diff --git a/src/Extension/Generated/GeneratedMapperFactory.php b/src/Extension/Generated/GeneratedMapperFactory.php
new file mode 100644
index 00000000..f41da6a1
--- /dev/null
+++ b/src/Extension/Generated/GeneratedMapperFactory.php
@@ -0,0 +1,63 @@
+ the generated mappers of a hydrator share their call stack */
+ private WeakMap $callStacks;
+
+ public function __construct(
+ private readonly MapperFiles $files,
+ ) {
+ $this->callStacks = new WeakMap();
+ }
+
+ /**
+ * @param ClassMetadata $metadata
+ *
+ * @return Mapper|null
+ *
+ * @template T of object
+ */
+ public function create(ClassMetadata $metadata, StackHydrator $hydrator): Mapper|null
+ {
+ $className = $this->files->className($metadata);
+ $fqcn = $this->files->fqcn($className);
+
+ if (!class_exists($fqcn, false)) {
+ $file = $this->files->path($className);
+
+ if (!is_file($file)) {
+ return null;
+ }
+
+ require_once $file;
+ }
+
+ /** @var Mapper $mapper */
+ $mapper = new $fqcn($metadata, $hydrator, $this->callStacks[$hydrator] ??= new CallStack());
+ assert($mapper instanceof GeneratedMapper);
+
+ return $mapper;
+ }
+}
diff --git a/src/Extension/Generated/GeneratedMapperNotWritable.php b/src/Extension/Generated/GeneratedMapperNotWritable.php
new file mode 100644
index 00000000..c9a85ba9
--- /dev/null
+++ b/src/Extension/Generated/GeneratedMapperNotWritable.php
@@ -0,0 +1,18 @@
+metadataFactory(), $cachePath))->warmup([ProfileCreated::class]);
+ */
+final class GeneratedMapperWarmer
+{
+ private readonly MapperFiles $files;
+
+ public function __construct(
+ private readonly MetadataFactory $metadataFactory,
+ string $cachePath,
+ ) {
+ $this->files = new MapperFiles($cachePath);
+ }
+
+ /**
+ * Generates a mapper for every class and every nested class whose objects may be mapped in place.
+ *
+ * @param list $classes
+ *
+ * @return list all classes a mapper was generated for
+ *
+ * @throws ClassNotGeneratable if a class of the list can not be generated.
+ * @throws GeneratedMapperNotWritable
+ */
+ public function warmup(array $classes): array
+ {
+ $generator = new MapperGenerator();
+ $queue = [];
+
+ foreach ($classes as $class) {
+ $queue[] = [ltrim($class, '\\'), true];
+ }
+
+ $done = [];
+ $generated = [];
+
+ while ($queue !== []) {
+ [$class, $requested] = array_shift($queue);
+
+ if (isset($done[$class])) {
+ continue;
+ }
+
+ $done[$class] = true;
+
+ try {
+ $metadata = $this->metadataFactory->metadata($class);
+ $className = $this->files->className($metadata);
+ $code = $generator->generate($metadata, $this->files->fqcn($className));
+ } catch (ClassNotFound) {
+ if ($requested) {
+ throw new ClassNotGeneratable($class, 'class not found');
+ }
+
+ continue;
+ } catch (ClassNotGeneratable $e) {
+ // nested classes without a mapper are hydrated by the hydrator
+ if ($requested) {
+ throw $e;
+ }
+
+ continue;
+ }
+
+ $this->write($this->files->path($className), $code->code);
+ $generated[] = $metadata->className;
+
+ foreach ($code->nested as $nested) {
+ $queue[] = [$nested, false];
+ }
+ }
+
+ return $generated;
+ }
+
+ /** @throws GeneratedMapperNotWritable */
+ private function write(string $file, string $code): void
+ {
+ $directory = $this->files->cachePath;
+
+ if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) {
+ throw new GeneratedMapperNotWritable($file);
+ }
+
+ $tmp = $file . '.' . uniqid('', true) . '.tmp';
+
+ if (@file_put_contents($tmp, $code) === false || !@rename($tmp, $file)) {
+ @unlink($tmp);
+
+ throw new GeneratedMapperNotWritable($file);
+ }
+ }
+}
diff --git a/src/Extension/Generated/Literal.php b/src/Extension/Generated/Literal.php
new file mode 100644
index 00000000..3e469bbe
--- /dev/null
+++ b/src/Extension/Generated/Literal.php
@@ -0,0 +1,86 @@
+name);
+ }
+
+ if (is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $literal = self::export($item);
+
+ if ($literal === null) {
+ return null;
+ }
+
+ $items[] = sprintf('%s => %s', is_string($key) ? self::string($key) : var_export($key, true), $literal);
+ }
+
+ return '[' . implode(', ', $items) . ']';
+ }
+
+ return null;
+ }
+
+ /**
+ * var_export() writes control characters like newlines verbatim, which breaks the indentation of the
+ * generated code. Such strings are written as double quoted literals with escape sequences instead.
+ */
+ private static function string(string $value): string
+ {
+ if (preg_match('/[\x00-\x1f\x7f]/', $value) !== 1) {
+ return var_export($value, true);
+ }
+
+ $escaped = preg_replace_callback(
+ '/[\x00-\x1f\x7f"\\\\$]/',
+ static fn (array $matches): string => match ($matches[0]) {
+ '"' => '\\"',
+ '\\' => '\\\\',
+ '$' => '\\$',
+ default => sprintf('\\x%02X', ord($matches[0])),
+ },
+ $value,
+ );
+
+ return '"' . $escaped . '"';
+ }
+}
diff --git a/src/Extension/Generated/MapperFiles.php b/src/Extension/Generated/MapperFiles.php
new file mode 100644
index 00000000..3fb90349
--- /dev/null
+++ b/src/Extension/Generated/MapperFiles.php
@@ -0,0 +1,59 @@
+ $metadata
+ *
+ * @template T of object
+ */
+ public function className(ClassMetadata $metadata): string
+ {
+ $hash = hash('sha256', serialize([self::VERSION, $metadata->className, ClassFingerprint::of($metadata)]));
+
+ return sprintf(
+ '%sMapper_%s',
+ preg_replace('/[^A-Za-z0-9_]/', '', $metadata->reflection->getShortName()),
+ substr($hash, 0, 16),
+ );
+ }
+
+ public function fqcn(string $className): string
+ {
+ return self::NAMESPACE . '\\' . $className;
+ }
+
+ public function path(string $className): string
+ {
+ return sprintf('%s/%s.php', $this->cachePath, $className);
+ }
+}
diff --git a/src/Extension/Generated/MapperGenerator.php b/src/Extension/Generated/MapperGenerator.php
new file mode 100644
index 00000000..230f1d79
--- /dev/null
+++ b/src/Extension/Generated/MapperGenerator.php
@@ -0,0 +1,56 @@
+ $metadata
+ *
+ * @throws ClassNotGeneratable
+ *
+ * @template T of object
+ */
+ public function generate(ClassMetadata $metadata, string $fqcn): GeneratedCode
+ {
+ if (!ClassPlanner::generatable($metadata)) {
+ throw new ClassNotGeneratable($metadata->className, 'the class is internal, abstract, anonymous, an enum or an interface');
+ }
+
+ if ($metadata->normalizer !== null) {
+ throw new ClassNotGeneratable($metadata->className, 'the class has a class normalizer');
+ }
+
+ $plan = (new ClassPlanner())->plan($metadata);
+
+ $parts = explode('\\', ltrim($fqcn, '\\'));
+ $className = array_pop($parts);
+ $namespace = implode('\\', $parts);
+
+ $nested = [];
+
+ foreach ($plan->nested() as $class) {
+ $nested[] = $class->class;
+ }
+
+ return new GeneratedCode(
+ (new CodeEmitter($plan))->emit($namespace, $className),
+ $nested,
+ );
+ }
+}
diff --git a/src/Extension/Generated/NestedMapper.php b/src/Extension/Generated/NestedMapper.php
new file mode 100644
index 00000000..e59a0509
--- /dev/null
+++ b/src/Extension/Generated/NestedMapper.php
@@ -0,0 +1,27 @@
+ $data
+ * @param array $context the call comes from the hydrator of the mapper, without an object to populate
+ */
+ public function hydrateNested(array $data, array $context): object;
+
+ /**
+ * @param array $context the call comes from the hydrator of the mapper
+ *
+ * @return array
+ */
+ public function extractNested(object $object, array $context): array;
+}
diff --git a/src/Extension/Generated/NestedPlan.php b/src/Extension/Generated/NestedPlan.php
new file mode 100644
index 00000000..e9b78ae8
--- /dev/null
+++ b/src/Extension/Generated/NestedPlan.php
@@ -0,0 +1,25 @@
+default !== null || $this->defaultSlot !== null;
+ }
+}
diff --git a/src/Extension/Generated/Templates.php b/src/Extension/Generated/Templates.php
new file mode 100644
index 00000000..935ce85e
--- /dev/null
+++ b/src/Extension/Generated/Templates.php
@@ -0,0 +1,286 @@
+ $variables */
+ public static function render(string $template, array $variables): string
+ {
+ $template = trim($template, "\n");
+
+ foreach ($variables as $name => $value) {
+ $template = self::replaceIndented($template, $name, $value);
+ }
+
+ return $template;
+ }
+
+ private static function replaceIndented(string $template, string $name, string $value): string
+ {
+ $placeholder = '{{' . $name . '}}';
+
+ $result = preg_replace_callback(
+ '/^([ \t]*)' . preg_quote($placeholder, '/') . '$/m',
+ static function (array $matches) use ($value): string {
+ $lines = explode("\n", $value);
+
+ return implode("\n", array_map(
+ static fn (string $line): string => $line === '' ? '' : $matches[1] . $line,
+ $lines,
+ ));
+ },
+ $template,
+ );
+
+ return str_replace($placeholder, $value, $result ?? $template);
+ }
+
+ public const MAPPER = <<<'PHP'
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ {{hydrate}}
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ {{extract}}
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ {{hydrateNested}}
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ {{extractNested}}
+ }
+
+ protected function initialize(): void
+ {
+ {{initialize}}
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return {{recursive}};
+ }
+}
+
+PHP;
+
+ public const CLOSURE = <<<'PHP'
+$this->{{slot}} = Closure::bind(function ({{signature}}): {{return}} {
+ {{body}}
+}, $this, \{{class}}::class);
+PHP;
+
+ public const HYDRATE_HELPER = <<<'PHP'
+$this->hp{{slot}} = Closure::bind(function (object $object, array $data, array $context, bool $inline): void {
+ {{body}}
+}, $this, \{{scope}}::class);
+PHP;
+
+ public const EXTRACT_TRACKED = <<<'PHP'
+if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \{{class}}::class;
+
+ try {
+ {{pre}}
+
+ return [
+ {{fields}}
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+}
+PHP;
+
+ /** Nested objects are only mapped right away for calls from the hydrator which created the mapper. */
+ public const OWNER = '($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator';
+
+ public const EXTRACT_PLAIN = <<<'PHP'
+{{pre}}
+
+return [
+ {{fields}}
+];
+PHP;
+
+ public const EXTRACT_HELPER = <<<'PHP'
+$this->ep{{slot}} = Closure::bind(function (object $object, array $context, bool $inline): array {
+ {{body}}
+}, $this, \{{scope}}::class);
+PHP;
+
+ public const ASSIGN = <<<'PHP'
+if (\array_key_exists({{field}}, $data)) {
+ {{value}}
+
+ try {
+ $object->{{name}} = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\{{class}}::class, {{property}}, $e);
+ }
+}
+PHP;
+
+ public const ASSIGN_DEFAULT = <<<'PHP'
+if (\array_key_exists({{field}}, $data)) {
+ {{value}}
+
+ try {
+ $object->{{name}} = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\{{class}}::class, {{property}}, $e);
+ }
+} else {
+ $object->{{name}} = {{default}};
+}
+PHP;
+
+ public const DENORMALIZE = <<<'PHP'
+try {
+ {{inner}}
+} catch (Throwable $e) {
+ throw new DenormalizationFailure(\{{class}}::class, {{property}}, $this->n{{slot}}::class, $e);
+}
+PHP;
+
+ public const DENORMALIZE_OBJECT = <<<'PHP'
+{{variable}} = $data[{{field}}];
+
+if ($this->i{{flag}} && $inline && \is_array({{variable}})) {
+ {{variable}} = {{nested}};
+} else {
+ {{variable}} = $this->n{{slot}}->denormalize({{variable}}, $context);
+}
+PHP;
+
+ public const DENORMALIZE_ARRAY = <<<'PHP'
+{{variable}} = $data[{{field}}];
+
+if ($this->i{{flag}} && $inline && \is_array({{variable}})) {
+ $items = [];
+
+ foreach ({{variable}} as $key => $item) {
+ $items[$key] = \is_array($item) ? {{nested}} : $this->n{{inner}}->denormalize($item, $context);
+ }
+
+ {{variable}} = $items;
+} else {
+ {{variable}} = $this->n{{slot}}->denormalize({{variable}}, $context);
+}
+PHP;
+
+ public const NORMALIZE = <<<'PHP'
+try {
+ {{inner}}
+} catch (CircularReference $e) {
+ throw $e;
+} catch (Throwable $e) {
+ throw new NormalizationFailure(\{{class}}::class, {{property}}, $this->n{{slot}}::class, $e);
+}
+PHP;
+
+ public const NORMALIZE_OBJECT = <<<'PHP'
+$value = $object->{{name}};
+{{variable}} = $this->i{{flag}} && $inline && {{check}} ? {{nested}} : $this->n{{slot}}->normalize($value, $context);
+PHP;
+
+ public const NORMALIZE_ARRAY = <<<'PHP'
+$value = $object->{{name}};
+
+if ($this->i{{flag}} && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = {{check}} ? {{nested}} : $this->n{{inner}}->normalize($item, $context);
+ }
+
+ {{variable}} = $items;
+} else {
+ {{variable}} = $this->n{{slot}}->normalize($value, $context);
+}
+PHP;
+}
diff --git a/src/Extension/Generated/ValueKind.php b/src/Extension/Generated/ValueKind.php
new file mode 100644
index 00000000..86ea63a5
--- /dev/null
+++ b/src/Extension/Generated/ValueKind.php
@@ -0,0 +1,25 @@
+metadataFactory(),
+ $this->middlewares(),
+ $this->defaultLazy,
+ $this->mapperFactories(),
+ );
+ }
+
+ /**
+ * The metadata factory the hydrator is built with, for example to generate code or warm up caches with the same
+ * metadata the hydrator ends up with. Every call creates a new instance.
+ */
+ public function metadataFactory(): MetadataFactory
{
$metadataFactory = new EnrichingMetadataFactory(
new AttributeMetadataFactory(
@@ -117,12 +132,7 @@ public function build(): StackHydrator
$metadataFactory = new Psr16MetadataFactory($metadataFactory, $this->cache);
}
- return new StackHydrator(
- $metadataFactory,
- $this->middlewares(),
- $this->defaultLazy,
- $this->mapperFactories(),
- );
+ return $metadataFactory;
}
public function defaultLazy(): bool
diff --git a/tests/Architecture/NoCodeGenerationAtRuntimeTest.php b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php
new file mode 100644
index 00000000..626fce65
--- /dev/null
+++ b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php
@@ -0,0 +1,43 @@
+classes(
+ Selector::classname(GeneratedMapperExtension::class),
+ Selector::classname(GeneratedMapperFactory::class),
+ Selector::classname(GeneratedMapper::class),
+ Selector::classname(ChainNestedMapper::class),
+ )
+ ->shouldNotDependOn()
+ ->classes(
+ Selector::classname(GeneratedMapperWarmer::class),
+ Selector::classname(MapperGenerator::class),
+ Selector::classname(CodeEmitter::class),
+ Selector::classname(ClassPlanner::class),
+ Selector::classname(ClassPlan::class),
+ Selector::classname(Templates::class),
+ );
+ }
+}
diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php
similarity index 86%
rename from tests/Benchmark/HydratorBench.php
rename to tests/Benchmark/GeneratedHydratorBench.php
index d760e689..e51b3278 100644
--- a/tests/Benchmark/HydratorBench.php
+++ b/tests/Benchmark/GeneratedHydratorBench.php
@@ -4,6 +4,8 @@
namespace Patchlevel\Hydrator\Tests\Benchmark;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperWarmer;
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\StackHydratorBuilder;
use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated;
@@ -12,14 +14,18 @@
use PhpBench\Attributes as Bench;
#[Bench\BeforeMethods('setUp')]
-final class HydratorBench
+final class GeneratedHydratorBench
{
private Hydrator $hydrator;
public function __construct()
{
- $this->hydrator = (new StackHydratorBuilder())
- ->build();
+ $builder = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedMapperExtension(__DIR__ . '/../../var/cache'));
+
+ (new GeneratedMapperWarmer($builder->metadataFactory(), __DIR__ . '/../../var/cache'))->warmup([ProfileCreated::class]);
+
+ $this->hydrator = $builder->build();
}
public function setUp(): void
diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/GeneratedHydratorWithCryptographyBench.php
similarity index 87%
rename from tests/Benchmark/HydratorWithCryptographyBench.php
rename to tests/Benchmark/GeneratedHydratorWithCryptographyBench.php
index 0851d555..cc691d08 100644
--- a/tests/Benchmark/HydratorWithCryptographyBench.php
+++ b/tests/Benchmark/GeneratedHydratorWithCryptographyBench.php
@@ -7,6 +7,8 @@
use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer;
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperExtension;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedMapperWarmer;
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\StackHydratorBuilder;
use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated;
@@ -15,7 +17,7 @@
use PhpBench\Attributes as Bench;
#[Bench\BeforeMethods('setUp')]
-final class HydratorWithCryptographyBench
+final class GeneratedHydratorWithCryptographyBench
{
private InMemoryCipherKeyStore $store;
@@ -25,9 +27,13 @@ public function __construct()
{
$this->store = new InMemoryCipherKeyStore();
- $this->hydrator = (new StackHydratorBuilder())
- ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($this->store)))
- ->build();
+ $builder = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedMapperExtension(__DIR__ . '/../../var/cache'))
+ ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($this->store)));
+
+ (new GeneratedMapperWarmer($builder->metadataFactory(), __DIR__ . '/../../var/cache'))->warmup([ProfileCreated::class]);
+
+ $this->hydrator = $builder->build();
}
public function setUp(): void
diff --git a/tests/Unit/Extension/Generated/GeneratedMapperWarmerTest.php b/tests/Unit/Extension/Generated/GeneratedMapperWarmerTest.php
new file mode 100644
index 00000000..c32c9664
--- /dev/null
+++ b/tests/Unit/Extension/Generated/GeneratedMapperWarmerTest.php
@@ -0,0 +1,148 @@
+cachePath = sys_get_temp_dir() . '/' . uniqid('patchlevel-hydrator-warmer-', true);
+ }
+
+ public function tearDown(): void
+ {
+ if (!is_dir($this->cachePath)) {
+ return;
+ }
+
+ foreach (glob($this->cachePath . '/*') ?: [] as $file) {
+ unlink($file);
+ }
+
+ rmdir($this->cachePath);
+ }
+
+ public function testWarmupGeneratesNestedClasses(): void
+ {
+ $metadataFactory = new AttributeMetadataFactory();
+ $warmer = new GeneratedMapperWarmer($metadataFactory, $this->cachePath);
+
+ self::assertSame(
+ [ProfileCreatedWrapper::class, ProfileCreated::class],
+ $warmer->warmup([ProfileCreatedWrapper::class]),
+ );
+
+ $files = new MapperFiles($this->cachePath);
+
+ foreach ([ProfileCreatedWrapper::class, ProfileCreated::class] as $class) {
+ self::assertFileExists($files->path($files->className($metadataFactory->metadata($class))));
+ }
+ }
+
+ public function testWarmupIsDeterministic(): void
+ {
+ $metadataFactory = new AttributeMetadataFactory();
+ $files = new MapperFiles($this->cachePath);
+ $file = $files->path($files->className($metadataFactory->metadata(ProfileCreated::class)));
+
+ (new GeneratedMapperWarmer($metadataFactory, $this->cachePath))->warmup([ProfileCreated::class]);
+ $first = file_get_contents($file);
+
+ (new GeneratedMapperWarmer($metadataFactory, $this->cachePath))->warmup([ProfileCreated::class]);
+
+ self::assertSame($first, file_get_contents($file));
+ }
+
+ public function testWarmedMappersAreUsed(): void
+ {
+ $builder = (new StackHydratorBuilder())->useExtension(new GeneratedMapperExtension($this->cachePath));
+ (new GeneratedMapperWarmer($builder->metadataFactory(), $this->cachePath))->warmup([ProfileCreatedWrapper::class]);
+ $hydrator = $builder->build();
+
+ $data = ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']];
+ $object = $hydrator->hydrate(ProfileCreatedWrapper::class, $data);
+
+ self::assertEquals(
+ new ProfileCreatedWrapper(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'))),
+ $object,
+ );
+ self::assertSame($data, $hydrator->extract($object));
+
+ $mappers = (new ReflectionProperty(StackHydrator::class, 'mappers'))->getValue($hydrator);
+ self::assertIsArray($mappers);
+ self::assertInstanceOf(GeneratedMapper::class, $mappers[ProfileCreatedWrapper::class]);
+ self::assertInstanceOf(GeneratedMapper::class, $mappers[ProfileCreated::class]);
+ }
+
+ public function testUnknownClassIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ /** @phpstan-ignore argument.type */
+ (new GeneratedMapperWarmer(new AttributeMetadataFactory(), $this->cachePath))->warmup(['Unknown']);
+ }
+
+ public function testAbstractClassIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ (new GeneratedMapperWarmer(new AttributeMetadataFactory(), $this->cachePath))->warmup([ChildDto::class]);
+ }
+
+ public function testClassWithClassNormalizerIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ (new GeneratedMapperWarmer(new AttributeMetadataFactory(), $this->cachePath))->warmup([ProfileId::class]);
+ }
+
+ public function testNotWritable(): void
+ {
+ $this->expectException(GeneratedMapperNotWritable::class);
+
+ $file = $this->cachePath . '-file';
+ touch($file);
+
+ try {
+ (new GeneratedMapperWarmer(new AttributeMetadataFactory(), $file))->warmup([ProfileCreated::class]);
+ } finally {
+ unlink($file);
+ }
+ }
+}
diff --git a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php
new file mode 100644
index 00000000..41a14954
--- /dev/null
+++ b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php
@@ -0,0 +1,1113 @@
+= 80400) {
+ $classes[] = AsymmetricVisibilityDto::class;
+ }
+
+ (new GeneratedMapperWarmer((new StackHydratorBuilder())->metadataFactory(), self::$cachePath))->warmup($classes);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ foreach (glob(self::$cachePath . '/*.php') ?: [] as $file) {
+ unlink($file);
+ }
+
+ rmdir(self::$cachePath);
+ }
+
+ public function setUp(): void
+ {
+ $this->hydrator = $this->builder()->build();
+ }
+
+ /**
+ * Closures in attributes (used by the inline normalizer fixture) are only supported since PHP 8.5.
+ *
+ * @return list
+ */
+ private static function supportedClasses(): array
+ {
+ if (PHP_VERSION_ID >= 80500) {
+ return self::CLASSES;
+ }
+
+ return array_values(array_filter(
+ self::CLASSES,
+ static fn (string $class): bool => $class !== ProfileCreatedWithInlineNormalizer::class,
+ ));
+ }
+
+ private function builder(): StackHydratorBuilder
+ {
+ return (new StackHydratorBuilder())->useExtension($this->extension());
+ }
+
+ private function extension(): GeneratedMapperExtension
+ {
+ return new GeneratedMapperExtension(self::$cachePath);
+ }
+
+ public function testExtract(): void
+ {
+ $event = new ProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ self::assertEquals(
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ $this->hydrator->extract($event),
+ );
+ }
+
+ public function testExtractWithInheritance(): void
+ {
+ $event = new ParentDto(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ self::assertEquals(
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ $this->hydrator->extract($event),
+ );
+ }
+
+ public function testExtractWithHydratorAwareNormalizer(): void
+ {
+ $event = new ProfileCreatedWrapper(
+ new ProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ ),
+ );
+
+ self::assertEquals(
+ ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']],
+ $this->hydrator->extract($event),
+ );
+ }
+
+ public function testExtractPassesContextToNormalizer(): void
+ {
+ $dto = new ContextAwareDto('value');
+
+ $data = $this->hydrator->extract($dto, ['prefix' => 'ctx-']);
+
+ self::assertSame(['value' => 'ctx-value'], $data);
+ }
+
+ public function testExtractCircularReference(): void
+ {
+ $this->expectException(CircularReference::class);
+ $this->expectExceptionMessage('Circular reference detected: Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto');
+
+ $dto1 = new Circle1Dto();
+ $dto2 = new Circle2Dto();
+ $dto3 = new Circle3Dto();
+
+ $dto1->to = $dto2;
+ $dto2->to = $dto3;
+ $dto3->to = $dto1;
+
+ $this->hydrator->extract($dto1);
+ }
+
+ public function testExtractWithInferNormalizer(): void
+ {
+ $result = $this->hydrator->extract(
+ new InferNormalizerWithNullableDto(
+ null,
+ null,
+ profileId: ProfileId::fromString('1'),
+ ),
+ );
+
+ self::assertEquals(
+ [
+ 'status' => null,
+ 'dateTimeImmutable' => null,
+ 'dateTime' => null,
+ 'dateTimeZone' => null,
+ 'profileId' => '1',
+ ],
+ $result,
+ );
+ }
+
+ public function testExtractWithContext(): void
+ {
+ $object = new InferNormalizerDto(
+ Status::Draft,
+ new DateTimeImmutable('2015-02-13 22:34:32+01:00'),
+ new DateTime('2015-02-13 22:34:32+01:00'),
+ new DateTimeZone('EDT'),
+ ['foo'],
+ );
+
+ $expect = [
+ 'status' => 'draft',
+ 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00',
+ 'dateTime' => '2015-02-13T22:34:32+01:00',
+ 'dateTimeZone' => 'EDT',
+ 'array' => ['foo'],
+ ];
+
+ $middleware = $this->createMock(Middleware::class);
+ $middleware
+ ->expects($this->once())
+ ->method('extract')
+ ->with(
+ $this->isInstanceOf(ClassMetadata::class),
+ $object,
+ $this->callback(static fn (array $context): bool => $context['context'] === '123'
+ && $context[Hydrator::HYDRATOR] instanceof StackHydrator),
+ $this->isInstanceOf(Mapper::class),
+ )->willReturn($expect);
+
+ $hydrator = $this->builder()
+ ->addMiddleware($middleware)
+ ->build();
+
+ $data = $hydrator->extract($object, ['context' => '123']);
+
+ self::assertEquals($expect, $data);
+ }
+
+ #[RequiresPhp('>=8.5')]
+ public function testExtractWithInlineNormalizer(): void
+ {
+ $event = new ProfileCreatedWithInlineNormalizer(
+ ProfileId::fromString('1'),
+ ValueObject::fromString('foo'),
+ );
+
+ self::assertEquals(
+ ['profileId' => '1', 'valueObject' => 'foo'],
+ $this->hydrator->extract($event),
+ );
+ }
+
+ public function testExtractWithClassNormalizer(): void
+ {
+ $data = $this->hydrator->extract(
+ ProfileId::fromString('id'),
+ );
+
+ self::assertEquals('id', $data);
+ }
+
+ public function testExtractPrivateProperties(): void
+ {
+ $dto = new PrivatePropertiesDto(ProfileId::fromString('1'), 'foo', 12);
+
+ self::assertSame(
+ ['profileId' => '1', 'name' => 'foo', 'age' => 12],
+ $this->hydrator->extract($dto),
+ );
+ }
+
+ public function testExtractPrivatePropertiesOfParent(): void
+ {
+ $dto = new PrivateChildDto(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'), 'note', 'foo');
+
+ self::assertSame(
+ ['profileId' => '1', 'name' => 'foo', 'email' => 'info@patchlevel.de', 'note' => 'note'],
+ $this->hydrator->extract($dto),
+ );
+ self::assertSame((new StackHydrator())->extract($dto), $this->hydrator->extract($dto));
+ }
+
+ public function testHydrate(): void
+ {
+ $expected = new ProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ $event = $this->hydrator->hydrate(
+ ProfileCreated::class,
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydratePassesContextToNormalizer(): void
+ {
+ $event = $this->hydrator->hydrate(
+ ContextAwareDto::class,
+ ['value' => 'value'],
+ ['suffix' => '-ctx'],
+ );
+
+ self::assertSame('value-ctx', $event->value);
+ }
+
+ public function testHydrateUnknownClass(): void
+ {
+ $this->expectException(ClassNotSupported::class);
+ $this->expectExceptionCode(0);
+
+ $this->hydrator->hydrate(
+ // @phpstan-ignore argument.type
+ 'Unknown',
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+ }
+
+ public function testHydrateWithArrayDataRequired(): void
+ {
+ $this->expectException(ArrayDataRequired::class);
+
+ $this->hydrator->hydrate(
+ ProfileCreated::class,
+ 'foo',
+ );
+ }
+
+ public function testHydrateWithDefaults(): void
+ {
+ $object = $this->hydrator->hydrate(
+ DefaultDto::class,
+ ['name' => 'test'],
+ );
+
+ self::assertEquals('test', $object->name);
+ self::assertEquals(new Email('info@patchlevel.de'), $object->email);
+ self::assertEquals(true, $object->admin);
+ }
+
+ public function testHydrateWithInheritance(): void
+ {
+ $expected = new ParentDto(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ $event = $this->hydrator->hydrate(
+ ParentDto::class,
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithHydratorAwareNormalizer(): void
+ {
+ $expected = new ProfileCreatedWrapper(
+ new ProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ ),
+ );
+
+ $event = $this->hydrator->hydrate(
+ ProfileCreatedWrapper::class,
+ [
+ 'event' => ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ ],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithObjectToPopulate(): void
+ {
+ $id = ProfileId::fromString('1');
+ $dto = new ProfileCreated($id, Email::fromString('foo@patchlevel.de'));
+
+ $processedDto = $this->hydrator->hydrate(
+ $dto::class,
+ ['email' => 'other@patchlevel.de'],
+ [StackHydrator::OBJECT_TO_POPULATE => $dto],
+ );
+
+ self::assertSame($dto, $processedDto);
+ self::assertSame($id, $processedDto->profileId);
+ self::assertEquals(Email::fromString('other@patchlevel.de'), $processedDto->email);
+ }
+
+ public function testHydrateWithTypeMismatch(): void
+ {
+ $this->expectException(TypeMismatch::class);
+ $this->expectExceptionMessage('The value could not be set because the expected type of the property "profileId" in class "Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated" does not match.');
+
+ $this->hydrator->hydrate(
+ ProfileCreated::class,
+ ['profileId' => null, 'email' => null],
+ );
+ }
+
+ public function testHydrateWithContext(): void
+ {
+ $expect = new InferNormalizerDto(
+ Status::Draft,
+ new DateTimeImmutable('2015-02-13 22:34:32+01:00'),
+ new DateTime('2015-02-13 22:34:32+01:00'),
+ new DateTimeZone('EDT'),
+ ['foo'],
+ );
+
+ $data = [
+ 'status' => 'draft',
+ 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00',
+ 'dateTime' => '2015-02-13T22:34:32+01:00',
+ 'dateTimeZone' => 'EDT',
+ 'array' => ['foo'],
+ ];
+
+ $middleware = $this->createMock(Middleware::class);
+ $middleware
+ ->expects($this->once())
+ ->method('hydrate')
+ ->with(
+ $this->isInstanceOf(ClassMetadata::class),
+ $data,
+ $this->callback(static fn (array $context): bool => $context['context'] === '123'
+ && $context[Hydrator::HYDRATOR] instanceof StackHydrator),
+ $this->isInstanceOf(Mapper::class),
+ )->willReturn($expect);
+
+ $hydrator = $this->builder()
+ ->addMiddleware($middleware)
+ ->build();
+
+ $object = $hydrator->hydrate(InferNormalizerDto::class, $data, ['context' => '123']);
+
+ self::assertEquals($expect, $object);
+ }
+
+ #[RequiresPhp('>=8.5')]
+ public function testHydrateWithInlineNormalizer(): void
+ {
+ $expected = new ProfileCreatedWithInlineNormalizer(
+ ProfileId::fromString('1'),
+ ValueObject::fromString('foo'),
+ );
+
+ $event = $this->hydrator->hydrate(
+ ProfileCreatedWithInlineNormalizer::class,
+ ['profileId' => '1', 'valueObject' => 'foo'],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testDenormalizationFailure(): void
+ {
+ $this->expectException(DenormalizationFailure::class);
+
+ $this->hydrator->hydrate(
+ ProfileCreated::class,
+ ['profileId' => 123, 'email' => 123],
+ );
+ }
+
+ public function testNormalizationFailure(): void
+ {
+ $this->expectException(NormalizationFailure::class);
+
+ $this->hydrator->extract(
+ new WrongNormalizer(true),
+ );
+ }
+
+ public function testHydrateWithNormalizerInBaseClass(): void
+ {
+ $expected = new NormalizerInBaseClassDefinedDto(
+ StatusWithNormalizer::Draft,
+ new ProfileCreatedWithNormalizer(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ ),
+ [StatusWithNormalizer::Draft],
+ [StatusWithNormalizer::Draft],
+ [StatusWithNormalizer::Draft],
+ [
+ 'foo' => new Skill('php'),
+ 'bar' => new Skill('symfony'),
+ ],
+ [
+ 'foo' => 'php',
+ 'bar' => 15,
+ 'baz' => ['test'],
+ ],
+ );
+
+ $event = $this->hydrator->hydrate(
+ NormalizerInBaseClassDefinedDto::class,
+ [
+ 'status' => 'draft',
+ 'profileCreated' => ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ 'defaultArray' => ['draft'],
+ 'listArray' => ['draft'],
+ 'iterableArray' => ['draft'],
+ 'skillsHashMap' => ['foo' => ['name' => 'php'], 'bar' => ['name' => 'symfony']],
+ 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']],
+ ],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithInferNormalizer(): void
+ {
+ $expected = new InferNormalizerDto(
+ Status::Draft,
+ new DateTimeImmutable('2015-02-13 22:34:32+01:00'),
+ new DateTime('2015-02-13 22:34:32+01:00'),
+ new DateTimeZone('EDT'),
+ ['foo'],
+ );
+
+ $event = $this->hydrator->hydrate(
+ InferNormalizerDto::class,
+ [
+ 'status' => 'draft',
+ 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00',
+ 'dateTime' => '2015-02-13T22:34:32+01:00',
+ 'dateTimeZone' => 'EDT',
+ 'array' => ['foo'],
+ ],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithInferNormalizerAndNullableProperties(): void
+ {
+ $expected = new InferNormalizerWithNullableDto(
+ null,
+ null,
+ null,
+ null,
+ );
+
+ $event = $this->hydrator->hydrate(
+ InferNormalizerWithNullableDto::class,
+ [
+ 'status' => null,
+ 'dateTimeImmutable' => null,
+ 'dateTime' => null,
+ 'dateTimeZone' => null,
+ ],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithInferNormalizerWitIterables(): void
+ {
+ $expected = new InferNormalizerWithIterablesDto(
+ [Status::Draft],
+ [Status::Draft],
+ [Status::Draft],
+ [
+ 'foo' => Status::Draft,
+ 'bar' => Status::Draft,
+ ],
+ [
+ 'foo' => [Status::Draft],
+ 'bar' => [Status::Draft],
+ ],
+ [
+ 'foo' => 'php',
+ 'bar' => 15,
+ 'baz' => ['test'],
+ ],
+ [
+ 'status' => Status::Draft,
+ 'other' => [Status::Draft],
+ ],
+ );
+
+ $event = $this->hydrator->hydrate(
+ InferNormalizerWithIterablesDto::class,
+ [
+ 'defaultArray' => ['draft'],
+ 'listArray' => ['draft'],
+ 'iterableArray' => ['draft'],
+ 'hashMap' => ['foo' => 'draft', 'bar' => 'draft'],
+ 'nested' => ['foo' => ['draft'], 'bar' => ['draft']],
+ 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']],
+ 'shapeArray' => ['status' => 'draft', 'other' => ['draft']],
+ ],
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ public function testHydrateWithClassNormalizer(): void
+ {
+ $object = $this->hydrator->hydrate(
+ ProfileId::class,
+ 'id',
+ );
+
+ self::assertEquals(ProfileId::fromString('id'), $object);
+ }
+
+ public function testHydratePrivateProperties(): void
+ {
+ $dto = $this->hydrator->hydrate(
+ PrivatePropertiesDto::class,
+ ['profileId' => '1', 'name' => 'foo', 'age' => 12],
+ );
+
+ self::assertEquals(ProfileId::fromString('1'), $dto->profileId());
+ self::assertSame('foo', $dto->name());
+ self::assertSame(12, $dto->age());
+ }
+
+ public function testHydratePrivatePropertiesOfParent(): void
+ {
+ $dto = $this->hydrator->hydrate(
+ PrivateChildDto::class,
+ ['email' => 'info@patchlevel.de', 'note' => 'note', 'profileId' => '1', 'name' => 'foo'],
+ );
+
+ self::assertEquals(ProfileId::fromString('1'), $dto->profileId);
+ self::assertEquals(Email::fromString('info@patchlevel.de'), $dto->email());
+ self::assertSame('note', $dto->note());
+ self::assertSame('foo', $dto->name());
+ }
+
+ #[RequiresPhp('>=8.4')]
+ public function testHydrateAsymmetricVisibility(): void
+ {
+ $data = ['name' => 'foo', 'age' => 12, 'note' => 'note'];
+ $hydrator = $this->builder()->build();
+
+ $dto = $hydrator->hydrate(AsymmetricVisibilityDto::class, $data);
+
+ self::assertEquals(new AsymmetricVisibilityDto('foo', 12, 'note'), $dto);
+ self::assertSame($data, $hydrator->extract($dto));
+ }
+
+ public function testHydrateCoercesScalarsLikeReflection(): void
+ {
+ $dto = $this->hydrator->hydrate(
+ PrivatePropertiesDto::class,
+ ['profileId' => '1', 'name' => 'foo', 'age' => '12'],
+ );
+
+ self::assertSame(12, $dto->age());
+ }
+
+ public function testChangedClassFallsBackToReflection(): void
+ {
+ // the renamed fields change the fingerprint, like a class which changed after the warmup
+ $hydrator = $this->builder()
+ ->addMetadataEnricher(new class implements MetadataEnricher {
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ foreach ($classMetadata->properties as $property) {
+ $property->fieldName = 'renamed_' . $property->fieldName;
+ }
+ }
+ })
+ ->build();
+
+ $data = ['renamed_profileId' => '1', 'renamed_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::assertInstanceOf(ReflectionMapper::class, self::mappers($hydrator)[ProfileCreated::class]);
+ }
+
+ #[RequiresPhp('>=8.4')]
+ public function testLazyHydrate(): void
+ {
+ $event = $this->hydrator->hydrate(
+ LazyProfileCreated::class,
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+
+ $expected = new LazyProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ $reflection = new ReflectionClass(LazyProfileCreated::class);
+ self::assertTrue($reflection->isUninitializedLazyObject($event));
+
+ $reflection->initializeLazyObject($event);
+ self::assertEquals($expected, $event);
+ }
+
+ #[RequiresPhp('<8.4')]
+ public function testLazyNotSupported(): void
+ {
+ $event = $this->hydrator->hydrate(
+ LazyProfileCreated::class,
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+
+ $expected = new LazyProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ self::assertEquals($expected, $event);
+ }
+
+ #[RequiresPhp('>=8.4')]
+ public function testLazyExtract(): void
+ {
+ $event = $this->hydrator->hydrate(
+ LazyProfileCreated::class,
+ ['profileId' => '1', 'email' => 'info@patchlevel.de'],
+ );
+
+ $data = $this->hydrator->extract($event);
+
+ self::assertEquals(['profileId' => '1', 'email' => 'info@patchlevel.de'], $data);
+ }
+
+ public function testDecrypt(): void
+ {
+ $object = new SensitiveDataProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ $encryptedPayload = ['id' => '1', 'email' => 'encrypted'];
+
+ $cryptographer = $this->createMock(Cryptographer::class);
+ $cryptographer
+ ->expects($this->once())
+ ->method('supports')
+ ->with('encrypted')
+ ->willReturn(true);
+
+ $cryptographer
+ ->expects($this->once())
+ ->method('decrypt')
+ ->with('1', 'encrypted')
+ ->willReturn('info@patchlevel.de');
+
+ $hydrator = $this->builder()
+ ->useExtension(new CryptographyExtension($cryptographer))
+ ->build();
+
+ $return = $hydrator->hydrate(SensitiveDataProfileCreated::class, $encryptedPayload);
+
+ self::assertEquals($object, $return);
+ }
+
+ public function testEncrypt(): void
+ {
+ $object = new SensitiveDataProfileCreated(
+ ProfileId::fromString('1'),
+ Email::fromString('info@patchlevel.de'),
+ );
+
+ $encryptedPayload = [
+ 'id' => '1',
+ 'email' => [
+ '__enc' => 'v1',
+ 'data' => 'encrypted',
+ 'method' => 'foo',
+ 'iv' => 'bar',
+ ],
+ ];
+
+ $cryptographer = $this->createMock(Cryptographer::class);
+
+ $cryptographer
+ ->expects($this->never())
+ ->method('supports');
+
+ $cryptographer
+ ->expects($this->once())
+ ->method('encrypt')
+ ->with('1', 'info@patchlevel.de')
+ ->willReturn([
+ '__enc' => 'v1',
+ 'data' => 'encrypted',
+ 'method' => 'foo',
+ 'iv' => 'bar',
+ ]);
+
+ $hydrator = $this->builder()
+ ->useExtension(new CryptographyExtension($cryptographer))
+ ->build();
+
+ $return = $hydrator->extract($object);
+
+ self::assertSame($encryptedPayload, $return);
+ }
+
+ /**
+ * Reads the inlining decisions of the generated mappers the hydrator uses.
+ *
+ * @return array flag name => mapped in place
+ */
+ private static function inlined(StackHydrator $hydrator, string $class): array
+ {
+ /** @var array $flags */
+ $flags = array_filter(
+ get_object_vars(self::generated($hydrator, $class)),
+ static fn (string $name): bool => preg_match('/^i\d+$/', $name) === 1,
+ ARRAY_FILTER_USE_KEY,
+ );
+
+ return $flags;
+ }
+
+ /** The generated mapper of the class, unwrapped from the middlewares and the lazy proxy. */
+ private static function generated(StackHydrator $hydrator, string $class): GeneratedMapper
+ {
+ $mapper = self::mappers($hydrator)[$class] ?? null;
+
+ while ($mapper instanceof MiddlewareMapper || $mapper instanceof LazyMapper) {
+ $mapper = (new ReflectionProperty($mapper, $mapper instanceof LazyMapper ? 'mapper' : 'next'))->getValue($mapper);
+ }
+
+ self::assertInstanceOf(GeneratedMapper::class, $mapper);
+
+ return $mapper;
+ }
+
+ /** @return array> */
+ private static function mappers(StackHydrator $hydrator): array
+ {
+ /** @var array> $mappers */
+ $mappers = (new ReflectionProperty(StackHydrator::class, 'mappers'))->getValue($hydrator);
+
+ return $mappers;
+ }
+
+ public function testGeneratedMappersAreUsed(): void
+ {
+ $data = ['profileId' => '1', 'email' => 'info@patchlevel.de'];
+ $this->hydrator->extract($this->hydrator->hydrate(ProfileCreated::class, $data));
+
+ $mapper = self::mappers($this->hydrator)[ProfileCreated::class];
+
+ self::assertInstanceOf(GeneratedMapper::class, $mapper);
+ self::assertStringStartsWith(MapperFiles::NAMESPACE . '\\', $mapper::class);
+ }
+
+ public function testNestedObjectsAreInlinedWithoutOtherMiddlewares(): void
+ {
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+
+ self::assertSame($data, $this->hydrator->extract($this->hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(
+ ['i0' => true, 'i1' => true],
+ self::inlined($this->hydrator, NestedLifecycleDto::class),
+ );
+ }
+
+ public function testNestedObjectsAreInlinedWhenOtherMiddlewaresSkipThem(): void
+ {
+ $counter = new CountingMiddleware([LifecycleFixture::class => Skip::Both]);
+
+ $hydrator = $this->builder()
+ ->addMiddleware($counter)
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+
+ self::assertSame(
+ ['i0' => true, 'i1' => true],
+ self::inlined($hydrator, NestedLifecycleDto::class),
+ );
+ self::assertSame([NestedLifecycleDto::class => 1], $counter->hydrated);
+ }
+
+ public function testNestedObjectsAreMappedWithTheirChainWhenAMiddlewareRunsForThem(): void
+ {
+ // an upcaster which can not be introspected runs for every class while hydrating
+ $upcaster = new class implements Upcaster {
+ /**
+ * @param array $data
+ * @param array $context
+ *
+ * @return array
+ */
+ public function upcast(ClassMetadata $metadata, array $data, array $context): array
+ {
+ return $data;
+ }
+ };
+
+ $hydrator = $this->builder()
+ ->useExtension(new UpcastExtension([$upcaster]))
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(['i0' => true, 'i1' => true], self::inlined($hydrator, NestedLifecycleDto::class));
+
+ // callback upcasters for other classes do not prevent inlining
+ $hydrator = $this->builder()
+ ->useExtension(new UpcastExtension([CallbackUpcaster::forClass(ProfileCreated::class, static fn (array $data): array => $data)]))
+ ->build();
+
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(
+ ['i0' => true, 'i1' => true],
+ self::inlined($hydrator, NestedLifecycleDto::class),
+ );
+ }
+
+ public function testGeneratedMapperRunsAfterOtherMiddlewares(): void
+ {
+ $counter = new CountingMiddleware();
+
+ $hydrator = $this->builder()
+ ->addMiddleware($counter)
+ ->build();
+
+ $data = ['profileId' => '1', 'email' => 'info@patchlevel.de'];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(ProfileCreated::class, $data)));
+
+ self::assertInstanceOf(MiddlewareMapper::class, self::mappers($hydrator)[ProfileCreated::class]);
+ self::generated($hydrator, ProfileCreated::class);
+ self::assertSame([ProfileCreated::class => 1], $counter->hydrated);
+ self::assertSame([ProfileCreated::class => 1], $counter->extracted);
+ }
+
+ public function testNestedObjectsGoThroughTheMiddlewaresOfTheirClass(): void
+ {
+ $counter = new CountingMiddleware();
+
+ $hydrator = $this->builder()
+ ->addMiddleware($counter)
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b'], ['name' => 'c']]];
+
+ // the chain of the nested class contains the middleware, it sees every nested object
+ $object = $hydrator->hydrate(NestedLifecycleDto::class, $data);
+ self::assertSame($data, $hydrator->extract($object));
+
+ self::assertSame([NestedLifecycleDto::class => 1, LifecycleFixture::class => 3], $counter->hydrated);
+ self::assertSame([NestedLifecycleDto::class => 1, LifecycleFixture::class => 3], $counter->extracted);
+ self::assertSame(['i0' => true, 'i1' => true], self::inlined($hydrator, NestedLifecycleDto::class));
+ }
+
+ public function testNestedObjectsRespectOtherMiddlewares(): void
+ {
+ $hydrator = $this->builder()
+ ->useExtension(new LifecycleExtension())
+ ->build();
+
+ $object = $hydrator->hydrate(
+ NestedLifecycleDto::class,
+ ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]],
+ );
+
+ self::assertSame('a [preHydrate] [postHydrate]', $object->child->name);
+ self::assertSame('b [preHydrate] [postHydrate]', $object->items[0]->name);
+
+ self::assertSame(
+ [
+ 'child' => ['name' => 'a [preHydrate] [postHydrate] [preExtract] [postExtract]'],
+ 'items' => [['name' => 'b [preHydrate] [postHydrate] [preExtract] [postExtract]']],
+ ],
+ $hydrator->extract($object),
+ );
+ }
+
+ public function testOuterHydratorIsUsedForNestedObjects(): void
+ {
+ $outer = new class ($this->hydrator) implements Hydrator {
+ /** @var list */
+ public array $calls = [];
+
+ public function __construct(private readonly Hydrator $inner)
+ {
+ }
+
+ /**
+ * @param class-string $class
+ * @param array $context
+ *
+ * @return T
+ *
+ * @template T of object
+ */
+ public function hydrate(string $class, mixed $data, array $context = []): object
+ {
+ $context[Hydrator::HYDRATOR] ??= $this;
+ $this->calls[] = 'hydrate ' . $class;
+
+ return $this->inner->hydrate($class, $data, $context);
+ }
+
+ /** @param array $context */
+ public function extract(object $object, array $context = []): mixed
+ {
+ $context[Hydrator::HYDRATOR] ??= $this;
+ $this->calls[] = 'extract ' . $object::class;
+
+ return $this->inner->extract($object, $context);
+ }
+ };
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+
+ // nested objects are mapped in place for calls of the hydrator itself
+ self::assertSame($data, $this->hydrator->extract($this->hydrator->hydrate(NestedLifecycleDto::class, $data)));
+
+ // but go through the outer hydrator for its calls
+ self::assertSame($data, $outer->extract($outer->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(
+ [
+ 'hydrate ' . NestedLifecycleDto::class,
+ 'hydrate ' . LifecycleFixture::class,
+ 'hydrate ' . LifecycleFixture::class,
+ 'extract ' . NestedLifecycleDto::class,
+ 'extract ' . LifecycleFixture::class,
+ 'extract ' . LifecycleFixture::class,
+ ],
+ $outer->calls,
+ );
+ }
+}
diff --git a/tests/Unit/Extension/Generated/MapperGeneratorTest.php b/tests/Unit/Extension/Generated/MapperGeneratorTest.php
new file mode 100644
index 00000000..920efe8a
--- /dev/null
+++ b/tests/Unit/Extension/Generated/MapperGeneratorTest.php
@@ -0,0 +1,208 @@
+metadata(ProfileCreated::class);
+
+ $first = (new MapperGenerator())->generate($metadata, 'Foo\\Bar');
+ $second = (new MapperGenerator())->generate($metadata, 'Foo\\Bar');
+
+ self::assertSame($first->code, $second->code);
+ self::assertStringContainsString('namespace Foo;', $first->code);
+ self::assertStringContainsString('final class Bar extends GeneratedMapper', $first->code);
+ self::assertStringNotContainsString('strict_types=1', $first->code);
+ }
+
+ public function testNestedClasses(): void
+ {
+ $code = (new MapperGenerator())->generate(
+ (new AttributeMetadataFactory())->metadata(NestedLifecycleDto::class),
+ 'Foo\\Bar',
+ );
+
+ self::assertSame([LifecycleFixture::class], $code->nested);
+ }
+
+ /**
+ * The snapshots make every change of the generated code visible in the diff of a pull request.
+ * Run "make snapshot" after an intended change of the generator.
+ */
+ #[RequiresPhp('>=8.5')]
+ public function testGeneratedCodeMatchesSnapshot(): void
+ {
+ $metadataFactory = new AttributeMetadataFactory();
+
+ foreach (GeneratedStackHydratorTest::CLASSES as $class) {
+ $metadata = $metadataFactory->metadata($class);
+ $className = $metadata->reflection->getShortName() . 'Mapper';
+ $file = __DIR__ . '/Snapshot/' . $className . '.php';
+
+ $code = (new MapperGenerator())->generate($metadata, 'Patchlevel\\Hydrator\\Tests\\Snapshot\\' . $className)->code;
+
+ if (getenv('UPDATE_SNAPSHOTS') === '1') {
+ file_put_contents($file, $code);
+ }
+
+ self::assertStringEqualsFile($file, $code, 'The generated code changed, run "make snapshot" if the change is intended.');
+ }
+ }
+
+ public function testDefaultsWithControlCharactersAreInlinedUnchanged(): void
+ {
+ $mapper = $this->load((new AttributeMetadataFactory())->metadata(MultilineDefaultDto::class));
+
+ $object = $mapper->hydrate([], []);
+
+ self::assertInstanceOf(MultilineDefaultDto::class, $object);
+ self::assertSame("foo bar\nbaz", $object->name);
+ self::assertSame("first\nsecond", $object->text);
+ self::assertSame(["a\tb" => "c\nd \$e \"f\""], $object->lines);
+ }
+
+ public function testFailedInitializationIsRetried(): void
+ {
+ $metadata = (new AttributeMetadataFactory())->metadata(ProfileCreated::class);
+ $mapper = $this->load($metadata);
+ $data = ['profileId' => '1', 'email' => 'info@patchlevel.de'];
+
+ // the metadata does not match the generated code anymore
+ $property = $metadata->properties['email'];
+ $normalizer = $property->normalizer;
+ $property->normalizer = null;
+
+ try {
+ $mapper->hydrate($data, []);
+ self::fail('the first call must fail');
+ } catch (OutdatedGeneratedMapper) {
+ }
+
+ $property->normalizer = $normalizer;
+
+ // the failed initialization must not leave the mapper half initialized
+ $object = $mapper->hydrate($data, []);
+
+ self::assertInstanceOf(ProfileCreated::class, $object);
+ self::assertSame('info@patchlevel.de', $object->email->toString());
+ }
+
+ /** @param class-string $changed a class with the same properties as FingerprintDto but one changed detail */
+ #[DataProvider('changedClasses')]
+ public function testChangedClassChangesTheFingerprint(string $changed): void
+ {
+ $metadataFactory = new AttributeMetadataFactory();
+
+ self::assertNotSame(
+ ClassFingerprint::of($metadataFactory->metadata(FingerprintDto::class)),
+ ClassFingerprint::of($metadataFactory->metadata($changed)),
+ );
+ }
+
+ /** @return iterable */
+ public static function changedClasses(): iterable
+ {
+ yield 'changed default' => [FingerprintChangedDefaultDto::class];
+ yield 'changed visibility' => [FingerprintChangedVisibilityDto::class];
+ }
+
+ public function testClassNormalizerIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ (new MapperGenerator())->generate(
+ (new AttributeMetadataFactory())->metadata(ProfileId::class),
+ 'Foo\\Bar',
+ );
+ }
+
+ public function testAnonymousClassIsNotGeneratable(): void
+ {
+ $object = new class {
+ public string $name = 'foo';
+ };
+
+ $this->expectException(ClassNotGeneratable::class);
+
+ (new MapperGenerator())->generate((new AttributeMetadataFactory())->metadata($object::class), 'Foo\\Bar');
+ }
+
+ /**
+ * @param ClassMetadata $metadata
+ *
+ * @template T of object
+ */
+ private function load(ClassMetadata $metadata, StackHydrator|null $hydrator = null): GeneratedMapper
+ {
+ $fqcn = 'Patchlevel\\Hydrator\\Tests\\Generated\\Mapper' . self::$counter++;
+ $code = (new MapperGenerator())->generate($metadata, $fqcn)->code;
+
+ $file = tempnam(sys_get_temp_dir(), 'mapper');
+ assert($file !== false);
+ file_put_contents($file, $code);
+
+ require $file;
+ unlink($file);
+
+ assert(is_a($fqcn, GeneratedMapper::class, true));
+
+ return new $fqcn($metadata, $hydrator ?? new StackHydrator(), new CallStack());
+ }
+}
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php
new file mode 100644
index 00000000..edcac335
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoMapper.php
@@ -0,0 +1,217 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('to');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php
new file mode 100644
index 00000000..ccb0f9fd
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoMapper.php
@@ -0,0 +1,217 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('to');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php
new file mode 100644
index 00000000..d62ad60e
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoMapper.php
@@ -0,0 +1,217 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ try {
+ $object->to = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n0::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('to');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php
new file mode 100644
index 00000000..6de0b8f8
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoMapper.php
@@ -0,0 +1,187 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('value', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['value'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ try {
+ $object->value = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class;
+
+ try {
+ try {
+ $v_value = $this->n0->normalize($object->value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ return [
+ 'value' => $v_value,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_value = $this->n0->normalize($object->value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ return [
+ 'value' => $v_value,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('value', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['value'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ try {
+ $object->value = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class;
+
+ try {
+ try {
+ $v_value = $this->n0->normalize($object->value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ return [
+ 'value' => $v_value,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_value = $this->n0->normalize($object->value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class, 'value', $this->n0::class, $e);
+ }
+
+ return [
+ 'value' => $v_value,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('value');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php
new file mode 100644
index 00000000..c8d31234
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoMapper.php
@@ -0,0 +1,271 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('admin', $data)) {
+ $value = $data['admin'];
+
+ try {
+ $object->admin = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'admin', $e);
+ }
+ }
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'name', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $data['email'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $e);
+ }
+ } else {
+ $object->email = $this->d0->getDefaultValue();
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class;
+
+ try {
+ try {
+ $value = $object->email;
+ $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'admin' => $object->admin,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->email;
+ $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'admin' => $object->admin,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('admin', $data)) {
+ $value = $data['admin'];
+
+ try {
+ $object->admin = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'admin', $e);
+ }
+ }
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'name', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $data['email'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $e);
+ }
+ } else {
+ $object->email = $this->d0->getDefaultValue();
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class;
+
+ try {
+ try {
+ $value = $object->email;
+ $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'admin' => $object->admin,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->email;
+ $v_email = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'admin' => $object->admin,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->d0 = $this->promotedDefault('email');
+ $this->n0 = $this->normalizer('email');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php
new file mode 100644
index 00000000..505368e7
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoMapper.php
@@ -0,0 +1,412 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeImmutable', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['dateTimeImmutable'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $object->dateTimeImmutable = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTime', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['dateTime'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $object->dateTime = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeZone', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['dateTimeZone'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $object->dateTimeZone = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $e);
+ }
+ }
+
+ if (\array_key_exists('array', $data)) {
+ $value = $data['array'];
+
+ try {
+ $object->array = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'array', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'array' => $object->array,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'array' => $object->array,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeImmutable', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['dateTimeImmutable'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $object->dateTimeImmutable = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTime', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['dateTime'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $object->dateTime = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeZone', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['dateTimeZone'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $object->dateTimeZone = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $e);
+ }
+ }
+
+ if (\array_key_exists('array', $data)) {
+ $value = $data['array'];
+
+ try {
+ $object->array = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'array', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'array' => $object->array,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'array' => $object->array,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('status');
+ $this->n1 = $this->normalizer('dateTimeImmutable');
+ $this->n2 = $this->normalizer('dateTime');
+ $this->n3 = $this->normalizer('dateTimeZone');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1)
+ || self::mayRecurse($this->n2)
+ || self::mayRecurse($this->n3);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php
new file mode 100644
index 00000000..d18881a7
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoMapper.php
@@ -0,0 +1,574 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('defaultArray', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['defaultArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $object->defaultArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $e);
+ }
+ } else {
+ $object->defaultArray = [];
+ }
+
+ if (\array_key_exists('listArray', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['listArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $object->listArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $e);
+ }
+ } else {
+ $object->listArray = [];
+ }
+
+ if (\array_key_exists('iterableArray', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['iterableArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $object->iterableArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $e);
+ }
+ } else {
+ $object->iterableArray = [];
+ }
+
+ if (\array_key_exists('hashMap', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['hashMap'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $object->hashMap = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $e);
+ }
+ } else {
+ $object->hashMap = [];
+ }
+
+ if (\array_key_exists('nested', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['nested'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $object->nested = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $e);
+ }
+ } else {
+ $object->nested = [];
+ }
+
+ if (\array_key_exists('jsonArray', $data)) {
+ $value = $data['jsonArray'];
+
+ try {
+ $object->jsonArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'jsonArray', $e);
+ }
+ } else {
+ $object->jsonArray = NULL;
+ }
+
+ if (\array_key_exists('shapeArray', $data)) {
+ try {
+ $value = $this->n5->denormalize($data['shapeArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ try {
+ $object->shapeArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $e);
+ }
+ } else {
+ $object->shapeArray = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class;
+
+ try {
+ try {
+ $v_defaultArray = $this->n0->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n1->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n2->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_hashMap = $this->n3->normalize($object->hashMap, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $v_nested = $this->n4->normalize($object->nested, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $v_shapeArray = $this->n5->normalize($object->shapeArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ return [
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'hashMap' => $v_hashMap,
+ 'nested' => $v_nested,
+ 'jsonArray' => $object->jsonArray,
+ 'shapeArray' => $v_shapeArray,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_defaultArray = $this->n0->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n1->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n2->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_hashMap = $this->n3->normalize($object->hashMap, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $v_nested = $this->n4->normalize($object->nested, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $v_shapeArray = $this->n5->normalize($object->shapeArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ return [
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'hashMap' => $v_hashMap,
+ 'nested' => $v_nested,
+ 'jsonArray' => $object->jsonArray,
+ 'shapeArray' => $v_shapeArray,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('defaultArray', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['defaultArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $object->defaultArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $e);
+ }
+ } else {
+ $object->defaultArray = [];
+ }
+
+ if (\array_key_exists('listArray', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['listArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $object->listArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $e);
+ }
+ } else {
+ $object->listArray = [];
+ }
+
+ if (\array_key_exists('iterableArray', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['iterableArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $object->iterableArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $e);
+ }
+ } else {
+ $object->iterableArray = [];
+ }
+
+ if (\array_key_exists('hashMap', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['hashMap'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $object->hashMap = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $e);
+ }
+ } else {
+ $object->hashMap = [];
+ }
+
+ if (\array_key_exists('nested', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['nested'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $object->nested = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $e);
+ }
+ } else {
+ $object->nested = [];
+ }
+
+ if (\array_key_exists('jsonArray', $data)) {
+ $value = $data['jsonArray'];
+
+ try {
+ $object->jsonArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'jsonArray', $e);
+ }
+ } else {
+ $object->jsonArray = NULL;
+ }
+
+ if (\array_key_exists('shapeArray', $data)) {
+ try {
+ $value = $this->n5->denormalize($data['shapeArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ try {
+ $object->shapeArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $e);
+ }
+ } else {
+ $object->shapeArray = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class;
+
+ try {
+ try {
+ $v_defaultArray = $this->n0->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n1->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n2->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_hashMap = $this->n3->normalize($object->hashMap, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $v_nested = $this->n4->normalize($object->nested, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $v_shapeArray = $this->n5->normalize($object->shapeArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ return [
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'hashMap' => $v_hashMap,
+ 'nested' => $v_nested,
+ 'jsonArray' => $object->jsonArray,
+ 'shapeArray' => $v_shapeArray,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_defaultArray = $this->n0->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'defaultArray', $this->n0::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n1->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'listArray', $this->n1::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n2->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'iterableArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_hashMap = $this->n3->normalize($object->hashMap, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'hashMap', $this->n3::class, $e);
+ }
+
+ try {
+ $v_nested = $this->n4->normalize($object->nested, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'nested', $this->n4::class, $e);
+ }
+
+ try {
+ $v_shapeArray = $this->n5->normalize($object->shapeArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class, 'shapeArray', $this->n5::class, $e);
+ }
+
+ return [
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'hashMap' => $v_hashMap,
+ 'nested' => $v_nested,
+ 'jsonArray' => $object->jsonArray,
+ 'shapeArray' => $v_shapeArray,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('defaultArray');
+ $this->n1 = $this->normalizer('listArray');
+ $this->n2 = $this->normalizer('iterableArray');
+ $this->n3 = $this->normalizer('hashMap');
+ $this->n4 = $this->normalizer('nested');
+ $this->n5 = $this->normalizer('shapeArray');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1)
+ || self::mayRecurse($this->n2)
+ || self::mayRecurse($this->n3)
+ || self::mayRecurse($this->n4)
+ || self::mayRecurse($this->n5);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php
new file mode 100644
index 00000000..891b8fd1
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoMapper.php
@@ -0,0 +1,467 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeImmutable', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['dateTimeImmutable'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $object->dateTimeImmutable = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTime', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['dateTime'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $object->dateTime = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $e);
+ }
+ } else {
+ $object->dateTime = NULL;
+ }
+
+ if (\array_key_exists('dateTimeZone', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['dateTimeZone'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $object->dateTimeZone = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $e);
+ }
+ } else {
+ $object->dateTimeZone = NULL;
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $e);
+ }
+ } else {
+ $object->profileId = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $v_profileId = $this->n4->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'profileId' => $v_profileId,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $v_profileId = $this->n4->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'profileId' => $v_profileId,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTimeImmutable', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['dateTimeImmutable'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $object->dateTimeImmutable = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $e);
+ }
+ }
+
+ if (\array_key_exists('dateTime', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['dateTime'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $object->dateTime = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $e);
+ }
+ } else {
+ $object->dateTime = NULL;
+ }
+
+ if (\array_key_exists('dateTimeZone', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['dateTimeZone'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $object->dateTimeZone = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $e);
+ }
+ } else {
+ $object->dateTimeZone = NULL;
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $e);
+ }
+ } else {
+ $object->profileId = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $v_profileId = $this->n4->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'profileId' => $v_profileId,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $v_dateTimeImmutable = $this->n1->normalize($object->dateTimeImmutable, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeImmutable', $this->n1::class, $e);
+ }
+
+ try {
+ $v_dateTime = $this->n2->normalize($object->dateTime, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTime', $this->n2::class, $e);
+ }
+
+ try {
+ $v_dateTimeZone = $this->n3->normalize($object->dateTimeZone, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'dateTimeZone', $this->n3::class, $e);
+ }
+
+ try {
+ $v_profileId = $this->n4->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class, 'profileId', $this->n4::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'dateTimeImmutable' => $v_dateTimeImmutable,
+ 'dateTime' => $v_dateTime,
+ 'dateTimeZone' => $v_dateTimeZone,
+ 'profileId' => $v_profileId,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('status');
+ $this->n1 = $this->normalizer('dateTimeImmutable');
+ $this->n2 = $this->normalizer('dateTime');
+ $this->n3 = $this->normalizer('dateTimeZone');
+ $this->n4 = $this->normalizer('profileId');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1)
+ || self::mayRecurse($this->n2)
+ || self::mayRecurse($this->n3)
+ || self::mayRecurse($this->n4);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php
new file mode 100644
index 00000000..ff945396
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedMapper.php
@@ -0,0 +1,254 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('email');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php
new file mode 100644
index 00000000..b9195766
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoMapper.php
@@ -0,0 +1,366 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('child', $data)) {
+ try {
+ $value = $data['child'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $object->child = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $e);
+ }
+ }
+
+ if (\array_key_exists('items', $data)) {
+ try {
+ $value = $data['items'];
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->x0->hydrateNested($item, $context) : $this->n2->denormalize($item, $context);
+ }
+
+ $value = $items;
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ try {
+ $object->items = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $e);
+ }
+ } else {
+ $object->items = [];
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class;
+
+ try {
+ try {
+ $value = $object->child;
+ $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->items;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context);
+ }
+
+ $v_items = $items;
+ } else {
+ $v_items = $this->n1->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ return [
+ 'child' => $v_child,
+ 'items' => $v_items,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->child;
+ $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->items;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context);
+ }
+
+ $v_items = $items;
+ } else {
+ $v_items = $this->n1->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ return [
+ 'child' => $v_child,
+ 'items' => $v_items,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('child', $data)) {
+ try {
+ $value = $data['child'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $object->child = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $e);
+ }
+ }
+
+ if (\array_key_exists('items', $data)) {
+ try {
+ $value = $data['items'];
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->x0->hydrateNested($item, $context) : $this->n2->denormalize($item, $context);
+ }
+
+ $value = $items;
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ try {
+ $object->items = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $e);
+ }
+ } else {
+ $object->items = [];
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class;
+
+ try {
+ try {
+ $value = $object->child;
+ $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->items;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context);
+ }
+
+ $v_items = $items;
+ } else {
+ $v_items = $this->n1->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ return [
+ 'child' => $v_child,
+ 'items' => $v_items,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->child;
+ $v_child = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'child', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->items;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->x0->extractNested($item, $context) : $this->n2->normalize($item, $context);
+ }
+
+ $v_items = $items;
+ } else {
+ $v_items = $this->n1->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class, 'items', $this->n1::class, $e);
+ }
+
+ return [
+ 'child' => $v_child,
+ 'items' => $v_items,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('child');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ $this->n1 = $this->normalizer('items');
+ $m1 = $this->nested($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class);
+ $this->i1 = $m1 !== null;
+ if ($m1 !== null) { $this->x0 = $m1; }
+ if ($this->i1) { $this->n2 = $this->n1->innerNormalizer(); }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0))
+ || ($this->i1 ? self::reaches($this->x0) : self::mayRecurse($this->n1));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php
new file mode 100644
index 00000000..a4a620d6
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoMapper.php
@@ -0,0 +1,676 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('profileCreated', $data)) {
+ try {
+ $value = $data['profileCreated'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $object->profileCreated = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $e);
+ }
+ }
+
+ if (\array_key_exists('defaultArray', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['defaultArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $object->defaultArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $e);
+ }
+ } else {
+ $object->defaultArray = [];
+ }
+
+ if (\array_key_exists('listArray', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['listArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $object->listArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $e);
+ }
+ } else {
+ $object->listArray = [];
+ }
+
+ if (\array_key_exists('iterableArray', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['iterableArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $object->iterableArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $e);
+ }
+ } else {
+ $object->iterableArray = [];
+ }
+
+ if (\array_key_exists('skillsHashMap', $data)) {
+ try {
+ $value = $data['skillsHashMap'];
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->x1->hydrateNested($item, $context) : $this->n6->denormalize($item, $context);
+ }
+
+ $value = $items;
+ } else {
+ $value = $this->n5->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ try {
+ $object->skillsHashMap = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $e);
+ }
+ } else {
+ $object->skillsHashMap = [];
+ }
+
+ if (\array_key_exists('jsonArray', $data)) {
+ $value = $data['jsonArray'];
+
+ try {
+ $object->jsonArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'jsonArray', $e);
+ }
+ } else {
+ $object->jsonArray = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $v_defaultArray = $this->n2->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n3->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n4->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n5->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'profileCreated' => $v_profileCreated,
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'skillsHashMap' => $v_skillsHashMap,
+ 'jsonArray' => $object->jsonArray,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $v_defaultArray = $this->n2->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n3->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n4->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n5->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'profileCreated' => $v_profileCreated,
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'skillsHashMap' => $v_skillsHashMap,
+ 'jsonArray' => $object->jsonArray,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('status', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['status'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $object->status = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $e);
+ }
+ }
+
+ if (\array_key_exists('profileCreated', $data)) {
+ try {
+ $value = $data['profileCreated'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $object->profileCreated = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $e);
+ }
+ }
+
+ if (\array_key_exists('defaultArray', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['defaultArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $object->defaultArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $e);
+ }
+ } else {
+ $object->defaultArray = [];
+ }
+
+ if (\array_key_exists('listArray', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['listArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $object->listArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $e);
+ }
+ } else {
+ $object->listArray = [];
+ }
+
+ if (\array_key_exists('iterableArray', $data)) {
+ try {
+ $value = $this->n4->denormalize($data['iterableArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $object->iterableArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $e);
+ }
+ } else {
+ $object->iterableArray = [];
+ }
+
+ if (\array_key_exists('skillsHashMap', $data)) {
+ try {
+ $value = $data['skillsHashMap'];
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->x1->hydrateNested($item, $context) : $this->n6->denormalize($item, $context);
+ }
+
+ $value = $items;
+ } else {
+ $value = $this->n5->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ try {
+ $object->skillsHashMap = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $e);
+ }
+ } else {
+ $object->skillsHashMap = [];
+ }
+
+ if (\array_key_exists('jsonArray', $data)) {
+ $value = $data['jsonArray'];
+
+ try {
+ $object->jsonArray = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'jsonArray', $e);
+ }
+ } else {
+ $object->jsonArray = NULL;
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class;
+
+ try {
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $v_defaultArray = $this->n2->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n3->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n4->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n5->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'profileCreated' => $v_profileCreated,
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'skillsHashMap' => $v_skillsHashMap,
+ 'jsonArray' => $object->jsonArray,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_status = $this->n0->normalize($object->status, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'status', $this->n0::class, $e);
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer ? $this->x0->extractNested($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'profileCreated', $this->n1::class, $e);
+ }
+
+ try {
+ $v_defaultArray = $this->n2->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n2::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n3->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n3::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n4->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n4::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->i1 && $inline && \is_array($value)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->x1->extractNested($item, $context) : $this->n6->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n5->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n5::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'profileCreated' => $v_profileCreated,
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'skillsHashMap' => $v_skillsHashMap,
+ 'jsonArray' => $object->jsonArray,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('status');
+ $this->n1 = $this->normalizer('profileCreated');
+ $m0 = $this->nested($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ $this->n2 = $this->normalizer('defaultArray');
+ $this->n3 = $this->normalizer('listArray');
+ $this->n4 = $this->normalizer('iterableArray');
+ $this->n5 = $this->normalizer('skillsHashMap');
+ $m1 = $this->nested($this->n5, \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill::class);
+ $this->i1 = $m1 !== null;
+ if ($m1 !== null) { $this->x1 = $m1; }
+ if ($this->i1) { $this->n6 = $this->n5->innerNormalizer(); }
+ self::initNested($this->x0 ?? null);
+ self::initNested($this->x1 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n1))
+ || self::mayRecurse($this->n2)
+ || self::mayRecurse($this->n3)
+ || self::mayRecurse($this->n4)
+ || ($this->i1 ? self::reaches($this->x1) : self::mayRecurse($this->n5));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php
new file mode 100644
index 00000000..5c8c3c93
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ParentDtoMapper.php
@@ -0,0 +1,254 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('email');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php
new file mode 100644
index 00000000..1e0fb37e
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoMapper.php
@@ -0,0 +1,211 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = false;
+
+ return ($this->h)($object, $data, $context, $inline);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = false;
+
+ return ($this->e)($object, $context, $inline);
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ return ($this->h)($object, $data, $context, $inline);
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ return ($this->e)($object, $context, $inline);
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('email');
+ $this->hp0 = Closure::bind(function (object $object, array $data, array $context, bool $inline): void {
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'email', $e);
+ }
+ }
+ if (\array_key_exists('note', $data)) {
+ $value = $data['note'];
+
+ try {
+ $object->note = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'note', $e);
+ }
+ }
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateParentDto::class);
+ $this->ep1 = Closure::bind(function (object $object, array $context, bool $inline): array {
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'email', $this->n1::class, $e);
+ }
+ return [$v_email, $object->note];
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateParentDto::class);
+ $this->h = Closure::bind(function (object $object, array $data, array $context, bool $inline): object {
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'name', $e);
+ }
+ }
+
+ ($this->hp0)($object, $data, $context, $inline);
+
+ return $object;
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class);
+ $this->e = Closure::bind(function (object $object, array $context, bool $inline): array {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class;
+
+ try {
+ [$v_email, $v_note] = ($this->ep1)($object, $context, $inline);
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ 'note' => $v_note,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ [$v_email, $v_note] = ($this->ep1)($object, $context, $inline);
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'name' => $object->name,
+ 'email' => $v_email,
+ 'note' => $v_note,
+ ];
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php
new file mode 100644
index 00000000..9265c88f
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoMapper.php
@@ -0,0 +1,176 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = false;
+
+ return ($this->h)($object, $data, $context, $inline);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = false;
+
+ return ($this->e)($object, $context, $inline);
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ return ($this->h)($object, $data, $context, $inline);
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ return ($this->e)($object, $context, $inline);
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->h = Closure::bind(function (object $object, array $data, array $context, bool $inline): object {
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'name', $e);
+ }
+ }
+
+ if (\array_key_exists('age', $data)) {
+ $value = $data['age'];
+
+ try {
+ $object->age = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'age', $e);
+ }
+ } else {
+ $object->age = 42;
+ }
+
+ return $object;
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class);
+ $this->e = Closure::bind(function (object $object, array $context, bool $inline): array {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'name' => $object->name,
+ 'age' => $object->age,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class, 'profileId', $this->n0::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'name' => $object->name,
+ 'age' => $object->age,
+ ];
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php
new file mode 100644
index 00000000..4302d55e
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedMapper.php
@@ -0,0 +1,254 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('email');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php
new file mode 100644
index 00000000..b8808c31
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerMapper.php
@@ -0,0 +1,254 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('valueObject', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['valueObject'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ try {
+ $object->valueObject = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_valueObject = $this->n1->normalize($object->valueObject, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'valueObject' => $v_valueObject,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_valueObject = $this->n1->normalize($object->valueObject, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'valueObject' => $v_valueObject,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('valueObject', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['valueObject'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ try {
+ $object->valueObject = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_valueObject = $this->n1->normalize($object->valueObject, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'valueObject' => $v_valueObject,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_valueObject = $this->n1->normalize($object->valueObject, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class, 'valueObject', $this->n1::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'valueObject' => $v_valueObject,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('valueObject');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php
new file mode 100644
index 00000000..07839025
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperMapper.php
@@ -0,0 +1,217 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if (\array_key_exists('event', $data)) {
+ try {
+ $value = $data['event'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileCreated = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ $inline = ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class;
+
+ try {
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ return [
+ 'event' => $v_profileCreated,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ return [
+ 'event' => $v_profileCreated,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ $inline = true;
+
+ if (\array_key_exists('event', $data)) {
+ try {
+ $value = $data['event'];
+
+ if ($this->i0 && $inline && \is_array($value)) {
+ $value = $this->x0->hydrateNested($value, $context);
+ } else {
+ $value = $this->n0->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileCreated = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ $inline = true;
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class;
+
+ try {
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ return [
+ 'event' => $v_profileCreated,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->i0 && $inline && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated ? $this->x0->extractNested($value, $context) : $this->n0->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class, 'profileCreated', $this->n0::class, $e);
+ }
+
+ return [
+ 'event' => $v_profileCreated,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileCreated');
+ $m0 = $this->nested($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class);
+ $this->i0 = $m0 !== null;
+ if ($m0 !== null) { $this->x0 = $m0; }
+ self::initNested($this->x0 ?? null);
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return ($this->i0 ? self::reaches($this->x0) : self::mayRecurse($this->n0));
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php
new file mode 100644
index 00000000..ca86e822
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedMapper.php
@@ -0,0 +1,254 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('id', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['id'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'id' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'id' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('id', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['id'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'id' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n0->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'profileId', $this->n0::class, $e);
+ }
+
+ try {
+ $v_email = $this->n1->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class, 'email', $this->n1::class, $e);
+ }
+
+ return [
+ 'id' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('profileId');
+ $this->n1 = $this->normalizer('email');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0)
+ || self::mayRecurse($this->n1);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php
new file mode 100644
index 00000000..8e0e712e
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerMapper.php
@@ -0,0 +1,187 @@
+ready) {
+ $this->init();
+ }
+
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+ } else {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ if (!$this->ready) {
+ $this->init();
+ }
+
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class;
+
+ try {
+ try {
+ $v_email = $this->n0->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_email = $this->n0->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'email' => $v_email,
+ ];
+ }
+
+ public function hydrateNested(array $data, array $context): object
+ {
+ $object = $this->reflection->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n0->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extractNested(object $object, array $context): array
+ {
+ if ($this->tracked) {
+ $id = \spl_object_id($object);
+
+ if (isset($this->callStack->objects[$id])) {
+ $references = \array_values($this->callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class;
+
+ try {
+ try {
+ $v_email = $this->n0->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($this->callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_email = $this->n0->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class, 'email', $this->n0::class, $e);
+ }
+
+ return [
+ 'email' => $v_email,
+ ];
+ }
+
+ protected function initialize(): void
+ {
+ $this->n0 = $this->normalizer('email');
+ }
+
+ protected function mayReachItself(): bool
+ {
+ return self::mayRecurse($this->n0);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Fixture/AsymmetricVisibilityDto.php b/tests/Unit/Fixture/AsymmetricVisibilityDto.php
new file mode 100644
index 00000000..4d072150
--- /dev/null
+++ b/tests/Unit/Fixture/AsymmetricVisibilityDto.php
@@ -0,0 +1,16 @@
+ $skip */
+ public function __construct(
+ private readonly array $skip = [],
+ ) {
+ }
+
+ /** @var array */
+ public array $hydrated = [];
+
+ /** @var array */
+ public array $extracted = [];
+
+ /**
+ * @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->hydrated[$metadata->className] = ($this->hydrated[$metadata->className] ?? 0) + 1;
+
+ 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->extracted[$metadata->className] = ($this->extracted[$metadata->className] ?? 0) + 1;
+
+ return $next->extract($object, $context);
+ }
+
+ /**
+ * @param ClassMetadata $metadata
+ *
+ * @template T of object
+ */
+ public function skip(ClassMetadata $metadata): Skip
+ {
+ return $this->skip[$metadata->className] ?? Skip::None;
+ }
+}
diff --git a/tests/Unit/Fixture/FingerprintChangedDefaultDto.php b/tests/Unit/Fixture/FingerprintChangedDefaultDto.php
new file mode 100644
index 00000000..1f05ab67
--- /dev/null
+++ b/tests/Unit/Fixture/FingerprintChangedDefaultDto.php
@@ -0,0 +1,20 @@
+text;
+ }
+}
diff --git a/tests/Unit/Fixture/FingerprintChangedVisibilityDto.php b/tests/Unit/Fixture/FingerprintChangedVisibilityDto.php
new file mode 100644
index 00000000..0d78638a
--- /dev/null
+++ b/tests/Unit/Fixture/FingerprintChangedVisibilityDto.php
@@ -0,0 +1,20 @@
+text;
+ }
+}
diff --git a/tests/Unit/Fixture/FingerprintDto.php b/tests/Unit/Fixture/FingerprintDto.php
new file mode 100644
index 00000000..5410b336
--- /dev/null
+++ b/tests/Unit/Fixture/FingerprintDto.php
@@ -0,0 +1,20 @@
+text;
+ }
+}
diff --git a/tests/Unit/Fixture/MultilineDefaultDto.php b/tests/Unit/Fixture/MultilineDefaultDto.php
new file mode 100644
index 00000000..3df847f0
--- /dev/null
+++ b/tests/Unit/Fixture/MultilineDefaultDto.php
@@ -0,0 +1,21 @@
+ $lines */
+ public function __construct(
+ // phpcs:disable Squiz.Functions.MultiLineFunctionDeclaration.Indent -- the sniff does not understand the nowdoc body
+ public string $name = <<<'TEXT'
+ foo bar
+ baz
+ TEXT,
+ // phpcs:enable
+ public string $text = "first\nsecond",
+ public array $lines = ["a\tb" => "c\nd \$e \"f\""],
+ ) {
+ }
+}
diff --git a/tests/Unit/Fixture/NestedLifecycleDto.php b/tests/Unit/Fixture/NestedLifecycleDto.php
new file mode 100644
index 00000000..994f5d5a
--- /dev/null
+++ b/tests/Unit/Fixture/NestedLifecycleDto.php
@@ -0,0 +1,15 @@
+ $items */
+ public function __construct(
+ public LifecycleFixture $child,
+ public array $items = [],
+ ) {
+ }
+}
diff --git a/tests/Unit/Fixture/PrivateChildDto.php b/tests/Unit/Fixture/PrivateChildDto.php
new file mode 100644
index 00000000..6e3cd349
--- /dev/null
+++ b/tests/Unit/Fixture/PrivateChildDto.php
@@ -0,0 +1,23 @@
+name;
+ }
+}
diff --git a/tests/Unit/Fixture/PrivateParentDto.php b/tests/Unit/Fixture/PrivateParentDto.php
new file mode 100644
index 00000000..fa2e6d3d
--- /dev/null
+++ b/tests/Unit/Fixture/PrivateParentDto.php
@@ -0,0 +1,25 @@
+email;
+ }
+
+ public function note(): string
+ {
+ return $this->note;
+ }
+}
diff --git a/tests/Unit/Fixture/PrivatePropertiesDto.php b/tests/Unit/Fixture/PrivatePropertiesDto.php
new file mode 100644
index 00000000..8f0a735b
--- /dev/null
+++ b/tests/Unit/Fixture/PrivatePropertiesDto.php
@@ -0,0 +1,31 @@
+profileId;
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function age(): int
+ {
+ return $this->age;
+ }
+}