From a0f89e514a1ed7b07bc1f934f1ac6921837626e8 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Fri, 22 Dec 2023 20:27:12 +0100 Subject: [PATCH] feat: add generic type to normalizer and use external formatter --- README.md | 4 ++ src/Library/Container.php | 20 +++++-- src/MapperBuilder.php | 13 +++-- src/Normalizer/Format.php | 9 +++- src/Normalizer/FormatNormalizer.php | 30 +++++++++++ src/Normalizer/Formatter/ArrayFormatter.php | 34 ++++++++++++ .../Formatter/ArrayFormatterFactory.php | 18 +++++++ src/Normalizer/Formatter/Formatter.php | 23 ++++++++ src/Normalizer/Formatter/FormatterFactory.php | 18 +++++++ src/Normalizer/Normalizer.php | 15 +++++- src/Normalizer/RecursiveNormalizer.php | 53 +++++++++++-------- .../Transformer/ValueTransformersHandler.php | 1 + .../inferring-normalizer-types.php | 32 +++++++++++ .../phpstan-with-extension.neon.dist | 1 + .../phpstan-without-extension.neon.dist | 1 + tests/StaticAnalysis/psalm-with-plugin.xml | 1 + tests/StaticAnalysis/psalm-without-plugin.xml | 1 + 17 files changed, 241 insertions(+), 33 deletions(-) create mode 100644 src/Normalizer/FormatNormalizer.php create mode 100644 src/Normalizer/Formatter/ArrayFormatter.php create mode 100644 src/Normalizer/Formatter/ArrayFormatterFactory.php create mode 100644 src/Normalizer/Formatter/Formatter.php create mode 100644 src/Normalizer/Formatter/FormatterFactory.php create mode 100644 tests/StaticAnalysis/inferring-normalizer-types.php diff --git a/README.md b/README.md index f7bb9ab8..61af9502 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ providing precise and human-readable error messages. The mapper can handle native PHP types as well as other advanced types supported by [PHPStan] and [Psalm] like shaped arrays, generics, integer ranges and more. +The library also provides a normalization mechanism that can help transform any +input into a data format (JSON, CSV, …), while preserving the original +structure. + ## Installation ```bash diff --git a/src/Library/Container.php b/src/Library/Container.php index 42707add..46349bc2 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -47,6 +47,9 @@ use CuyZ\Valinor\Mapper\TreeMapper; use CuyZ\Valinor\Mapper\TypeArgumentsMapper; use CuyZ\Valinor\Mapper\TypeTreeMapper; +use CuyZ\Valinor\Normalizer\FormatNormalizer; +use CuyZ\Valinor\Normalizer\Formatter\Formatter; +use CuyZ\Valinor\Normalizer\Formatter\FormatterFactory; use CuyZ\Valinor\Normalizer\Normalizer; use CuyZ\Valinor\Normalizer\RecursiveNormalizer; use CuyZ\Valinor\Normalizer\Transformer\KeyTransformersHandler; @@ -181,7 +184,7 @@ public function __construct(Settings $settings) return new CacheObjectBuilderFactory($factory, $cache); }, - Normalizer::class => fn () => new RecursiveNormalizer( + RecursiveNormalizer::class => fn () => new RecursiveNormalizer( $this->get(ClassDefinitionRepository::class), new ValueTransformersHandler( $this->get(FunctionDefinitionRepository::class), @@ -198,7 +201,7 @@ public function __construct(Settings $settings) $this->get(TypeParserFactory::class), $this->get(AttributesRepository::class), ), - $this->get(CacheInterface::class) + $this->get(CacheInterface::class), ), FunctionDefinitionRepository::class => fn () => new CacheFunctionDefinitionRepository( @@ -245,9 +248,18 @@ public function argumentsMapper(): ArgumentsMapper return $this->get(ArgumentsMapper::class); } - public function normalizer(): Normalizer + /** + * @template T + * + * @param FormatterFactory> $formatterFactory + * @return Normalizer + */ + public function normalizer(FormatterFactory $formatterFactory): Normalizer { - return $this->get(Normalizer::class); + return new FormatNormalizer( + $formatterFactory, + $this->get(RecursiveNormalizer::class), + ); } public function cacheWarmupService(): RecursiveCacheWarmupService diff --git a/src/MapperBuilder.php b/src/MapperBuilder.php index 91ba8500..f2355740 100644 --- a/src/MapperBuilder.php +++ b/src/MapperBuilder.php @@ -9,7 +9,8 @@ use CuyZ\Valinor\Mapper\ArgumentsMapper; use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage; use CuyZ\Valinor\Mapper\TreeMapper; -use CuyZ\Valinor\Normalizer\Format; +use CuyZ\Valinor\Normalizer\Formatter\Formatter; +use CuyZ\Valinor\Normalizer\Formatter\FormatterFactory; use CuyZ\Valinor\Normalizer\Normalizer; use Psr\SimpleCache\CacheInterface; use Throwable; @@ -550,9 +551,15 @@ public function argumentsMapper(): ArgumentsMapper return $this->container()->argumentsMapper(); } - public function normalizer(Format $format): Normalizer + /** + * @template T + * + * @param FormatterFactory> $formatterFactory + * @return Normalizer + */ + public function normalizer(FormatterFactory $formatterFactory): Normalizer { - return $this->container()->normalizer(); + return $this->container()->normalizer($formatterFactory); } public function __clone() diff --git a/src/Normalizer/Format.php b/src/Normalizer/Format.php index b7c9920d..62abe220 100644 --- a/src/Normalizer/Format.php +++ b/src/Normalizer/Format.php @@ -4,11 +4,16 @@ namespace CuyZ\Valinor\Normalizer; +use CuyZ\Valinor\Normalizer\Formatter\ArrayFormatterFactory; + /** @api */ final class Format { - public static function array(): self + /** + * @todo doc + */ + public static function array(): ArrayFormatterFactory { - return new self(); + return new ArrayFormatterFactory(); } } diff --git a/src/Normalizer/FormatNormalizer.php b/src/Normalizer/FormatNormalizer.php new file mode 100644 index 00000000..47d1f463 --- /dev/null +++ b/src/Normalizer/FormatNormalizer.php @@ -0,0 +1,30 @@ + + */ +final class FormatNormalizer implements Normalizer +{ + /** + * @param FormatterFactory> $formatterFactory + */ + public function __construct( + private FormatterFactory $formatterFactory, + private RecursiveNormalizer $recursiveNormalizer, + ) {} + + public function normalize(mixed $value): mixed + { + return $this->recursiveNormalizer->normalize($value, $this->formatterFactory->new()); + } +} diff --git a/src/Normalizer/Formatter/ArrayFormatter.php b/src/Normalizer/Formatter/ArrayFormatter.php new file mode 100644 index 00000000..0d9509a7 --- /dev/null +++ b/src/Normalizer/Formatter/ArrayFormatter.php @@ -0,0 +1,34 @@ +|scalar|null> + */ +final class ArrayFormatter implements Formatter +{ + /** @var iterable|scalar|null */ + private mixed $value; + + public function push(mixed $value): void + { + $this->value = $value; + } + + public function value(): mixed + { + if (is_iterable($this->value) && ! is_array($this->value)) { + $this->value = iterator_to_array($this->value); + } + + return $this->value; + } +} diff --git a/src/Normalizer/Formatter/ArrayFormatterFactory.php b/src/Normalizer/Formatter/ArrayFormatterFactory.php new file mode 100644 index 00000000..f738e781 --- /dev/null +++ b/src/Normalizer/Formatter/ArrayFormatterFactory.php @@ -0,0 +1,18 @@ + + */ +final class ArrayFormatterFactory implements FormatterFactory +{ + public function new(): ArrayFormatter + { + return new ArrayFormatter(); + } +} diff --git a/src/Normalizer/Formatter/Formatter.php b/src/Normalizer/Formatter/Formatter.php new file mode 100644 index 00000000..ef148a69 --- /dev/null +++ b/src/Normalizer/Formatter/Formatter.php @@ -0,0 +1,23 @@ +|scalar|null $value + */ + public function push(mixed $value): void; + + /** + * @return T + */ + public function value(): mixed; +} diff --git a/src/Normalizer/Formatter/FormatterFactory.php b/src/Normalizer/Formatter/FormatterFactory.php new file mode 100644 index 00000000..df7f6805 --- /dev/null +++ b/src/Normalizer/Formatter/FormatterFactory.php @@ -0,0 +1,18 @@ + $formatter + */ + public function normalize(mixed $value, Formatter $formatter): mixed { - return $this->doNormalize($value, new WeakMap()); // @phpstan-ignore-line + $this->doNormalize($value, $formatter, new WeakMap()); // @phpstan-ignore-line + + return $formatter->value(); } /** + * @param Formatter $formatter * @param WeakMap $references * @param list $attributes + * @return iterable|scalar|null */ - private function doNormalize(mixed $value, WeakMap $references, array $attributes = []): mixed + private function doNormalize(mixed $value, Formatter $formatter, WeakMap $references, array $attributes = []): mixed { if (is_object($value)) { if (isset($references[$value])) { @@ -55,14 +63,9 @@ private function doNormalize(mixed $value, WeakMap $references, array $attribute } if ($this->transformers === [] && $this->transformerAttributes === []) { - $value = $this->defaultTransformer($value, $references); + $value = $this->defaultTransformer($value, $formatter, $references); - if (is_array($value)) { - $value = array_map( - fn (mixed $value) => $this->doNormalize($value, $references), - $value, - ); - } + $formatter->push($value); return $value; } @@ -73,18 +76,24 @@ private function doNormalize(mixed $value, WeakMap $references, array $attribute $attributes = [...$attributes, ...$classAttributes]; } - return $this->valueTransformers->transform( + $value = $this->valueTransformers->transform( $value, $attributes, $this->transformers, - fn (mixed $value) => $this->defaultTransformer($value, $references), + fn (mixed $value) => $this->defaultTransformer($value, $formatter, $references), ); + + $formatter->push($value); + + return $value; } /** + * @param Formatter $formatter * @param WeakMap $references + * @return iterable|scalar|null */ - private function defaultTransformer(mixed $value, WeakMap $references): mixed + private function defaultTransformer(mixed $value, Formatter $formatter, WeakMap $references): mixed { if ($value === null) { return null; @@ -105,7 +114,7 @@ private function defaultTransformer(mixed $value, WeakMap $references): mixed if ($value::class === stdClass::class) { return array_map( - fn (mixed $value) => $this->doNormalize($value, $references), + fn (mixed $value) => $this->doNormalize($value, $formatter, $references), (array)$value ); } @@ -120,18 +129,18 @@ private function defaultTransformer(mixed $value, WeakMap $references): mixed $key = $this->keyTransformers->transformKey($key, $attributes); - $transformed[$key] = $this->doNormalize($subValue, $references, $attributes); + $transformed[$key] = $this->doNormalize($subValue, $formatter, $references, $attributes); } return $transformed; } if (is_iterable($value)) { - if (! is_array($value)) { - $value = iterator_to_array($value); - } - - return $value; + return (function () use ($value, $formatter, $references) { + foreach ($value as $key => $item) { + yield $key => $this->doNormalize($item, $formatter, $references); + } + })(); } throw new TypeUnhandledByNormalizer($value); diff --git a/src/Normalizer/Transformer/ValueTransformersHandler.php b/src/Normalizer/Transformer/ValueTransformersHandler.php index 6ef38734..ad5b629f 100644 --- a/src/Normalizer/Transformer/ValueTransformersHandler.php +++ b/src/Normalizer/Transformer/ValueTransformersHandler.php @@ -28,6 +28,7 @@ public function __construct( /** * @param array $attributes * @param list $transformers + * @return array|scalar|null */ public function transform(mixed $value, array $attributes, array $transformers, callable $defaultTransformer): mixed { diff --git a/tests/StaticAnalysis/inferring-normalizer-types.php b/tests/StaticAnalysis/inferring-normalizer-types.php new file mode 100644 index 00000000..a911228b --- /dev/null +++ b/tests/StaticAnalysis/inferring-normalizer-types.php @@ -0,0 +1,32 @@ +normalizer(Format::array())->normalize(['foo' => 'bar']); + + /** @psalm-check-type $result = array|bool|float|int|string|null */ + assertType('array|bool|float|int|string|null', $result); +} + +function normalize_with_covariant_template_is_inferred_properly(): void +{ + $normalizer = (new MapperBuilder())->normalizer(Format::array()); + + normalizeWithMixedType($normalizer); +} + +/** + * @param Normalizer $normalizer + */ +function normalizeWithMixedType(Normalizer $normalizer): mixed +{ + return $normalizer->normalize('foo'); +} diff --git a/tests/StaticAnalysis/phpstan-with-extension.neon.dist b/tests/StaticAnalysis/phpstan-with-extension.neon.dist index 3ed74176..0be9c9f2 100644 --- a/tests/StaticAnalysis/phpstan-with-extension.neon.dist +++ b/tests/StaticAnalysis/phpstan-with-extension.neon.dist @@ -4,6 +4,7 @@ includes: parameters: level: max paths: + - inferring-normalizer-types.php - inferring-types-from-annotations.php - inferring-types-with-plugin.php tmpDir: ../../var/cache/phpstan diff --git a/tests/StaticAnalysis/phpstan-without-extension.neon.dist b/tests/StaticAnalysis/phpstan-without-extension.neon.dist index 5495007e..1e45b2ca 100644 --- a/tests/StaticAnalysis/phpstan-without-extension.neon.dist +++ b/tests/StaticAnalysis/phpstan-without-extension.neon.dist @@ -1,5 +1,6 @@ parameters: level: max paths: + - inferring-normalizer-types.php - inferring-types-from-annotations.php tmpDir: ../../var/cache/phpstan diff --git a/tests/StaticAnalysis/psalm-with-plugin.xml b/tests/StaticAnalysis/psalm-with-plugin.xml index f834a618..e069ebb0 100644 --- a/tests/StaticAnalysis/psalm-with-plugin.xml +++ b/tests/StaticAnalysis/psalm-with-plugin.xml @@ -7,6 +7,7 @@ xsi:schemaLocation="https://getpsalm.org/schema/config ../../vendor/vimeo/psalm/config.xsd" > + diff --git a/tests/StaticAnalysis/psalm-without-plugin.xml b/tests/StaticAnalysis/psalm-without-plugin.xml index 31c184c1..1e7ab091 100644 --- a/tests/StaticAnalysis/psalm-without-plugin.xml +++ b/tests/StaticAnalysis/psalm-without-plugin.xml @@ -7,6 +7,7 @@ xsi:schemaLocation="https://getpsalm.org/schema/config ../../vendor/vimeo/psalm/config.xsd" > +