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'], [])); }