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/Extension/Generated/GeneratedCoreExtension.php b/src/Extension/Generated/GeneratedCoreExtension.php new file mode 100644 index 00000000..bb3114f9 --- /dev/null +++ b/src/Extension/Generated/GeneratedCoreExtension.php @@ -0,0 +1,51 @@ +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; + + $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); + } +} diff --git a/src/Extension/Generated/MiddlewareGenerator.php b/src/Extension/Generated/MiddlewareGenerator.php new file mode 100644 index 00000000..e1685517 --- /dev/null +++ b/src/Extension/Generated/MiddlewareGenerator.php @@ -0,0 +1,369 @@ + $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 + } + } + + // Map normalizers by the declaring class of the property to support inheritance + $declaringClass = $property->reflection->getDeclaringClass()->getName(); + + $normalizers[] = [ + 'class' => $declaringClass, + 'normalizer' => $property->normalizer::class, + 'fieldName' => $property->fieldName, + 'propertyName' => $property->propertyName, + ]; + $normalizerMap[$declaringClass][$property->fieldName] = count($normalizers) - 1; + } + } + } + + // 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"; + } + + // Phase 3: Generate Class Methods + $methods = ''; + $hydrateCases = ''; + $extractCases = ''; + + 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"; + + $methods .= $this->generateClassMethods($metadata, $shortName, $normalizerMap); + } + + return <<padLeft($propertiesCode, 1)} + + public function __construct(MetadataFactory \$metadataFactory) + { +{$this->padLeft($setupCode, 2)} + } + + 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) { +{$this->padLeft($hydrateCases, 3)} + 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) { +{$this->padLeft($extractCases, 4)} + default => null, + }; + } finally { + \\array_pop(\$this->callStack); + } + } + +{$this->padLeft($methods, 1)} +} +PHP; + } + + private function generateClassMethods(ClassMetadata $metadata, string $shortName, array $normalizerMap): string + { + $targetClass = $metadata->className; + + $constructor = $metadata->reflection->getConstructor(); + + if ($constructor === null) { + dd($metadata->className); + } + + $befores = []; + $map = []; + + foreach ($constructor->getParameters() as $parameter) { + $tupple = $this->generatePropertyDenormalization($metadata->properties[$parameter->getName()], $normalizerMap); + + $map[] = $tupple[0]; + + if ($tupple[1] !== '') { + $befores[] = $tupple[1]; + } + } + + $methods = <<padLeft(implode("\n", $befores), 1)} + return new \\$targetClass( +{$this->padLeft(implode(",\n", $map), 2)} + ); +} + +PHP; + + $befores = []; + $map = []; + + foreach ($metadata->properties as $property) { + $tupple = $this->generatePropertyNormalization($property, $normalizerMap); + + $map[] = $tupple[0]; + + if ($tupple[1] !== '') { + $befores[] = $tupple[1]; + } + } + + $methods .= <<padLeft(implode("\n", $befores), 1)} + return [ +{$this->padLeft(implode("\n", $map), 2)} + ]; +} + +PHP; + + return $methods; + } + + /** + * @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::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(); + $before = <<doHydrate(\\$nestedClass::class, \${$propertyName}Item, \$context, \$stack); +} +PHP; + $valueCode = "\$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']"; + } + + if ($property->reflection->getType()?->allowsNull()) { + $valueCode = "\\array_key_exists('$fieldName', \$data) ? $valueCode : null"; + } + + return [$valueCode, $before]; + } + + /** + * @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) { + $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) { + $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)"; + } + } elseif ($globalIndex !== null) { + $valueCode = "\$this->n{$globalIndex}->normalize(\$object->$propertyName, \$context)"; + } else { + $valueCode = "\$object->$propertyName"; + } + } else { + $valueCode = "\$object->$propertyName"; + } + + 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/src/HydratorBuilder.php b/src/HydratorBuilder.php index 31a2061b..773f9148 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; @@ -84,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); @@ -105,4 +101,17 @@ public function build(): Hydrator $this->defaultLazy, ); } + + public function getMetadataFactory(): MetadataFactory + { + krsort($this->guessers); + krsort($this->metadataEnrichers); + + 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 new file mode 100644 index 00000000..ca432837 --- /dev/null +++ b/tests/Benchmark/GeneratedHydratorBench.php @@ -0,0 +1,139 @@ +hydrator = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../var/cache', + [ + ProfileCreated::class, + Skill::class, + ] + )) + ->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(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)] + 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); + } + } + + #[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/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php new file mode 100644 index 00000000..107ca8c4 --- /dev/null +++ b/tests/Unit/Extension/Generated/GeneratedMetadataHydratorTest.php @@ -0,0 +1,618 @@ +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, + WrongNormalizer::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(TypeError::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(InvalidArgument::class); + + $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => 123, 'email' => 123], + ); + } + + public function testNormalizationFailure(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->hydrator->extract( + new WrongNormalizer(true), + ); + } + + 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 = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../../../var/cache', + [ + SensitiveDataProfileCreated::class, + ], + )) + ->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 = (new HydratorBuilder()) + ->useExtension(new GeneratedCoreExtension( + __DIR__ . '/../../../../var/cache', + [ + SensitiveDataProfileCreated::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/Extension/Generated/GeneratedTransformerMiddlewareTest.php b/tests/Unit/Extension/Generated/GeneratedTransformerMiddlewareTest.php new file mode 100644 index 00000000..51c553f0 --- /dev/null +++ b/tests/Unit/Extension/Generated/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); + } +} 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 + ) + { + } }