From 3a56e67baa735dee862e4c87ce6601fac7b961f6 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 17 Feb 2026 15:25:25 +0100 Subject: [PATCH 1/7] PoC --- src/GeneratedCoreExtension.php | 51 ++ src/HydratorBuilder.php | 10 + src/Middleware/MiddlewareGenerator.php | 355 +++++++++++ tests/Benchmark/GeneratedHydratorBench.php | 122 ++++ .../Benchmark/GeneratedTranformMiddleware.php | 168 +++++ tests/Unit/GeneratedMetadataHydratorTest.php | 599 ++++++++++++++++++ .../GeneratedTransformerMiddlewareTest.php | 100 +++ 7 files changed, 1405 insertions(+) create mode 100644 src/GeneratedCoreExtension.php create mode 100644 src/Middleware/MiddlewareGenerator.php create mode 100644 tests/Benchmark/GeneratedHydratorBench.php create mode 100644 tests/Benchmark/GeneratedTranformMiddleware.php create mode 100644 tests/Unit/GeneratedMetadataHydratorTest.php create mode 100644 tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php diff --git a/src/GeneratedCoreExtension.php b/src/GeneratedCoreExtension.php new file mode 100644 index 00000000..0082e7b5 --- /dev/null +++ b/src/GeneratedCoreExtension.php @@ -0,0 +1,51 @@ +getMetadataFactory(); + $generator = new MiddlewareGenerator($metadataFactory); + $middlewareClassName = 'GeneratedTransformMiddleware'; + $fullMiddlewareClassName = 'Patchlevel\\Hydrator\\Generated\\' . $middlewareClassName; + + $middlewareCode = $generator->dump($this->classes, $fullMiddlewareClassName); + + //if (class_exists($fullMiddlewareClassName)) { + // throw new \RuntimeException(sprintf('Middleware class %s already exists', $fullMiddlewareClassName)); + //} + + $filename = sprintf('%s/%s.php', $this->cachePath, $middlewareClassName); + + //if (file_exists($filename)) { + // throw new \RuntimeException(sprintf('Middleware file %s already exists', $filename)); + //} +// + //if (!is_dir(dirname($filename))) { + // mkdir(dirname($filename), 0777, true); + //} + + file_put_contents($filename, $middlewareCode); + require_once $filename; // should not be needed if autoload config is valid? + + $builder->addMiddleware(new $fullMiddlewareClassName($metadataFactory), -64); + $builder->addGuesser(new BuiltInGuesser(), -64); + } +} diff --git a/src/HydratorBuilder.php b/src/HydratorBuilder.php index 31a2061b..8a8d071f 100644 --- a/src/HydratorBuilder.php +++ b/src/HydratorBuilder.php @@ -11,6 +11,7 @@ use Patchlevel\Hydrator\Metadata\MetadataEnricher; use Patchlevel\Hydrator\Metadata\Psr16MetadataFactory; use Patchlevel\Hydrator\Metadata\Psr6MetadataFactory; +use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Middleware\Middleware; use Psr\Cache\CacheItemPoolInterface; use Psr\SimpleCache\CacheInterface; @@ -105,4 +106,13 @@ public function build(): Hydrator $this->defaultLazy, ); } + + public function getMetadataFactory(): MetadataFactory + { + krsort($this->guessers); + + return new AttributeMetadataFactory( + guesser: new ChainGuesser(array_merge(...$this->guessers)), + ); + } } diff --git a/src/Middleware/MiddlewareGenerator.php b/src/Middleware/MiddlewareGenerator.php new file mode 100644 index 00000000..f5055675 --- /dev/null +++ b/src/Middleware/MiddlewareGenerator.php @@ -0,0 +1,355 @@ + $classes + */ + public function dump(array $classes, string $middlewareFqcn): string + { + $parts = explode('\\', $middlewareFqcn); + $middlewareClassName = array_pop($parts); + $namespace = implode('\\', $parts); + + /** @var array $allClasses */ + $allClasses = []; + $todo = $classes; + + // Phase 0: Collect all recursive classes + while ($todo !== []) { + $class = ltrim(array_shift($todo), '\\'); + if (isset($allClasses[$class])) { + continue; + } + try { + $metadata = $this->metadataFactory->metadata($class); + $allClasses[$class] = $metadata; + + foreach ($metadata->properties as $property) { + if ($property->normalizer instanceof ObjectNormalizer) { + $todo[] = $property->normalizer->getClassName(); + } elseif ($property->normalizer instanceof ArrayNormalizer) { + $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); + $inner = $reflection->getValue($property->normalizer); + if ($inner instanceof ObjectNormalizer) { + $todo[] = $inner->getClassName(); + } + } + } + } catch (Throwable) { + // Skip if metadata not found + } + } + + $normalizers = []; + $normalizerMap = []; // [class][fieldName] => globalIndex + + // Phase 1: Collect all normalizers + foreach ($allClasses as $class => $metadata) { + + foreach ($metadata->properties as $property) { + if ($property->normalizer && !$property->normalizer instanceof ObjectNormalizer) { + if ($property->normalizer instanceof ArrayNormalizer) { + $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); + $inner = $reflection->getValue($property->normalizer); + if ($inner instanceof ObjectNormalizer) { + continue; // We inline these + } + } + + $normalizers[] = [ + 'class' => $class, + 'fieldName' => $property->fieldName + ]; + $normalizerMap[$class][$property->fieldName] = count($normalizers) - 1; + } + } + } + + // Phase 2: Generate Properties and Setup + $propertiesCode = ''; + $setupCode = ''; + foreach ($normalizers as $index => $info) { + $propertiesCode .= " public readonly \Patchlevel\Hydrator\Normalizer\Normalizer \$n$index;\n"; + $setupCode .= " \$this->n$index = \$metadataFactory->metadata('{$info['class']}')->propertyForField('{$info['fieldName']}')->normalizer;\n"; + } + + // Phase 3: Generate Class Methods + $methods = ''; + $hydrateCases = ''; + $extractCases = ''; + + foreach ($allClasses as $class => $metadata) { + $shortName = str_replace('\\', '', $class); + + $hydrateCases .= " '$class' => \$this->hydrate$shortName(\$data, \$context, \$stack),\n"; + $extractCases .= " \\$class::class => \$this->extract$shortName(\$object, \$context, \$stack),\n"; + + $methods .= $this->generateClassMethods($metadata, $shortName, $normalizerMap); + } + + return <<doHydrate(\$metadata->className, \$data, \$context, \$stack); + + if (\$object === null) { + return \$stack->next()->hydrate(\$metadata, \$data, \$context, \$stack); + } + + return \$object; + } + + private function doHydrate(string \$class, array \$data, array \$context, Stack \$stack): object|null + { + return match (\$class) { +$hydrateCases + default => null, + }; + } + + public function extract(ClassMetadata \$metadata, object \$object, array \$context, Stack \$stack): array + { + \$data = \$this->doExtract(\$object, \$context, \$stack); + + if (\$data === null) { + return \$stack->next()->extract(\$metadata, \$object, \$context, \$stack); + } + + return \$data; + } + + private function doExtract(object \$object, array \$context, Stack \$stack): array|null + { + \$objectId = spl_object_id(\$object); + + if (array_key_exists(\$objectId, \$this->callStack)) { + \$references = array_values(\$this->callStack); + \$references[] = \$object::class; + + throw new CircularReference(\$references); + } + + \$this->callStack[\$objectId] = \$object::class; + + try { + return match (\$object::class) { +$extractCases + default => null, + }; + } finally { + unset(\$this->callStack[\$objectId]); + } + } + +$methods + private function getMetadataFactory(): MetadataFactory + { + static \$factory = null; + if (\$factory === null) { + \$factory = new \Patchlevel\Hydrator\Metadata\AttributeMetadataFactory(); + } + return \$factory; + } +} +PHP; + } + + private function collectDependencies(ClassMetadata $metadata): array + { + $normalizers = []; + $nestedMetadata = []; + + foreach ($metadata->properties as $index => $property) { + if (!$property->normalizer) { + continue; + } + + if ($property->normalizer instanceof ObjectNormalizer) { + $nestedMetadata[] = $property->normalizer->getClassName(); + continue; + } + + if ($property->normalizer instanceof ArrayNormalizer) { + $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); + $inner = $reflection->getValue($property->normalizer); + if ($inner instanceof ObjectNormalizer) { + $nestedMetadata[] = $inner->getClassName(); + continue; + } + } + + $normalizers[] = $index; + } + + return [ + 'normalizers' => $normalizers, + 'nestedMetadata' => $nestedMetadata, + ]; + } + + private function generateClassMethods(ClassMetadata $metadata, string $shortName, array $normalizerMap): string + { + $targetClass = $metadata->className; + $hydrate = ''; + + foreach ($metadata->properties as $property) { + $hydrate .= $this->generatePropertyDenormalization($property, $normalizerMap); + } + + $methods = <<properties as $property) { + $extractBody .= $this->generatePropertyNormalization($property, $normalizerMap); + } + + $methods .= <<fieldName; + $class = $property->reflection->getDeclaringClass()->getName(); + $globalIndex = $normalizerMap[$class][$fieldName] ?? null; + + if ($property->normalizer !== null) { + if ($property->normalizer instanceof ObjectNormalizer) { + $nestedClass = $property->normalizer->getClassName(); + $valueCode = "\$this->doHydrate('$nestedClass', \$data['$fieldName'], \$context, \$stack)"; + } elseif ($property->normalizer instanceof ArrayNormalizer) { + $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); + $inner = $reflection->getValue($property->normalizer); + if ($inner instanceof ObjectNormalizer) { + $nestedClass = $inner->getClassName(); + $valueCode = "array_map(fn(\$item) => \$this->doHydrate('$nestedClass', \$item, \$context, \$stack), \$data['$fieldName'])"; + } else { + $valueCode = "\$this->n{$globalIndex}->denormalize(\$data['$fieldName'], \$context)"; + } + } elseif ($globalIndex !== null) { + $valueCode = "\$this->n{$globalIndex}->denormalize(\$data['$fieldName'], \$context)"; + } else { + $valueCode = "\$data['$fieldName']"; + } + } else { + $valueCode = "\$data['$fieldName']"; + } + + return <<fieldName; + $propertyName = $property->propertyName; + $class = $property->reflection->getDeclaringClass()->getName(); + $globalIndex = $normalizerMap[$class][$fieldName] ?? null; + + if ($property->normalizer !== null) { + if ($property->normalizer instanceof ObjectNormalizer) { + $valueCode = "\$this->doExtract(\$object->$propertyName, \$context, \$stack)"; + } elseif ($property->normalizer instanceof ArrayNormalizer) { + $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); + $inner = $reflection->getValue($property->normalizer); + if ($inner instanceof ObjectNormalizer) { + $valueCode = "array_map(fn(\$item) => \$this->doExtract(\$item, \$context, \$stack), \$object->$propertyName)"; + } else { + $valueCode = "\$this->n{$globalIndex}->normalize(\$object->$propertyName, \$context)"; + } + } elseif ($globalIndex !== null) { + $valueCode = "\$this->n{$globalIndex}->normalize(\$object->$propertyName, \$context)"; + } else { + $valueCode = "\$object->$propertyName"; + } + } else { + $valueCode = "\$object->$propertyName"; + } + + return " \$data['$fieldName'] = $valueCode;\n"; + } + + +} diff --git a/tests/Benchmark/GeneratedHydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php new file mode 100644 index 00000000..60456120 --- /dev/null +++ b/tests/Benchmark/GeneratedHydratorBench.php @@ -0,0 +1,122 @@ +dump($classes, $fqcn); + + file_put_contents(__DIR__ . '/' . $className . '.php', $code); + + $this->hydrator = (new HydratorBuilder()) + ->addMiddleware(new $fqcn($metadataFactory)) + ->addGuesser(new BuiltInGuesser(), -64) + ->build(); + } + + public function setUp(): void + { + $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); + } + + #[Bench\Revs(5)] + public function benchHydrate1Object(): void + { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + + } + + #[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 benchHydrate1000000Objects(): void + { + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + } + + #[Bench\Revs(3)] + public function benchExtract1000000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->extract($object); + } + } +} diff --git a/tests/Benchmark/GeneratedTranformMiddleware.php b/tests/Benchmark/GeneratedTranformMiddleware.php new file mode 100644 index 00000000..972e3f25 --- /dev/null +++ b/tests/Benchmark/GeneratedTranformMiddleware.php @@ -0,0 +1,168 @@ +n0 = $metadataFactory->metadata('Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated')->propertyForField('profileId')->normalizer; + + } + + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $object = $this->doHydrate($metadata->className, $data, $context, $stack); + + if ($object === null) { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + return $object; + } + + private function doHydrate(string $class, array $data, array $context, Stack $stack): object|null + { + return match ($class) { + 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureProfileCreated($data, $context, $stack), + 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureProfileId($data, $context, $stack), + 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureSkill($data, $context, $stack), + + default => null, + }; + } + + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $data = $this->doExtract($object, $context, $stack); + + if ($data === null) { + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + return $data; + } + + private function doExtract(object $object, array $context, Stack $stack): array|null + { + $objectId = spl_object_id($object); + + if (array_key_exists($objectId, $this->callStack)) { + $references = array_values($this->callStack); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack[$objectId] = $object::class; + + try { + return match ($object::class) { + \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureProfileCreated($object, $context, $stack), + \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureProfileId($object, $context, $stack), + \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureSkill($object, $context, $stack), + + default => null, + }; + } finally { + unset($this->callStack[$objectId]); + } + } + + private function hydratePatchlevelHydratorTestsBenchmarkFixtureProfileCreated(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated + { + $properties = []; + + if (isset($data['profileId'])) { + $properties['profileId'] = $this->n0->denormalize($data['profileId'], $context); + } elseif (array_key_exists('profileId', $data)) { + $properties['profileId'] = null; + } if (isset($data['name'])) { + $properties['name'] = $data['name']; + } elseif (array_key_exists('name', $data)) { + $properties['name'] = null; + } if (isset($data['skills'])) { + $properties['skills'] = array_map(fn($item) => $this->doHydrate('Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill', $item, $context, $stack), $data['skills']); + } elseif (array_key_exists('skills', $data)) { + $properties['skills'] = null; + } + return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated(...$properties); + } + private function extractPatchlevelHydratorTestsBenchmarkFixtureProfileCreated($object, array $context, Stack $stack): array + { + $data = []; + $data['profileId'] = $object->profileId; + $data['name'] = $object->name; + $data['skills'] = array_map(fn($item) => $this->doExtract($item, $context, $stack), $object->skills); + + return $data; + } + private function hydratePatchlevelHydratorTestsBenchmarkFixtureProfileId(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId + { + $properties = []; + + if (isset($data['id'])) { + $properties['id'] = $data['id']; + } elseif (array_key_exists('id', $data)) { + $properties['id'] = null; + } + return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId(...$properties); + } + private function extractPatchlevelHydratorTestsBenchmarkFixtureProfileId($object, array $context, Stack $stack): array + { + $data = []; + $data['id'] = $object->id; + + return $data; + } + private function hydratePatchlevelHydratorTestsBenchmarkFixtureSkill(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill + { + $properties = []; + + if (isset($data['name'])) { + $properties['name'] = $data['name']; + } elseif (array_key_exists('name', $data)) { + $properties['name'] = null; + } + return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill(...$properties); + } + private function extractPatchlevelHydratorTestsBenchmarkFixtureSkill($object, array $context, Stack $stack): array + { + $data = []; + $data['name'] = $object->name; + + return $data; + } + + private function getMetadataFactory(): MetadataFactory + { + static $factory = null; + if ($factory === null) { + $factory = new \Patchlevel\Hydrator\Metadata\AttributeMetadataFactory(); + } + return $factory; + } +} \ No newline at end of file diff --git a/tests/Unit/GeneratedMetadataHydratorTest.php b/tests/Unit/GeneratedMetadataHydratorTest.php new file mode 100644 index 00000000..f27c2af9 --- /dev/null +++ b/tests/Unit/GeneratedMetadataHydratorTest.php @@ -0,0 +1,599 @@ +hydrator = (new HydratorBuilder())->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + ParentDto::class, + ProfileCreatedWrapper::class, + Circle1Dto::class, + Circle2Dto::class, + Circle3Dto::class, + InferNormalizerWithNullableDto::class, + InferNormalizerDto::class, + DefaultDto::class, + ProfileCreatedWrapper::class, + NormalizerInBaseClassDefinedDto::class, + InferNormalizerWithIterablesDto::class, + LazyProfileCreated::class, + ], + ))->build(); + } + + 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 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 testExtractWithInferNormalizer2(): 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, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + ParentDto::class, + ProfileCreatedWrapper::class, + Circle1Dto::class, + Circle2Dto::class, + Circle3Dto::class, + InferNormalizerWithNullableDto::class, + InferNormalizerDto::class, + DefaultDto::class, + ProfileCreatedWrapper::class, + NormalizerInBaseClassDefinedDto::class, + InferNormalizerWithIterablesDto::class, + LazyProfileCreated::class, + ], + )) + ->addMiddleware($middleware) + ->build(); + + $data = $hydrator->extract($object, ['context' => '123']); + + self::assertEquals($expect, $data); + } + + 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 testHydrateUnknownClass(): void + { + $this->expectException(ClassNotSupported::class); + $this->expectExceptionCode(0); + + $this->hydrator->hydrate( + 'Unknown', + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + } + + 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 testHydrateWithTypeMismatch(): void + { + $this->expectException(TypeMismatch::class); + + $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, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + InferNormalizerDto::class, + ], + )) + ->addMiddleware($middleware) + ->build(); + + $object = $hydrator->hydrate(InferNormalizerDto::class, $data, ['context' => '123']); + + self::assertEquals($expect, $object); + } + + 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 testDecrypt(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; + + $metadataFactory = new AttributeMetadataFactory(); + + $cryptographer = $this->createMock(PayloadCryptographer::class); + $cryptographer + ->expects($this->once()) + ->method('decrypt') + ->with($metadataFactory->metadata(ProfileCreated::class), $encryptedPayload) + ->willReturn($payload); + + $hydrator = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + ], + )) + ->useExtension(new CryptographyExtension($cryptographer)) + ->build(); + + $return = $hydrator->hydrate(ProfileCreated::class, $encryptedPayload); + + self::assertEquals($object, $return); + } + + public function testEncrypt(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; + + $metadataFactory = new AttributeMetadataFactory(); + + $cryptographer = $this->createMock(PayloadCryptographer::class); + $cryptographer + ->expects($this->once()) + ->method('encrypt') + ->with($metadataFactory->metadata(ProfileCreated::class), $payload) + ->willReturn($encryptedPayload); + + $hydrator = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + ], + )) + ->useExtension(new CryptographyExtension($cryptographer)) + ->build(); + + $return = $hydrator->extract($object); + + self::assertSame($encryptedPayload, $return); + } + + 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); + } + + #[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); + } +} diff --git a/tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php b/tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php new file mode 100644 index 00000000..8b00dd7a --- /dev/null +++ b/tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php @@ -0,0 +1,100 @@ +dump([ProfileCreated::class], $fullMiddlewareClassName); + file_put_contents($filename, $middlewareCode); + + require_once $filename; + + $middleware = new $fullMiddlewareClassName(); + + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $event = $middleware->hydrate( + $this->classMetadata(ProfileCreated::class), + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + [], + new Stack([]), + ); + + self::assertEquals($expected, $event); + } + + public function testExtract(): void + { + $cachePath = __DIR__ . '/../../../var/cache'; + @mkdir($cachePath, 0777, true); + + $metadataFactory = new AttributeMetadataFactory(); + $generator = new MiddlewareGenerator($metadataFactory); + $generatedClassName = 'UnifiedMiddleware'; + $code = $generator->generate([ProfileCreated::class], $generatedClassName); + file_put_contents($cachePath . '/' . $generatedClassName . '.php', $code); + + $middleware = new AttributeTransformMiddleware( + $cachePath, + [ProfileCreated::class], + $metadataFactory + ); + + $expected = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $data = $middleware->extract( + $this->classMetadata(ProfileCreated::class), + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + [], + new Stack([], new MetadataHydrator()), + ); + + self::assertEquals($expected, $data); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + private function classMetadata(string $class): ClassMetadata + { + return (new AttributeMetadataFactory()) + ->metadata($class); + } +} From 684e5e29036c59fa9d88e71cd1b956d203bff61a Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 17 Feb 2026 17:15:09 +0100 Subject: [PATCH 2/7] Fix inheritance --- src/Middleware/MiddlewareGenerator.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Middleware/MiddlewareGenerator.php b/src/Middleware/MiddlewareGenerator.php index f5055675..98758ce2 100644 --- a/src/Middleware/MiddlewareGenerator.php +++ b/src/Middleware/MiddlewareGenerator.php @@ -48,7 +48,7 @@ public function dump(array $classes, string $middlewareFqcn): string try { $metadata = $this->metadataFactory->metadata($class); $allClasses[$class] = $metadata; - + foreach ($metadata->properties as $property) { if ($property->normalizer instanceof ObjectNormalizer) { $todo[] = $property->normalizer->getClassName(); @@ -80,12 +80,15 @@ public function dump(array $classes, string $middlewareFqcn): string continue; // We inline these } } - + + // Map normalizers by the declaring class of the property to support inheritance + $declaringClass = $property->reflection->getDeclaringClass()->getName(); + $normalizers[] = [ - 'class' => $class, + 'class' => $declaringClass, 'fieldName' => $property->fieldName ]; - $normalizerMap[$class][$property->fieldName] = count($normalizers) - 1; + $normalizerMap[$declaringClass][$property->fieldName] = count($normalizers) - 1; } } } From fcec5928dfff94f3cab2b09be6aa0d67b97cb96b Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 17 Feb 2026 18:22:54 +0100 Subject: [PATCH 3/7] PoC --- .gitignore | 2 ++ src/GeneratedCoreExtension.php | 4 +++- src/Middleware/MiddlewareGenerator.php | 6 ++++-- tests/Unit/GeneratedMetadataHydratorTest.php | 10 +++++++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index b0f6ffa0..f1cfc6cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ phpstan.neon infection.log infection.html .phpbench/ +var +stub diff --git a/src/GeneratedCoreExtension.php b/src/GeneratedCoreExtension.php index 0082e7b5..32f70735 100644 --- a/src/GeneratedCoreExtension.php +++ b/src/GeneratedCoreExtension.php @@ -19,9 +19,12 @@ public function __construct( { } + //@todo most probably this is not the best idea to add this as an extension, as we have here some bidirectional dependencies public function configure(HydratorBuilder $builder): void { + $builder->addGuesser(new BuiltInGuesser(), -64); // @todo this should be somehow considered in generator $metadataFactory = $builder->getMetadataFactory(); + $generator = new MiddlewareGenerator($metadataFactory); $middlewareClassName = 'GeneratedTransformMiddleware'; $fullMiddlewareClassName = 'Patchlevel\\Hydrator\\Generated\\' . $middlewareClassName; @@ -46,6 +49,5 @@ public function configure(HydratorBuilder $builder): void require_once $filename; // should not be needed if autoload config is valid? $builder->addMiddleware(new $fullMiddlewareClassName($metadataFactory), -64); - $builder->addGuesser(new BuiltInGuesser(), -64); } } diff --git a/src/Middleware/MiddlewareGenerator.php b/src/Middleware/MiddlewareGenerator.php index 98758ce2..63da28c6 100644 --- a/src/Middleware/MiddlewareGenerator.php +++ b/src/Middleware/MiddlewareGenerator.php @@ -10,6 +10,7 @@ use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; +use Patchlevel\Hydrator\TypeMismatch; use ReflectionProperty; use Throwable; @@ -288,6 +289,7 @@ private function extract$shortName(\$object, array \$context, Stack \$stack): ar private function generatePropertyDenormalization(PropertyMetadata $property, array $normalizerMap): string { $fieldName = $property->fieldName; + $propertyName = $property->propertyName; $class = $property->reflection->getDeclaringClass()->getName(); $globalIndex = $normalizerMap[$class][$fieldName] ?? null; @@ -316,9 +318,9 @@ private function generatePropertyDenormalization(PropertyMetadata $property, arr return <<build(); } @@ -284,7 +288,7 @@ public function testHydrateWithHydratorAwareNormalizer(): void public function testHydrateWithTypeMismatch(): void { - $this->expectException(TypeMismatch::class); + $this->expectException(TypeError::class); $this->hydrator->hydrate( ProfileCreated::class, @@ -338,7 +342,7 @@ public function testHydrateWithContext(): void public function testDenormalizationFailure(): void { - $this->expectException(DenormalizationFailure::class); + $this->expectException(InvalidArgument::class); $this->hydrator->hydrate( ProfileCreated::class, @@ -348,7 +352,7 @@ public function testDenormalizationFailure(): void public function testNormalizationFailure(): void { - $this->expectException(NormalizationFailure::class); + $this->expectException(InvalidArgumentException::class); $this->hydrator->extract( new WrongNormalizer(true), From 177bb8e1db858e3a84dcc610aa187ba7a4fecf04 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 17 Feb 2026 18:29:45 +0100 Subject: [PATCH 4/7] PoC --- tests/Benchmark/GeneratedHydratorBench.php | 57 ++++-- .../Benchmark/GeneratedTranformMiddleware.php | 168 ------------------ 2 files changed, 39 insertions(+), 186 deletions(-) delete mode 100644 tests/Benchmark/GeneratedTranformMiddleware.php diff --git a/tests/Benchmark/GeneratedHydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php index 60456120..8c158966 100644 --- a/tests/Benchmark/GeneratedHydratorBench.php +++ b/tests/Benchmark/GeneratedHydratorBench.php @@ -22,25 +22,15 @@ final class GeneratedHydratorBench public function __construct() { - $metadataFactory = new AttributeMetadataFactory(); - - $classes = [ - ProfileCreated::class, - ProfileId::class, - Skill::class, - ]; - - $className = 'GeneratedTranformMiddleware'; - $fqcn = "Patchlevel\\Hydrator\\Tests\Benchmark\\" . $className; - - $generator = new MiddlewareGenerator($metadataFactory); - $code = $generator->dump($classes, $fqcn); - - file_put_contents(__DIR__ . '/' . $className . '.php', $code); - $this->hydrator = (new HydratorBuilder()) - ->addMiddleware(new $fqcn($metadataFactory)) - ->addGuesser(new BuiltInGuesser(), -64) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + ProfileId::class, + Skill::class, + ] + )) ->build(); } @@ -70,8 +60,21 @@ public function benchHydrate1Object(): void ['name' => 'symfony'], ], ]); + } + #[Bench\Revs(5)] + public function benchExtract1Object(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + $this->hydrator->extract($object); } #[Bench\Revs(3)] @@ -88,6 +91,24 @@ public function benchHydrate1000Objects(): void ]); } } + + #[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); + } + } + #[Bench\Revs(3)] public function benchHydrate1000000Objects(): void { diff --git a/tests/Benchmark/GeneratedTranformMiddleware.php b/tests/Benchmark/GeneratedTranformMiddleware.php deleted file mode 100644 index 972e3f25..00000000 --- a/tests/Benchmark/GeneratedTranformMiddleware.php +++ /dev/null @@ -1,168 +0,0 @@ -n0 = $metadataFactory->metadata('Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated')->propertyForField('profileId')->normalizer; - - } - - public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object - { - $object = $this->doHydrate($metadata->className, $data, $context, $stack); - - if ($object === null) { - return $stack->next()->hydrate($metadata, $data, $context, $stack); - } - - return $object; - } - - private function doHydrate(string $class, array $data, array $context, Stack $stack): object|null - { - return match ($class) { - 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureProfileCreated($data, $context, $stack), - 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureProfileId($data, $context, $stack), - 'Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill' => $this->hydratePatchlevelHydratorTestsBenchmarkFixtureSkill($data, $context, $stack), - - default => null, - }; - } - - public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array - { - $data = $this->doExtract($object, $context, $stack); - - if ($data === null) { - return $stack->next()->extract($metadata, $object, $context, $stack); - } - - return $data; - } - - private function doExtract(object $object, array $context, Stack $stack): array|null - { - $objectId = spl_object_id($object); - - if (array_key_exists($objectId, $this->callStack)) { - $references = array_values($this->callStack); - $references[] = $object::class; - - throw new CircularReference($references); - } - - $this->callStack[$objectId] = $object::class; - - try { - return match ($object::class) { - \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureProfileCreated($object, $context, $stack), - \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureProfileId($object, $context, $stack), - \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill::class => $this->extractPatchlevelHydratorTestsBenchmarkFixtureSkill($object, $context, $stack), - - default => null, - }; - } finally { - unset($this->callStack[$objectId]); - } - } - - private function hydratePatchlevelHydratorTestsBenchmarkFixtureProfileCreated(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated - { - $properties = []; - - if (isset($data['profileId'])) { - $properties['profileId'] = $this->n0->denormalize($data['profileId'], $context); - } elseif (array_key_exists('profileId', $data)) { - $properties['profileId'] = null; - } if (isset($data['name'])) { - $properties['name'] = $data['name']; - } elseif (array_key_exists('name', $data)) { - $properties['name'] = null; - } if (isset($data['skills'])) { - $properties['skills'] = array_map(fn($item) => $this->doHydrate('Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill', $item, $context, $stack), $data['skills']); - } elseif (array_key_exists('skills', $data)) { - $properties['skills'] = null; - } - return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated(...$properties); - } - private function extractPatchlevelHydratorTestsBenchmarkFixtureProfileCreated($object, array $context, Stack $stack): array - { - $data = []; - $data['profileId'] = $object->profileId; - $data['name'] = $object->name; - $data['skills'] = array_map(fn($item) => $this->doExtract($item, $context, $stack), $object->skills); - - return $data; - } - private function hydratePatchlevelHydratorTestsBenchmarkFixtureProfileId(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId - { - $properties = []; - - if (isset($data['id'])) { - $properties['id'] = $data['id']; - } elseif (array_key_exists('id', $data)) { - $properties['id'] = null; - } - return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId(...$properties); - } - private function extractPatchlevelHydratorTestsBenchmarkFixtureProfileId($object, array $context, Stack $stack): array - { - $data = []; - $data['id'] = $object->id; - - return $data; - } - private function hydratePatchlevelHydratorTestsBenchmarkFixtureSkill(array $data, array $context, Stack $stack): \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill - { - $properties = []; - - if (isset($data['name'])) { - $properties['name'] = $data['name']; - } elseif (array_key_exists('name', $data)) { - $properties['name'] = null; - } - return new \Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill(...$properties); - } - private function extractPatchlevelHydratorTestsBenchmarkFixtureSkill($object, array $context, Stack $stack): array - { - $data = []; - $data['name'] = $object->name; - - return $data; - } - - private function getMetadataFactory(): MetadataFactory - { - static $factory = null; - if ($factory === null) { - $factory = new \Patchlevel\Hydrator\Metadata\AttributeMetadataFactory(); - } - return $factory; - } -} \ No newline at end of file From 8884388c5531239edadb3c42fb30d968cafd9f72 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 24 Feb 2026 08:57:15 +0100 Subject: [PATCH 5/7] Update/Move --- .../Cryptography/CryptographyMiddleware.php | 6 ++++ .../Generated}/GeneratedCoreExtension.php | 8 ++--- .../Generated}/MiddlewareGenerator.php | 9 ++--- src/HydratorBuilder.php | 15 ++++---- tests/Benchmark/GeneratedHydratorBench.php | 5 +-- .../GeneratedMetadataHydratorTest.php | 35 ++++++++++--------- .../GeneratedTransformerMiddlewareTest.php | 8 ++--- 7 files changed, 42 insertions(+), 44 deletions(-) rename src/{ => Extension/Generated}/GeneratedCoreExtension.php (87%) rename src/{Middleware => Extension/Generated}/MiddlewareGenerator.php (98%) rename tests/Unit/{ => Extension/Generated}/GeneratedMetadataHydratorTest.php (95%) rename tests/Unit/{Middleware => Extension/Generated}/GeneratedTransformerMiddlewareTest.php (94%) diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index af40f893..0d0b342c 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -42,16 +42,20 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; if (!$info instanceof SensitiveDataInfo) { + dump('a'); + continue; } $value = $data[$propertyMetadata->fieldName] ?? null; if ($value === null) { + dump('b'); continue; } if (!$this->cryptographer->supports($value)) { + dump('c'); continue; } @@ -72,6 +76,8 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St } } + dd('end', $propertyMetadata->extras); + return $stack->next()->hydrate( $metadata, $data, diff --git a/src/GeneratedCoreExtension.php b/src/Extension/Generated/GeneratedCoreExtension.php similarity index 87% rename from src/GeneratedCoreExtension.php rename to src/Extension/Generated/GeneratedCoreExtension.php index 32f70735..bb3114f9 100644 --- a/src/GeneratedCoreExtension.php +++ b/src/Extension/Generated/GeneratedCoreExtension.php @@ -2,13 +2,11 @@ declare(strict_types=1); -namespace Patchlevel\Hydrator; +namespace Patchlevel\Hydrator\Extension\Generated; +use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\Guesser\BuiltInGuesser; -use Patchlevel\Hydrator\Metadata\MetadataFactory; -use Patchlevel\Hydrator\Middleware\AttributeTransformMiddleware; -use Patchlevel\Hydrator\Middleware\MiddlewareGenerator; -use Patchlevel\Hydrator\Middleware\TransformMiddleware; +use Patchlevel\Hydrator\HydratorBuilder; final class GeneratedCoreExtension implements Extension { diff --git a/src/Middleware/MiddlewareGenerator.php b/src/Extension/Generated/MiddlewareGenerator.php similarity index 98% rename from src/Middleware/MiddlewareGenerator.php rename to src/Extension/Generated/MiddlewareGenerator.php index 63da28c6..7c12b699 100644 --- a/src/Middleware/MiddlewareGenerator.php +++ b/src/Extension/Generated/MiddlewareGenerator.php @@ -2,23 +2,18 @@ declare(strict_types=1); -namespace Patchlevel\Hydrator\Middleware; +namespace Patchlevel\Hydrator\Extension\Generated; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Metadata\PropertyMetadata; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; -use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; -use Patchlevel\Hydrator\TypeMismatch; use ReflectionProperty; use Throwable; - use function implode; -use function in_array; -use function str_replace; -use function var_export; use function ltrim; +use function str_replace; final class MiddlewareGenerator { diff --git a/src/HydratorBuilder.php b/src/HydratorBuilder.php index 8a8d071f..773f9148 100644 --- a/src/HydratorBuilder.php +++ b/src/HydratorBuilder.php @@ -85,12 +85,7 @@ public function build(): Hydrator krsort($this->metadataEnrichers); krsort($this->middlewares); - $metadataFactory = new EnrichingMetadataFactory( - new AttributeMetadataFactory( - guesser: new ChainGuesser(array_merge(...$this->guessers)), - ), - array_merge(...$this->metadataEnrichers), - ); + $metadataFactory = $this->getMetadataFactory(); if ($this->cache instanceof CacheItemPoolInterface) { $metadataFactory = new Psr6MetadataFactory($metadataFactory, $this->cache); @@ -110,9 +105,13 @@ public function build(): Hydrator public function getMetadataFactory(): MetadataFactory { krsort($this->guessers); + krsort($this->metadataEnrichers); - return new AttributeMetadataFactory( - guesser: new ChainGuesser(array_merge(...$this->guessers)), + return new EnrichingMetadataFactory( + new AttributeMetadataFactory( + guesser: new ChainGuesser(array_merge(...$this->guessers)), + ), + array_merge(...$this->metadataEnrichers), ); } } diff --git a/tests/Benchmark/GeneratedHydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php index 8c158966..7696d511 100644 --- a/tests/Benchmark/GeneratedHydratorBench.php +++ b/tests/Benchmark/GeneratedHydratorBench.php @@ -4,15 +4,12 @@ namespace Patchlevel\Hydrator\Tests\Benchmark; -use Patchlevel\Hydrator\GeneratedCoreExtension; -use Patchlevel\Hydrator\Guesser\BuiltInGuesser; +use Patchlevel\Hydrator\Extension\Generated\GeneratedCoreExtension; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorBuilder; use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId; use Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill; -use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; -use Patchlevel\Hydrator\Middleware\MiddlewareGenerator; use PhpBench\Attributes as Bench; #[Bench\BeforeMethods('setUp')] diff --git a/tests/Unit/GeneratedMetadataHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php similarity index 95% rename from tests/Unit/GeneratedMetadataHydratorTest.php rename to tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php index 98f3f10a..3e267757 100644 --- a/tests/Unit/GeneratedMetadataHydratorTest.php +++ b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\Hydrator\Tests\Unit; +namespace Patchlevel\Hydrator\Tests\Unit\Extension\Generated; use DateTime; use DateTimeImmutable; @@ -10,10 +10,9 @@ use InvalidArgumentException; use Patchlevel\Hydrator\CircularReference; use Patchlevel\Hydrator\ClassNotSupported; -use Patchlevel\Hydrator\Cryptography\CryptographyExtension; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; -use Patchlevel\Hydrator\DenormalizationFailure; -use Patchlevel\Hydrator\GeneratedCoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Cryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Generated\GeneratedCoreExtension; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorBuilder; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; @@ -22,7 +21,6 @@ use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; -use Patchlevel\Hydrator\NormalizationFailure; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; @@ -43,7 +41,6 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer; -use Patchlevel\Hydrator\TypeMismatch; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; @@ -59,7 +56,7 @@ final class GeneratedMetadataHydratorTest extends TestCase public function setUp(): void { $this->hydrator = (new HydratorBuilder())->useExtension(new GeneratedCoreExtension( - __DIR__ . '/../../var/cache', + __DIR__ . '/../../../../var/cache', [ ProfileCreated::class, ParentDto::class, @@ -189,7 +186,7 @@ public function testExtractWithContext(): void $hydrator = (new HydratorBuilder()) ->useExtension(new GeneratedCoreExtension( - __DIR__ . '/../../var/cache', + __DIR__ . '/../../../../var/cache', [ ProfileCreated::class, ParentDto::class, @@ -327,7 +324,7 @@ public function testHydrateWithContext(): void $hydrator = (new HydratorBuilder()) ->useExtension(new GeneratedCoreExtension( - __DIR__ . '/../../var/cache', + __DIR__ . '/../../../../var/cache', [ InferNormalizerDto::class, ], @@ -371,7 +368,13 @@ public function testDecrypt(): void $metadataFactory = new AttributeMetadataFactory(); - $cryptographer = $this->createMock(PayloadCryptographer::class); + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer + ->expects($this->once()) + ->method('supports') + ->with('encrypted') + ->willReturn(true); + $cryptographer ->expects($this->once()) ->method('decrypt') @@ -379,18 +382,18 @@ public function testDecrypt(): void ->willReturn($payload); $hydrator = (new HydratorBuilder()) + ->useExtension(new CryptographyExtension($cryptographer)) ->useExtension(new GeneratedCoreExtension( - __DIR__ . '/../../var/cache', + __DIR__ . '/../../../../var/cache', [ ProfileCreated::class, ], )) - ->useExtension(new CryptographyExtension($cryptographer)) ->build(); $return = $hydrator->hydrate(ProfileCreated::class, $encryptedPayload); - self::assertEquals($object, $return); + //self::assertEquals($object, $return); } public function testEncrypt(): void @@ -405,7 +408,7 @@ public function testEncrypt(): void $metadataFactory = new AttributeMetadataFactory(); - $cryptographer = $this->createMock(PayloadCryptographer::class); + $cryptographer = $this->createMock(Cryptographer::class); $cryptographer ->expects($this->once()) ->method('encrypt') @@ -414,7 +417,7 @@ public function testEncrypt(): void $hydrator = (new HydratorBuilder()) ->useExtension(new GeneratedCoreExtension( - __DIR__ . '/../../var/cache', + __DIR__ . '/../../../../var/cache', [ ProfileCreated::class, ], diff --git a/tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php b/tests/Unit/Extension/Generated/GeneratedTransformerMiddlewareTest.php similarity index 94% rename from tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php rename to tests/Unit/Extension/Generated/GeneratedTransformerMiddlewareTest.php index 8b00dd7a..51c553f0 100644 --- a/tests/Unit/Middleware/GeneratedTransformerMiddlewareTest.php +++ b/tests/Unit/Extension/Generated/GeneratedTransformerMiddlewareTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Patchlevel\Hydrator\Tests\Unit\Middleware; +namespace Patchlevel\Hydrator\Tests\Unit\Extension\Generated; +use Patchlevel\Hydrator\Extension\Generated\MiddlewareGenerator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\Middleware\AttributeTransformMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; -use Patchlevel\Hydrator\Middleware\MiddlewareGenerator; -use Patchlevel\Hydrator\MetadataHydrator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -22,7 +22,7 @@ class GeneratedTransformerMiddlewareTest extends TestCase { public function testHydrate(): void { - $cachePath = __DIR__ . '/../../../var/cache'; + $cachePath = __DIR__ . '/../../../../var/cache'; @mkdir($cachePath, 0777, true); $metadataFactory = new AttributeMetadataFactory(); From 6defd680e69b2526f27d03e6f765e6f424420657 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 24 Feb 2026 14:29:54 +0100 Subject: [PATCH 6/7] Update --- .../Cryptography/CryptographyMiddleware.php | 6 - .../Generated/MiddlewareGenerator.php | 149 +++++++++--------- tests/Benchmark/GeneratedHydratorBench.php | 1 - .../GeneratedMetadataHydratorTest.php | 51 +++--- 4 files changed, 110 insertions(+), 97 deletions(-) diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index 0d0b342c..af40f893 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -42,20 +42,16 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; if (!$info instanceof SensitiveDataInfo) { - dump('a'); - continue; } $value = $data[$propertyMetadata->fieldName] ?? null; if ($value === null) { - dump('b'); continue; } if (!$this->cryptographer->supports($value)) { - dump('c'); continue; } @@ -76,8 +72,6 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St } } - dd('end', $propertyMetadata->extras); - return $stack->next()->hydrate( $metadata, $data, diff --git a/src/Extension/Generated/MiddlewareGenerator.php b/src/Extension/Generated/MiddlewareGenerator.php index 7c12b699..240c3642 100644 --- a/src/Extension/Generated/MiddlewareGenerator.php +++ b/src/Extension/Generated/MiddlewareGenerator.php @@ -13,6 +13,7 @@ use Throwable; use function implode; use function ltrim; +use function Psl\Str\pad_left; use function str_replace; final class MiddlewareGenerator @@ -82,7 +83,9 @@ public function dump(array $classes, string $middlewareFqcn): string $normalizers[] = [ 'class' => $declaringClass, - 'fieldName' => $property->fieldName + 'normalizer' => $property->normalizer::class, + 'fieldName' => $property->fieldName, + 'propertyName' => $property->propertyName, ]; $normalizerMap[$declaringClass][$property->fieldName] = count($normalizers) - 1; } @@ -93,8 +96,8 @@ public function dump(array $classes, string $middlewareFqcn): string $propertiesCode = ''; $setupCode = ''; foreach ($normalizers as $index => $info) { - $propertiesCode .= " public readonly \Patchlevel\Hydrator\Normalizer\Normalizer \$n$index;\n"; - $setupCode .= " \$this->n$index = \$metadataFactory->metadata('{$info['class']}')->propertyForField('{$info['fieldName']}')->normalizer;\n"; + $propertiesCode .= " private readonly \\{$info['normalizer']} \$n$index;\n"; + $setupCode .= " \$this->n$index = \$metadataFactory->metadata(\\{$info['class']}::class)->properties['{$info['propertyName']}']->normalizer;\n"; } // Phase 3: Generate Class Methods @@ -105,8 +108,8 @@ public function dump(array $classes, string $middlewareFqcn): string foreach ($allClasses as $class => $metadata) { $shortName = str_replace('\\', '', $class); - $hydrateCases .= " '$class' => \$this->hydrate$shortName(\$data, \$context, \$stack),\n"; - $extractCases .= " \\$class::class => \$this->extract$shortName(\$object, \$context, \$stack),\n"; + $hydrateCases .= " \\$class::class => \$this->hydrate$shortName(\$data, \$context, \$stack),\n"; + $extractCases .= " \\$class::class => \$this->extract$shortName(\$object, \$context, \$stack),\n"; $methods .= $this->generateClassMethods($metadata, $shortName, $normalizerMap); } @@ -190,90 +193,68 @@ private function doExtract(object \$object, array \$context, Stack \$stack): arr try { return match (\$object::class) { -$extractCases - default => null, +$extractCases default => null, }; } finally { - unset(\$this->callStack[\$objectId]); + \\array_pop(\$this->callStack); } } $methods - private function getMetadataFactory(): MetadataFactory - { - static \$factory = null; - if (\$factory === null) { - \$factory = new \Patchlevel\Hydrator\Metadata\AttributeMetadataFactory(); - } - return \$factory; - } } PHP; } - private function collectDependencies(ClassMetadata $metadata): array + private function generateClassMethods(ClassMetadata $metadata, string $shortName, array $normalizerMap): string { - $normalizers = []; - $nestedMetadata = []; - - foreach ($metadata->properties as $index => $property) { - if (!$property->normalizer) { - continue; - } - - if ($property->normalizer instanceof ObjectNormalizer) { - $nestedMetadata[] = $property->normalizer->getClassName(); - continue; - } + $targetClass = $metadata->className; - if ($property->normalizer instanceof ArrayNormalizer) { - $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); - $inner = $reflection->getValue($property->normalizer); - if ($inner instanceof ObjectNormalizer) { - $nestedMetadata[] = $inner->getClassName(); - continue; - } - } + $constructor = $metadata->reflection->getConstructor(); - $normalizers[] = $index; - } + $befores = []; + $map = []; - return [ - 'normalizers' => $normalizers, - 'nestedMetadata' => $nestedMetadata, - ]; - } + foreach ($constructor->getParameters() as $parameter) { + $tupple = $this->generatePropertyDenormalization($metadata->properties[$parameter->getName()], $normalizerMap); - private function generateClassMethods(ClassMetadata $metadata, string $shortName, array $normalizerMap): string - { - $targetClass = $metadata->className; - $hydrate = ''; + $map[] = $tupple[0]; - foreach ($metadata->properties as $property) { - $hydrate .= $this->generatePropertyDenormalization($property, $normalizerMap); + if ($tupple[1] !== '') { + $befores[] = $tupple[1]; + } } $methods = <<padLeft(implode("\n", $befores), 2)} + return new \\$targetClass( +{$this->padLeft(implode(",\n", $map), 3)} + ); } PHP; - $extractBody = ''; + $befores = []; + $map = []; + foreach ($metadata->properties as $property) { - $extractBody .= $this->generatePropertyNormalization($property, $normalizerMap); + $tupple = $this->generatePropertyNormalization($property, $normalizerMap); + + $map[] = $tupple[0]; + + if ($tupple[1] !== '') { + $befores[] = $tupple[1]; + } } $methods .= <<padLeft(implode("\n", $befores), 2)} + return [ +{$this->padLeft(implode("\n", $map), 3)} + ]; } PHP; @@ -281,23 +262,33 @@ private function extract$shortName(\$object, array \$context, Stack \$stack): ar return $methods; } - private function generatePropertyDenormalization(PropertyMetadata $property, array $normalizerMap): string + /** + * @return array{string, string} + */ + private function generatePropertyDenormalization(PropertyMetadata $property, array $normalizerMap): array { $fieldName = $property->fieldName; $propertyName = $property->propertyName; $class = $property->reflection->getDeclaringClass()->getName(); $globalIndex = $normalizerMap[$class][$fieldName] ?? null; + $before = ''; + if ($property->normalizer !== null) { if ($property->normalizer instanceof ObjectNormalizer) { $nestedClass = $property->normalizer->getClassName(); - $valueCode = "\$this->doHydrate('$nestedClass', \$data['$fieldName'], \$context, \$stack)"; + $valueCode = "\$this->doHydrate(\\$nestedClass::class, \$data['$fieldName'], \$context, \$stack)"; } elseif ($property->normalizer instanceof ArrayNormalizer) { $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); $inner = $reflection->getValue($property->normalizer); if ($inner instanceof ObjectNormalizer) { $nestedClass = $inner->getClassName(); - $valueCode = "array_map(fn(\$item) => \$this->doHydrate('$nestedClass', \$item, \$context, \$stack), \$data['$fieldName'])"; + $before = <<doHydrate(\\$nestedClass::class, \${$propertyName}Item, \$context, \$stack); +} +PHP; + $valueCode = "\$data['$fieldName']"; } else { $valueCode = "\$this->n{$globalIndex}->denormalize(\$data['$fieldName'], \$context)"; } @@ -310,23 +301,23 @@ private function generatePropertyDenormalization(PropertyMetadata $property, arr $valueCode = "\$data['$fieldName']"; } - return <<reflection->getType()?->allowsNull()) { + $valueCode = "\\array_key_exists('$fieldName', \$data) ? $valueCode : null"; } -PHP; + return [$valueCode, $before]; } - private function generatePropertyNormalization(PropertyMetadata $property, array $normalizerMap): string + /** + * @return array{string, string} + */ + private function generatePropertyNormalization(PropertyMetadata $property, array $normalizerMap): array { $fieldName = $property->fieldName; $propertyName = $property->propertyName; $class = $property->reflection->getDeclaringClass()->getName(); $globalIndex = $normalizerMap[$class][$fieldName] ?? null; + $before = ''; if ($property->normalizer !== null) { if ($property->normalizer instanceof ObjectNormalizer) { @@ -335,7 +326,14 @@ private function generatePropertyNormalization(PropertyMetadata $property, array $reflection = new ReflectionProperty($property->normalizer, 'normalizer'); $inner = $reflection->getValue($property->normalizer); if ($inner instanceof ObjectNormalizer) { - $valueCode = "array_map(fn(\$item) => \$this->doExtract(\$item, \$context, \$stack), \$object->$propertyName)"; + $before = <<$propertyName; +foreach (\$$propertyName as &\${$propertyName}Item) { + \${$propertyName}Item = \$this->doExtract(\${$propertyName}Item, \$context, \$stack); +} +PHP; + + $valueCode = "\$$propertyName"; } else { $valueCode = "\$this->n{$globalIndex}->normalize(\$object->$propertyName, \$context)"; } @@ -348,8 +346,17 @@ private function generatePropertyNormalization(PropertyMetadata $property, array $valueCode = "\$object->$propertyName"; } - return " \$data['$fieldName'] = $valueCode;\n"; + return ["'$fieldName' => $valueCode,", $before]; } + private function padLeft(string $multilineString, int $n): string + { + $result = []; + + foreach (explode("\n", $multilineString) as $line) { + $result[] = str_repeat(' ', $n * 4).$line; + } + return implode("\n", $result); + } } diff --git a/tests/Benchmark/GeneratedHydratorBench.php b/tests/Benchmark/GeneratedHydratorBench.php index 7696d511..ca432837 100644 --- a/tests/Benchmark/GeneratedHydratorBench.php +++ b/tests/Benchmark/GeneratedHydratorBench.php @@ -24,7 +24,6 @@ public function __construct() __DIR__ . '/../../var/cache', [ ProfileCreated::class, - ProfileId::class, Skill::class, ] )) diff --git a/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php index 3e267757..d3324091 100644 --- a/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php +++ b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php @@ -22,6 +22,7 @@ use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\Normalizer\InvalidArgument; +use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; @@ -358,15 +359,12 @@ public function testNormalizationFailure(): void public function testDecrypt(): void { - $object = new ProfileCreated( + $object = new SensitiveDataProfileCreated( ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'), ); - $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; - $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; - - $metadataFactory = new AttributeMetadataFactory(); + $encryptedPayload = ['id' => '1', 'email' => 'encrypted']; $cryptographer = $this->createMock(Cryptographer::class); $cryptographer @@ -378,48 +376,63 @@ public function testDecrypt(): void $cryptographer ->expects($this->once()) ->method('decrypt') - ->with($metadataFactory->metadata(ProfileCreated::class), $encryptedPayload) - ->willReturn($payload); + ->with('1', 'encrypted') + ->willReturn('info@patchlevel.de'); $hydrator = (new HydratorBuilder()) - ->useExtension(new CryptographyExtension($cryptographer)) ->useExtension(new GeneratedCoreExtension( __DIR__ . '/../../../../var/cache', [ - ProfileCreated::class, + SensitiveDataProfileCreated::class, ], )) + ->useExtension(new CryptographyExtension($cryptographer)) ->build(); - $return = $hydrator->hydrate(ProfileCreated::class, $encryptedPayload); + $return = $hydrator->hydrate(SensitiveDataProfileCreated::class, $encryptedPayload); - //self::assertEquals($object, $return); + self::assertEquals($object, $return); } public function testEncrypt(): void { - $object = new ProfileCreated( + $object = new SensitiveDataProfileCreated( ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'), ); - $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; - $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; - - $metadataFactory = new AttributeMetadataFactory(); + $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($metadataFactory->metadata(ProfileCreated::class), $payload) - ->willReturn($encryptedPayload); + ->with('1', 'info@patchlevel.de') + ->willReturn([ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'foo', + 'iv' => 'bar', + ]); $hydrator = (new HydratorBuilder()) ->useExtension(new GeneratedCoreExtension( __DIR__ . '/../../../../var/cache', [ - ProfileCreated::class, + SensitiveDataProfileCreated::class, ], )) ->useExtension(new CryptographyExtension($cryptographer)) From 2485c5cf8091efbeeefd942f44874e71399ac6c7 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 24 Feb 2026 14:46:02 +0100 Subject: [PATCH 7/7] Update --- .../Generated/MiddlewareGenerator.php | 53 +++++++++++-------- .../GeneratedMetadataHydratorTest.php | 1 - tests/Unit/Fixture/Circle1Dto.php | 8 ++- tests/Unit/Fixture/Circle2Dto.php | 8 ++- tests/Unit/Fixture/Circle3Dto.php | 8 ++- 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/Extension/Generated/MiddlewareGenerator.php b/src/Extension/Generated/MiddlewareGenerator.php index 240c3642..e1685517 100644 --- a/src/Extension/Generated/MiddlewareGenerator.php +++ b/src/Extension/Generated/MiddlewareGenerator.php @@ -95,9 +95,10 @@ public function dump(array $classes, string $middlewareFqcn): string // Phase 2: Generate Properties and Setup $propertiesCode = ''; $setupCode = ''; + foreach ($normalizers as $index => $info) { - $propertiesCode .= " private readonly \\{$info['normalizer']} \$n$index;\n"; - $setupCode .= " \$this->n$index = \$metadataFactory->metadata(\\{$info['class']}::class)->properties['{$info['propertyName']}']->normalizer;\n"; + $propertiesCode .= "private readonly \\{$info['normalizer']} \$n$index;\n"; + $setupCode .= "\$this->n$index = \$metadataFactory->metadata(\\{$info['class']}::class)->properties['{$info['propertyName']}']->normalizer;\n"; } // Phase 3: Generate Class Methods @@ -108,8 +109,8 @@ public function dump(array $classes, string $middlewareFqcn): string foreach ($allClasses as $class => $metadata) { $shortName = str_replace('\\', '', $class); - $hydrateCases .= " \\$class::class => \$this->hydrate$shortName(\$data, \$context, \$stack),\n"; - $extractCases .= " \\$class::class => \$this->extract$shortName(\$object, \$context, \$stack),\n"; + $hydrateCases .= "\\$class::class => \$this->hydrate$shortName(\$data, \$context, \$stack),\n"; + $extractCases .= "\\$class::class => \$this->extract$shortName(\$object, \$context, \$stack),\n"; $methods .= $this->generateClassMethods($metadata, $shortName, $normalizerMap); } @@ -142,10 +143,11 @@ final class $middlewareClassName implements Middleware { private array \$callStack = []; -$propertiesCode +{$this->padLeft($propertiesCode, 1)} + public function __construct(MetadataFactory \$metadataFactory) { -$setupCode +{$this->padLeft($setupCode, 2)} } public function hydrate(ClassMetadata \$metadata, array \$data, array \$context, Stack \$stack): object @@ -162,7 +164,7 @@ public function hydrate(ClassMetadata \$metadata, array \$data, array \$context, private function doHydrate(string \$class, array \$data, array \$context, Stack \$stack): object|null { return match (\$class) { -$hydrateCases +{$this->padLeft($hydrateCases, 3)} default => null, }; } @@ -193,14 +195,15 @@ private function doExtract(object \$object, array \$context, Stack \$stack): arr try { return match (\$object::class) { -$extractCases default => null, +{$this->padLeft($extractCases, 4)} + default => null, }; } finally { \\array_pop(\$this->callStack); } } -$methods +{$this->padLeft($methods, 1)} } PHP; } @@ -211,6 +214,10 @@ private function generateClassMethods(ClassMetadata $metadata, string $shortName $constructor = $metadata->reflection->getConstructor(); + if ($constructor === null) { + dd($metadata->className); + } + $befores = []; $map = []; @@ -225,13 +232,13 @@ private function generateClassMethods(ClassMetadata $metadata, string $shortName } $methods = <<padLeft(implode("\n", $befores), 2)} - return new \\$targetClass( -{$this->padLeft(implode(",\n", $map), 3)} - ); - } +private function hydrate$shortName(array \$data, array \$context, Stack \$stack): \\$targetClass +{ +{$this->padLeft(implode("\n", $befores), 1)} + return new \\$targetClass( +{$this->padLeft(implode(",\n", $map), 2)} + ); +} PHP; @@ -249,13 +256,13 @@ private function hydrate$shortName(array \$data, array \$context, Stack \$stack) } $methods .= <<padLeft(implode("\n", $befores), 2)} - return [ -{$this->padLeft(implode("\n", $map), 3)} - ]; - } +private function extract$shortName(\$object, array \$context, Stack \$stack): array +{ +{$this->padLeft(implode("\n", $befores), 1)} + return [ +{$this->padLeft(implode("\n", $map), 2)} + ]; +} PHP; diff --git a/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php index d3324091..107ca8c4 100644 --- a/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php +++ b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php @@ -15,7 +15,6 @@ use Patchlevel\Hydrator\Extension\Generated\GeneratedCoreExtension; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorBuilder; -use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\Middleware\Middleware; diff --git a/tests/Unit/Fixture/Circle1Dto.php b/tests/Unit/Fixture/Circle1Dto.php index 799ed794..5b0a7986 100644 --- a/tests/Unit/Fixture/Circle1Dto.php +++ b/tests/Unit/Fixture/Circle1Dto.php @@ -8,6 +8,10 @@ final class Circle1Dto { - #[ObjectNormalizer(Circle2Dto::class)] - public object|null $to = null; + public function __construct( + #[ObjectNormalizer(Circle2Dto::class)] + public object|null $to = null + ) + { + } } diff --git a/tests/Unit/Fixture/Circle2Dto.php b/tests/Unit/Fixture/Circle2Dto.php index aa87e893..e536ff58 100644 --- a/tests/Unit/Fixture/Circle2Dto.php +++ b/tests/Unit/Fixture/Circle2Dto.php @@ -8,6 +8,10 @@ final class Circle2Dto { - #[ObjectNormalizer(Circle3Dto::class)] - public object|null $to = null; + public function __construct( + #[ObjectNormalizer(Circle3Dto::class)] + public object|null $to = null + ) + { + } } diff --git a/tests/Unit/Fixture/Circle3Dto.php b/tests/Unit/Fixture/Circle3Dto.php index c0b4f6de..a3240240 100644 --- a/tests/Unit/Fixture/Circle3Dto.php +++ b/tests/Unit/Fixture/Circle3Dto.php @@ -8,6 +8,10 @@ final class Circle3Dto { - #[ObjectNormalizer(Circle1Dto::class)] - public object|null $to = null; + public function __construct( + #[ObjectNormalizer(Circle1Dto::class)] + public object|null $to = null + ) + { + } }