From fd2c0f8379e101427e4ac32bf1ea9b054a01d41f Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Tue, 29 Aug 2023 21:27:08 +0200 Subject: [PATCH] feat: introduce normalizer --- src/Definition/FunctionsContainer.php | 9 +- .../{Container.php => MapperContainer.php} | 81 ++--- .../{Settings.php => MapperSettings.php} | 2 +- src/Library/NormalizerContainer.php | 55 +++ src/Library/NormalizerSettings.php | 35 ++ src/Library/SharedContainer.php | 96 +++++ .../Factory/DateTimeObjectBuilderFactory.php | 4 +- src/MapperBuilder.php | 14 +- src/Normalizer/FunctionsCheckerNormalizer.php | 45 +++ src/Normalizer/Normalizer.php | 14 + src/Normalizer/RecursiveNormalizer.php | 111 ++++++ src/NormalizerBuilder.php | 51 +++ src/Type/Parser/Lexer/NativeLexer.php | 2 + src/Type/Parser/Lexer/Token/CallableToken.php | 26 ++ src/Type/Types/CallableType.php | 32 ++ .../Serializing/NormalizerTest.php | 337 ++++++++++++++++++ 16 files changed, 843 insertions(+), 71 deletions(-) rename src/Library/{Container.php => MapperContainer.php} (72%) rename src/Library/{Settings.php => MapperSettings.php} (98%) create mode 100644 src/Library/NormalizerContainer.php create mode 100644 src/Library/NormalizerSettings.php create mode 100644 src/Library/SharedContainer.php create mode 100644 src/Normalizer/FunctionsCheckerNormalizer.php create mode 100644 src/Normalizer/Normalizer.php create mode 100644 src/Normalizer/RecursiveNormalizer.php create mode 100644 src/NormalizerBuilder.php create mode 100644 src/Type/Parser/Lexer/Token/CallableToken.php create mode 100644 src/Type/Types/CallableType.php create mode 100644 tests/Integration/Serializing/NormalizerTest.php diff --git a/src/Definition/FunctionsContainer.php b/src/Definition/FunctionsContainer.php index d6269f5b..0381780f 100644 --- a/src/Definition/FunctionsContainer.php +++ b/src/Definition/FunctionsContainer.php @@ -4,18 +4,20 @@ namespace CuyZ\Valinor\Definition; +use Countable; use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; use IteratorAggregate; use Traversable; use function array_keys; +use function count; /** * @internal * * @implements IteratorAggregate */ -final class FunctionsContainer implements IteratorAggregate +final class FunctionsContainer implements IteratorAggregate, Countable { /** @var array */ private array $functions = []; @@ -50,4 +52,9 @@ private function function(string|int $key): FunctionObject $this->callables[$key] ); } + + public function count(): int + { + return count($this->callables); + } } diff --git a/src/Library/Container.php b/src/Library/MapperContainer.php similarity index 72% rename from src/Library/Container.php rename to src/Library/MapperContainer.php index eb19d8bb..4aab7631 100644 --- a/src/Library/Container.php +++ b/src/Library/MapperContainer.php @@ -4,19 +4,11 @@ namespace CuyZ\Valinor\Library; -use CuyZ\Valinor\Cache\ChainCache; -use CuyZ\Valinor\Cache\KeySanitizerCache; use CuyZ\Valinor\Cache\RuntimeCache; use CuyZ\Valinor\Cache\Warmup\RecursiveCacheWarmupService; use CuyZ\Valinor\Definition\FunctionsContainer; -use CuyZ\Valinor\Definition\Repository\AttributesRepository; -use CuyZ\Valinor\Definition\Repository\Cache\CacheClassDefinitionRepository; -use CuyZ\Valinor\Definition\Repository\Cache\CacheFunctionDefinitionRepository; use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; -use CuyZ\Valinor\Definition\Repository\Reflection\NativeAttributesRepository; -use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionClassDefinitionRepository; -use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionFunctionDefinitionRepository; use CuyZ\Valinor\Mapper\ArgumentsMapper; use CuyZ\Valinor\Mapper\Object\Factory\CacheObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\CollisionObjectBuilderFactory; @@ -30,14 +22,14 @@ use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\CasterNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\CasterProxyNodeBuilder; -use CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ErrorCatcherNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\InterfaceNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder; +use CuyZ\Valinor\Mapper\Tree\Builder\NativeClassNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\NodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ObjectImplementations; -use CuyZ\Valinor\Mapper\Tree\Builder\NativeClassNodeBuilder; +use CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder; @@ -48,8 +40,6 @@ use CuyZ\Valinor\Mapper\TypeArgumentsMapper; use CuyZ\Valinor\Mapper\TypeTreeMapper; use CuyZ\Valinor\Type\ClassType; -use CuyZ\Valinor\Type\Parser\Factory\LexingTypeParserFactory; -use CuyZ\Valinor\Type\Parser\Factory\TypeParserFactory; use CuyZ\Valinor\Type\Parser\TypeParser; use CuyZ\Valinor\Type\ScalarType; use CuyZ\Valinor\Type\Types\ArrayType; @@ -64,24 +54,27 @@ use function count; /** @internal */ -final class Container +final class MapperContainer { + private SharedContainer $shared; + /** @var array */ private array $services = []; /** @var array */ private array $factories; - public function __construct(Settings $settings) + public function __construct(MapperSettings $settings) { + $this->shared = SharedContainer::new($settings->cache ?? null); $this->factories = [ TreeMapper::class => fn () => new TypeTreeMapper( - $this->get(TypeParser::class), + $this->shared->get(TypeParser::class), $this->get(RootNodeBuilder::class) ), ArgumentsMapper::class => fn () => new TypeArgumentsMapper( - $this->get(FunctionDefinitionRepository::class), + $this->shared->get(FunctionDefinitionRepository::class), $this->get(RootNodeBuilder::class) ), @@ -102,7 +95,7 @@ public function __construct(Settings $settings) ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys), ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting), ClassType::class => new NativeClassNodeBuilder( - $this->get(ClassDefinitionRepository::class), + $this->shared->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), $this->get(ObjectNodeBuilder::class), $settings->enableFlexibleCasting, @@ -111,7 +104,7 @@ public function __construct(Settings $settings) $builder = new UnionNodeBuilder( $builder, - $this->get(ClassDefinitionRepository::class), + $this->shared->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), $this->get(ObjectNodeBuilder::class), $settings->enableFlexibleCasting @@ -120,7 +113,7 @@ public function __construct(Settings $settings) $builder = new InterfaceNodeBuilder( $builder, $this->get(ObjectImplementations::class), - $this->get(ClassDefinitionRepository::class), + $this->shared->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), $this->get(ObjectNodeBuilder::class), $settings->enableFlexibleCasting @@ -133,7 +126,7 @@ public function __construct(Settings $settings) $builder = new ValueAlteringNodeBuilder( $builder, new FunctionsContainer( - $this->get(FunctionDefinitionRepository::class), + $this->shared->get(FunctionDefinitionRepository::class), $settings->valueModifier ) ); @@ -148,22 +141,22 @@ public function __construct(Settings $settings) ObjectImplementations::class => fn () => new ObjectImplementations( new FunctionsContainer( - $this->get(FunctionDefinitionRepository::class), + $this->shared->get(FunctionDefinitionRepository::class), $settings->inferredMapping ), - $this->get(TypeParser::class), + $this->shared->get(TypeParser::class), ), ObjectBuilderFactory::class => function () use ($settings) { $constructors = new FunctionsContainer( - $this->get(FunctionDefinitionRepository::class), + $this->shared->get(FunctionDefinitionRepository::class), $settings->customConstructors ); $factory = new ReflectionObjectBuilderFactory(); $factory = new ConstructorObjectBuilderFactory($factory, $settings->nativeConstructors, $constructors); - $factory = new DateTimeZoneObjectBuilderFactory($factory, $this->get(FunctionDefinitionRepository::class)); - $factory = new DateTimeObjectBuilderFactory($factory, $settings->supportedDateFormats, $this->get(FunctionDefinitionRepository::class)); + $factory = new DateTimeZoneObjectBuilderFactory($factory, $this->shared->get(FunctionDefinitionRepository::class)); + $factory = new DateTimeObjectBuilderFactory($factory, $settings->supportedDateFormats, $this->shared->get(FunctionDefinitionRepository::class)); $factory = new CollisionObjectBuilderFactory($factory); if (! $settings->allowPermissiveTypes) { @@ -176,45 +169,13 @@ public function __construct(Settings $settings) return new CacheObjectBuilderFactory($factory, $cache); }, - ClassDefinitionRepository::class => fn () => new CacheClassDefinitionRepository( - new ReflectionClassDefinitionRepository( - $this->get(TypeParserFactory::class), - $this->get(AttributesRepository::class), - ), - $this->get(CacheInterface::class) - ), - - FunctionDefinitionRepository::class => fn () => new CacheFunctionDefinitionRepository( - new ReflectionFunctionDefinitionRepository( - $this->get(TypeParserFactory::class), - $this->get(AttributesRepository::class), - ), - $this->get(CacheInterface::class) - ), - - AttributesRepository::class => fn () => new NativeAttributesRepository(), - - TypeParserFactory::class => fn () => new LexingTypeParserFactory(), - - TypeParser::class => fn () => $this->get(TypeParserFactory::class)->get(), - RecursiveCacheWarmupService::class => fn () => new RecursiveCacheWarmupService( - $this->get(TypeParser::class), - $this->get(CacheInterface::class), + $this->shared->get(TypeParser::class), + $this->shared->get(CacheInterface::class), $this->get(ObjectImplementations::class), - $this->get(ClassDefinitionRepository::class), + $this->shared->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class) ), - - CacheInterface::class => function () use ($settings) { - $cache = new RuntimeCache(); - - if (isset($settings->cache)) { - $cache = new ChainCache($cache, new KeySanitizerCache($settings->cache)); - } - - return $cache; - }, ]; } diff --git a/src/Library/Settings.php b/src/Library/MapperSettings.php similarity index 98% rename from src/Library/Settings.php rename to src/Library/MapperSettings.php index 54d2617d..0be67053 100644 --- a/src/Library/Settings.php +++ b/src/Library/MapperSettings.php @@ -11,7 +11,7 @@ use Throwable; /** @internal */ -final class Settings +final class MapperSettings { /** @var non-empty-array */ public const DEFAULT_SUPPORTED_DATETIME_FORMATS = [ diff --git a/src/Library/NormalizerContainer.php b/src/Library/NormalizerContainer.php new file mode 100644 index 00000000..6e517026 --- /dev/null +++ b/src/Library/NormalizerContainer.php @@ -0,0 +1,55 @@ + */ + private array $services = []; + + /** @var array */ + private array $factories; + + public function __construct(NormalizerSettings $settings) + { + $this->shared = SharedContainer::new($settings->cache); + $this->factories = [ + Normalizer::class => function () use ($settings) { + $functions = new FunctionsContainer( + $this->shared->get(FunctionDefinitionRepository::class), + $settings->sortedHandlers() + ); + + $normalizer = new RecursiveNormalizer($functions); + + return new FunctionsCheckerNormalizer($normalizer, $functions); + }, + ]; + } + + public function normalizer(): Normalizer + { + return $this->get(Normalizer::class); + } + + /** + * @template T of object + * @param class-string $name + * @return T + */ + private function get(string $name): object + { + return $this->services[$name] ??= call_user_func($this->factories[$name]); // @phpstan-ignore-line + } +} diff --git a/src/Library/NormalizerSettings.php b/src/Library/NormalizerSettings.php new file mode 100644 index 00000000..78b32c44 --- /dev/null +++ b/src/Library/NormalizerSettings.php @@ -0,0 +1,35 @@ +|null */ + public ?CacheInterface $cache = null; + + /** @var array> */ + public array $handlers = []; + + /** + * @return array + */ + public function sortedHandlers(): array + { + krsort($this->handlers); + + $callables = []; + + foreach ($this->handlers as $list) { + $callables = [...$callables, ...$list]; + } + + return $callables; + } +} diff --git a/src/Library/SharedContainer.php b/src/Library/SharedContainer.php new file mode 100644 index 00000000..61ddd82a --- /dev/null +++ b/src/Library/SharedContainer.php @@ -0,0 +1,96 @@ + */ + private static array $instances = []; + + /** @var array */ + private array $services = []; + + /** @var array */ + private array $factories; + + /** + * @param CacheInterface|null $cache + */ + private function __construct(?CacheInterface $cache) + { + $this->factories = [ + ClassDefinitionRepository::class => fn () => new CacheClassDefinitionRepository( + new ReflectionClassDefinitionRepository( + $this->get(TypeParserFactory::class), + $this->get(AttributesRepository::class), + ), + $this->get(CacheInterface::class) + ), + + FunctionDefinitionRepository::class => fn () => new CacheFunctionDefinitionRepository( + new ReflectionFunctionDefinitionRepository( + $this->get(TypeParserFactory::class), + $this->get(AttributesRepository::class), + ), + $this->get(CacheInterface::class) + ), + + AttributesRepository::class => fn () => new NativeAttributesRepository(), + + TypeParserFactory::class => fn () => new LexingTypeParserFactory(), + + TypeParser::class => fn () => $this->get(TypeParserFactory::class)->get(), + + CacheInterface::class => function () use ($cache) { + $cacheWrapper = new RuntimeCache(); + + if ($cache) { + $cacheWrapper = new ChainCache($cacheWrapper, new KeySanitizerCache($cache)); + } + + return $cacheWrapper; + }, + ]; + } + + /** + * @param CacheInterface|null $cache + */ + public static function new(?CacheInterface $cache): self + { + $key = $cache ? spl_object_hash($cache) : 'default'; + + return self::$instances[$key] ??= new self($cache); + } + + /** + * @template T of object + * @param class-string $name + * @return T + */ + public function get(string $name): object + { + return $this->services[$name] ??= call_user_func($this->factories[$name]); // @phpstan-ignore-line + } +} diff --git a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php index f1c80bc2..23b5378f 100644 --- a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php @@ -7,7 +7,7 @@ use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Definition\FunctionObject; use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; -use CuyZ\Valinor\Library\Settings; +use CuyZ\Valinor\Library\MapperSettings; use CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor; use CuyZ\Valinor\Mapper\Object\FunctionObjectBuilder; use CuyZ\Valinor\Mapper\Object\NativeConstructorObjectBuilder; @@ -44,7 +44,7 @@ public function for(ClassDefinition $class): array $buildersWithOneArgument = array_filter($builders, fn (ObjectBuilder $builder) => count($builder->describeArguments()) === 1); - if (count($buildersWithOneArgument) === 0 || $this->supportedDateFormats !== Settings::DEFAULT_SUPPORTED_DATETIME_FORMATS) { + if (count($buildersWithOneArgument) === 0 || $this->supportedDateFormats !== MapperSettings::DEFAULT_SUPPORTED_DATETIME_FORMATS) { $builders[] = $this->internalDateTimeBuilder($class->type()); } diff --git a/src/MapperBuilder.php b/src/MapperBuilder.php index 3cce949e..7f8cade8 100644 --- a/src/MapperBuilder.php +++ b/src/MapperBuilder.php @@ -4,8 +4,8 @@ namespace CuyZ\Valinor; -use CuyZ\Valinor\Library\Container; -use CuyZ\Valinor\Library\Settings; +use CuyZ\Valinor\Library\MapperContainer; +use CuyZ\Valinor\Library\MapperSettings; use CuyZ\Valinor\Mapper\ArgumentsMapper; use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage; use CuyZ\Valinor\Mapper\TreeMapper; @@ -18,13 +18,13 @@ /** @api */ final class MapperBuilder { - private Settings $settings; + private MapperSettings $settings; - private Container $container; + private MapperContainer $container; public function __construct() { - $this->settings = new Settings(); + $this->settings = new MapperSettings(); } /** @@ -482,8 +482,8 @@ public function __clone() unset($this->container); } - private function container(): Container + private function container(): MapperContainer { - return ($this->container ??= new Container($this->settings)); + return ($this->container ??= new MapperContainer($this->settings)); } } diff --git a/src/Normalizer/FunctionsCheckerNormalizer.php b/src/Normalizer/FunctionsCheckerNormalizer.php new file mode 100644 index 00000000..1235e56c --- /dev/null +++ b/src/Normalizer/FunctionsCheckerNormalizer.php @@ -0,0 +1,45 @@ +checkWasDone) { + $this->checkWasDone = true; + + foreach ($this->handlers as $function) { + $parameters = $function->definition()->parameters(); + + if ($parameters->count() === 0) { + throw new RuntimeException('@todo'); // @todo + } + + if ($parameters->count() > 2) { + throw new RuntimeException('@todo'); // @todo + } + + if ($parameters->count() > 1 && ! $parameters->at(1)->type() instanceof CallableType) { + throw new RuntimeException('@todo'); // @todo + } + } + } + + return $this->delegate->normalize($value); + } +} diff --git a/src/Normalizer/Normalizer.php b/src/Normalizer/Normalizer.php new file mode 100644 index 00000000..f0794866 --- /dev/null +++ b/src/Normalizer/Normalizer.php @@ -0,0 +1,14 @@ +normalize($this->normalizeObject($value)); + } + + throw new RuntimeException('@todo unhandled type'); // @todo + } + + private function normalizeObject(object $object): mixed + { + if ($this->handlers->count() === 0) { + return ($this->defaultObjectNormalizer($object))(); + } + + $type = new NativeClassType($object::class); + + $handlers = array_filter( + [...$this->handlers], + fn (FunctionObject $function) => $type->matches($function->definition()->parameters()->at(0)->type()) + ); + + return $this->nextNormalizer($handlers, $object)(); + } + + /** + * @param array $handlers + */ + private function nextNormalizer(array $handlers, object $object): callable + { + if (count($handlers) === 0) { + return $this->defaultObjectNormalizer($object); + } + + $handler = array_shift($handlers); + $arguments = [ + $object, + fn () => $this->nextNormalizer($handlers, $object)(), + ]; + + return fn () => ($handler->callback())(...$arguments); + } + + private function defaultObjectNormalizer(object $object): callable + { + if ($object instanceof UnitEnum) { + return fn () => $object instanceof BackedEnum ? $object->value : $object->name; + } + + if ($object instanceof DateTimeInterface) { + return fn () => $object->format('Y-m-d\\TH:i:sP'); // RFC 3339 + } + + if ($object::class === stdClass::class) { + return fn () => (array)$object; + } + + if (method_exists($object, '__serialize')) { + return fn () => $object->__serialize(); + } + + return fn () => (fn () => get_object_vars($this))->call($object); + } +} diff --git a/src/NormalizerBuilder.php b/src/NormalizerBuilder.php new file mode 100644 index 00000000..ae948bf9 --- /dev/null +++ b/src/NormalizerBuilder.php @@ -0,0 +1,51 @@ +settings = new NormalizerSettings(); + } + + /** + * @todo doc + * + * @param callable(object, callable(): mixed): mixed $callback + */ + public function addHandler(callable $callback, int $priority = 0): self + { + $clone = clone $this; + $clone->settings->handlers[$priority][] = $callback; + + return $clone; + } + + public function normalizer(): Normalizer + { + return $this->container()->normalizer(); + } + + public function __clone() + { + $this->settings = clone $this->settings; + unset($this->container); + } + + private function container(): NormalizerContainer + { + return ($this->container ??= new NormalizerContainer($this->settings)); + } +} diff --git a/src/Type/Parser/Lexer/NativeLexer.php b/src/Type/Parser/Lexer/NativeLexer.php index f319f356..68b1ad04 100644 --- a/src/Type/Parser/Lexer/NativeLexer.php +++ b/src/Type/Parser/Lexer/NativeLexer.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Type\Parser\Lexer; use CuyZ\Valinor\Type\Parser\Lexer\Token\ArrayToken; +use CuyZ\Valinor\Type\Parser\Lexer\Token\CallableToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\ClassNameToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\ClassStringToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\ClosingBracketToken; @@ -66,6 +67,7 @@ public function tokenize(string $symbol): Token 'non-empty-list' => ListToken::nonEmptyList(), 'iterable' => IterableToken::get(), 'class-string' => ClassStringToken::get(), + 'callable' => CallableToken::get(), default => null, }; diff --git a/src/Type/Parser/Lexer/Token/CallableToken.php b/src/Type/Parser/Lexer/Token/CallableToken.php new file mode 100644 index 00000000..ccdd9dfe --- /dev/null +++ b/src/Type/Parser/Lexer/Token/CallableToken.php @@ -0,0 +1,26 @@ + $callbacks + */ + public function test_normalize_basic_values_yields_expected_output(mixed $input, mixed $expected, array $callbacks = []): void + { + $builder = new NormalizerBuilder(); + + foreach ($callbacks as $priority => $callback) { + $builder = $builder->addHandler($callback, $priority); + } + + $result = $builder->normalizer()->normalize($input); + + self::assertSame($expected, $result); + } + + public function normalize_basic_values_yields_expected_output_data_provider(): iterable + { + yield 'null' => [ + 'input' => null, + 'expected' => null, + ]; + + yield 'string' => [ + 'input' => 'foo bar', + 'expected' => 'foo bar', + ]; + + yield 'integer' => [ + 'input' => 42, + 'expected' => 42, + ]; + + yield 'float' => [ + 'input' => 1337.404, + 'expected' => 1337.404, + ]; + + yield 'boolean' => [ + 'input' => true, + 'expected' => true, + ]; + + yield 'array of scalar' => [ + 'input' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + 'expected' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + ]; + + yield 'iterable of scalar' => [ + 'input' => (function (): iterable { + yield 'string' => 'foo'; + yield 'integer' => 42; + yield 'float' => 1337.404; + yield 'boolean' => true; + })(), + 'expected' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + ]; + + yield 'stdClass' => [ + 'input' => (function () { + $object = new stdClass(); + $object->foo = 'foo'; + $object->bar = 'bar'; + + return $object; + })(), + 'expected' => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + ]; + + yield 'array of object' => [ + 'input' => [ + 'foo' => new BasicObject('foo'), + 'bar' => new BasicObject('bar'), + ], + 'expected' => [ + 'foo' => ['value' => 'foo'], + 'bar' => ['value' => 'bar'], + ], + ]; + + yield 'unit enum' => [ + 'input' => PureEnum::FOO, + 'expected' => 'FOO', + ]; + + yield 'backed string enum' => [ + 'input' => BackedStringEnum::FOO, + 'expected' => 'foo', + ]; + + yield 'backed integer enum' => [ + 'input' => BackedIntegerEnum::FOO, + 'expected' => 42, + ]; + + yield 'class with public properties' => [ + 'input' => new class () { + public string $string = 'foo'; + public int $integer = 42; + public float $float = 1337.404; + public bool $boolean = true; + }, + 'output' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + ]; + + yield 'class with protected properties' => [ + 'input' => new class () { + protected string $string = 'foo'; + protected int $integer = 42; + protected float $float = 1337.404; + protected bool $boolean = true; + }, + 'output' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + ]; + + yield 'class with private properties' => [ + 'input' => new class () { + private string $string = 'foo'; // @phpstan-ignore-line + private int $integer = 42; // @phpstan-ignore-line + private float $float = 1337.404; // @phpstan-ignore-line + private bool $boolean = true; // @phpstan-ignore-line + }, + 'output' => [ + 'string' => 'foo', + 'integer' => 42, + 'float' => 1337.404, + 'boolean' => true, + ], + ]; + + yield 'class with child properties' => [ + 'input' => new SomeChildClass(), + 'output' => [ + 'stringFromParentClass' => 'foo', + 'stringFromChildClass' => 'bar', + ], + ]; + + yield 'class with serialize method' => [ + 'input' => new SomeClassWithSerializeMethod(), + 'output' => [ + 'some_string' => 'foo', + 'some_integer' => 42, + ], + ]; + + yield 'date with default normalizer' => [ + 'input' => new DateTimeImmutable('1971-11-08'), + 'expected' => '1971-11-08T00:00:00+00:00', + ]; + + yield 'date with custom normalizer' => [ + 'input' => new DateTimeImmutable('1971-11-08'), + 'expected' => '1971-11-08', + 'callbacks' => [ + fn (DateTimeInterface $object) => $object->format('Y-m-d') + ], + ]; + + yield 'object with custom normalizer' => [ + 'input' => new BasicObject('foo'), + 'expected' => 'foo!', + 'callbacks' => [ + fn (BasicObject $object) => $object->value . '!', + ], + ]; + + yield 'object with custom undefined object normalizer' => [ + 'input' => new BasicObject('foo'), + 'expected' => 'foo!', + 'callbacks' => [ + fn (object $object) => $object->value . '!', // @phpstan-ignore-line + ], + ]; + + yield 'object with custom union object normalizer' => [ + 'input' => new BasicObject('foo'), + 'expected' => 'foo!', + 'callbacks' => [ + fn (stdClass|BasicObject $object) => $object->value . '!', + ], + ]; + + yield 'object with custom normalizer calling next' => [ + 'input' => new BasicObject('foo'), + 'expected' => [ + 'value' => 'foo', + 'bar' => 'bar', + ], + 'callbacks' => [ + function (object $object, callable $next) { + $result = $next(); + $result['bar'] = 'bar'; + + return $result; + }, + ], + ]; + + yield 'object with several prioritized normalizers' => [ + 'input' => new BasicObject('foo'), + 'expected' => 'foo*!?', + 'callbacks' => [ + -20 => fn (BasicObject $object, callable $next) => $object->value, + -15 => fn (stdClass $object) => 'bar', // Should be ignored by the normalizer + -10 => fn (BasicObject $object, callable $next) => $next() . '*', + 0 => fn (BasicObject $object, callable $next) => $next() . '!', + 10 => fn (stdClass $object) => 'baz', // Should be ignored by the normalizer + 20 => fn (BasicObject $object, callable $next) => $next() . '?', + ], + ]; + } + + public function test_no_priority_given_is_set_to_0(): void + { + $result = (new NormalizerBuilder()) + ->addHandler(fn (object $object) => 'foo', -2) + ->addHandler(fn (object $object, callable $next) => $next() . '!', -1) + ->addHandler(fn (object $object, callable $next) => $next() . '?') + ->addHandler(fn (object $object, callable $next) => $next() . '*', 1) + ->normalizer() + ->normalize(new stdClass()); + + self::assertSame('foo!?*', $result); + } + + public function test_no_param_in_callable_throws_exception(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('@todo'); + + (new NormalizerBuilder()) + ->addHandler(fn () => 42) + ->normalizer() + ->normalize(new stdClass()); + } + + public function test_too_many_params_in_callable_throws_exception(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('@todo'); + + (new NormalizerBuilder()) + // @phpstan-ignore-next-line + ->addHandler(fn (stdClass $object, callable $next, int $unexpectedParameter) => 42) + ->normalizer() + ->normalize(new stdClass()); + } + + public function test_second_param_is_not_callable_throws_exception(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('@todo'); + + (new NormalizerBuilder()) + // @phpstan-ignore-next-line + ->addHandler(fn (stdClass $object, int $unexpectedParameterType) => 42) + ->normalizer() + ->normalize(new stdClass()); + } +} + +final class BasicObject +{ + public function __construct(public string $value) {} +} + +class SomeParentClass +{ + public string $stringFromParentClass = 'foo'; +} + +final class SomeChildClass extends SomeParentClass +{ + public string $stringFromChildClass = 'bar'; +} + +final class SomeClassWithSerializeMethod +{ + public string $string = 'foo'; + public int $integer = 42; + + public function __serialize(): array + { + return [ + 'some_string' => $this->string, + 'some_integer' => $this->integer, + ]; + } +}