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..19107014 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 transformers
+ 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 4c47de30..840b4d80 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. |
+| `GeneratedTransformerExtension` | [Generated code](generated-code.md), generated mapping code instead of reflection. |
## Middleware
@@ -156,7 +157,7 @@ serializable.
At the end of the stack a `ClassTransformer` turns the array into the object
and back. By default this is the `ReflectionTransformer`, which sets and reads
the properties with reflection. A transformer factory can provide another
-transformer for a class, for example generated code.
+transformer for a class, for example [generated code](generated-code.md).
```php
use Patchlevel\Hydrator\Metadata\ClassMetadata;
diff --git a/docs/generated-code.md b/docs/generated-code.md
new file mode 100644
index 00000000..b66d96a5
--- /dev/null
+++ b/docs/generated-code.md
@@ -0,0 +1,98 @@
+# Generated Code
+
+By default the `ReflectionTransformer` maps arrays to objects with reflection:
+it 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 `GeneratedTransformerExtension`
+uses generated plain PHP code instead, one class per hydrated class.
+
+## Enable the extension
+
+Register the `GeneratedTransformerExtension` with the directory where the
+generated transformers are stored.
+
+```php
+use Patchlevel\Hydrator\Extension\Generated\GeneratedTransformerExtension;
+use Patchlevel\Hydrator\StackHydratorBuilder;
+
+$hydrator = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension(__DIR__ . '/var/cache/hydrator'))
+ ->build();
+```
+The extension registers a [transformer factory](extensions.md#transformer-factories),
+nothing else changes: all middlewares run as before and the generated code
+takes the place of reflection at the end of the stack. If no middleware has to
+run for a class, the hydrator calls the generated code directly.
+
+:::success
+The generated code behaves exactly like the `ReflectionTransformer`: 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.
+:::
+
+## Warmup
+
+Code is never generated while the hydrator is used. The extension only loads
+transformers which already exist, so the cache path can be read-only at
+runtime. Generate them ahead of time with the `GeneratedTransformerWarmer`, for
+example when the application is deployed. Pass the hydrator itself, so the code
+is generated with the guessers and enrichers of all extensions.
+
+```php
+use Patchlevel\Hydrator\Extension\Generated\GeneratedTransformerExtension;
+use Patchlevel\Hydrator\Extension\Generated\GeneratedTransformerWarmer;
+use Patchlevel\Hydrator\StackHydratorBuilder;
+
+$hydrator = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension(__DIR__ . '/var/cache/hydrator'))
+ ->build();
+
+$warmer = new GeneratedTransformerWarmer($hydrator, __DIR__ . '/var/cache/hydrator');
+$warmer->warmup([
+ ProfileCreated::class,
+ NameChanged::class,
+]);
+```
+Classes referenced by these classes, such as nested value objects, are
+discovered and generated automatically.
+
+:::note
+Classes without generated code are transformed with reflection. A missing
+warmup makes the hydrator slower, but never breaks it.
+:::
+
+## Outdated code
+
+The generated code is tied to the [metadata](caching.md) and the definition of
+your classes. The name of a generated transformer contains a fingerprint of
+the class and its nested classes: their properties, field names, normalizers,
+visibility and defaults. If one of them changes, the existing code no longer
+matches and the class is transformed with reflection until the warmup runs
+again. Old files are never used again, you can delete them when you deploy.
+
+## Nested objects
+
+The biggest speedup comes from inlining nested objects: instead of going through
+the hydrator again for every nested object, the generated code hydrates and
+extracts them directly. Inlining a class would hide it from the middlewares,
+so it only happens when every middleware is a `SkippableMiddleware` which
+[skips](extensions.md#skipping-middlewares) that class. The decision is made
+per class and per direction: with the [cryptography](cryptography.md) extension
+for example, value objects without sensitive data are inlined while classes
+with sensitive data still go through the stack.
+
+:::note
+Nested classes with a class level normalizer, [lazy](lazy.md) classes and
+nested classes which changed since the warmup are never inlined, they always
+take the regular path through the hydrator. The same applies if you wrap the
+hydrator in your own `Hydrator`, so your wrapper also sees nested objects.
+:::
+
+## 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/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/ClassFingerprint.php b/src/Extension/Generated/ClassFingerprint.php
new file mode 100644
index 00000000..4b12a03e
--- /dev/null
+++ b/src/Extension/Generated/ClassFingerprint.php
@@ -0,0 +1,125 @@
+ $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();
+
+ $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,
+ ];
+ }
+
+ return substr(hash('sha256', serialize($shape)), 0, 16);
+ }
+
+ /**
+ * Fingerprint of the class together with every class reachable through object normalizers, since the generated
+ * code of a class contains the inlined code of its nested classes.
+ *
+ * @param ClassMetadata $metadata
+ *
+ * @template T of object
+ */
+ public static function ofGraph(ClassMetadata $metadata, MetadataFactory $metadataFactory): string
+ {
+ /** @var array $fingerprints */
+ $fingerprints = [];
+ $queue = [$metadata];
+
+ while ($queue !== []) {
+ $current = array_pop($queue);
+
+ if (isset($fingerprints[$current->className])) {
+ continue;
+ }
+
+ $fingerprints[$current->className] = self::of($current);
+
+ foreach ($current->properties as $property) {
+ $nested = self::nestedClass($property->normalizer);
+
+ if ($nested === null || isset($fingerprints[$nested])) {
+ continue;
+ }
+
+ try {
+ $queue[] = $metadataFactory->metadata($nested);
+ } catch (ClassNotFound) {
+ continue;
+ }
+ }
+ }
+
+ ksort($fingerprints);
+
+ return substr(hash('sha256', serialize($fingerprints)), 0, 16);
+ }
+
+ /** @return class-string|null */
+ private static function nestedClass(Normalizer|null $normalizer): string|null
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ $normalizer = $normalizer->innerNormalizer();
+ }
+
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return null;
+ }
+
+ try {
+ return $normalizer->className();
+ } catch (InvalidType) {
+ return null;
+ }
+ }
+}
diff --git a/src/Extension/Generated/ClassNotGeneratable.php b/src/Extension/Generated/ClassNotGeneratable.php
new file mode 100644
index 00000000..8b4f821c
--- /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 int $index,
+ 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;
+ }
+}
diff --git a/src/Extension/Generated/ClassPlanner.php b/src/Extension/Generated/ClassPlanner.php
new file mode 100644
index 00000000..9741a59f
--- /dev/null
+++ b/src/Extension/Generated/ClassPlanner.php
@@ -0,0 +1,257 @@
+ class => index, null for classes which can not be inlined */
+ private array $index = [];
+
+ /** @var array */
+ private array $classes = [];
+
+ private int $nextIndex = 0;
+ private int $normalizerSlots = 0;
+ private int $flagSlots = 0;
+ private int $defaultSlots = 0;
+
+ public function __construct(
+ private readonly MetadataFactory $metadataFactory,
+ ) {
+ }
+
+ /**
+ * @param class-string $class
+ *
+ * @throws ClassNotGeneratable
+ */
+ public function add(string $class): void
+ {
+ $class = ltrim($class, '\\');
+
+ try {
+ $metadata = $this->metadataFactory->metadata($class);
+ } catch (ClassNotFound) {
+ throw new ClassNotGeneratable($class, 'class not found');
+ }
+
+ if ($metadata->normalizer !== null) {
+ // handled by the hydrator itself, never reaches a middleware
+ return;
+ }
+
+ if (!TransformerFiles::generatable($metadata)) {
+ throw new ClassNotGeneratable($class, 'the class is internal, abstract, anonymous or an interface');
+ }
+
+ $this->register($metadata);
+ }
+
+ /** @return list ordered by index */
+ public function plans(): array
+ {
+ ksort($this->classes);
+
+ return array_values($this->classes);
+ }
+
+ /** @return int index of the registered class */
+ private function register(ClassMetadata $metadata): int
+ {
+ $class = $metadata->className;
+ $index = $this->index[$class] ?? null;
+
+ if ($index !== null) {
+ return $index;
+ }
+
+ $index = $this->nextIndex++;
+ // reserve the index before planning the properties: nested classes may reference this class again
+ $this->index[$class] = $index;
+
+ $properties = [];
+
+ foreach ($metadata->properties as $property) {
+ $properties[] = $this->plan($metadata, $property);
+ }
+
+ [$scopedHydrate, $scopedExtract] = self::scoped($metadata, $properties);
+
+ $this->classes[$index] = new ClassPlan($index, $metadata, $properties, $scopedHydrate, $scopedExtract);
+
+ return $index;
+ }
+
+ private function plan(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 = $this->nestedClass($normalizer);
+
+ if ($nested !== null) {
+ $kind = ValueKind::NestedObject;
+ } elseif ($normalizer instanceof ArrayNormalizer) {
+ $nested = $this->nestedClass($normalizer->innerNormalizer());
+
+ if ($nested !== null) {
+ $kind = ValueKind::NestedArray;
+ $inner = $this->normalizerSlots++;
+ }
+ }
+
+ if ($nested !== null) {
+ $flag = $this->flagSlots++;
+ }
+ }
+
+ 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,
+ default: $default,
+ defaultSlot: $defaultSlot,
+ hydrateScope: $inherited && ($reflection->isPrivate() || $reflection->isReadOnly() || self::privateSet($reflection)) ? $declaringClass : null,
+ extractScope: $inherited && $reflection->isPrivate() ? $declaringClass : null,
+ );
+ }
+
+ /** @return int|null the index of the nested class if the normalizer is an object normalizer for an inlinable class */
+ private function nestedClass(Normalizer $normalizer): int|null
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return null;
+ }
+
+ try {
+ $class = $normalizer->className();
+ } catch (InvalidType) {
+ return null;
+ }
+
+ return $this->nestedIndex($class);
+ }
+
+ /**
+ * Nested classes can only be inlined if code can be generated for them.
+ *
+ * @param class-string $class
+ */
+ private function nestedIndex(string $class): int|null
+ {
+ if (array_key_exists($class, $this->index)) {
+ return $this->index[$class];
+ }
+
+ try {
+ $metadata = $this->metadataFactory->metadata($class);
+ } catch (ClassNotFound) {
+ return $this->index[$class] = null;
+ }
+
+ if ($metadata->normalizer !== null || $metadata->lazy === true || !TransformerFiles::generatable($metadata)) {
+ return $this->index[$class] = null;
+ }
+
+ return $this->register($metadata);
+ }
+
+ /**
+ * Code runs in the scope of the class (a bound closure) only if needed, otherwise plain methods are used.
+ *
+ * @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..29ea4c5a
--- /dev/null
+++ b/src/Extension/Generated/CodeEmitter.php
@@ -0,0 +1,492 @@
+ property declarations of the generated class */
+ private array $properties = [];
+
+ /** @var list init methods, one per class */
+ private array $inits = [];
+
+ /** @var list hydrate/extract methods which do not need the scope of a class */
+ private array $methods = [];
+
+ private int $helperSlots = 0;
+
+ /** @param list $classes indexed by {@see ClassPlan::$index} */
+ public function __construct(
+ private readonly array $classes,
+ ) {
+ }
+
+ /** The first plan is the class of the transformer, the others are nested classes which can be inlined. */
+ public function emit(string $namespace, string $className): string
+ {
+ $constructor = [];
+
+ foreach ($this->classes as $plan) {
+ $index = $plan->index;
+
+ // until the class is initialized, the closures point to a stub which initializes the class first
+ $constructor[] = sprintf(
+ '$this->h%1$d = function (array $data, array $context, object|null $object = null): object { $this->init%1$d(); return ($this->h%1$d)($data, $context, $object); };',
+ $index,
+ );
+ $constructor[] = sprintf(
+ '$this->e%1$d = function (object $object, array $context): array { $this->init%1$d(); return ($this->e%1$d)($object, $context); };',
+ $index,
+ );
+
+ $this->emitClass($plan);
+ }
+
+ $root = $this->classes[0];
+
+ return Templates::render(Templates::TRANSFORMER, [
+ 'namespace' => $namespace,
+ 'className' => $className,
+ 'class' => $root->className(),
+ 'version' => (string)TransformerFiles::VERSION,
+ 'properties' => implode("\n", $this->properties),
+ 'constructor' => implode("\n", $constructor),
+ 'hydrateCall' => $this->nestedHydrateCall($root),
+ 'extractCall' => $this->nestedExtractCall($root),
+ 'inits' => implode("\n\n", $this->inits),
+ 'methods' => implode("\n\n", $this->methods),
+ ]);
+ }
+
+ /** Emits the properties, the init method and the hydrate/extract code of one class. */
+ private function emitClass(ClassPlan $plan): void
+ {
+ $class = $plan->className();
+ $index = $plan->index;
+
+ $this->declareProperties($plan);
+
+ $init = [];
+
+ // the fingerprint of the class is part of the file name and nested classes are checked before they are inlined
+ $init[] = sprintf('$metadata = $this->hydrator->metadata(\\%s::class);', $class);
+ $init[] = sprintf('$this->r%d = $metadata->reflection;', $index);
+ $nestedInits = [];
+
+ foreach ($plan->properties as $property) {
+ if ($property->defaultSlot !== null) {
+ $init[] = sprintf('$this->d%d = $this->promotedDefault($metadata, %s);', $property->defaultSlot, var_export($property->name, true));
+ }
+
+ if ($property->slot === null) {
+ continue;
+ }
+
+ $init[] = sprintf('$this->n%d = $this->normalizer($metadata, %s);', $property->slot, var_export($property->name, true));
+
+ if ($property->nested === null) {
+ continue;
+ }
+
+ $nested = $this->nested($property);
+ $fingerprint = var_export(ClassFingerprint::of($nested->metadata), true);
+
+ $check = $property->kind === ValueKind::NestedObject ? 'inlineObject' : 'inlineArray';
+ $init[] = sprintf('$this->ih%d = $this->%s($this->n%d, \\%s::class, %s, Skip::Hydrate);', $property->flag, $check, $property->slot, $nested->className(), $fingerprint);
+ $init[] = sprintf('$this->ie%d = $this->%s($this->n%d, \\%s::class, %s, Skip::Extract);', $property->flag, $check, $property->slot, $nested->className(), $fingerprint);
+
+ if ($property->kind === ValueKind::NestedArray) {
+ $init[] = sprintf('if ($this->ih%1$d || $this->ie%1$d) { $this->n%2$d = $this->n%3$d->innerNormalizer(); }', $property->flag, $property->inner, $property->slot);
+ }
+
+ // the nested code is only initialized if it is inlined, outdated code is never touched
+ $nestedInits[] = sprintf('if ($this->ih%1$d || $this->ie%1$d) { $this->init%2$d(); }', $property->flag, $nested->index);
+ }
+
+ [$hydrate, $hydrateHelpers] = $this->hydrateBody($plan);
+ [$extract, $extractHelpers] = $this->extractBody($plan);
+
+ foreach ($hydrateHelpers as $helper) {
+ $init[] = $helper;
+ }
+
+ $init[] = $this->wrap($plan, 'h', 'hydrate', 'array $data, array $context, object|null $object = null', 'object', $hydrate, $plan->scopedHydrate);
+
+ foreach ($extractHelpers as $helper) {
+ $init[] = $helper;
+ }
+
+ $init[] = $this->wrap($plan, 'e', 'extract', 'object $object, array $context', 'array', $extract, $plan->scopedExtract);
+
+ foreach ($nestedInits as $nestedInit) {
+ $init[] = $nestedInit;
+ }
+
+ if (!$plan->leaf()) {
+ $init[] = sprintf('$this->tracked%d = $this->recursive%d();', $index, $index);
+ $this->properties[] = sprintf('public bool $tracked%d = true;', $index);
+ $this->methods[] = $this->recursiveMethod($plan);
+ }
+
+ $this->inits[] = Templates::render(Templates::INIT, [
+ 'index' => (string)$index,
+ 'body' => implode("\n", $init),
+ ]);
+ }
+
+ private function declareProperties(ClassPlan $plan): void
+ {
+ foreach ($plan->properties as $property) {
+ if ($property->slot !== null) {
+ $this->properties[] = sprintf('public Normalizer $n%d;', $property->slot);
+ }
+
+ if ($property->inner !== null) {
+ $this->properties[] = sprintf('public ObjectNormalizer $n%d;', $property->inner);
+ }
+
+ if ($property->flag !== null) {
+ $this->properties[] = sprintf('public bool $ih%d = false;', $property->flag);
+ $this->properties[] = sprintf('public bool $ie%d = false;', $property->flag);
+ }
+
+ if ($property->defaultSlot === null) {
+ continue;
+ }
+
+ $this->properties[] = sprintf('public ReflectionParameter $d%d;', $property->defaultSlot);
+ }
+
+ $index = $plan->index;
+
+ $this->properties[] = sprintf('public bool $ready%d = false;', $index);
+ $this->properties[] = sprintf('public bool $initializing%d = false;', $index);
+ $this->properties[] = sprintf('public bool|null $recursive%d = null;', $index);
+ $this->properties[] = sprintf('public Closure $h%d;', $index);
+ $this->properties[] = sprintf('public Closure $e%d;', $index);
+ $this->properties[] = sprintf('public ReflectionClass $r%d;', $index);
+ }
+
+ /**
+ * Circular references are only possible through normalizers which call the hydrator again. A class whose
+ * normalizers can not reach the class itself again does not need the (expensive) call stack tracking.
+ * Unknown paths and cycles are conservatively treated as recursive.
+ */
+ private function recursiveMethod(ClassPlan $plan): string
+ {
+ $checks = [];
+
+ foreach ($plan->properties as $property) {
+ if ($property->kind === ValueKind::Raw) {
+ continue;
+ }
+
+ if ($property->nested === null) {
+ $checks[] = sprintf('$this->mayRecurse($this->n%d)', $property->slot);
+
+ continue;
+ }
+
+ $nested = $this->nested($property);
+ $recursive = $nested->leaf() ? 'false' : sprintf('$this->recursive%d()', $nested->index);
+ $checks[] = sprintf('($this->ie%d ? %s : $this->mayRecurse($this->n%d))', $property->flag, $recursive, $property->slot);
+ }
+
+ return Templates::render(Templates::RECURSIVE, [
+ 'index' => (string)$plan->index,
+ 'checks' => implode("\n || ", $checks),
+ ]);
+ }
+
+ /**
+ * Emits the body either as closure bound to the class scope (assigned in init) or as a plain method.
+ *
+ * @return string init code which assigns the closure
+ */
+ private function wrap(ClassPlan $plan, string $slot, string $name, string $signature, string $return, string $body, bool $scoped): string
+ {
+ $index = $plan->index;
+
+ if ($scoped) {
+ return Templates::render(Templates::CLOSURE, [
+ 'slot' => $slot . $index,
+ 'signature' => $signature,
+ 'return' => $return,
+ 'body' => $body,
+ 'class' => $plan->className(),
+ ]);
+ }
+
+ $this->methods[] = Templates::render(Templates::METHOD, [
+ 'name' => $name . $index,
+ 'signature' => $signature,
+ 'return' => $return,
+ 'body' => $body,
+ ]);
+
+ return sprintf('$this->%s%d = $this->%s%d(...);', $slot, $index, $name, $index);
+ }
+
+ /**
+ * 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(ClassPlan $plan): array
+ {
+ $main = [];
+ $scoped = [];
+
+ foreach ($plan->properties as $property) {
+ if ($property->hydrateScope !== null) {
+ $scoped[$property->hydrateScope][] = $this->assignment($plan, $property);
+
+ continue;
+ }
+
+ $main[] = $this->assignment($plan, $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);', $slot);
+ }
+
+ $body = [sprintf('$object ??= $this->r%d->newInstanceWithoutConstructor();', $plan->index)];
+ $body[] = implode("\n\n", $main);
+ $body[] = 'return $object;';
+
+ return [
+ implode("\n\n", array_filter($body, static fn (string $code): bool => $code !== '')),
+ $helpers,
+ ];
+ }
+
+ private function assignment(ClassPlan $plan, 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' => $plan->className(),
+ 'value' => $this->denormalize($plan, $property, '$value'),
+ 'default' => $property->default ?? sprintf('$this->d%d->getDefaultValue()', $property->defaultSlot),
+ ]);
+ }
+
+ /** Code which denormalizes the field into the given variable. */
+ private function denormalize(ClassPlan $plan, 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->nestedHydrateCall($this->nested($property)),
+ ]),
+ 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->nestedHydrateCall($this->nested($property)),
+ ]),
+ };
+
+ return Templates::render(Templates::DENORMALIZE, [
+ 'inner' => $inner,
+ 'class' => $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(ClassPlan $plan): array
+ {
+ $pre = [];
+ $scoped = [];
+ $fields = [];
+
+ foreach ($plan->properties as $property) {
+ $field = var_export($property->field, true);
+ [$code, $expression] = $this->normalize($plan, $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);', implode(', ', $variables), $slot));
+ }
+
+ $variables = [
+ 'index' => (string)$plan->index,
+ 'class' => $plan->className(),
+ 'pre' => implode("\n\n", $pre),
+ 'fields' => implode("\n", $fields),
+ ];
+
+ if ($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(ClassPlan $plan, 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->nestedExtractCall($this->nested($property)),
+ 'check' => $this->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->nestedExtractCall($this->nested($property)),
+ 'check' => $this->instanceCheck('$item', $property),
+ ]),
+ };
+
+ $code = Templates::render(Templates::NORMALIZE, [
+ 'inner' => $inner,
+ 'class' => $plan->className(),
+ 'property' => var_export($name, true),
+ 'slot' => (string)$property->slot,
+ ]);
+
+ return [$code, $variable];
+ }
+
+ /** Only exact instances are inlined, subclasses take the generic path through the hydrator. */
+ private function instanceCheck(string $variable, PropertyPlan $property): string
+ {
+ $nested = $this->nested($property);
+ $class = $nested->className();
+
+ if ($nested->metadata->reflection->isFinal()) {
+ return sprintf('%s instanceof \\%s', $variable, $class);
+ }
+
+ return sprintf('%1$s instanceof \\%2$s && %1$s::class === \\%2$s::class', $variable, $class);
+ }
+
+ private function nestedHydrateCall(ClassPlan $nested): string
+ {
+ return $nested->scopedHydrate ? sprintf('($this->h%d)', $nested->index) : sprintf('$this->hydrate%d', $nested->index);
+ }
+
+ private function nestedExtractCall(ClassPlan $nested): string
+ {
+ return $nested->scopedExtract ? sprintf('($this->e%d)', $nested->index) : sprintf('$this->extract%d', $nested->index);
+ }
+
+ private function nested(PropertyPlan $property): ClassPlan
+ {
+ assert(is_int($property->nested));
+
+ return $this->classes[$property->nested];
+ }
+}
diff --git a/src/Extension/Generated/GeneratedCode.php b/src/Extension/Generated/GeneratedCode.php
new file mode 100644
index 00000000..081835af
--- /dev/null
+++ b/src/Extension/Generated/GeneratedCode.php
@@ -0,0 +1,19 @@
+ $classes the class of the transformer first, followed by the inlined nested classes
+ */
+ public function __construct(
+ public readonly string $code,
+ public readonly array $classes,
+ ) {
+ }
+}
diff --git a/src/Extension/Generated/GeneratedTransformerExtension.php b/src/Extension/Generated/GeneratedTransformerExtension.php
new file mode 100644
index 00000000..13ebc6a4
--- /dev/null
+++ b/src/Extension/Generated/GeneratedTransformerExtension.php
@@ -0,0 +1,26 @@
+addTransformerFactory(new GeneratedTransformerFactory(new TransformerFiles($this->cachePath)));
+ }
+}
diff --git a/src/Extension/Generated/GeneratedTransformerFactory.php b/src/Extension/Generated/GeneratedTransformerFactory.php
new file mode 100644
index 00000000..06003d57
--- /dev/null
+++ b/src/Extension/Generated/GeneratedTransformerFactory.php
@@ -0,0 +1,59 @@
+files->className($metadata, $hydrator);
+ $fqcn = $this->files->fqcn($className);
+
+ if (!class_exists($fqcn, false)) {
+ $file = $this->files->path($className);
+
+ if (!is_file($file)) {
+ return null;
+ }
+
+ require_once $file;
+ }
+
+ try {
+ $transformer = new $fqcn($hydrator);
+ } catch (OutdatedGeneratedTransformer) {
+ return null;
+ }
+
+ assert($transformer instanceof ClassTransformer);
+
+ return $transformer;
+ }
+}
diff --git a/src/Extension/Generated/GeneratedTransformerNotWritable.php b/src/Extension/Generated/GeneratedTransformerNotWritable.php
new file mode 100644
index 00000000..4208469b
--- /dev/null
+++ b/src/Extension/Generated/GeneratedTransformerNotWritable.php
@@ -0,0 +1,18 @@
+warmup([ProfileCreated::class]);
+ */
+final class GeneratedTransformerWarmer
+{
+ private readonly TransformerFiles $files;
+
+ public function __construct(
+ private readonly MetadataFactory $metadataFactory,
+ string $cachePath,
+ ) {
+ $this->files = new TransformerFiles($cachePath);
+ }
+
+ /**
+ * Generates a transformer for every class and every nested class it references.
+ *
+ * @param list $classes
+ *
+ * @return list all classes a transformer was generated for
+ *
+ * @throws ClassNotGeneratable
+ * @throws GeneratedTransformerNotWritable
+ */
+ public function warmup(array $classes): array
+ {
+ $generator = new TransformerGenerator($this->metadataFactory);
+ $queue = $classes;
+ $done = [];
+ $generated = [];
+
+ while ($queue !== []) {
+ /** @var class-string $class */
+ $class = ltrim(array_shift($queue), '\\');
+
+ if (isset($done[$class])) {
+ continue;
+ }
+
+ $done[$class] = true;
+
+ try {
+ $metadata = $this->metadataFactory->metadata($class);
+ } catch (ClassNotFound) {
+ throw new ClassNotGeneratable($class, 'class not found');
+ }
+
+ $className = $this->files->className($metadata, $this->metadataFactory);
+ $code = $generator->generate($class, $this->files->fqcn($className));
+
+ if ($code === null) {
+ continue;
+ }
+
+ $this->write($this->files->path($className), $code->code);
+ $generated[] = $class;
+
+ // nested classes are also hydrated on their own if they can not be inlined
+ foreach ($code->classes as $nested) {
+ $queue[] = $nested;
+ }
+ }
+
+ return $generated;
+ }
+
+ /** @throws GeneratedTransformerNotWritable */
+ 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 GeneratedTransformerNotWritable($file);
+ }
+
+ $tmp = $file . '.' . uniqid('', true) . '.tmp';
+
+ if (@file_put_contents($tmp, $code) === false || !@rename($tmp, $file)) {
+ @unlink($tmp);
+
+ throw new GeneratedTransformerNotWritable($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/OutdatedGeneratedTransformer.php b/src/Extension/Generated/OutdatedGeneratedTransformer.php
new file mode 100644
index 00000000..e7f2d318
--- /dev/null
+++ b/src/Extension/Generated/OutdatedGeneratedTransformer.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..f56fb172
--- /dev/null
+++ b/src/Extension/Generated/Templates.php
@@ -0,0 +1,412 @@
+ $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 TRANSFORMER = <<<'PHP'
+ */
+ public array $middlewares;
+
+ {{properties}}
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ {{constructor}}
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return {{hydrateCall}}($data, $context, $object);
+ }
+
+ return {{hydrateCall}}($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return {{extractCall}}($object, $context);
+ }
+
+ {{inits}}
+
+ {{methods}}
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
+
+PHP;
+
+ public const INIT = <<<'PHP'
+private function init{{index}}(): void
+{
+ if ($this->ready{{index}} || $this->initializing{{index}}) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing{{index}} = true;
+
+ try {
+ {{body}}
+
+ $this->ready{{index}} = true;
+ } finally {
+ $this->initializing{{index}} = false;
+ }
+}
+PHP;
+
+ public const CLOSURE = <<<'PHP'
+$this->{{slot}} = Closure::bind(function ({{signature}}): {{return}} {
+ {{body}}
+}, $this, \{{class}}::class);
+PHP;
+
+ public const METHOD = <<<'PHP'
+public function {{name}}({{signature}}): {{return}}
+{
+ {{body}}
+}
+PHP;
+
+ public const HYDRATE_HELPER = <<<'PHP'
+$this->hp{{slot}} = Closure::bind(function (object $object, array $data, array $context): void {
+ {{body}}
+}, $this, \{{scope}}::class);
+PHP;
+
+ public const EXTRACT_TRACKED = <<<'PHP'
+if ($this->tracked{{index}}) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \{{class}}::class;
+
+ try {
+ {{pre}}
+
+ return [
+ {{fields}}
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+}
+PHP;
+
+ public const RECURSIVE = <<<'PHP'
+private function recursive{{index}}(): bool
+{
+ if ($this->recursive{{index}} !== null) {
+ return $this->recursive{{index}};
+ }
+
+ $this->recursive{{index}} = true;
+
+ return $this->recursive{{index}} = {{checks}};
+}
+PHP;
+
+ public const EXTRACT_PLAIN = <<<'PHP'
+{{pre}}
+
+return [
+ {{fields}}
+];
+PHP;
+
+ public const EXTRACT_HELPER = <<<'PHP'
+$this->ep{{slot}} = Closure::bind(function (object $object, array $context): 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->ih{{flag}} && \is_array({{variable}}) && $this->direct($context)) {
+ {{variable}} = {{nested}}({{variable}}, $context);
+} else {
+ {{variable}} = $this->n{{slot}}->denormalize({{variable}}, $context);
+}
+PHP;
+
+ public const DENORMALIZE_ARRAY = <<<'PHP'
+{{variable}} = $data[{{field}}];
+
+if ($this->ih{{flag}} && \is_array({{variable}}) && $this->direct($context)) {
+ $items = [];
+
+ foreach ({{variable}} as $key => $item) {
+ $items[$key] = \is_array($item) ? {{nested}}($item, $context) : $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->ie{{flag}} && {{check}} && $this->direct($context) ? {{nested}}($value, $context) : $this->n{{slot}}->normalize($value, $context);
+PHP;
+
+ public const NORMALIZE_ARRAY = <<<'PHP'
+$value = $object->{{name}};
+
+if ($this->ie{{flag}} && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = {{check}} ? {{nested}}($item, $context) : $this->n{{inner}}->normalize($item, $context);
+ }
+
+ {{variable}} = $items;
+} else {
+ {{variable}} = $this->n{{slot}}->normalize($value, $context);
+}
+PHP;
+}
diff --git a/src/Extension/Generated/TransformerFiles.php b/src/Extension/Generated/TransformerFiles.php
new file mode 100644
index 00000000..84869ef7
--- /dev/null
+++ b/src/Extension/Generated/TransformerFiles.php
@@ -0,0 +1,92 @@
+ $metadata
+ *
+ * @template T of object
+ */
+ public static function generatable(ClassMetadata $metadata): bool
+ {
+ $reflection = $metadata->reflection;
+
+ return !$reflection->isInternal()
+ && !$reflection->isAbstract()
+ && !$reflection->isInterface()
+ && !$reflection->isEnum()
+ && !$reflection->isAnonymous();
+ }
+
+ /**
+ * @param ClassMetadata $metadata
+ *
+ * @template T of object
+ */
+ public function className(ClassMetadata $metadata, MetadataFactory $metadataFactory): string
+ {
+ $hash = hash('sha256', serialize([
+ self::VERSION,
+ $metadata->className,
+ ClassFingerprint::ofGraph($metadata, $metadataFactory),
+ ]));
+
+ return sprintf(
+ '%sTransformer_%s',
+ preg_replace('/[^A-Za-z0-9_]/', '', $metadata->reflection->getShortName()),
+ substr($hash, 0, 16),
+ );
+ }
+
+ /** @return class-string */
+ public function fqcn(string $className): string
+ {
+ /** @var class-string $fqcn */
+ $fqcn = self::NAMESPACE . '\\' . $className;
+
+ return $fqcn;
+ }
+
+ public function path(string $className): string
+ {
+ return sprintf('%s/%s.php', $this->cachePath, $className);
+ }
+
+ public function cachePath(): string
+ {
+ return $this->cachePath;
+ }
+}
diff --git a/src/Extension/Generated/TransformerGenerator.php b/src/Extension/Generated/TransformerGenerator.php
new file mode 100644
index 00000000..3bc037ed
--- /dev/null
+++ b/src/Extension/Generated/TransformerGenerator.php
@@ -0,0 +1,58 @@
+metadataFactory);
+ $planner->add($class);
+
+ $plans = $planner->plans();
+
+ if ($plans === []) {
+ return null;
+ }
+
+ $parts = explode('\\', ltrim($transformerFqcn, '\\'));
+ $className = array_pop($parts);
+ $namespace = implode('\\', $parts);
+
+ return new GeneratedCode(
+ (new CodeEmitter($plans))->emit($namespace, $className),
+ array_map(static fn (ClassPlan $plan): string => $plan->className(), $plans),
+ );
+ }
+}
diff --git a/src/Extension/Generated/ValueKind.php b/src/Extension/Generated/ValueKind.php
new file mode 100644
index 00000000..adb76646
--- /dev/null
+++ b/src/Extension/Generated/ValueKind.php
@@ -0,0 +1,25 @@
+ */
private array $classMetadata = [];
@@ -62,6 +65,17 @@ public function __construct(
$this->hasSkippableMiddlewares = $hasSkippableMiddlewares;
}
+ /** @return list */
+ public function middlewares(): array
+ {
+ return $this->middlewares;
+ }
+
+ public function defaultLazy(): bool
+ {
+ return $this->defaultLazy;
+ }
+
/**
* @param class-string $class
* @param array $context
@@ -162,7 +176,7 @@ private function hydrateWithMiddlewares(ClassMetadata $metadata, array $data, ar
private function transformer(ClassMetadata $metadata): ClassTransformer
{
return $this->transformers[$metadata->className]
- ??= $this->transformerFactory->create($metadata)
+ ??= $this->transformerFactory->create($metadata, $this)
?? throw new ClassNotSupported($metadata->className);
}
diff --git a/src/Transformer/CallStack.php b/src/Transformer/CallStack.php
index b32c3fd8..7dee1afd 100644
--- a/src/Transformer/CallStack.php
+++ b/src/Transformer/CallStack.php
@@ -5,8 +5,8 @@
namespace Patchlevel\Hydrator\Transformer;
/**
- * The objects which are currently extracted, shared by the transformers of a factory to detect circular references
- * across classes.
+ * The objects which are currently extracted within one call of the hydrator, to detect circular references. It is
+ * passed along in the context, so every call has its own and nested objects share the one of their parent.
*
* @internal
*/
@@ -14,4 +14,20 @@ final class CallStack
{
/** @var array object id => class */
public array $objects = [];
+
+ /**
+ * The call stack of the context, a new one is added to the context if there is none yet.
+ *
+ * @param array $context
+ */
+ public static function of(array &$context): self
+ {
+ $callStack = $context[self::class] ?? null;
+
+ if ($callStack instanceof self) {
+ return $callStack;
+ }
+
+ return $context[self::class] = new self();
+ }
}
diff --git a/src/Transformer/ChainTransformerFactory.php b/src/Transformer/ChainTransformerFactory.php
index 2b6d1071..bb53f58c 100644
--- a/src/Transformer/ChainTransformerFactory.php
+++ b/src/Transformer/ChainTransformerFactory.php
@@ -5,6 +5,7 @@
namespace Patchlevel\Hydrator\Transformer;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
+use Patchlevel\Hydrator\StackHydrator;
final readonly class ChainTransformerFactory implements ClassTransformerFactory
{
@@ -14,10 +15,10 @@ public function __construct(
) {
}
- public function create(ClassMetadata $metadata): ClassTransformer|null
+ public function create(ClassMetadata $metadata, StackHydrator $hydrator): ClassTransformer|null
{
foreach ($this->factories as $factory) {
- $transformer = $factory->create($metadata);
+ $transformer = $factory->create($metadata, $hydrator);
if ($transformer !== null) {
return $transformer;
diff --git a/src/Transformer/ClassTransformerFactory.php b/src/Transformer/ClassTransformerFactory.php
index 32c49faf..c4113ef0 100644
--- a/src/Transformer/ClassTransformerFactory.php
+++ b/src/Transformer/ClassTransformerFactory.php
@@ -5,6 +5,7 @@
namespace Patchlevel\Hydrator\Transformer;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
+use Patchlevel\Hydrator\StackHydrator;
/**
* Provides the transformer of a class. Factories are registered with
@@ -17,10 +18,11 @@ interface ClassTransformerFactory
* Called once per class and hydrator, the transformer is cached by the hydrator.
*
* @param ClassMetadata $metadata
+ * @param StackHydrator $hydrator the hydrator which asks, for the metadata of other classes and its configuration
*
* @return ClassTransformer|null null if this factory is not responsible for the class
*
* @template T of object
*/
- public function create(ClassMetadata $metadata): ClassTransformer|null;
+ public function create(ClassMetadata $metadata, StackHydrator $hydrator): ClassTransformer|null;
}
diff --git a/src/Transformer/ReflectionTransformer.php b/src/Transformer/ReflectionTransformer.php
index 9abc5f90..e6e60fa8 100644
--- a/src/Transformer/ReflectionTransformer.php
+++ b/src/Transformer/ReflectionTransformer.php
@@ -27,7 +27,6 @@ final class ReflectionTransformer implements ClassTransformer
/** @param ClassMetadata $metadata */
public function __construct(
private readonly ClassMetadata $metadata,
- private readonly CallStack $callStack = new CallStack(),
) {
}
@@ -108,16 +107,17 @@ public function hydrate(array $data, array $context): object
*/
public function extract(object $object, array $context): array
{
+ $callStack = CallStack::of($context);
$objectId = spl_object_id($object);
- if (array_key_exists($objectId, $this->callStack->objects)) {
- $references = array_values($this->callStack->objects);
+ if (array_key_exists($objectId, $callStack->objects)) {
+ $references = array_values($callStack->objects);
$references[] = $object::class;
throw new CircularReference($references);
}
- $this->callStack->objects[$objectId] = $object::class;
+ $callStack->objects[$objectId] = $object::class;
try {
$data = [];
@@ -145,7 +145,7 @@ public function extract(object $object, array $context): array
}
}
} finally {
- unset($this->callStack->objects[$objectId]);
+ unset($callStack->objects[$objectId]);
}
return $data;
diff --git a/src/Transformer/ReflectionTransformerFactory.php b/src/Transformer/ReflectionTransformerFactory.php
index 9d748147..ea9d370d 100644
--- a/src/Transformer/ReflectionTransformerFactory.php
+++ b/src/Transformer/ReflectionTransformerFactory.php
@@ -5,17 +5,11 @@
namespace Patchlevel\Hydrator\Transformer;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
+use Patchlevel\Hydrator\StackHydrator;
/** Transforms every class with reflection, it is the fallback after all other factories. */
final class ReflectionTransformerFactory implements ClassTransformerFactory
{
- private readonly CallStack $callStack;
-
- public function __construct()
- {
- $this->callStack = new CallStack();
- }
-
/**
* @param ClassMetadata $metadata
*
@@ -23,8 +17,8 @@ public function __construct()
*
* @template T of object
*/
- public function create(ClassMetadata $metadata): ReflectionTransformer
+ public function create(ClassMetadata $metadata, StackHydrator $hydrator): ReflectionTransformer
{
- return new ReflectionTransformer($metadata, $this->callStack);
+ return new ReflectionTransformer($metadata);
}
}
diff --git a/tests/Architecture/NoCodeGenerationAtRuntimeTest.php b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php
new file mode 100644
index 00000000..e8e00eb3
--- /dev/null
+++ b/tests/Architecture/NoCodeGenerationAtRuntimeTest.php
@@ -0,0 +1,50 @@
+classes(
+ Selector::classname(GeneratedTransformerExtension::class),
+ Selector::classname(GeneratedTransformerFactory::class),
+ Selector::classname(TransformerFiles::class),
+ Selector::classname(ClassFingerprint::class),
+ )
+ ->shouldNotDependOn()
+ ->classes(
+ Selector::classname(GeneratedTransformerWarmer::class),
+ Selector::classname(TransformerGenerator::class),
+ Selector::classname(ClassPlanner::class),
+ Selector::classname(ClassPlan::class),
+ Selector::classname(PropertyPlan::class),
+ Selector::classname(CodeEmitter::class),
+ Selector::classname(Templates::class),
+ Selector::classname(GeneratedCode::class),
+ );
+ }
+}
diff --git a/tests/Benchmark/GeneratedHydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php
new file mode 100644
index 00000000..7686e371
--- /dev/null
+++ b/tests/Benchmark/GeneratedHydratorBench.php
@@ -0,0 +1,118 @@
+hydrator = self::hydrator();
+ }
+
+ /** Runs once before the benchmarks, not in every iteration. */
+ public static function warmup(): void
+ {
+ (new GeneratedTransformerWarmer(self::hydrator(), self::CACHE_PATH))->warmup([ProfileCreated::class]);
+ }
+
+ private static function hydrator(): StackHydrator
+ {
+ return (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension(self::CACHE_PATH))
+ ->build();
+ }
+
+ public function setUp(): void
+ {
+ $object = $this->hydrator->hydrate(
+ ProfileCreated::class,
+ [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ],
+ );
+
+ $this->hydrator->extract($object);
+ }
+
+ #[Bench\Revs(1000)]
+ public function benchHydrate1Object(): void
+ {
+ $this->hydrator->hydrate(ProfileCreated::class, [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ]);
+ }
+
+ #[Bench\Revs(1000)]
+ public function benchExtract1Object(): void
+ {
+ $object = new ProfileCreated(
+ ProfileId::fromString('1'),
+ 'foo',
+ [
+ new Skill('php'),
+ new Skill('symfony'),
+ ],
+ );
+
+ $this->hydrator->extract($object);
+ }
+
+ #[Bench\Revs(3)]
+ public function benchHydrate1000Objects(): void
+ {
+ for ($i = 0; $i < 1_000; $i++) {
+ $this->hydrator->hydrate(ProfileCreated::class, [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ]);
+ }
+ }
+
+ #[Bench\Revs(3)]
+ public function benchExtract1000Objects(): void
+ {
+ $object = new ProfileCreated(
+ ProfileId::fromString('1'),
+ 'foo',
+ [
+ new Skill('php'),
+ new Skill('symfony'),
+ ],
+ );
+
+ for ($i = 0; $i < 1_000; $i++) {
+ $this->hydrator->extract($object);
+ }
+ }
+}
diff --git a/tests/Benchmark/GeneratedHydratorWithCryptographyBench.php b/tests/Benchmark/GeneratedHydratorWithCryptographyBench.php
new file mode 100644
index 00000000..4f6eaa10
--- /dev/null
+++ b/tests/Benchmark/GeneratedHydratorWithCryptographyBench.php
@@ -0,0 +1,134 @@
+store = new InMemoryCipherKeyStore();
+ $this->hydrator = self::hydrator($this->store);
+ }
+
+ /** Runs once before the benchmarks, not in every iteration. */
+ public static function warmup(): void
+ {
+ (new GeneratedTransformerWarmer(self::hydrator(new InMemoryCipherKeyStore()), self::CACHE_PATH))
+ ->warmup([ProfileCreated::class]);
+ }
+
+ private static function hydrator(InMemoryCipherKeyStore $store): StackHydrator
+ {
+ return (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension(self::CACHE_PATH))
+ ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($store)))
+ ->build();
+ }
+
+ public function setUp(): void
+ {
+ $this->store->clear();
+
+ $object = $this->hydrator->hydrate(
+ ProfileCreated::class,
+ [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ],
+ );
+
+ $this->hydrator->extract($object);
+ }
+
+ #[Bench\Revs(1000)]
+ public function benchHydrate1Object(): void
+ {
+ $this->hydrator->hydrate(
+ ProfileCreated::class,
+ [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ],
+ );
+ }
+
+ #[Bench\Revs(1000)]
+ public function benchExtract1Object(): void
+ {
+ $object = new ProfileCreated(
+ ProfileId::fromString('1'),
+ 'foo',
+ [
+ new Skill('php'),
+ new Skill('symfony'),
+ ],
+ );
+
+ $this->hydrator->extract($object);
+ }
+
+ #[Bench\Revs(3)]
+ public function benchHydrate1000Objects(): void
+ {
+ for ($i = 0; $i < 1_000; $i++) {
+ $this->hydrator->hydrate(
+ ProfileCreated::class,
+ [
+ 'profileId' => '1',
+ 'name' => 'foo',
+ 'skills' => [
+ ['name' => 'php'],
+ ['name' => 'symfony'],
+ ],
+ ],
+ );
+ }
+ }
+
+ #[Bench\Revs(3)]
+ public function benchExtract1000Objects(): void
+ {
+ $object = new ProfileCreated(
+ ProfileId::fromString('1'),
+ 'foo',
+ [
+ new Skill('php'),
+ new Skill('symfony'),
+ ],
+ );
+
+ for ($i = 0; $i < 1_000; $i++) {
+ $this->hydrator->extract($object);
+ }
+ }
+}
diff --git a/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php
new file mode 100644
index 00000000..25ec6172
--- /dev/null
+++ b/tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php
@@ -0,0 +1,1057 @@
+useExtension(new GeneratedTransformerExtension(self::cachePath()))->build());
+ }
+
+ public function setUp(): void
+ {
+ $this->hydrator = $this->builder()->build();
+ }
+
+ private function builder(): StackHydratorBuilder
+ {
+ return (new StackHydratorBuilder())->useExtension($this->extension());
+ }
+
+ private function extension(): GeneratedTransformerExtension
+ {
+ return new GeneratedTransformerExtension(self::cachePath());
+ }
+
+ public static function cachePath(): string
+ {
+ return sys_get_temp_dir() . '/patchlevel-hydrator-tests';
+ }
+
+ /** Removes the generated files, the generator may have changed since they were written. */
+ public static function clearCache(): void
+ {
+ foreach (glob(self::cachePath() . '/*.php') ?: [] as $file) {
+ unlink($file);
+ }
+ }
+
+ /** Code is never generated at runtime, the transformers of all fixtures are generated beforehand. */
+ public static function warmup(StackHydrator $hydrator): void
+ {
+ $classes = [...self::CLASSES, ParentDto::class, ProfileCreatedWrapper::class];
+
+ if (PHP_VERSION_ID >= 80400) {
+ $classes[] = AsymmetricVisibilityDto::class;
+ }
+
+ if (PHP_VERSION_ID < 80500) {
+ // closures in attributes are only supported since PHP 8.5
+ $classes = array_values(array_filter(
+ $classes,
+ static fn (string $class): bool => $class !== ProfileCreatedWithInlineNormalizer::class,
+ ));
+ }
+
+ (new GeneratedTransformerWarmer($hydrator, self::cachePath()))->warmup($classes);
+ }
+
+ /** @param class-string $class */
+ private static function transformer(StackHydrator $hydrator, string $class): ClassTransformer
+ {
+ $transformer = (new ReflectionMethod(StackHydrator::class, 'transformer'))->invoke($hydrator, $hydrator->metadata($class));
+ self::assertInstanceOf(ClassTransformer::class, $transformer);
+
+ return $transformer;
+ }
+
+ 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(Next::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(Next::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 testHydrateWithoutGeneratedCodeFallsBackToReflection(): void
+ {
+ // a unique field name gives the class a fingerprint no transformer was generated or loaded for yet
+ $field = uniqid('email_');
+
+ $hydrator = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension(self::cachePath()))
+ ->addMetadataEnricher(new class ($field) implements MetadataEnricher {
+ public function __construct(private readonly string $field)
+ {
+ }
+
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ $property = $classMetadata->properties['email'] ?? null;
+
+ if ($property === null) {
+ return;
+ }
+
+ $property->fieldName = $this->field;
+ }
+ })
+ ->build();
+
+ $files = glob(self::cachePath() . '/*.php');
+
+ $event = $hydrator->hydrate(
+ ParentDto::class,
+ ['profileId' => '1', $field => 'info@patchlevel.de'],
+ );
+
+ self::assertEquals(new ParentDto(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), $event);
+ self::assertEquals(['profileId' => '1', $field => 'info@patchlevel.de'], $hydrator->extract($event));
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, ParentDto::class));
+ // nothing is generated at runtime
+ self::assertSame($files, glob(self::cachePath() . '/*.php'));
+ }
+
+ #[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 transformer.
+ *
+ * @param class-string $class
+ *
+ * @return array flag name => inlined
+ */
+ private static function inlined(StackHydrator $hydrator, string $class = NestedLifecycleDto::class): array
+ {
+ $transformer = self::transformer($hydrator, $class);
+ self::assertNotInstanceOf(ReflectionTransformer::class, $transformer);
+
+ /** @var array $flags */
+ $flags = array_filter(
+ get_object_vars($transformer),
+ static fn (string $name): bool => preg_match('/^i[he]\d+$/', $name) === 1,
+ ARRAY_FILTER_USE_KEY,
+ );
+
+ return $flags;
+ }
+
+ public function testNestedObjectsAreInlinedWithoutOtherMiddlewares(): void
+ {
+ $hydrator = $this->builder()->build();
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(['ih0' => true, 'ie0' => true, 'ih1' => true, 'ie1' => true], self::inlined($hydrator));
+ }
+
+ public function testNestedObjectsAreInlinedWhenOtherMiddlewaresSkipThem(): void
+ {
+ $counter = new CountingMiddleware([LifecycleFixture::class => Skip::Both]);
+
+ $hydrator = (new StackHydratorBuilder())
+ ->useExtension($this->extension())
+ ->addMiddleware($counter)
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+
+ self::assertSame(['ih0' => true, 'ie0' => true, 'ih1' => true, 'ie1' => true], self::inlined($hydrator));
+ self::assertSame([NestedLifecycleDto::class => 1], $counter->hydrated);
+ }
+
+ public function testInliningIsDecidedPerDirection(): void
+ {
+ // an upcaster which can not be introspected keeps the upcast middleware in the hydrate stack
+ $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 = (new StackHydratorBuilder())
+ ->useExtension($this->extension())
+ ->useExtension(new UpcastExtension([$upcaster]))
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(['ih0' => false, 'ie0' => true, 'ih1' => false, 'ie1' => true], self::inlined($hydrator));
+
+ // callback upcasters for other classes do not prevent inlining
+ $hydrator = (new StackHydratorBuilder())
+ ->useExtension($this->extension())
+ ->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(['ih0' => true, 'ie0' => true, 'ih1' => true, 'ie1' => true], self::inlined($hydrator));
+ }
+
+ public function testGeneratedTransformerIsUsed(): void
+ {
+ $hydrator = $this->builder()->build();
+
+ $transformer = self::transformer($hydrator, ProfileCreated::class);
+
+ self::assertStringStartsWith(TransformerFiles::NAMESPACE . '\\ProfileCreatedTransformer_', $transformer::class);
+ }
+
+ public function testStackRunsWithOtherMiddlewares(): void
+ {
+ $counter = new CountingMiddleware();
+
+ $hydrator = (new StackHydratorBuilder())
+ ->useExtension($this->extension())
+ ->addMiddleware($counter)
+ ->build();
+
+ $event = $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']);
+ $hydrator->extract($event);
+
+ self::assertSame([ProfileCreated::class => 1], $counter->hydrated);
+ self::assertSame([ProfileCreated::class => 1], $counter->extracted);
+ self::assertNotInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, ProfileCreated::class));
+ }
+
+ public function testNestedObjectsAreNotInlinedWithNonSkippableMiddleware(): void
+ {
+ $counter = new CountingMiddleware();
+
+ $hydrator = $this->builder()->build();
+ $direct = (new StackHydratorBuilder())
+ ->useExtension($this->extension())
+ ->addMiddleware($counter)
+ ->build();
+
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b'], ['name' => 'c']]];
+
+ $object = $hydrator->hydrate(NestedLifecycleDto::class, $data);
+ self::assertSame($data, $hydrator->extract($object));
+
+ // with another middleware in the stack, nested objects go through the whole stack
+ $object = $direct->hydrate(NestedLifecycleDto::class, $data);
+ self::assertSame($data, $direct->extract($object));
+
+ self::assertSame([NestedLifecycleDto::class => 1, LifecycleFixture::class => 3], $counter->hydrated);
+ self::assertSame([NestedLifecycleDto::class => 1, LifecycleFixture::class => 3], $counter->extracted);
+ self::assertNotContains(true, self::inlined($direct));
+ }
+
+ 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),
+ );
+ }
+}
diff --git a/tests/Unit/Extension/Generated/GeneratedTransformerExtensionTest.php b/tests/Unit/Extension/Generated/GeneratedTransformerExtensionTest.php
new file mode 100644
index 00000000..a56213fe
--- /dev/null
+++ b/tests/Unit/Extension/Generated/GeneratedTransformerExtensionTest.php
@@ -0,0 +1,139 @@
+useExtension(new GeneratedTransformerExtension('/tmp'));
+
+ self::assertCount(1, $builder->transformerFactories());
+ self::assertInstanceOf(GeneratedTransformerFactory::class, $builder->transformerFactories()[0]);
+ }
+
+ public function testWarmedUpTransformerIsUsed(): void
+ {
+ $path = self::uniquePath();
+ $field = uniqid('email_');
+ $hydrator = self::hydrator($path, $field);
+
+ (new GeneratedTransformerWarmer($hydrator, $path))->warmup([ProfileCreated::class]);
+
+ $event = $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', $field => 'info@patchlevel.de']);
+
+ self::assertEquals(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), $event);
+ self::assertSame(['profileId' => '1', $field => 'info@patchlevel.de'], $hydrator->extract($event));
+ self::assertStringStartsWith(TransformerFiles::NAMESPACE . '\\ProfileCreatedTransformer_', self::transformer($hydrator, ProfileCreated::class)::class);
+ }
+
+ public function testNothingIsGeneratedAtRuntime(): void
+ {
+ $path = self::uniquePath();
+ $field = uniqid('email_');
+ $hydrator = self::hydrator($path, $field);
+
+ $event = $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', $field => 'info@patchlevel.de']);
+ $hydrator->extract($event);
+
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, ProfileCreated::class));
+ self::assertDirectoryDoesNotExist($path);
+ }
+
+ public function testChangedClassFallsBackToReflection(): void
+ {
+ $path = self::uniquePath();
+ $field = uniqid('email_');
+
+ (new GeneratedTransformerWarmer(self::hydrator($path, $field), $path))->warmup([ProfileCreated::class]);
+
+ // the field was renamed after the warmup, the generated code no longer matches and is not used
+ $hydrator = self::hydrator($path, $field . '_renamed');
+ $event = $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', $field . '_renamed' => 'info@patchlevel.de']);
+
+ self::assertSame('info@patchlevel.de', $event->email->toString());
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, ProfileCreated::class));
+ self::assertCount(1, glob($path . '/ProfileCreatedTransformer_*.php') ?: []);
+ }
+
+ public function testAnonymousClassesAreTransformedWithReflection(): void
+ {
+ $object = new class {
+ public string $name = 'foo';
+ };
+
+ $hydrator = self::hydrator(self::uniquePath());
+
+ self::assertSame(['name' => 'foo'], $hydrator->extract($object));
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, $object::class));
+ }
+
+ /**
+ * Classes are loaded once per process, renaming the email field to a unique name gives the class a fingerprint
+ * no transformer was loaded for yet.
+ */
+ private static function hydrator(string $path, string|null $emailField = null): StackHydrator
+ {
+ $builder = (new StackHydratorBuilder())->useExtension(new GeneratedTransformerExtension($path));
+
+ if ($emailField !== null) {
+ $builder->addMetadataEnricher(new class ($emailField) implements MetadataEnricher {
+ public function __construct(private readonly string $field)
+ {
+ }
+
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ $property = $classMetadata->properties['email'] ?? null;
+
+ if ($property === null) {
+ return;
+ }
+
+ $property->fieldName = $this->field;
+ }
+ });
+ }
+
+ return $builder->build();
+ }
+
+ /** @param class-string $class */
+ private static function transformer(StackHydrator $hydrator, string $class): ClassTransformer
+ {
+ $transformer = (new ReflectionMethod(StackHydrator::class, 'transformer'))->invoke($hydrator, $hydrator->metadata($class));
+ self::assertInstanceOf(ClassTransformer::class, $transformer);
+
+ return $transformer;
+ }
+
+ private static function uniquePath(): string
+ {
+ return GeneratedStackHydratorTest::cachePath() . '/' . uniqid('cache', true);
+ }
+}
diff --git a/tests/Unit/Extension/Generated/GeneratedTransformerWarmerTest.php b/tests/Unit/Extension/Generated/GeneratedTransformerWarmerTest.php
new file mode 100644
index 00000000..141bb9eb
--- /dev/null
+++ b/tests/Unit/Extension/Generated/GeneratedTransformerWarmerTest.php
@@ -0,0 +1,122 @@
+build(), $path))->warmup([
+ '\\' . NestedLifecycleDto::class,
+ NestedLifecycleDto::class,
+ ProfileId::class,
+ ]);
+
+ self::assertSame([NestedLifecycleDto::class, LifecycleFixture::class], $compiled);
+
+ $files = glob($path . '/*.php') ?: [];
+ sort($files);
+ self::assertCount(2, $files);
+ self::assertStringContainsString('/LifecycleFixtureTransformer_', $files[0]);
+ self::assertStringContainsString('/NestedLifecycleDtoTransformer_', $files[1]);
+
+ // the hydrator picks them up without generating anything itself
+ $hydrator = $builder->build();
+
+ $data = ['child' => [$field => 'a'], 'items' => [[$field => 'b']]];
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertNotInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, NestedLifecycleDto::class));
+ self::assertNotInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, LifecycleFixture::class));
+ }
+
+ public function testUnknownClassIsNotGeneratable(): void
+ {
+ $path = GeneratedStackHydratorTest::cachePath() . '/' . uniqid('compiled', true);
+
+ $this->expectException(ClassNotGeneratable::class);
+
+ /** @phpstan-ignore argument.type */
+ (new GeneratedTransformerWarmer(self::builder($path, uniqid('name_'))->build(), $path))->warmup(['Unknown']);
+ }
+
+ public function testWarmupWithoutClasses(): void
+ {
+ $path = GeneratedStackHydratorTest::cachePath() . '/' . uniqid('compiled', true);
+
+ self::assertSame([], (new GeneratedTransformerWarmer(self::builder($path, uniqid('name_'))->build(), $path))->warmup([]));
+ self::assertSame([], glob($path . '/*.php'));
+ }
+
+ public function testNotWritable(): void
+ {
+ $file = sys_get_temp_dir() . '/' . uniqid('not-a-directory');
+ touch($file);
+
+ $this->expectException(GeneratedTransformerNotWritable::class);
+
+ (new GeneratedTransformerWarmer(self::builder($file, uniqid('name_'))->build(), $file))->warmup([NestedLifecycleDto::class]);
+ }
+
+ /** @param class-string $class */
+ private static function transformer(StackHydrator $hydrator, string $class): ClassTransformer
+ {
+ $transformer = (new ReflectionMethod(StackHydrator::class, 'transformer'))->invoke($hydrator, $hydrator->metadata($class));
+ self::assertInstanceOf(ClassTransformer::class, $transformer);
+
+ return $transformer;
+ }
+
+ /** A unique field name gives the classes fingerprints no transformer was loaded for yet in this process. */
+ private static function builder(string $path, string $field): StackHydratorBuilder
+ {
+ return (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension($path))
+ ->addMetadataEnricher(new class ($field) implements MetadataEnricher {
+ public function __construct(private readonly string $field)
+ {
+ }
+
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ if ($classMetadata->className !== LifecycleFixture::class) {
+ return;
+ }
+
+ $property = $classMetadata->properties['name'];
+ $property->fieldName = $this->field;
+ }
+ });
+ }
+}
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle1DtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoTransformer.php
new file mode 100644
index 00000000..e2ef48c3
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle1DtoTransformer.php
@@ -0,0 +1,534 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih2 = false;
+ public bool $ie2 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public Normalizer $n1;
+ public bool $ih1 = false;
+ public bool $ie1 = false;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+ public bool $tracked1 = true;
+ public Normalizer $n2;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public bool $ready2 = false;
+ public bool $initializing2 = false;
+ public bool|null $recursive2 = null;
+ public Closure $h2;
+ public Closure $e2;
+ public ReflectionClass $r2;
+ public bool $tracked2 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+ $this->h2 = function (array $data, array $context, object|null $object = null): object { $this->init2(); return ($this->h2)($data, $context, $object); };
+ $this->e2 = function (object $object, array $context): array { $this->init2(); return ($this->e2)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'to');
+ $this->ih2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih2 || $this->ie2) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class);
+ $this->r1 = $metadata->reflection;
+ $this->n1 = $this->normalizer($metadata, 'to');
+ $this->ih1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+ if ($this->ih1 || $this->ie1) { $this->init2(); }
+ $this->tracked1 = $this->recursive1();
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ private function init2(): void
+ {
+ if ($this->ready2 || $this->initializing2) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing2 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class);
+ $this->r2 = $metadata->reflection;
+ $this->n2 = $this->normalizer($metadata, 'to');
+ $this->ih0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h2 = $this->hydrate2(...);
+ $this->e2 = $this->extract2(...);
+ if ($this->ih0 || $this->ie0) { $this->init0(); }
+ $this->tracked2 = $this->recursive2();
+
+ $this->ready2 = true;
+ } finally {
+ $this->initializing2 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih2 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract1($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract1($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie2 ? $this->recursive1() : $this->mayRecurse($this->n0));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih1 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate2($value, $context);
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n1::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 extract1(object $object, array $context): array
+ {
+ if ($this->tracked1) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive1(): bool
+ {
+ if ($this->recursive1 !== null) {
+ return $this->recursive1;
+ }
+
+ $this->recursive1 = true;
+
+ return $this->recursive1 = ($this->ie1 ? $this->recursive2() : $this->mayRecurse($this->n1));
+ }
+
+ public function hydrate2(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r2->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate0($value, $context);
+ } else {
+ $value = $this->n2->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n2::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 extract2(object $object, array $context): array
+ {
+ if ($this->tracked2) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive2(): bool
+ {
+ if ($this->recursive2 !== null) {
+ return $this->recursive2;
+ }
+
+ $this->recursive2 = true;
+
+ return $this->recursive2 = ($this->ie0 ? $this->recursive0() : $this->mayRecurse($this->n2));
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle2DtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoTransformer.php
new file mode 100644
index 00000000..9baa31a8
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle2DtoTransformer.php
@@ -0,0 +1,534 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih2 = false;
+ public bool $ie2 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public Normalizer $n1;
+ public bool $ih1 = false;
+ public bool $ie1 = false;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+ public bool $tracked1 = true;
+ public Normalizer $n2;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public bool $ready2 = false;
+ public bool $initializing2 = false;
+ public bool|null $recursive2 = null;
+ public Closure $h2;
+ public Closure $e2;
+ public ReflectionClass $r2;
+ public bool $tracked2 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+ $this->h2 = function (array $data, array $context, object|null $object = null): object { $this->init2(); return ($this->h2)($data, $context, $object); };
+ $this->e2 = function (object $object, array $context): array { $this->init2(); return ($this->e2)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'to');
+ $this->ih2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih2 || $this->ie2) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class);
+ $this->r1 = $metadata->reflection;
+ $this->n1 = $this->normalizer($metadata, 'to');
+ $this->ih1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+ if ($this->ih1 || $this->ie1) { $this->init2(); }
+ $this->tracked1 = $this->recursive1();
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ private function init2(): void
+ {
+ if ($this->ready2 || $this->initializing2) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing2 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class);
+ $this->r2 = $metadata->reflection;
+ $this->n2 = $this->normalizer($metadata, 'to');
+ $this->ih0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h2 = $this->hydrate2(...);
+ $this->e2 = $this->extract2(...);
+ if ($this->ih0 || $this->ie0) { $this->init0(); }
+ $this->tracked2 = $this->recursive2();
+
+ $this->ready2 = true;
+ } finally {
+ $this->initializing2 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih2 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract1($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract1($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie2 ? $this->recursive1() : $this->mayRecurse($this->n0));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih1 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate2($value, $context);
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n1::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 extract1(object $object, array $context): array
+ {
+ if ($this->tracked1) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive1(): bool
+ {
+ if ($this->recursive1 !== null) {
+ return $this->recursive1;
+ }
+
+ $this->recursive1 = true;
+
+ return $this->recursive1 = ($this->ie1 ? $this->recursive2() : $this->mayRecurse($this->n1));
+ }
+
+ public function hydrate2(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r2->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate0($value, $context);
+ } else {
+ $value = $this->n2->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n2::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 extract2(object $object, array $context): array
+ {
+ if ($this->tracked2) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive2(): bool
+ {
+ if ($this->recursive2 !== null) {
+ return $this->recursive2;
+ }
+
+ $this->recursive2 = true;
+
+ return $this->recursive2 = ($this->ie0 ? $this->recursive0() : $this->mayRecurse($this->n2));
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/Circle3DtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoTransformer.php
new file mode 100644
index 00000000..8af458b4
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/Circle3DtoTransformer.php
@@ -0,0 +1,534 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih2 = false;
+ public bool $ie2 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public Normalizer $n1;
+ public bool $ih1 = false;
+ public bool $ie1 = false;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+ public bool $tracked1 = true;
+ public Normalizer $n2;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public bool $ready2 = false;
+ public bool $initializing2 = false;
+ public bool|null $recursive2 = null;
+ public Closure $h2;
+ public Closure $e2;
+ public ReflectionClass $r2;
+ public bool $tracked2 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+ $this->h2 = function (array $data, array $context, object|null $object = null): object { $this->init2(); return ($this->h2)($data, $context, $object); };
+ $this->e2 = function (object $object, array $context): array { $this->init2(); return ($this->e2)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'to');
+ $this->ih2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie2 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih2 || $this->ie2) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class);
+ $this->r1 = $metadata->reflection;
+ $this->n1 = $this->normalizer($metadata, 'to');
+ $this->ih1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie1 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+ if ($this->ih1 || $this->ie1) { $this->init2(); }
+ $this->tracked1 = $this->recursive1();
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ private function init2(): void
+ {
+ if ($this->ready2 || $this->initializing2) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing2 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class);
+ $this->r2 = $metadata->reflection;
+ $this->n2 = $this->normalizer($metadata, 'to');
+ $this->ih0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n2, \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class, '0903da95af9a7396', Skip::Extract);
+ $this->h2 = $this->hydrate2(...);
+ $this->e2 = $this->extract2(...);
+ if ($this->ih0 || $this->ie0) { $this->init0(); }
+ $this->tracked2 = $this->recursive2();
+
+ $this->ready2 = true;
+ } finally {
+ $this->initializing2 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih2 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract1($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie2 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto && $this->direct($context) ? $this->extract1($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie2 ? $this->recursive1() : $this->mayRecurse($this->n0));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih1 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate2($value, $context);
+ } else {
+ $value = $this->n1->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n1::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 extract1(object $object, array $context): array
+ {
+ if ($this->tracked1) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie1 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto && $this->direct($context) ? $this->extract2($value, $context) : $this->n1->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto::class, 'to', $this->n1::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive1(): bool
+ {
+ if ($this->recursive1 !== null) {
+ return $this->recursive1;
+ }
+
+ $this->recursive1 = true;
+
+ return $this->recursive1 = ($this->ie1 ? $this->recursive2() : $this->mayRecurse($this->n1));
+ }
+
+ public function hydrate2(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r2->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('to', $data)) {
+ try {
+ $value = $data['to'];
+
+ if ($this->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate0($value, $context);
+ } else {
+ $value = $this->n2->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n2::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 extract2(object $object, array $context): array
+ {
+ if ($this->tracked2) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class;
+
+ try {
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->to;
+ $v_to = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto && $this->direct($context) ? $this->extract0($value, $context) : $this->n2->normalize($value, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto::class, 'to', $this->n2::class, $e);
+ }
+
+ return [
+ 'to' => $v_to,
+ ];
+ }
+
+ private function recursive2(): bool
+ {
+ if ($this->recursive2 !== null) {
+ return $this->recursive2;
+ }
+
+ $this->recursive2 = true;
+
+ return $this->recursive2 = ($this->ie0 ? $this->recursive0() : $this->mayRecurse($this->n2));
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoTransformer.php
new file mode 100644
index 00000000..f493bb5e
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ContextAwareDtoTransformer.php
@@ -0,0 +1,273 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'value');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/DefaultDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoTransformer.php
new file mode 100644
index 00000000..3a8b1eae
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/DefaultDtoTransformer.php
@@ -0,0 +1,364 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public ReflectionParameter $d0;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->d0 = $this->promotedDefault($metadata, 'email');
+ $this->n0 = $this->normalizer($metadata, 'email');
+ $this->ih0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class, '136535d19b213863', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class, '136535d19b213863', Skip::Extract);
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih0 || $this->ie0) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class);
+ $this->r1 = $metadata->reflection;
+ $this->h1 = Closure::bind(function (array $data, array $context, object|null $object = null): object {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('value', $data)) {
+ $value = $data['value'];
+
+ try {
+ $object->value = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class, 'value', $e);
+ }
+ }
+
+ return $object;
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class);
+ $this->e1 = Closure::bind(function (object $object, array $context): array {
+
+
+ return [
+ 'value' => $object->value,
+ ];
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\Email::class);
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ 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->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = ($this->h1)($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto::class;
+
+ try {
+ try {
+ $value = $object->email;
+ $v_email = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email && $this->direct($context) ? ($this->e1)($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->email;
+ $v_email = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Email && $this->direct($context) ? ($this->e1)($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie0 ? false : $this->mayRecurse($this->n0));
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoTransformer.php
new file mode 100644
index 00000000..bf1ba356
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerDtoTransformer.php
@@ -0,0 +1,390 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public Normalizer $n2;
+ public Normalizer $n3;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'status');
+ $this->n1 = $this->normalizer($metadata, 'dateTimeImmutable');
+ $this->n2 = $this->normalizer($metadata, 'dateTime');
+ $this->n3 = $this->normalizer($metadata, 'dateTimeZone');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1)
+ || $this->mayRecurse($this->n2)
+ || $this->mayRecurse($this->n3);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoTransformer.php
new file mode 100644
index 00000000..d4d3ec3d
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithIterablesDtoTransformer.php
@@ -0,0 +1,474 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public Normalizer $n2;
+ public Normalizer $n3;
+ public Normalizer $n4;
+ public Normalizer $n5;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'defaultArray');
+ $this->n1 = $this->normalizer($metadata, 'listArray');
+ $this->n2 = $this->normalizer($metadata, 'iterableArray');
+ $this->n3 = $this->normalizer($metadata, 'hashMap');
+ $this->n4 = $this->normalizer($metadata, 'nested');
+ $this->n5 = $this->normalizer($metadata, 'shapeArray');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1)
+ || $this->mayRecurse($this->n2)
+ || $this->mayRecurse($this->n3)
+ || $this->mayRecurse($this->n4)
+ || $this->mayRecurse($this->n5);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoTransformer.php
new file mode 100644
index 00000000..ce6432c5
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/InferNormalizerWithNullableDtoTransformer.php
@@ -0,0 +1,419 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public Normalizer $n2;
+ public Normalizer $n3;
+ public Normalizer $n4;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'status');
+ $this->n1 = $this->normalizer($metadata, 'dateTimeImmutable');
+ $this->n2 = $this->normalizer($metadata, 'dateTime');
+ $this->n3 = $this->normalizer($metadata, 'dateTimeZone');
+ $this->n4 = $this->normalizer($metadata, 'profileId');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1)
+ || $this->mayRecurse($this->n2)
+ || $this->mayRecurse($this->n3)
+ || $this->mayRecurse($this->n4);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedTransformer.php b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedTransformer.php
new file mode 100644
index 00000000..a0cf092c
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/LazyProfileCreatedTransformer.php
@@ -0,0 +1,308 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'email');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoTransformer.php
new file mode 100644
index 00000000..4715d77c
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/NestedLifecycleDtoTransformer.php
@@ -0,0 +1,422 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public Normalizer $n1;
+ public ObjectNormalizer $n2;
+ public bool $ih1 = false;
+ public bool $ie1 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'child');
+ $this->ih0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class, 'f6938c7b12413f3a', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class, 'f6938c7b12413f3a', Skip::Extract);
+ $this->n1 = $this->normalizer($metadata, 'items');
+ $this->ih1 = $this->inlineArray($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class, 'f6938c7b12413f3a', Skip::Hydrate);
+ $this->ie1 = $this->inlineArray($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class, 'f6938c7b12413f3a', Skip::Extract);
+ if ($this->ih1 || $this->ie1) { $this->n2 = $this->n1->innerNormalizer(); }
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih0 || $this->ie0) { $this->init1(); }
+ if ($this->ih1 || $this->ie1) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class);
+ $this->r1 = $metadata->reflection;
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('child', $data)) {
+ try {
+ $value = $data['child'];
+
+ if ($this->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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->ih1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->hydrate1($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\NestedLifecycleDto::class;
+
+ try {
+ try {
+ $value = $object->child;
+ $v_child = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture && $this->direct($context) ? $this->extract1($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->ie1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->extract1($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->child;
+ $v_child = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture && $this->direct($context) ? $this->extract1($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->ie1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture ? $this->extract1($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie0 ? false : $this->mayRecurse($this->n0))
+ || ($this->ie1 ? false : $this->mayRecurse($this->n1));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture::class, 'name', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract1(object $object, array $context): array
+ {
+
+
+ return [
+ 'name' => $object->name,
+ ];
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoTransformer.php
new file mode 100644
index 00000000..244b4065
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/NormalizerInBaseClassDefinedDtoTransformer.php
@@ -0,0 +1,728 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public Normalizer $n4;
+ public Normalizer $n5;
+ public Normalizer $n6;
+ public Normalizer $n7;
+ public ObjectNormalizer $n8;
+ public bool $ih1 = false;
+ public bool $ie1 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public Normalizer $n2;
+ public Normalizer $n3;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+ public bool $tracked1 = true;
+ public bool $ready2 = false;
+ public bool $initializing2 = false;
+ public bool|null $recursive2 = null;
+ public Closure $h2;
+ public Closure $e2;
+ public ReflectionClass $r2;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+ $this->h2 = function (array $data, array $context, object|null $object = null): object { $this->init2(); return ($this->h2)($data, $context, $object); };
+ $this->e2 = function (object $object, array $context): array { $this->init2(); return ($this->e2)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'status');
+ $this->n1 = $this->normalizer($metadata, 'profileCreated');
+ $this->ih0 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, '626de5f0f972600f', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n1, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, '626de5f0f972600f', Skip::Extract);
+ $this->n4 = $this->normalizer($metadata, 'defaultArray');
+ $this->n5 = $this->normalizer($metadata, 'listArray');
+ $this->n6 = $this->normalizer($metadata, 'iterableArray');
+ $this->n7 = $this->normalizer($metadata, 'skillsHashMap');
+ $this->ih1 = $this->inlineArray($this->n7, \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill::class, 'f6938c7b12413f3a', Skip::Hydrate);
+ $this->ie1 = $this->inlineArray($this->n7, \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill::class, 'f6938c7b12413f3a', Skip::Extract);
+ if ($this->ih1 || $this->ie1) { $this->n8 = $this->n7->innerNormalizer(); }
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih0 || $this->ie0) { $this->init1(); }
+ if ($this->ih1 || $this->ie1) { $this->init2(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class);
+ $this->r1 = $metadata->reflection;
+ $this->n2 = $this->normalizer($metadata, 'profileId');
+ $this->n3 = $this->normalizer($metadata, 'email');
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+ $this->tracked1 = $this->recursive1();
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ private function init2(): void
+ {
+ if ($this->ready2 || $this->initializing2) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing2 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\Skill::class);
+ $this->r2 = $metadata->reflection;
+ $this->h2 = $this->hydrate2(...);
+ $this->e2 = $this->extract2(...);
+
+ $this->ready2 = true;
+ } finally {
+ $this->initializing2 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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\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->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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->n4->denormalize($data['defaultArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n4::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->n5->denormalize($data['listArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n5::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->n6->denormalize($data['iterableArray'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n6::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->ih1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = \is_array($item) ? $this->hydrate2($item, $context) : $this->n8->denormalize($item, $context);
+ }
+
+ $value = $items;
+ } else {
+ $value = $this->n7->denormalize($value, $context);
+ }
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n7::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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer && $this->direct($context) ? $this->extract1($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->n4->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n4::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n5->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n5::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n6->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n6::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->ie1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->extract2($item, $context) : $this->n8->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n7->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n7::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($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->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer && $this->direct($context) ? $this->extract1($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->n4->normalize($object->defaultArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'defaultArray', $this->n4::class, $e);
+ }
+
+ try {
+ $v_listArray = $this->n5->normalize($object->listArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'listArray', $this->n5::class, $e);
+ }
+
+ try {
+ $v_iterableArray = $this->n6->normalize($object->iterableArray, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'iterableArray', $this->n6::class, $e);
+ }
+
+ try {
+ $value = $object->skillsHashMap;
+
+ if ($this->ie1 && \is_array($value) && $this->direct($context)) {
+ $items = [];
+
+ foreach ($value as $key => $item) {
+ $items[$key] = $item instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\Skill ? $this->extract2($item, $context) : $this->n8->normalize($item, $context);
+ }
+
+ $v_skillsHashMap = $items;
+ } else {
+ $v_skillsHashMap = $this->n7->normalize($value, $context);
+ }
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto::class, 'skillsHashMap', $this->n7::class, $e);
+ }
+
+ return [
+ 'status' => $v_status,
+ 'profileCreated' => $v_profileCreated,
+ 'defaultArray' => $v_defaultArray,
+ 'listArray' => $v_listArray,
+ 'iterableArray' => $v_iterableArray,
+ 'skillsHashMap' => $v_skillsHashMap,
+ 'jsonArray' => $object->jsonArray,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || ($this->ie0 ? $this->recursive1() : $this->mayRecurse($this->n1))
+ || $this->mayRecurse($this->n4)
+ || $this->mayRecurse($this->n5)
+ || $this->mayRecurse($this->n6)
+ || ($this->ie1 ? false : $this->mayRecurse($this->n7));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n2->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'profileId', $this->n2::class, $e);
+ }
+
+ try {
+ $object->profileId = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'profileId', $e);
+ }
+ }
+
+ if (\array_key_exists('email', $data)) {
+ try {
+ $value = $this->n3->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'email', $this->n3::class, $e);
+ }
+
+ try {
+ $object->email = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'email', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract1(object $object, array $context): array
+ {
+ if ($this->tracked1) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class;
+
+ try {
+ try {
+ $v_profileId = $this->n2->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'profileId', $this->n2::class, $e);
+ }
+
+ try {
+ $v_email = $this->n3->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'email', $this->n3::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n2->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'profileId', $this->n2::class, $e);
+ }
+
+ try {
+ $v_email = $this->n3->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer::class, 'email', $this->n3::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ private function recursive1(): bool
+ {
+ if ($this->recursive1 !== null) {
+ return $this->recursive1;
+ }
+
+ $this->recursive1 = true;
+
+ return $this->recursive1 = $this->mayRecurse($this->n2)
+ || $this->mayRecurse($this->n3);
+ }
+
+ public function hydrate2(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r2->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('name', $data)) {
+ $value = $data['name'];
+
+ try {
+ $object->name = $value;
+ } catch (TypeError $e) {
+ throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\Skill::class, 'name', $e);
+ }
+ }
+
+ return $object;
+ }
+
+ public function extract2(object $object, array $context): array
+ {
+
+
+ return [
+ 'name' => $object->name,
+ ];
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ParentDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/ParentDtoTransformer.php
new file mode 100644
index 00000000..1b956024
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ParentDtoTransformer.php
@@ -0,0 +1,308 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'email');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoTransformer.php
new file mode 100644
index 00000000..899e846f
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/PrivateChildDtoTransformer.php
@@ -0,0 +1,328 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public Closure $hp0;
+ public Closure $ep1;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return ($this->h0)($data, $context, $object);
+ }
+
+ return ($this->h0)($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return ($this->e0)($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'email');
+ $this->hp0 = Closure::bind(function (object $object, array $data, array $context): 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->h0 = Closure::bind(function (array $data, array $context, object|null $object = null): object {
+ $object ??= $this->r0->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\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);
+
+ return $object;
+ }, $this, \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class);
+ $this->ep1 = Closure::bind(function (object $object, array $context): 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->e0 = Closure::bind(function (object $object, array $context): array {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto::class;
+
+ try {
+ [$v_email, $v_note] = ($this->ep1)($object, $context);
+
+ 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($callStack->objects[$id]);
+ }
+ }
+
+ [$v_email, $v_note] = ($this->ep1)($object, $context);
+
+ 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);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoTransformer.php b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoTransformer.php
new file mode 100644
index 00000000..0796a77f
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/PrivatePropertiesDtoTransformer.php
@@ -0,0 +1,293 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return ($this->h0)($data, $context, $object);
+ }
+
+ return ($this->h0)($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return ($this->e0)($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->h0 = Closure::bind(function (array $data, array $context, object|null $object = null): object {
+ $object ??= $this->r0->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\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->e0 = Closure::bind(function (object $object, array $context): array {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedTransformer.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedTransformer.php
new file mode 100644
index 00000000..7f77b3ac
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedTransformer.php
@@ -0,0 +1,308 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'email');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerTransformer.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerTransformer.php
new file mode 100644
index 00000000..a91a1500
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWithInlineNormalizerTransformer.php
@@ -0,0 +1,308 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'valueObject');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperTransformer.php b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperTransformer.php
new file mode 100644
index 00000000..26b720dd
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/ProfileCreatedWrapperTransformer.php
@@ -0,0 +1,432 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ih0 = false;
+ public bool $ie0 = false;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+ public Normalizer $n1;
+ public Normalizer $n2;
+ public bool $ready1 = false;
+ public bool $initializing1 = false;
+ public bool|null $recursive1 = null;
+ public Closure $h1;
+ public Closure $e1;
+ public ReflectionClass $r1;
+ public bool $tracked1 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+ $this->h1 = function (array $data, array $context, object|null $object = null): object { $this->init1(); return ($this->h1)($data, $context, $object); };
+ $this->e1 = function (object $object, array $context): array { $this->init1(); return ($this->e1)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileCreated');
+ $this->ih0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, '626de5f0f972600f', Skip::Hydrate);
+ $this->ie0 = $this->inlineObject($this->n0, \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, '626de5f0f972600f', Skip::Extract);
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ if ($this->ih0 || $this->ie0) { $this->init1(); }
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ private function init1(): void
+ {
+ if ($this->ready1 || $this->initializing1) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing1 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class);
+ $this->r1 = $metadata->reflection;
+ $this->n1 = $this->normalizer($metadata, 'profileId');
+ $this->n2 = $this->normalizer($metadata, 'email');
+ $this->h1 = $this->hydrate1(...);
+ $this->e1 = $this->extract1(...);
+ $this->tracked1 = $this->recursive1();
+
+ $this->ready1 = true;
+ } finally {
+ $this->initializing1 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('event', $data)) {
+ try {
+ $value = $data['event'];
+
+ if ($this->ih0 && \is_array($value) && $this->direct($context)) {
+ $value = $this->hydrate1($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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper::class;
+
+ try {
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated && $this->direct($context) ? $this->extract1($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($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $value = $object->profileCreated;
+ $v_profileCreated = $this->ie0 && $value instanceof \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated && $this->direct($context) ? $this->extract1($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = ($this->ie0 ? $this->recursive1() : $this->mayRecurse($this->n0));
+ }
+
+ public function hydrate1(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r1->newInstanceWithoutConstructor();
+
+ if (\array_key_exists('profileId', $data)) {
+ try {
+ $value = $this->n1->denormalize($data['profileId'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n1::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->n2->denormalize($data['email'], $context);
+ } catch (Throwable $e) {
+ throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n2::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 extract1(object $object, array $context): array
+ {
+ if ($this->tracked1) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class;
+
+ try {
+ try {
+ $v_profileId = $this->n1->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n1::class, $e);
+ }
+
+ try {
+ $v_email = $this->n2->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n2::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ } finally {
+ unset($callStack->objects[$id]);
+ }
+ }
+
+ try {
+ $v_profileId = $this->n1->normalize($object->profileId, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'profileId', $this->n1::class, $e);
+ }
+
+ try {
+ $v_email = $this->n2->normalize($object->email, $context);
+ } catch (CircularReference $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated::class, 'email', $this->n2::class, $e);
+ }
+
+ return [
+ 'profileId' => $v_profileId,
+ 'email' => $v_email,
+ ];
+ }
+
+ private function recursive1(): bool
+ {
+ if ($this->recursive1 !== null) {
+ return $this->recursive1;
+ }
+
+ $this->recursive1 = true;
+
+ return $this->recursive1 = $this->mayRecurse($this->n1)
+ || $this->mayRecurse($this->n2);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedTransformer.php b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedTransformer.php
new file mode 100644
index 00000000..13606a44
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/SensitiveDataProfileCreatedTransformer.php
@@ -0,0 +1,308 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public Normalizer $n1;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'profileId');
+ $this->n1 = $this->normalizer($metadata, 'email');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0)
+ || $this->mayRecurse($this->n1);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerTransformer.php b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerTransformer.php
new file mode 100644
index 00000000..9ed4eabc
--- /dev/null
+++ b/tests/Unit/Extension/Generated/Snapshot/WrongNormalizerTransformer.php
@@ -0,0 +1,273 @@
+ */
+ public array $middlewares;
+
+ public Normalizer $n0;
+ public bool $ready0 = false;
+ public bool $initializing0 = false;
+ public bool|null $recursive0 = null;
+ public Closure $h0;
+ public Closure $e0;
+ public ReflectionClass $r0;
+ public bool $tracked0 = true;
+
+ /** @throws OutdatedGeneratedTransformer */
+ public function __construct(public readonly StackHydrator $hydrator)
+ {
+ $this->middlewares = $hydrator->middlewares();
+
+ $this->h0 = function (array $data, array $context, object|null $object = null): object { $this->init0(); return ($this->h0)($data, $context, $object); };
+ $this->e0 = function (object $object, array $context): array { $this->init0(); return ($this->e0)($object, $context); };
+
+ $this->init0();
+ }
+
+ public function hydrate(array $data, array $context): object
+ {
+ if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
+ $object = $context[Hydrator::OBJECT_TO_POPULATE];
+ unset($context[Hydrator::OBJECT_TO_POPULATE]);
+
+ return $this->hydrate0($data, $context, $object);
+ }
+
+ return $this->hydrate0($data, $context);
+ }
+
+ public function extract(object $object, array $context): array
+ {
+ return $this->extract0($object, $context);
+ }
+
+ private function init0(): void
+ {
+ if ($this->ready0 || $this->initializing0) {
+ return;
+ }
+
+ // ready is only set at the end, so a failed initialization runs again on the next call instead of leaving
+ // a half initialized class behind. initializing stops the recursion between nested classes meanwhile.
+ $this->initializing0 = true;
+
+ try {
+ $metadata = $this->hydrator->metadata(\Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer::class);
+ $this->r0 = $metadata->reflection;
+ $this->n0 = $this->normalizer($metadata, 'email');
+ $this->h0 = $this->hydrate0(...);
+ $this->e0 = $this->extract0(...);
+ $this->tracked0 = $this->recursive0();
+
+ $this->ready0 = true;
+ } finally {
+ $this->initializing0 = false;
+ }
+ }
+
+ public function hydrate0(array $data, array $context, object|null $object = null): object
+ {
+ $object ??= $this->r0->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 extract0(object $object, array $context): array
+ {
+ if ($this->tracked0) {
+ $callStack = CallStack::of($context);
+ $id = \spl_object_id($object);
+
+ if (isset($callStack->objects[$id])) {
+ $references = \array_values($callStack->objects);
+ $references[] = $object::class;
+
+ throw new CircularReference($references);
+ }
+
+ $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($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,
+ ];
+ }
+
+ private function recursive0(): bool
+ {
+ if ($this->recursive0 !== null) {
+ return $this->recursive0;
+ }
+
+ $this->recursive0 = true;
+
+ return $this->recursive0 = $this->mayRecurse($this->n0);
+ }
+
+ private function normalizer(ClassMetadata $metadata, string $property): Normalizer
+ {
+ return $metadata->properties[$property]->normalizer ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ private function promotedDefault(ClassMetadata $metadata, string $property): ReflectionParameter
+ {
+ return $metadata->promotedConstructorDefaults()[$property] ?? throw new OutdatedGeneratedTransformer($metadata->className);
+ }
+
+ /** @param string $fingerprint the {@see ClassFingerprint} of the nested class when the code was generated */
+ private function inlineObject(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ if (!$normalizer instanceof ObjectNormalizer) {
+ return false;
+ }
+
+ try {
+ if ($normalizer->className() !== $class) {
+ return false;
+ }
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $this->inlinable($class, $fingerprint, $direction);
+ }
+
+ private function inlineArray(Normalizer $normalizer, string $class, string $fingerprint, Skip $direction): bool
+ {
+ return $normalizer instanceof ArrayNormalizer && $this->inlineObject($normalizer->innerNormalizer(), $class, $fingerprint, $direction);
+ }
+
+ /**
+ * Nested objects are only inlined when this does not change the behaviour: the code of the nested class must be
+ * up to date, the class must not be lazy and every middleware must skip it in this direction. Otherwise
+ * they take the regular path through the hydrator. A hydrator which wraps this one is checked on every call,
+ * see {@see self::direct()}.
+ */
+ private function inlinable(string $class, string $fingerprint, Skip $direction): bool
+ {
+ try {
+ $metadata = $this->hydrator->metadata($class);
+ } catch (Throwable) {
+ return false;
+ }
+
+ if ($metadata->normalizer !== null || ClassFingerprint::of($metadata) !== $fingerprint) {
+ return false;
+ }
+
+ if (\PHP_VERSION_ID >= 80400 && ($metadata->lazy ?? $this->hydrator->defaultLazy())) {
+ return false;
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ if (!$middleware instanceof SkippableMiddleware) {
+ return false;
+ }
+
+ $skip = $middleware->skip($metadata);
+
+ if ($skip !== Skip::Both && $skip !== $direction) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** A hydrator which wraps this one has to see nested objects, they are only inlined if this one was called directly. */
+ private function direct(array $context): bool
+ {
+ return ($context[Hydrator::HYDRATOR] ?? null) === $this->hydrator;
+ }
+
+ /** Whether the normalizer may call the hydrator again, only then a circular reference is possible. */
+ private function mayRecurse(Normalizer $normalizer): bool
+ {
+ if ($normalizer instanceof ArrayNormalizer) {
+ return $this->mayRecurse($normalizer->innerNormalizer());
+ }
+
+ return !$normalizer instanceof EnumNormalizer
+ && !$normalizer instanceof DateTimeImmutableNormalizer
+ && !$normalizer instanceof DateTimeNormalizer
+ && !$normalizer instanceof DateTimeZoneNormalizer
+ && !$normalizer instanceof DateIntervalNormalizer;
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Extension/Generated/TransformerGeneratorTest.php b/tests/Unit/Extension/Generated/TransformerGeneratorTest.php
new file mode 100644
index 00000000..c0fdd3e9
--- /dev/null
+++ b/tests/Unit/Extension/Generated/TransformerGeneratorTest.php
@@ -0,0 +1,386 @@
+generate(ProfileCreated::class, 'Foo\\Bar');
+ $second = $generator->generate(ProfileCreated::class, 'Foo\\Bar');
+
+ self::assertNotNull($first);
+ self::assertNotNull($second);
+ self::assertSame($first->code, $second->code);
+ self::assertSame([ProfileCreated::class], $first->classes);
+ self::assertStringContainsString('namespace Foo;', $first->code);
+ self::assertStringContainsString('final class Bar implements ClassTransformer', $first->code);
+ self::assertStringNotContainsString('strict_types=1', $first->code);
+ }
+
+ public function testNestedClassesAreReported(): void
+ {
+ $code = (new TransformerGenerator((new StackHydratorBuilder())->build()))
+ ->generate(NestedLifecycleDto::class, 'Foo\\Bar');
+
+ self::assertNotNull($code);
+ self::assertSame([NestedLifecycleDto::class, LifecycleFixture::class], $code->classes);
+ }
+
+ /**
+ * 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
+ {
+ $generator = new TransformerGenerator(new AttributeMetadataFactory());
+ $update = getenv('UPDATE_SNAPSHOTS') === '1';
+
+ if ($update) {
+ foreach (glob(__DIR__ . '/Snapshot/*.php') ?: [] as $file) {
+ unlink($file);
+ }
+
+ if (!is_dir(__DIR__ . '/Snapshot')) {
+ mkdir(__DIR__ . '/Snapshot');
+ }
+ }
+
+ foreach (GeneratedStackHydratorTest::CLASSES as $class) {
+ $name = (new ReflectionClass($class))->getShortName() . 'Transformer';
+ $code = $generator->generate($class, 'Patchlevel\\Hydrator\\Tests\\Snapshot\\' . $name);
+
+ if ($code === null) {
+ continue;
+ }
+
+ $file = sprintf('%s/Snapshot/%s.php', __DIR__, $name);
+
+ if ($update) {
+ file_put_contents($file, $code->code);
+ }
+
+ self::assertStringEqualsFile($file, $code->code, 'The generated code changed, run "make snapshot" if the change is intended.');
+ }
+ }
+
+ public function testClassesWithClassNormalizerAreSkipped(): void
+ {
+ self::assertNull((new TransformerGenerator(new AttributeMetadataFactory()))->generate(ProfileId::class, 'Foo\\Bar'));
+ }
+
+ public function testAbstractClassIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ (new TransformerGenerator(new AttributeMetadataFactory()))->generate(ChildDto::class, 'Foo\\Bar');
+ }
+
+ public function testUnknownClassIsNotGeneratable(): void
+ {
+ $this->expectException(ClassNotGeneratable::class);
+
+ /** @phpstan-ignore argument.type */
+ (new TransformerGenerator(new AttributeMetadataFactory()))->generate('Unknown', 'Foo\\Bar');
+ }
+
+ public function testDefaultsWithControlCharactersAreInlinedUnchanged(): void
+ {
+ $hydrator = $this->hydrator(MultilineDefaultDto::class);
+
+ $object = $hydrator->hydrate(MultilineDefaultDto::class, []);
+
+ 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 testNestedClassesAreInlined(): void
+ {
+ $hydrator = $this->hydrator(NestedLifecycleDto::class);
+ $data = ['child' => ['name' => 'a'], 'items' => [['name' => 'b']]];
+
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(['ih0' => true, 'ie0' => true, 'ih1' => true, 'ie1' => true], self::inlined($hydrator));
+ }
+
+ public function testChangedNestedClassIsNotInlined(): void
+ {
+ // the nested class changed since the code was generated, the outer class is still the same
+ $hydrator = $this->hydrator(NestedLifecycleDto::class, new class implements MetadataEnricher {
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ if ($classMetadata->className !== LifecycleFixture::class) {
+ return;
+ }
+
+ $property = $classMetadata->properties['name'];
+ $property->fieldName = 'title';
+ }
+ });
+
+ $data = ['child' => ['title' => 'a'], 'items' => [['title' => 'b']]];
+
+ self::assertSame($data, $hydrator->extract($hydrator->hydrate(NestedLifecycleDto::class, $data)));
+ self::assertSame(['ih0' => false, 'ie0' => false, 'ih1' => false, 'ie1' => false], self::inlined($hydrator));
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, LifecycleFixture::class));
+ }
+
+ public function testOutdatedCodeIsDetected(): void
+ {
+ $this->expectException(OutdatedGeneratedTransformer::class);
+
+ // the normalizer of the property was removed since the code was generated
+ $hydrator = $this->hydrator(ProfileCreated::class, self::removeNormalizer());
+ $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']);
+ }
+
+ public function testOutdatedCodeFallsBackToReflection(): void
+ {
+ $path = GeneratedStackHydratorTest::cachePath() . '/outdated-' . self::$counter++;
+ $files = new TransformerFiles($path);
+
+ // outdated code stored under the name of the current metadata, which the fingerprint normally prevents
+ $metadataFactory = (new StackHydratorBuilder())
+ ->addMetadataEnricher(self::removeNormalizer())
+ ->build();
+
+ $className = $files->className($metadataFactory->metadata(ProfileCreated::class), $metadataFactory);
+ $code = (new TransformerGenerator(new AttributeMetadataFactory()))->generate(ProfileCreated::class, $files->fqcn($className));
+ self::assertNotNull($code);
+ mkdir($path, 0777, true);
+ file_put_contents($files->path($className), $code->code);
+
+ $hydrator = (new StackHydratorBuilder())
+ ->useExtension(new GeneratedTransformerExtension($path))
+ ->addMetadataEnricher(self::removeNormalizer())
+ ->build();
+
+ $object = $hydrator->hydrate(ProfileCreated::class, ['profileId' => ProfileId::fromString('1'), 'email' => 'info@patchlevel.de']);
+
+ self::assertSame('1', $object->profileId->toString());
+ self::assertInstanceOf(ReflectionTransformer::class, self::transformer($hydrator, ProfileCreated::class));
+ }
+
+ /** @param class-string $changed a class with the same properties as FingerprintDto but one changed detail */
+ #[DataProvider('changedClasses')]
+ public function testChangedClassGetsAnotherName(string $changed): void
+ {
+ $factory = new AttributeMetadataFactory();
+ $files = new TransformerFiles('/tmp');
+
+ self::assertNotSame(ClassFingerprint::of($factory->metadata(FingerprintDto::class)), ClassFingerprint::of($factory->metadata($changed)));
+ self::assertSame(
+ $files->className($factory->metadata(FingerprintDto::class), $factory),
+ $files->className((new AttributeMetadataFactory())->metadata(FingerprintDto::class), $factory),
+ );
+ self::assertNotSame(
+ $files->className($factory->metadata(FingerprintDto::class), $factory),
+ $files->className($factory->metadata($changed), $factory),
+ );
+ }
+
+ /** @param class-string $class */
+ #[DataProvider('classesWithNestedClass')]
+ public function testChangedNestedClassGetsAnotherName(string $class): void
+ {
+ $factory = (new StackHydratorBuilder())->build();
+ $changed = (new StackHydratorBuilder())
+ ->addMetadataEnricher(new class implements MetadataEnricher {
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ if ($classMetadata->className !== LifecycleFixture::class) {
+ return;
+ }
+
+ $property = $classMetadata->properties['name'];
+ $property->fieldName = 'title';
+ }
+ })
+ ->build();
+ $files = new TransformerFiles('/tmp');
+
+ self::assertSame(ClassFingerprint::of($factory->metadata($class)), ClassFingerprint::of($changed->metadata($class)));
+ self::assertNotSame(
+ $files->className($factory->metadata($class), $factory),
+ $files->className($changed->metadata($class), $changed),
+ );
+ }
+
+ /** @return iterable */
+ public static function classesWithNestedClass(): iterable
+ {
+ yield 'nested object first' => [NestedLifecycleDto::class];
+ yield 'nested object after a scalar' => [ScalarAndNestedDto::class];
+ }
+
+ /** @return iterable */
+ public static function changedClasses(): iterable
+ {
+ yield 'changed default' => [FingerprintChangedDefaultDto::class];
+ yield 'changed visibility' => [FingerprintChangedVisibilityDto::class];
+ }
+
+ private static function removeNormalizer(): MetadataEnricher
+ {
+ return new class implements MetadataEnricher {
+ public function enrich(ClassMetadata $classMetadata): void
+ {
+ if ($classMetadata->className !== ProfileCreated::class) {
+ return;
+ }
+
+ $property = $classMetadata->properties['profileId'];
+ $property->normalizer = null;
+ }
+ };
+ }
+
+ /**
+ * Generates the code for the class with the metadata of a plain hydrator and loads it into a hydrator with the
+ * given enrichers, which can simulate classes changing after the code was generated.
+ *
+ * @param class-string $class
+ */
+ private function hydrator(string $class, MetadataEnricher ...$enrichers): StackHydrator
+ {
+ $fqcn = 'Patchlevel\\Hydrator\\Tests\\Generated\\Transformer' . self::$counter++;
+ $metadataFactory = (new StackHydratorBuilder())->build();
+ $code = (new TransformerGenerator($metadataFactory))->generate($class, $fqcn);
+ self::assertNotNull($code);
+
+ $directory = GeneratedStackHydratorTest::cachePath();
+
+ if (!is_dir($directory)) {
+ mkdir($directory, 0777, true);
+ }
+
+ $file = tempnam($directory, 'transformer');
+ assert($file !== false);
+ file_put_contents($file, $code->code);
+
+ require $file;
+ unlink($file);
+
+ $builder = (new StackHydratorBuilder())
+ ->addTransformerFactory(new class ($class, $fqcn) implements ClassTransformerFactory {
+ /** @param class-string $class */
+ public function __construct(
+ private readonly string $class,
+ private readonly string $fqcn,
+ ) {
+ }
+
+ public function create(ClassMetadata $metadata, StackHydrator $hydrator): ClassTransformer|null
+ {
+ if ($metadata->className !== $this->class) {
+ return null;
+ }
+
+ $transformer = new ($this->fqcn)($hydrator);
+ assert($transformer instanceof ClassTransformer);
+
+ return $transformer;
+ }
+ });
+
+ foreach ($enrichers as $enricher) {
+ $builder->addMetadataEnricher($enricher);
+ }
+
+ return $builder->build();
+ }
+
+ /** @param class-string $class */
+ private static function transformer(StackHydrator $hydrator, string $class): ClassTransformer
+ {
+ $transformer = (new ReflectionMethod(StackHydrator::class, 'transformer'))->invoke($hydrator, $hydrator->metadata($class));
+ self::assertInstanceOf(ClassTransformer::class, $transformer);
+
+ return $transformer;
+ }
+
+ /** @return array flag name => inlined */
+ private static function inlined(StackHydrator $hydrator): array
+ {
+ /** @var array $flags */
+ $flags = array_filter(
+ get_object_vars(self::transformer($hydrator, NestedLifecycleDto::class)),
+ static fn (string $name): bool => preg_match('/^i[he]\d+$/', $name) === 1,
+ ARRAY_FILTER_USE_KEY,
+ );
+
+ return $flags;
+ }
+}
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
+ *
+ * @return T
+ *
+ * @template T of object
+ */
+ public function hydrate(ClassMetadata $metadata, array $data, array $context, Next $next): object
+ {
+ $this->hydrated[$metadata->className] = ($this->hydrated[$metadata->className] ?? 0) + 1;
+
+ return $next->hydrate($metadata, $data, $context);
+ }
+
+ /**
+ * @param ClassMetadata $metadata
+ * @param T $object
+ * @param array $context
+ *
+ * @return array
+ *
+ * @template T of object
+ */
+ public function extract(ClassMetadata $metadata, object $object, array $context, Next $next): array
+ {
+ $this->extracted[$metadata->className] = ($this->extracted[$metadata->className] ?? 0) + 1;
+
+ return $next->extract($metadata, $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/InferredDateDto.php b/tests/Unit/Fixture/InferredDateDto.php
new file mode 100644
index 00000000..b3ee154f
--- /dev/null
+++ b/tests/Unit/Fixture/InferredDateDto.php
@@ -0,0 +1,16 @@
+ $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;
+ }
+}
diff --git a/tests/Unit/Fixture/ScalarAndNestedDto.php b/tests/Unit/Fixture/ScalarAndNestedDto.php
new file mode 100644
index 00000000..f1fcd4a0
--- /dev/null
+++ b/tests/Unit/Fixture/ScalarAndNestedDto.php
@@ -0,0 +1,14 @@
+className !== Skill::class) {
return null;
diff --git a/tests/Unit/Transformer/ReflectionTransformerTest.php b/tests/Unit/Transformer/ReflectionTransformerTest.php
index 80fd77f1..27db8194 100644
--- a/tests/Unit/Transformer/ReflectionTransformerTest.php
+++ b/tests/Unit/Transformer/ReflectionTransformerTest.php
@@ -6,6 +6,7 @@
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory;
+use Patchlevel\Hydrator\StackHydrator;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Skill;
use Patchlevel\Hydrator\Transformer\ReflectionTransformer;
use Patchlevel\Hydrator\Transformer\ReflectionTransformerFactory;
@@ -40,7 +41,7 @@ public function testHydrateObjectToPopulate(): void
public function testFactoryCreatesReflectionTransformer(): void
{
$factory = new ReflectionTransformerFactory();
- $transformer = $factory->create((new AttributeMetadataFactory())->metadata(Skill::class));
+ $transformer = $factory->create((new AttributeMetadataFactory())->metadata(Skill::class), new StackHydrator());
self::assertEquals(new Skill('php'), $transformer->hydrate(['name' => 'php'], []));
}