From b1017ce55729f0698c7629d57a3d3a30c0f9bff3 Mon Sep 17 00:00:00 2001 From: Robert Chettleburgh Date: Fri, 19 Jul 2024 15:52:43 +0100 Subject: [PATCH 01/12] feat: add support for `value-of` type This type can be used as follows: ```php enum Suit: string { case Hearts = 'H'; case Diamonds = 'D'; case Clubs = 'C'; case Spades = 'S'; } $suit = (new \CuyZ\Valinor\MapperBuilder()) ->mapper() ->map(Suit::class, 'D'); // $suit === Suit::Diamonds ``` --- docs/pages/usage/type-reference.md | 3 + .../Magic/ValueOfClosingBracketMissing.php | 21 +++++ .../Magic/ValueOfIncorrectSubType.php | 21 +++++ .../Magic/ValueOfOpeningBracketMissing.php | 20 ++++ src/Type/Parser/Lexer/NativeLexer.php | 2 + src/Type/Parser/Lexer/Token/ValueOfToken.php | 65 +++++++++++++ .../Type/Parser/LexingParserTest.php | 53 +++++++++++ .../Mapping/EnumValueOfMappingTest.php | 94 +++++++++++++++++++ 8 files changed, 279 insertions(+) create mode 100644 src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php create mode 100644 src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php create mode 100644 src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php create mode 100644 src/Type/Parser/Lexer/Token/ValueOfToken.php create mode 100644 tests/Integration/Mapping/EnumValueOfMappingTest.php diff --git a/docs/pages/usage/type-reference.md b/docs/pages/usage/type-reference.md index 2fb54fe0..b91a8f26 100644 --- a/docs/pages/usage/type-reference.md +++ b/docs/pages/usage/type-reference.md @@ -50,6 +50,9 @@ final class SomeClass /** @var class-string */ private string $classStringOfAnInterface, + + /** @var value-of */ + private string $valueOfEnum, ) {} } ``` diff --git a/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php b/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php new file mode 100644 index 00000000..f9d48e23 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php @@ -0,0 +1,21 @@ +toString()}>`.", + 1717702289 + ); + } +} diff --git a/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php b/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php new file mode 100644 index 00000000..dc272871 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php @@ -0,0 +1,21 @@ +toString()}>`, it should be a `BackedEnum`.", + 1717702683 + ); + } +} diff --git a/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php b/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php new file mode 100644 index 00000000..28d1cf36 --- /dev/null +++ b/src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php @@ -0,0 +1,20 @@ +`.", + 1717702268 + ); + } +} diff --git a/src/Type/Parser/Lexer/NativeLexer.php b/src/Type/Parser/Lexer/NativeLexer.php index 6f6172d3..8801cc7d 100644 --- a/src/Type/Parser/Lexer/NativeLexer.php +++ b/src/Type/Parser/Lexer/NativeLexer.php @@ -20,6 +20,7 @@ use CuyZ\Valinor\Type\Parser\Lexer\Token\IterableToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\ListToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\NullableToken; +use CuyZ\Valinor\Type\Parser\Lexer\Token\ValueOfToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningBracketToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningCurlyBracketToken; use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningSquareBracketToken; @@ -78,6 +79,7 @@ public function tokenize(string $symbol): Token 'iterable' => IterableToken::get(), 'class-string' => ClassStringToken::get(), 'callable' => CallableToken::get(), + 'value-of' => ValueOfToken::get(), 'null' => new TypeToken(NullType::get()), 'true' => new TypeToken(BooleanValueType::true()), diff --git a/src/Type/Parser/Lexer/Token/ValueOfToken.php b/src/Type/Parser/Lexer/Token/ValueOfToken.php new file mode 100644 index 00000000..5d350615 --- /dev/null +++ b/src/Type/Parser/Lexer/Token/ValueOfToken.php @@ -0,0 +1,65 @@ +done() || !$stream->forward() instanceof OpeningBracketToken) { + throw new ValueOfOpeningBracketMissing(); + } + + $subType = $stream->read(); + + if ($stream->done() || !$stream->forward() instanceof ClosingBracketToken) { + throw new ValueOfClosingBracketMissing($subType); + } + + if (! $subType instanceof EnumType) { + throw new ValueOfIncorrectSubType($subType); + } + + if (! is_a($subType->className(), BackedEnum::class, true)) { + throw new ValueOfIncorrectSubType($subType); + } + + $cases = array_map( + // @phpstan-ignore-next-line / We know it's a BackedEnum + fn (BackedEnum $case) => ValueTypeFactory::from($case->value), + array_values($subType->cases()), + ); + + if (count($cases) > 1) { + return new UnionType(...$cases); + } + + return $cases[0]; + } + + public function symbol(): string + { + return 'value-of'; + } +} diff --git a/tests/Functional/Type/Parser/LexingParserTest.php b/tests/Functional/Type/Parser/LexingParserTest.php index db84ab9e..88d9903e 100644 --- a/tests/Functional/Type/Parser/LexingParserTest.php +++ b/tests/Functional/Type/Parser/LexingParserTest.php @@ -38,6 +38,9 @@ use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\SimpleArrayClosingBracketMissing; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfClosingBracketMissing; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfIncorrectSubType; +use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfOpeningBracketMissing; use CuyZ\Valinor\Type\Parser\Exception\MissingClosingQuoteChar; use CuyZ\Valinor\Type\Parser\Exception\RightIntersectionTypeMissing; use CuyZ\Valinor\Type\Parser\Exception\RightUnionTypeMissing; @@ -1064,6 +1067,16 @@ public static function parse_valid_types_returns_valid_result_data_provider(): i 'transformed' => PureEnum::class . '::*A*', 'type' => EnumType::class, ]; + yield 'value-of' => [ + 'raw' => "value-of<" . BackedStringEnum::class . ">", + 'transformed' => "'foo'|'bar'|'baz'", + 'type' => UnionType::class, + ]; + yield 'value-of' => [ + 'raw' => "value-of<" . BackedIntegerEnum::class . ">", + 'transformed' => "42|404|1337", + 'type' => UnionType::class, + ]; } public function test_multiple_union_types_are_parsed(): void @@ -1603,6 +1616,46 @@ public function test_duplicated_template_name_throws_exception(): void $this->parser->parse("$className"); } + + public function test_value_of_enum_missing_opening_bracket_throws_exception(): void + { + $this->expectException(ValueOfOpeningBracketMissing::class); + $this->expectExceptionCode(1717702268); + $this->expectExceptionMessage('The opening bracket is missing for `value-of<...>`.'); + + $this->parser->parse('value-of'); + } + + public function test_value_of_enum_missing_closing_bracket_throws_exception(): void + { + $enumName = BackedStringEnum::class; + + $this->expectException(ValueOfClosingBracketMissing::class); + $this->expectExceptionCode(1717702289); + $this->expectExceptionMessage("The closing bracket is missing for `value-of<$enumName>`."); + + $this->parser->parse("value-of<$enumName"); + } + + public function test_value_of_incorrect_type_throws_exception(): void + { + $this->expectException(ValueOfIncorrectSubType::class); + $this->expectExceptionCode(1717702683); + $this->expectExceptionMessage('Invalid subtype `value-of`, it should be a `BackedEnum`.'); + + $this->parser->parse('value-of'); + } + + public function test_value_of_unit_enum_type_throws_exception(): void + { + $enumName = PureEnum::class; + + $this->expectException(ValueOfIncorrectSubType::class); + $this->expectExceptionCode(1717702683); + $this->expectExceptionMessage("Invalid subtype `value-of<$enumName>`, it should be a `BackedEnum`."); + + $this->parser->parse("value-of<$enumName>"); + } } /** diff --git a/tests/Integration/Mapping/EnumValueOfMappingTest.php b/tests/Integration/Mapping/EnumValueOfMappingTest.php new file mode 100644 index 00000000..fd3875ec --- /dev/null +++ b/tests/Integration/Mapping/EnumValueOfMappingTest.php @@ -0,0 +1,94 @@ +mapperBuilder() + ->mapper() + ->map('value-of<' . SomeStringEnumForValueOf::class . '>', SomeStringEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeStringEnumForValueOf::FOO->value, $result); + } + + public function test_can_map_value_of_enum_with_one_case(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('value-of<' . SomeStringEnumForValueOfWithOneCase::class . '>', SomeStringEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeStringEnumForValueOf::FOO->value, $result); // @phpstan-ignore-line + } + + public function test_can_map_value_of_integer_enum(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('value-of<' . SomeIntegerEnumForValueOf::class . '>', SomeIntegerEnumForValueOf::FOO->value); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(SomeIntegerEnumForValueOf::FOO->value, $result); + } + + public function test_array_keys_using_value_of(): void + { + try { + $result = $this->mapperBuilder() + ->mapper() + ->map('array, string>', [SomeStringEnumForValueOf::FOO->value => 'foo']); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([SomeStringEnumForValueOf::FOO->value => 'foo'], $result); + } + + public function test_array_keys_using_value_of_error(): void + { + try { + $this->mapperBuilder() + ->mapper() + ->map('array, string>', ['oof' => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['oof']->messages()[0]; + self::assertSame("Key 'oof' does not match type `'FOO'|'FOZ'|'BAZ'`.", (string)$error); + } + } +} + +enum SomeStringEnumForValueOf: string +{ + case FOO = 'FOO'; + case FOZ = 'FOZ'; + case BAZ = 'BAZ'; +} + +enum SomeStringEnumForValueOfWithOneCase: string +{ + case FOO = 'FOO'; +} + +enum SomeIntegerEnumForValueOf: int +{ + case FOO = 42; + case FOZ = 404; + case BAZ = 1337; +} From 69e0e3a5f1de6a5eedcfa4125d8639be91f0c303 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 19 Jul 2024 16:56:36 +0200 Subject: [PATCH 02/12] fix: allow docblock for transformer callable type --- .../Transformer/ValueTransformersHandler.php | 4 ++-- tests/Integration/Normalizer/NormalizerTest.php | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Normalizer/Transformer/ValueTransformersHandler.php b/src/Normalizer/Transformer/ValueTransformersHandler.php index d5cdf90a..ef42b56c 100644 --- a/src/Normalizer/Transformer/ValueTransformersHandler.php +++ b/src/Normalizer/Transformer/ValueTransformersHandler.php @@ -118,8 +118,8 @@ private function checkTransformer(MethodDefinition|FunctionDefinition $method): throw new TransformerHasTooManyParameters($method); } - if ($parameters->count() > 1 && ! $parameters->at(1)->type instanceof CallableType) { - throw new TransformerHasInvalidCallableParameter($method, $parameters->at(1)->type); + if ($parameters->count() > 1 && ! $parameters->at(1)->nativeType instanceof CallableType) { + throw new TransformerHasInvalidCallableParameter($method, $parameters->at(1)->nativeType); } } } diff --git a/tests/Integration/Normalizer/NormalizerTest.php b/tests/Integration/Normalizer/NormalizerTest.php index 7f3183fc..15a0ad55 100644 --- a/tests/Integration/Normalizer/NormalizerTest.php +++ b/tests/Integration/Normalizer/NormalizerTest.php @@ -979,6 +979,23 @@ public function test_second_param_in_transformer_is_not_callable_throws_exceptio ->normalize(new stdClass()); } + public function test_second_param_in_transformer_is_callable_with_phpdoc_spec_does_not_throw(): void + { + $class = new class () { + /** @param callable():mixed $next */ + public function __invoke(stdClass $object, callable $next): int + { + return 42; + } + }; + $this->mapperBuilder() + ->registerTransformer($class) + ->normalizer(Format::array()) + ->normalize(new stdClass()); + + self::addToAssertionCount(1); + } + public function test_no_param_in_transformer_attribute_throws_exception(): void { $this->expectException(TransformerHasNoParameter::class); From ba22b5233e80f0ffbbe9591a5099b9dd62715eb8 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Mon, 22 Jul 2024 22:30:28 +0200 Subject: [PATCH 03/12] fix: properly format empty object with JSON normalizer Objects that yield no value or have no property will now properly be formatted into `{}` instead of `[]`. --- src/Normalizer/ArrayNormalizer.php | 3 +++ src/Normalizer/Formatter/JsonFormatter.php | 3 +++ src/Normalizer/Transformer/EmptyObject.php | 13 ++++++++++ .../Transformer/RecursiveTransformer.php | 17 ++++++++++-- .../Integration/Normalizer/NormalizerTest.php | 26 +++++++++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/Normalizer/Transformer/EmptyObject.php diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index c76a000f..b351f72d 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Normalizer; +use CuyZ\Valinor\Normalizer\Transformer\EmptyObject; use CuyZ\Valinor\Normalizer\Transformer\RecursiveTransformer; use function array_map; @@ -38,6 +39,8 @@ private function normalizeIterator(mixed $value): mixed } $value = array_map($this->normalizeIterator(...), $value); + } elseif ($value instanceof EmptyObject) { + return []; } return $value; diff --git a/src/Normalizer/Formatter/JsonFormatter.php b/src/Normalizer/Formatter/JsonFormatter.php index feec8356..6e46220d 100644 --- a/src/Normalizer/Formatter/JsonFormatter.php +++ b/src/Normalizer/Formatter/JsonFormatter.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Normalizer\Formatter; use CuyZ\Valinor\Normalizer\Formatter\Exception\CannotFormatInvalidTypeToJson; +use CuyZ\Valinor\Normalizer\Transformer\EmptyObject; use Generator; use function array_is_list; @@ -41,6 +42,8 @@ public function format(mixed $value): void * tools understand that JSON_THROW_ON_ERROR is always set. */ $this->write(json_encode($value, $this->jsonEncodingOptions)); + } elseif ($value instanceof EmptyObject) { + $this->write('{}'); } elseif (is_iterable($value)) { // Note: when a generator is formatted, it is considered as a list // if its first key is 0. This is done early because the first JSON diff --git a/src/Normalizer/Transformer/EmptyObject.php b/src/Normalizer/Transformer/EmptyObject.php new file mode 100644 index 00000000..7bd4e45b --- /dev/null +++ b/src/Normalizer/Transformer/EmptyObject.php @@ -0,0 +1,13 @@ + $item) { yield $key => $this->doTransform($item, $references); } })(); + + if (! $result->valid()) { + return EmptyObject::get(); + } + + return $result; } if (is_object($value) && ! $value instanceof Closure) { @@ -126,9 +133,15 @@ private function defaultTransformer(mixed $value, WeakMap $references): mixed } if ($value::class === stdClass::class) { + $result = (array)$value; + + if ($result === []) { + return EmptyObject::get(); + } + return array_map( fn (mixed $value) => $this->doTransform($value, $references), - (array)$value + $result ); } diff --git a/tests/Integration/Normalizer/NormalizerTest.php b/tests/Integration/Normalizer/NormalizerTest.php index 15a0ad55..80bbb59e 100644 --- a/tests/Integration/Normalizer/NormalizerTest.php +++ b/tests/Integration/Normalizer/NormalizerTest.php @@ -695,6 +695,32 @@ public function __construct( 'transformerAttributes' => [], 'jsonEncodingOptions' => JSON_HEX_AMP, ]; + + yield 'stdClass with no property' => [ + 'input' => new stdClass(), + 'expected array' => [], + 'expected_json' => '{}', + ]; + + yield 'ArrayObject with no property' => [ + 'input' => new ArrayObject(), + 'expected array' => [], + 'expected_json' => '{}', + ]; + + yield 'iterable class with no property' => [ + 'input' => new class () implements IteratorAggregate { + public function getIterator(): Traversable + { + // @phpstan-ignore-next-line / Empty array is here on purpose + foreach ([] as $value) { + yield $value; + } + } + }, + 'expected array' => [], + 'expected_json' => '{}', + ]; } public function test_generator_of_scalar_yields_expected_array(): void From 84b1ffbc8190a709d752a9882f71f6f419ad0434 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Fri, 16 Aug 2024 11:01:13 +0200 Subject: [PATCH 04/12] misc: move setting values in shell This, for instance, allows to more easily check for unexpected keys in the source. --- src/Library/Container.php | 21 ++++----- src/Mapper/Object/ArgumentsValues.php | 33 +++++-------- src/Mapper/Tree/Builder/ArrayNodeBuilder.php | 4 +- .../Builder/FilteredObjectNodeBuilder.php | 15 +----- .../Tree/Builder/InterfaceNodeBuilder.php | 6 +-- src/Mapper/Tree/Builder/ListNodeBuilder.php | 6 +-- src/Mapper/Tree/Builder/ObjectNodeBuilder.php | 3 +- src/Mapper/Tree/Builder/ScalarNodeBuilder.php | 4 +- .../Tree/Builder/ShapedArrayNodeBuilder.php | 12 +---- src/Mapper/Tree/Builder/StrictNodeBuilder.php | 6 +-- src/Mapper/Tree/Builder/TreeNode.php | 20 ++++++-- .../Exception/UnexpectedArrayKeysForClass.php | 46 ------------------- ...rayKeys.php => UnexpectedKeysInSource.php} | 2 +- src/Mapper/Tree/Shell.php | 36 +++++++++++++-- src/Mapper/TypeArgumentsMapper.php | 17 +++---- src/Mapper/TypeTreeMapper.php | 6 ++- tests/Fake/Mapper/FakeShell.php | 5 +- .../Mapping/InterfaceInferringMappingTest.php | 2 +- .../Object/ObjectValuesMappingTest.php | 2 +- .../Tree/Builder/ArrayNodeBuilderTest.php | 10 +++- .../Tree/Builder/ListNodeBuilderTest.php | 10 +++- .../Tree/Builder/ScalarNodeBuilderTest.php | 2 +- .../Builder/ShapedArrayNodeBuilderTest.php | 4 +- .../Unit/Mapper/Tree/Builder/TreeNodeTest.php | 3 +- tests/Unit/Mapper/Tree/ShellTest.php | 13 +++--- tests/Unit/Mapper/TypeTreeMapperTest.php | 2 + 26 files changed, 128 insertions(+), 162 deletions(-) delete mode 100644 src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php rename src/Mapper/Tree/Exception/{UnexpectedShapedArrayKeys.php => UnexpectedKeysInSource.php} (93%) diff --git a/src/Library/Container.php b/src/Library/Container.php index 523dab68..f453fc43 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -86,12 +86,14 @@ public function __construct(Settings $settings) $this->factories = [ TreeMapper::class => fn () => new TypeTreeMapper( $this->get(TypeParser::class), - $this->get(RootNodeBuilder::class) + $this->get(RootNodeBuilder::class), + $settings, ), ArgumentsMapper::class => fn () => new TypeArgumentsMapper( $this->get(FunctionDefinitionRepository::class), - $this->get(RootNodeBuilder::class) + $this->get(RootNodeBuilder::class), + $settings, ), RootNodeBuilder::class => fn () => new RootNodeBuilder( @@ -99,8 +101,8 @@ public function __construct(Settings $settings) ), NodeBuilder::class => function () use ($settings) { - $listNodeBuilder = new ListNodeBuilder($settings->enableFlexibleCasting); - $arrayNodeBuilder = new ArrayNodeBuilder($settings->enableFlexibleCasting); + $listNodeBuilder = new ListNodeBuilder(); + $arrayNodeBuilder = new ArrayNodeBuilder(); $builder = new CasterNodeBuilder([ ListType::class => $listNodeBuilder, @@ -108,14 +110,13 @@ public function __construct(Settings $settings) ArrayType::class => $arrayNodeBuilder, NonEmptyArrayType::class => $arrayNodeBuilder, IterableType::class => $arrayNodeBuilder, - ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys), - ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting), + ShapedArrayType::class => new ShapedArrayNodeBuilder(), + ScalarType::class => new ScalarNodeBuilder(), NullType::class => new NullNodeBuilder(), ObjectType::class => new ObjectNodeBuilder( $this->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), $this->get(FilteredObjectNodeBuilder::class), - $settings->enableFlexibleCasting, ), ]); @@ -131,8 +132,6 @@ public function __construct(Settings $settings) $this->get(FunctionDefinitionRepository::class), $settings->customConstructors ), - $settings->enableFlexibleCasting, - $settings->allowSuperfluousKeys, ); $builder = new CasterProxyNodeBuilder($builder); @@ -148,12 +147,12 @@ public function __construct(Settings $settings) ); } - $builder = new StrictNodeBuilder($builder, $settings->allowPermissiveTypes, $settings->enableFlexibleCasting); + $builder = new StrictNodeBuilder($builder); return new ErrorCatcherNodeBuilder($builder, $settings->exceptionFilter); }, - FilteredObjectNodeBuilder::class => fn () => new FilteredObjectNodeBuilder($settings->allowSuperfluousKeys), + FilteredObjectNodeBuilder::class => fn () => new FilteredObjectNodeBuilder(), ObjectImplementations::class => fn () => new ObjectImplementations( new FunctionsContainer( diff --git a/src/Mapper/Object/ArgumentsValues.php b/src/Mapper/Object/ArgumentsValues.php index f2d7aee4..407112d9 100644 --- a/src/Mapper/Object/ArgumentsValues.php +++ b/src/Mapper/Object/ArgumentsValues.php @@ -5,12 +5,12 @@ namespace CuyZ\Valinor\Mapper\Object; use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; +use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\CompositeTraversableType; use CuyZ\Valinor\Type\Types\ArrayKeyType; use IteratorAggregate; use Traversable; -use function array_filter; use function array_key_exists; use function count; use function is_array; @@ -36,22 +36,22 @@ private function __construct(Arguments $arguments) $this->arguments = $arguments; } - public static function forInterface(Arguments $arguments, mixed $value, bool $allowSuperfluousKeys): self + public static function forInterface(Arguments $arguments, Shell $shell): self { $self = new self($arguments); $self->forInterface = true; if (count($arguments) > 0) { - $self = $self->transform($value, $allowSuperfluousKeys); + $self = $self->transform($shell); } return $self; } - public static function forClass(Arguments $arguments, mixed $value, bool $allowSuperfluousKeys): self + public static function forClass(Arguments $arguments, Shell $shell): self { $self = new self($arguments); - $self = $self->transform($value, $allowSuperfluousKeys); + $self = $self->transform($shell); return $self; } @@ -66,33 +66,22 @@ public function getValue(string $name): mixed return $this->value[$name]; } - /** - * @return array - */ - public function superfluousKeys(): array - { - return array_filter( - array_keys($this->value), - fn ($key) => ! $this->arguments->has((string)$key) - ); - } - public function hadSingleArgument(): bool { return $this->hadSingleArgument; } - private function transform(mixed $value, bool $allowSuperfluousKeys): self + private function transform(Shell $shell): self { $clone = clone $this; - $transformedValue = $this->transformValueForSingleArgument($value, $allowSuperfluousKeys); + $transformedValue = $this->transformValueForSingleArgument($shell); if (! is_array($transformedValue)) { throw new InvalidSource($transformedValue, $this->arguments); } - if ($transformedValue !== $value) { + if ($transformedValue !== $shell->value()) { $clone->hadSingleArgument = true; } @@ -109,8 +98,10 @@ private function transform(mixed $value, bool $allowSuperfluousKeys): self return $clone; } - private function transformValueForSingleArgument(mixed $value, bool $allowSuperfluousKeys): mixed + private function transformValueForSingleArgument(Shell $shell): mixed { + $value = $shell->value(); + if (count($this->arguments) !== 1) { return $value; } @@ -122,7 +113,7 @@ private function transformValueForSingleArgument(mixed $value, bool $allowSuperf && $type->keyType() !== ArrayKeyType::integer(); if (is_array($value) && array_key_exists($name, $value)) { - if ($this->forInterface || ! $isTraversableAndAllowsStringKeys || $allowSuperfluousKeys || count($value) === 1) { + if ($this->forInterface || ! $isTraversableAndAllowsStringKeys || $shell->allowSuperfluousKeys() || count($value) === 1) { return $value; } } diff --git a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php index 2ab1340a..c75e67e9 100644 --- a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php @@ -18,8 +18,6 @@ /** @internal */ final class ArrayNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -27,7 +25,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ArrayType || $type instanceof NonEmptyArrayType || $type instanceof IterableType); - if ($this->enableFlexibleCasting && $value === null) { + if ($shell->enableFlexibleCasting() && $value === null) { return TreeNode::branch($shell, [], []); } diff --git a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php b/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php index 712de130..5eb70e07 100644 --- a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php +++ b/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php @@ -6,33 +6,22 @@ use CuyZ\Valinor\Mapper\Object\ArgumentsValues; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; -use CuyZ\Valinor\Mapper\Tree\Exception\UnexpectedArrayKeysForClass; use CuyZ\Valinor\Mapper\Tree\Shell; -use function count; - /** @internal */ final class FilteredObjectNodeBuilder { - public function __construct(private bool $allowSuperfluousKeys) {} - public function build(ObjectBuilder $builder, Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { - $arguments = ArgumentsValues::forClass($builder->describeArguments(), $shell->value(), $this->allowSuperfluousKeys); + $arguments = ArgumentsValues::forClass($builder->describeArguments(), $shell); $children = $this->children($shell, $arguments, $rootBuilder); $object = $this->buildObject($builder, $children); - $node = $arguments->hadSingleArgument() + return $arguments->hadSingleArgument() ? TreeNode::flattenedBranch($shell, $object, $children[0]) : TreeNode::branch($shell, $object, $children); - - if (! $this->allowSuperfluousKeys && count($arguments->superfluousKeys()) > 0) { - $node = $node->withMessage(new UnexpectedArrayKeysForClass($arguments)); - } - - return $node; } /** diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php index b96b663a..73c9b7e2 100644 --- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php +++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php @@ -30,8 +30,6 @@ public function __construct( private ObjectBuilderFactory $objectBuilderFactory, private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, private FunctionsContainer $constructors, - private bool $enableFlexibleCasting, - private bool $allowSuperfluousKeys, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -50,7 +48,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode return $this->delegate->build($shell, $rootBuilder); } - if ($this->enableFlexibleCasting && $shell->value() === null) { + if ($shell->enableFlexibleCasting() && $shell->value() === null) { $shell = $shell->withValue([]); } @@ -140,7 +138,7 @@ private function transformSourceForClass(Shell $shell, Arguments $interfaceArgum */ private function children(Shell $shell, Arguments $arguments, RootNodeBuilder $rootBuilder): array { - $arguments = ArgumentsValues::forInterface($arguments, $shell->value(), $this->allowSuperfluousKeys); + $arguments = ArgumentsValues::forInterface($arguments, $shell); $children = []; diff --git a/src/Mapper/Tree/Builder/ListNodeBuilder.php b/src/Mapper/Tree/Builder/ListNodeBuilder.php index 8033c4d8..83406881 100644 --- a/src/Mapper/Tree/Builder/ListNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ListNodeBuilder.php @@ -17,8 +17,6 @@ /** @internal */ final class ListNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -26,7 +24,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ListType || $type instanceof NonEmptyListType); - if ($this->enableFlexibleCasting && $value === null) { + if ($shell->enableFlexibleCasting() && $value === null) { return TreeNode::branch($shell, [], []); } @@ -53,7 +51,7 @@ private function children(CompositeTraversableType $type, Shell $shell, RootNode $children = []; foreach ($values as $key => $value) { - if ($this->enableFlexibleCasting || $key === $expected) { + if ($shell->enableFlexibleCasting() || $key === $expected) { $child = $shell->child((string)$expected, $subType); $children[$expected] = $rootBuilder->build($child->withValue($value)); } else { diff --git a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php index e67b613a..007de11a 100644 --- a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php @@ -20,7 +20,6 @@ public function __construct( private ClassDefinitionRepository $classDefinitionRepository, private ObjectBuilderFactory $objectBuilderFactory, private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, - private bool $enableFlexibleCasting, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -30,7 +29,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode // @infection-ignore-all assert($type instanceof ObjectType); - if ($this->enableFlexibleCasting && $shell->value() === null) { + if ($shell->enableFlexibleCasting() && $shell->value() === null) { $shell = $shell->withValue([]); } diff --git a/src/Mapper/Tree/Builder/ScalarNodeBuilder.php b/src/Mapper/Tree/Builder/ScalarNodeBuilder.php index 9a0d8a76..63b3df6a 100644 --- a/src/Mapper/Tree/Builder/ScalarNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ScalarNodeBuilder.php @@ -12,8 +12,6 @@ /** @internal */ final class ScalarNodeBuilder implements NodeBuilder { - public function __construct(private bool $enableFlexibleCasting) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -21,7 +19,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode assert($type instanceof ScalarType); - if (! $this->enableFlexibleCasting || ! $type->canCast($value)) { + if (! $shell->enableFlexibleCasting() || ! $type->canCast($value)) { throw $type->errorMessage(); } diff --git a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php index 6dc633f2..e270f36a 100644 --- a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php @@ -5,20 +5,16 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Exception\UnexpectedShapedArrayKeys; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\Types\ShapedArrayType; use function array_key_exists; use function assert; -use function count; use function is_array; /** @internal */ final class ShapedArrayNodeBuilder implements NodeBuilder { - public function __construct(private bool $allowSuperfluousKeys) {} - public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); @@ -34,13 +30,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode $array = $this->buildArray($children); - $node = TreeNode::branch($shell, $array, $children); - - if (! $this->allowSuperfluousKeys && count($value) > count($children)) { - $node = $node->withMessage(new UnexpectedShapedArrayKeys($value, $children)); - } - - return $node; + return TreeNode::branch($shell, $array, $children); } /** diff --git a/src/Mapper/Tree/Builder/StrictNodeBuilder.php b/src/Mapper/Tree/Builder/StrictNodeBuilder.php index ecff6142..7361e9f2 100644 --- a/src/Mapper/Tree/Builder/StrictNodeBuilder.php +++ b/src/Mapper/Tree/Builder/StrictNodeBuilder.php @@ -13,20 +13,18 @@ final class StrictNodeBuilder implements NodeBuilder { public function __construct( private NodeBuilder $delegate, - private bool $allowPermissiveTypes, - private bool $enableFlexibleCasting ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode { $type = $shell->type(); - if (! $this->allowPermissiveTypes) { + if (! $shell->allowPermissiveTypes()) { TypeHelper::checkPermissiveType($type); } if (! $shell->hasValue()) { - if ($this->enableFlexibleCasting) { + if ($shell->enableFlexibleCasting()) { return $this->delegate->build($shell->withValue(null), $rootBuilder); } diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php index 890c2ec0..ae31fdb5 100644 --- a/src/Mapper/Tree/Builder/TreeNode.php +++ b/src/Mapper/Tree/Builder/TreeNode.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidNodeValue; +use CuyZ\Valinor\Mapper\Tree\Exception\UnexpectedKeysInSource; use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Shell; @@ -14,6 +15,8 @@ use function array_map; use function assert; +use function count; +use function is_array; /** @internal */ final class TreeNode @@ -141,14 +144,23 @@ private function check(): void foreach ($this->children as $child) { if (! $child->valid) { $this->valid = false; - - return; } } - if ($this->valid && ! $this->shell->type()->accepts($this->value)) { + $value = $this->shell->value(); + $type = $this->shell->type(); + + if (! $this->shell->allowSuperfluousKeys() + && is_array($value) + && count($value) > count($this->children) + ) { + $this->valid = false; + $this->messages[] = new UnexpectedKeysInSource($value, $this->children); + } + + if ($this->valid && ! $type->accepts($this->value)) { $this->valid = false; - $this->messages[] = new InvalidNodeValue($this->shell->type()); + $this->messages[] = new InvalidNodeValue($type); } } diff --git a/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php b/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php deleted file mode 100644 index 9ae2de4b..00000000 --- a/src/Mapper/Tree/Exception/UnexpectedArrayKeysForClass.php +++ /dev/null @@ -1,46 +0,0 @@ - */ - private array $parameters; - - public function __construct(ArgumentsValues $arguments) - { - $expected = array_map(fn (Argument $argument) => $argument->name(), [...$arguments]); - - $this->parameters = [ - 'keys' => '`' . implode('`, `', $arguments->superfluousKeys()) . '`', - 'expected_keys' => '`' . implode('`, `', $expected) . '`', - ]; - - parent::__construct(StringFormatter::for($this), 1655149208); - } - - public function body(): string - { - return $this->body; - } - - public function parameters(): array - { - return $this->parameters; - } -} diff --git a/src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php b/src/Mapper/Tree/Exception/UnexpectedKeysInSource.php similarity index 93% rename from src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php rename to src/Mapper/Tree/Exception/UnexpectedKeysInSource.php index dc55919b..c32ff4d5 100644 --- a/src/Mapper/Tree/Exception/UnexpectedShapedArrayKeys.php +++ b/src/Mapper/Tree/Exception/UnexpectedKeysInSource.php @@ -17,7 +17,7 @@ use function in_array; /** @internal */ -final class UnexpectedShapedArrayKeys extends RuntimeException implements ErrorMessage, HasParameters +final class UnexpectedKeysInSource extends RuntimeException implements ErrorMessage, HasParameters { private string $body = 'Unexpected key(s) {keys}, expected {expected_keys}.'; diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index ca87bafe..f7e2235a 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Mapper\Tree; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Exception\UnresolvableShellType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\UnresolvableType; @@ -16,6 +17,10 @@ /** @internal */ final class Shell { + private Settings $settings; + + private Type $type; + private string $name; private bool $hasValue = false; @@ -26,21 +31,27 @@ final class Shell private self $parent; - private function __construct(private Type $type) + private function __construct(Settings $settings, Type $type) { if ($type instanceof UnresolvableType) { throw new UnresolvableShellType($type); } + + $this->settings = $settings; + $this->type = $type; } - public static function root(Type $type, mixed $value): self - { - return (new self($type))->withValue($value); + public static function root( + Settings $settings, + Type $type, + mixed $value, + ): self { + return (new self($settings, $type))->withValue($value); } public function child(string $name, Type $type, Attributes $attributes = null): self { - $instance = new self($type); + $instance = new self($this->settings, $type); $instance->name = $name; $instance->parent = $this; @@ -95,6 +106,21 @@ public function value(): mixed return $this->value; } + public function enableFlexibleCasting(): bool + { + return $this->settings->enableFlexibleCasting; + } + + public function allowSuperfluousKeys(): bool + { + return $this->settings->allowSuperfluousKeys; + } + + public function allowPermissiveTypes(): bool + { + return $this->settings->allowPermissiveTypes; + } + public function attributes(): Attributes { return $this->attributes ?? Attributes::empty(); diff --git a/src/Mapper/TypeArgumentsMapper.php b/src/Mapper/TypeArgumentsMapper.php index f0d73f81..465ec624 100644 --- a/src/Mapper/TypeArgumentsMapper.php +++ b/src/Mapper/TypeArgumentsMapper.php @@ -6,6 +6,7 @@ use CuyZ\Valinor\Definition\ParameterDefinition; use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\TypeErrorDuringArgumentsMapping; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\UnresolvableShellType; @@ -17,15 +18,11 @@ /** @internal */ final class TypeArgumentsMapper implements ArgumentsMapper { - private FunctionDefinitionRepository $functionDefinitionRepository; - - private RootNodeBuilder $nodeBuilder; - - public function __construct(FunctionDefinitionRepository $functionDefinitionRepository, RootNodeBuilder $nodeBuilder) - { - $this->functionDefinitionRepository = $functionDefinitionRepository; - $this->nodeBuilder = $nodeBuilder; - } + public function __construct( + private FunctionDefinitionRepository $functionDefinitionRepository, + private RootNodeBuilder $nodeBuilder, + private Settings $settings, + ) {} /** @pure */ public function mapArguments(callable $callable, mixed $source): array @@ -42,7 +39,7 @@ public function mapArguments(callable $callable, mixed $source): array ); $type = new ShapedArrayType(...$elements); - $shell = Shell::root($type, $source); + $shell = Shell::root($this->settings, $type, $source); try { $node = $this->nodeBuilder->build($shell); diff --git a/src/Mapper/TypeTreeMapper.php b/src/Mapper/TypeTreeMapper.php index d7691dc1..6c0734ca 100644 --- a/src/Mapper/TypeTreeMapper.php +++ b/src/Mapper/TypeTreeMapper.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Exception\TypeErrorDuringMapping; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; @@ -18,7 +19,8 @@ final class TypeTreeMapper implements TreeMapper { public function __construct( private TypeParser $typeParser, - private RootNodeBuilder $nodeBuilder + private RootNodeBuilder $nodeBuilder, + private Settings $settings, ) {} /** @pure */ @@ -41,7 +43,7 @@ private function node(string $signature, mixed $source): TreeNode throw new InvalidMappingTypeSignature($signature, $exception); } - $shell = Shell::root($type, $source); + $shell = Shell::root($this->settings, $type, $source); try { return $this->nodeBuilder->build($shell); diff --git a/tests/Fake/Mapper/FakeShell.php b/tests/Fake/Mapper/FakeShell.php index eddfef42..91e5bb2b 100644 --- a/tests/Fake/Mapper/FakeShell.php +++ b/tests/Fake/Mapper/FakeShell.php @@ -4,15 +4,16 @@ namespace CuyZ\Valinor\Tests\Fake\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Type; final class FakeShell { - public static function new(Type $type, mixed $value = null): Shell + public static function new(Type $type, mixed $value = null, ?Settings $settings = null): Shell { - return Shell::root($type, $value); + return Shell::root($settings ?? new Settings(), $type, $value); } public static function any(): Shell diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php index a4a8457d..159ee3ab 100644 --- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php +++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php @@ -393,7 +393,7 @@ public function test_superfluous_values_throws_exception(): void } catch (MappingError $exception) { $error = $exception->node()->messages()[0]; - self::assertSame('1655149208', $error->code()); + self::assertSame('1655117782', $error->code()); self::assertSame('Unexpected key(s) `superfluousValue`, expected `valueA`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php index 04681cb8..0fc8dcd2 100644 --- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php @@ -59,7 +59,7 @@ public function test_superfluous_values_throws_exception_and_keeps_nested_errors $rootError = $exception->node()->messages()[0]; $nestedError = $exception->node()->children()['stringA']->messages()[0]; - self::assertSame('1655149208', $rootError->code()); + self::assertSame('1655117782', $rootError->code()); self::assertSame('Unexpected key(s) `unexpectedValueA`, `unexpectedValueB`, `42`, expected `stringA`, `stringB`.', (string)$rootError); self::assertSame('Value 42 is not a valid string.', (string)$nestedError); } diff --git a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php index d5510b5a..71720616 100644 --- a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; @@ -16,7 +17,12 @@ final class ArrayNodeBuilderTest extends TestCase { public function test_build_with_null_value_in_flexible_mode_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new(ArrayType::native())); + $setting = new Settings(); + $setting->enableFlexibleCasting = true; + + $shell = FakeShell::new(ArrayType::native(), settings: $setting); + + $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build($shell); self::assertSame([], $node->value()); self::assertEmpty($node->node()->children()); @@ -26,6 +32,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new(new FakeType())); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(new FakeType())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php index 17cf660f..312b7ee2 100644 --- a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; @@ -16,7 +17,12 @@ final class ListNodeBuilderTest extends TestCase { public function test_build_with_null_value_in_flexible_mode_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ListNodeBuilder(true)))->build(FakeShell::new(ListType::native())); + $setting = new Settings(); + $setting->enableFlexibleCasting = true; + + $shell = FakeShell::new(ListType::native(), settings: $setting); + + $node = (new RootNodeBuilder(new ListNodeBuilder()))->build($shell); self::assertSame([], $node->value()); self::assertEmpty($node->node()->children()); @@ -26,6 +32,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ListNodeBuilder(true)))->build(FakeShell::new(new FakeType())); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(new FakeType())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php index ebdc268f..e1785ff7 100644 --- a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php @@ -16,6 +16,6 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ScalarNodeBuilder(true)))->build(FakeShell::any()); + (new RootNodeBuilder(new ScalarNodeBuilder()))->build(FakeShell::any()); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php index 9c8d6cce..71b2cc56 100644 --- a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php @@ -21,7 +21,7 @@ public function test_invalid_type_fails_assertion(): void { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ShapedArrayNodeBuilder(true)))->build(FakeShell::any()); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::any()); } public function test_build_with_null_source_throws_exception(): void @@ -32,6 +32,6 @@ public function test_build_with_null_source_throws_exception(): void $this->expectExceptionCode(1618739163); $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder(true)))->build(FakeShell::new($type)); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php b/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php index e9bfb4c7..fc329e34 100644 --- a/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php +++ b/tests/Unit/Mapper/Tree/Builder/TreeNodeTest.php @@ -6,6 +6,7 @@ use AssertionError; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Builder\TreeNode; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Builder\FakeTreeNode; @@ -22,7 +23,7 @@ public function test_node_leaf_values_can_be_retrieved(): void { $type = FakeType::permissive(); - $shell = Shell::root($type, 'some source value'); + $shell = Shell::root(new Settings(), $type, 'some source value'); $node = TreeNode::leaf($shell, 'some value')->node(); self::assertTrue($node->isRoot()); diff --git a/tests/Unit/Mapper/Tree/ShellTest.php b/tests/Unit/Mapper/Tree/ShellTest.php index 40571784..96cb70b0 100644 --- a/tests/Unit/Mapper/Tree/ShellTest.php +++ b/tests/Unit/Mapper/Tree/ShellTest.php @@ -5,6 +5,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree; use CuyZ\Valinor\Definition\Attributes; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -16,7 +17,7 @@ public function test_type_and_value_can_be_retrieved(): void $type = new FakeType(); $value = 'foo'; - $shell = Shell::root($type, $value); + $shell = Shell::root(new Settings(), $type, $value); self::assertSame($type, $shell->type()); self::assertSame($value, $shell->value()); @@ -24,7 +25,7 @@ public function test_type_and_value_can_be_retrieved(): void public function test_root_path_is_fixed(): void { - $shell = Shell::root(new FakeType(), 'foo'); + $shell = Shell::root(new Settings(), new FakeType(), 'foo'); self::assertSame('*root*', $shell->path()); } @@ -34,7 +35,7 @@ public function test_change_type_changes_type(): void $typeA = new FakeType(); $typeB = FakeType::matching($typeA); - $shellA = Shell::root($typeA, []); + $shellA = Shell::root(new Settings(), $typeA, []); $shellB = $shellA->withType($typeB); self::assertNotSame($shellA, $shellB); @@ -46,7 +47,7 @@ public function test_change_value_changes_value(): void $valueA = 'foo'; $valueB = 'bar'; - $shellA = Shell::root(new FakeType(), $valueA); + $shellA = Shell::root(new Settings(), new FakeType(), $valueA); $shellB = $shellA->withValue($valueB); self::assertNotSame($shellA, $shellB); @@ -55,7 +56,7 @@ public function test_change_value_changes_value(): void public function test_root_shell_is_root(): void { - $shell = Shell::root(new FakeType(), []); + $shell = Shell::root(new Settings(), new FakeType(), []); self::assertTrue($shell->isRoot()); self::assertSame('', $shell->name()); @@ -67,7 +68,7 @@ public function test_shell_child_values_can_be_retrieved(): void $type = FakeType::permissive(); $attributes = new Attributes(); - $shell = Shell::root(new FakeType(), []); + $shell = Shell::root(new Settings(), new FakeType(), []); $child = $shell->child('foo', $type, $attributes)->withValue($value); self::assertSame('foo', $child->name()); diff --git a/tests/Unit/Mapper/TypeTreeMapperTest.php b/tests/Unit/Mapper/TypeTreeMapperTest.php index c31117dc..ba9c6db9 100644 --- a/tests/Unit/Mapper/TypeTreeMapperTest.php +++ b/tests/Unit/Mapper/TypeTreeMapperTest.php @@ -4,6 +4,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper; +use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\TypeTreeMapper; @@ -22,6 +23,7 @@ protected function setUp(): void $this->mapper = new TypeTreeMapper( new FakeTypeParser(), new RootNodeBuilder(new FakeNodeBuilder()), + new Settings(), ); } From 0479532fbc96fca35dcbfb4c1f5a9ef63e7625c5 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Mon, 19 Aug 2024 16:04:13 +0200 Subject: [PATCH 05/12] misc: move "float type accepting integer value" logic in `Shell` --- src/Mapper/Tree/Builder/TreeNode.php | 9 --------- src/Mapper/Tree/Shell.php | 9 +++++++++ src/Type/Types/NativeFloatType.php | 3 +-- tests/Unit/Type/Types/NativeFloatTypeTest.php | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php index ae31fdb5..3b37d5ee 100644 --- a/src/Mapper/Tree/Builder/TreeNode.php +++ b/src/Mapper/Tree/Builder/TreeNode.php @@ -9,7 +9,6 @@ use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Type\FloatType; use CuyZ\Valinor\Type\Type; use Throwable; @@ -35,14 +34,6 @@ final class TreeNode private function __construct(Shell $shell, mixed $value) { - // When the value is an integer and the type is a float, the value needs - // to be cast to float — this special case needs to be handled in case a - // node is not a *native* PHP float type (for instance a class property - // with a `@var float` annotation). - if ($shell->type() instanceof FloatType && is_int($value)) { - $value = (float)$value; - } - $this->shell = $shell; $this->value = $value; } diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index f7e2235a..32c2d131 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -7,6 +7,7 @@ use CuyZ\Valinor\Definition\Attributes; use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Exception\UnresolvableShellType; +use CuyZ\Valinor\Type\FloatType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\UnresolvableType; @@ -87,6 +88,14 @@ public function type(): Type public function withValue(mixed $value): self { + // When the value is an integer and the type is a float, the value is + // cast to float, to follow the rule of PHP regarding acceptance of an + // integer value in a float type. Note that PHPStan/Psalm analysis + // applies the same rule. + if ($this->type instanceof FloatType && is_int($value)) { + $value = (float)$value; + } + $clone = clone $this; $clone->hasValue = true; $clone->value = $value; diff --git a/src/Type/Types/NativeFloatType.php b/src/Type/Types/NativeFloatType.php index 4d5be2b5..2545d312 100644 --- a/src/Type/Types/NativeFloatType.php +++ b/src/Type/Types/NativeFloatType.php @@ -12,7 +12,6 @@ use function assert; use function is_float; -use function is_integer; use function is_numeric; /** @internal */ @@ -22,7 +21,7 @@ final class NativeFloatType implements FloatType public function accepts(mixed $value): bool { - return is_float($value) || is_integer($value); + return is_float($value); } public function matches(Type $other): bool diff --git a/tests/Unit/Type/Types/NativeFloatTypeTest.php b/tests/Unit/Type/Types/NativeFloatTypeTest.php index 8a59298a..fe1623cd 100644 --- a/tests/Unit/Type/Types/NativeFloatTypeTest.php +++ b/tests/Unit/Type/Types/NativeFloatTypeTest.php @@ -30,13 +30,13 @@ protected function setUp(): void public function test_accepts_correct_values(): void { self::assertTrue($this->floatType->accepts(42.1337)); - self::assertTrue($this->floatType->accepts(404)); } public function test_does_not_accept_incorrect_values(): void { self::assertFalse($this->floatType->accepts(null)); self::assertFalse($this->floatType->accepts('Schwifty!')); + self::assertFalse($this->floatType->accepts(404)); self::assertFalse($this->floatType->accepts(['foo' => 'bar'])); self::assertFalse($this->floatType->accepts(false)); self::assertFalse($this->floatType->accepts(new stdClass())); From 2150dcad4ce821bfe36c3718346ccc412e37832a Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Mon, 2 Sep 2024 14:23:26 +0200 Subject: [PATCH 06/12] feat: improve object constructors parameters types inferring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision system that checks object constructors parameters types is now way more clever, as it no longer checks for parameters' names only. Types are now also checked, and only true collision will be detected, for instance when two constructors share a parameter with the same name and type. Note that when two parameters share the same name, the following type priority operates: 1. Non-scalar type 2. Integer type 3. Float type 4. String type 5. Boolean type With this change, the code below is now valid: ```php final readonly class Money { private function __construct( public int $value, ) {} #[\CuyZ\Valinor\Mapper\Object\Constructor] public static function fromInt(int $value): self { return new self($value); } #[\CuyZ\Valinor\Mapper\Object\Constructor] public static function fromString(string $value): self { if (! preg_match('/^\d+€$/', $value)) { throw new \InvalidArgumentException('Invalid money format'); } return new self((int)rtrim($value, '€')); } } $mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper(); $mapper->map(Money::class, 42); // ✅ $mapper->map(Money::class, '42€'); // ✅ ``` --- src/Library/Container.php | 10 +- src/Mapper/Object/Arguments.php | 35 +- src/Mapper/Object/ArgumentsValues.php | 26 +- .../Exception/ObjectBuildersCollision.php | 11 +- .../Exception/SeveralObjectBuildersFound.php | 24 -- .../Factory/CollisionObjectBuilderFactory.php | 69 ---- .../Factory/DateTimeObjectBuilderFactory.php | 1 + .../DateTimeZoneObjectBuilderFactory.php | 1 + .../Object/Factory/ObjectBuilderFactory.php | 2 +- .../ReflectionObjectBuilderFactory.php | 5 - .../Factory/SortingObjectBuilderFactory.php | 109 ++++++ src/Mapper/Object/FilteredObjectBuilder.php | 106 ------ .../Tree/Builder/CasterProxyNodeBuilder.php | 26 +- .../Builder/FilteredObjectNodeBuilder.php | 68 ---- .../Tree/Builder/InterfaceNodeBuilder.php | 52 +-- src/Mapper/Tree/Builder/ObjectNodeBuilder.php | 81 +++- .../Tree/Builder/ShapedArrayNodeBuilder.php | 5 +- src/Mapper/Tree/Builder/TreeNode.php | 29 +- src/Mapper/Tree/Builder/UnionNodeBuilder.php | 30 +- src/Mapper/Tree/Shell.php | 22 ++ src/Utility/TypeHelper.php | 20 +- .../ConstructorRegistrationMappingTest.php | 351 ++++++++++++------ tests/Unit/Mapper/Tree/ShellTest.php | 9 + tests/Unit/Utility/TypeHelperTest.php | 14 + 24 files changed, 582 insertions(+), 524 deletions(-) delete mode 100644 src/Mapper/Object/Exception/SeveralObjectBuildersFound.php delete mode 100644 src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php create mode 100644 src/Mapper/Object/Factory/SortingObjectBuilderFactory.php delete mode 100644 src/Mapper/Object/FilteredObjectBuilder.php delete mode 100644 src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php diff --git a/src/Library/Container.php b/src/Library/Container.php index f453fc43..488f9d0d 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -18,7 +18,7 @@ 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; +use CuyZ\Valinor\Mapper\Object\Factory\SortingObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\ConstructorObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\DateTimeObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\DateTimeZoneObjectBuilderFactory; @@ -37,7 +37,6 @@ use CuyZ\Valinor\Mapper\Tree\Builder\NodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\NullNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ObjectImplementations; -use CuyZ\Valinor\Mapper\Tree\Builder\FilteredObjectNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder; @@ -116,7 +115,6 @@ public function __construct(Settings $settings) ObjectType::class => new ObjectNodeBuilder( $this->get(ClassDefinitionRepository::class), $this->get(ObjectBuilderFactory::class), - $this->get(FilteredObjectNodeBuilder::class), ), ]); @@ -126,8 +124,6 @@ public function __construct(Settings $settings) $builder, $this->get(ObjectImplementations::class), $this->get(ClassDefinitionRepository::class), - $this->get(ObjectBuilderFactory::class), - $this->get(FilteredObjectNodeBuilder::class), new FunctionsContainer( $this->get(FunctionDefinitionRepository::class), $settings->customConstructors @@ -152,8 +148,6 @@ public function __construct(Settings $settings) return new ErrorCatcherNodeBuilder($builder, $settings->exceptionFilter); }, - FilteredObjectNodeBuilder::class => fn () => new FilteredObjectNodeBuilder(), - ObjectImplementations::class => fn () => new ObjectImplementations( new FunctionsContainer( $this->get(FunctionDefinitionRepository::class), @@ -172,7 +166,7 @@ public function __construct(Settings $settings) $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 CollisionObjectBuilderFactory($factory); + $factory = new SortingObjectBuilderFactory($factory); if (! $settings->allowPermissiveTypes) { $factory = new StrictTypesObjectBuilderFactory($factory); diff --git a/src/Mapper/Object/Arguments.php b/src/Mapper/Object/Arguments.php index 023e390d..67d06f68 100644 --- a/src/Mapper/Object/Arguments.php +++ b/src/Mapper/Object/Arguments.php @@ -12,8 +12,10 @@ use IteratorAggregate; use Traversable; +use function array_keys; use function array_map; use function array_values; +use function count; /** * @internal @@ -22,19 +24,21 @@ */ final class Arguments implements IteratorAggregate, Countable { - /** @var Argument[] */ - private array $arguments; + /** @var array */ + private array $arguments = []; public function __construct(Argument ...$arguments) { - $this->arguments = $arguments; + foreach ($arguments as $argument) { + $this->arguments[$argument->name()] = $argument; + } } public static function fromParameters(Parameters $parameters): self { return new self(...array_map( fn (ParameterDefinition $parameter) => Argument::fromParameter($parameter), - array_values([...$parameters]) + [...$parameters], )); } @@ -42,24 +46,29 @@ public static function fromProperties(Properties $properties): self { return new self(...array_map( fn (PropertyDefinition $property) => Argument::fromProperty($property), - array_values([...$properties]) + [...$properties], )); } public function at(int $index): Argument { - return $this->arguments[$index]; + return array_values($this->arguments)[$index]; } - public function has(string $name): bool + /** + * @return list + */ + public function names(): array { - foreach ($this->arguments as $argument) { - if ($argument->name() === $name) { - return true; - } - } + return array_keys($this->arguments); + } - return false; + /** + * @return array + */ + public function toArray(): array + { + return $this->arguments; } public function count(): int diff --git a/src/Mapper/Object/ArgumentsValues.php b/src/Mapper/Object/ArgumentsValues.php index 407112d9..9b595de3 100644 --- a/src/Mapper/Object/ArgumentsValues.php +++ b/src/Mapper/Object/ArgumentsValues.php @@ -4,7 +4,6 @@ namespace CuyZ\Valinor\Mapper\Object; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\CompositeTraversableType; use CuyZ\Valinor\Type\Types\ArrayKeyType; @@ -27,6 +26,8 @@ final class ArgumentsValues implements IteratorAggregate private Arguments $arguments; + private bool $hasInvalidValue = false; + private bool $forInterface = false; private bool $hadSingleArgument = false; @@ -42,7 +43,7 @@ public static function forInterface(Arguments $arguments, Shell $shell): self $self->forInterface = true; if (count($arguments) > 0) { - $self = $self->transform($shell); + $self->transform($shell); } return $self; @@ -51,11 +52,16 @@ public static function forInterface(Arguments $arguments, Shell $shell): self public static function forClass(Arguments $arguments, Shell $shell): self { $self = new self($arguments); - $self = $self->transform($shell); + $self->transform($shell); return $self; } + public function hasInvalidValue(): bool + { + return $this->hasInvalidValue; + } + public function hasValue(string $name): bool { return array_key_exists($name, $this->value); @@ -71,18 +77,18 @@ public function hadSingleArgument(): bool return $this->hadSingleArgument; } - private function transform(Shell $shell): self + private function transform(Shell $shell): void { - $clone = clone $this; - $transformedValue = $this->transformValueForSingleArgument($shell); if (! is_array($transformedValue)) { - throw new InvalidSource($transformedValue, $this->arguments); + $this->hasInvalidValue = true; + + return; } if ($transformedValue !== $shell->value()) { - $clone->hadSingleArgument = true; + $this->hadSingleArgument = true; } foreach ($this->arguments as $argument) { @@ -93,9 +99,7 @@ private function transform(Shell $shell): self } } - $clone->value = $transformedValue; - - return $clone; + $this->value = $transformedValue; } private function transformValueForSingleArgument(Shell $shell): mixed diff --git a/src/Mapper/Object/Exception/ObjectBuildersCollision.php b/src/Mapper/Object/Exception/ObjectBuildersCollision.php index 697fed7c..02648a98 100644 --- a/src/Mapper/Object/Exception/ObjectBuildersCollision.php +++ b/src/Mapper/Object/Exception/ObjectBuildersCollision.php @@ -4,23 +4,16 @@ namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use RuntimeException; -use function array_map; -use function implode; - /** @internal */ final class ObjectBuildersCollision extends RuntimeException { - public function __construct(ClassDefinition $class, ObjectBuilder ...$builders) + public function __construct(ObjectBuilder $builderA, ObjectBuilder $builderB) { - $constructors = array_map(fn (ObjectBuilder $builder) => $builder->signature(), $builders); - $constructors = implode('`, `', $constructors); - parent::__construct( - "A collision was detected between the following constructors of the class `{$class->type->toString()}`: `$constructors`.", + "A type collision was detected between the constructors `{$builderA->signature()}` and `{$builderB->signature()}`.", 1654955787 ); } diff --git a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php deleted file mode 100644 index 732bb66e..00000000 --- a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php +++ /dev/null @@ -1,24 +0,0 @@ -body, 1642787246); - } - - public function body(): string - { - return $this->body; - } -} diff --git a/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php b/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php deleted file mode 100644 index 702a278b..00000000 --- a/src/Mapper/Object/Factory/CollisionObjectBuilderFactory.php +++ /dev/null @@ -1,69 +0,0 @@ -delegate->for($class); - - $sortedBuilders = []; - - foreach ($builders as $builder) { - $sortedBuilders[count($builder->describeArguments())][] = $builder; - } - - foreach ($sortedBuilders as $argumentsCount => $buildersList) { - if (count($buildersList) <= 1) { - continue; - } - - if ($argumentsCount <= 1) { - throw new ObjectBuildersCollision($class, ...$buildersList); - } - - // @phpstan-ignore-next-line // false positive - while (($current = array_shift($buildersList)) && count($buildersList) > 0) { - $arguments = $current->describeArguments(); - - do { - $other = current($buildersList); - - $collisions = 0; - - foreach ($arguments as $argumentA) { - $name = $argumentA->name(); - - foreach ($other->describeArguments() as $argumentB) { - if ($argumentB->name() === $name) { - $collisions++; - // @infection-ignore-all - break; - } - } - } - - if ($collisions >= count($arguments)) { - throw new ObjectBuildersCollision($class, $current, $other); - } - } while (next($buildersList)); - } - } - - return $builders; - } -} diff --git a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php index fa5613ac..86269286 100644 --- a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php @@ -48,6 +48,7 @@ public function for(ClassDefinition $class): array $builders[] = $this->internalDateTimeBuilder($class->type); } + /** @var non-empty-list */ return $builders; } diff --git a/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php index cc17713f..0e929d03 100644 --- a/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeZoneObjectBuilderFactory.php @@ -57,6 +57,7 @@ public function for(ClassDefinition $class): array $builders[] = $this->defaultBuilder($class->type); } + /** @var non-empty-list */ return $builders; } diff --git a/src/Mapper/Object/Factory/ObjectBuilderFactory.php b/src/Mapper/Object/Factory/ObjectBuilderFactory.php index 760f3d42..a641b363 100644 --- a/src/Mapper/Object/Factory/ObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/ObjectBuilderFactory.php @@ -11,7 +11,7 @@ interface ObjectBuilderFactory { /** - * @return list + * @return non-empty-list */ public function for(ClassDefinition $class): array; } diff --git a/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php b/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php index 187f11bc..0242b452 100644 --- a/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/ReflectionObjectBuilderFactory.php @@ -6,17 +6,12 @@ use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Mapper\Object\ReflectionObjectBuilder; -use CuyZ\Valinor\Utility\Reflection\Reflection; /** @internal */ final class ReflectionObjectBuilderFactory implements ObjectBuilderFactory { public function for(ClassDefinition $class): array { - if (Reflection::enumExists($class->name)) { - return []; - } - return [new ReflectionObjectBuilder($class)]; } } diff --git a/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php b/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php new file mode 100644 index 00000000..e7dea8c8 --- /dev/null +++ b/src/Mapper/Object/Factory/SortingObjectBuilderFactory.php @@ -0,0 +1,109 @@ +delegate->for($class); + + $sortedByArgumentsNumber = []; + $sortedByPriority = []; + + foreach ($builders as $builder) { + $sortedByArgumentsNumber[$builder->describeArguments()->count()][] = $builder; + } + + krsort($sortedByArgumentsNumber); + + foreach ($sortedByArgumentsNumber as $sortedBuilders) { + usort($sortedBuilders, $this->sortObjectBuilders(...)); + + $sortedByPriority = array_merge($sortedByPriority, $sortedBuilders); + } + + return $sortedByPriority; + } + + private function sortObjectBuilders(ObjectBuilder $builderA, ObjectBuilder $builderB): int + { + $argumentsA = $builderA->describeArguments()->toArray(); + $argumentsB = $builderB->describeArguments()->toArray(); + + $sharedArguments = array_keys(array_intersect_key($argumentsA, $argumentsB)); + + $winner = null; + + foreach ($sharedArguments as $name) { + $typeA = $argumentsA[$name]->type(); + $typeB = $argumentsB[$name]->type(); + + $score = $this->sortTypes($typeA, $typeB); + + if ($score === 0) { + continue; + } + + $newWinner = $score === 1 ? $builderB : $builderA; + + if ($winner && $winner !== $newWinner) { + throw new ObjectBuildersCollision($builderA, $builderB); + } + + $winner = $newWinner; + } + + if ($winner === null && count($sharedArguments) === count($argumentsA)) { + throw new ObjectBuildersCollision($builderA, $builderB); + } + + // @infection-ignore-all / Incrementing or decrementing sorting value makes no sense, so we ignore it. + return $winner === $builderA ? -1 : 1; + } + + private function sortTypes(Type $typeA, Type $typeB): int + { + if ($typeA instanceof ScalarType && $typeB instanceof ScalarType) { + return TypeHelper::typePriority($typeB) <=> TypeHelper::typePriority($typeA); + } + + if (! $typeA instanceof ScalarType) { + // @infection-ignore-all / Decrementing sorting value makes no sense, so we ignore it. + return -1; + } + + return 1; + } +} diff --git a/src/Mapper/Object/FilteredObjectBuilder.php b/src/Mapper/Object/FilteredObjectBuilder.php deleted file mode 100644 index acabadfe..00000000 --- a/src/Mapper/Object/FilteredObjectBuilder.php +++ /dev/null @@ -1,106 +0,0 @@ -delegate = $this->filterBuilder($source, ...$builders); - } - - public static function from(mixed $source, ObjectBuilder ...$builders): ObjectBuilder - { - if (count($builders) === 1) { - return $builders[0]; - } - - return new self($source, ...$builders); - } - - public function describeArguments(): Arguments - { - return $this->delegate->describeArguments(); - } - - public function build(array $arguments): object - { - return $this->delegate->build($arguments); - } - - public function signature(): string - { - return $this->delegate->signature(); - } - - private function filterBuilder(mixed $source, ObjectBuilder ...$builders): ObjectBuilder - { - if (count($builders) === 1) { - return reset($builders); - } - - /** @var non-empty-list $builders */ - $constructors = []; - - foreach ($builders as $builder) { - $filledNumber = $this->filledArguments($builder, $source); - - if ($filledNumber === false) { - continue; - } - - $constructors[$filledNumber][] = $builder; - } - - ksort($constructors); - - $constructorsWithMostArguments = array_pop($constructors) ?: []; - - if (count($constructorsWithMostArguments) === 0) { - throw new CannotFindObjectBuilder($builders); - } - - if (count($constructorsWithMostArguments) > 1) { - throw new SeveralObjectBuildersFound(); - } - - return $constructorsWithMostArguments[0]; - } - - /** - * @return false|int<0, max> - */ - private function filledArguments(ObjectBuilder $builder, mixed $source): false|int - { - $arguments = $builder->describeArguments(); - - if (! is_array($source)) { - return count($arguments) === 1 ? 1 : false; - } - - /** @infection-ignore-all */ - $filled = 0; - - foreach ($arguments as $argument) { - if (isset($source[$argument->name()])) { - $filled++; - } elseif ($argument->isRequired()) { - return false; - } - } - - return $filled; - } -} diff --git a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php index 9a50c340..71f9d086 100644 --- a/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php +++ b/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php @@ -20,28 +20,36 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode if ($shell->hasValue()) { $value = $shell->value(); - if ($this->typeAcceptsValue($shell->type(), $value)) { - return TreeNode::leaf($shell, $value); + $typeAcceptingValue = $this->typeAcceptingValue($shell->type(), $value); + + if ($typeAcceptingValue) { + return TreeNode::leaf($shell->withType($typeAcceptingValue), $value); } } return $this->delegate->build($shell, $rootBuilder); } - private function typeAcceptsValue(Type $type, mixed $value): bool + private function typeAcceptingValue(Type $type, mixed $value): ?Type { if ($type instanceof UnionType) { foreach ($type->types() as $subType) { - if ($this->typeAcceptsValue($subType, $value)) { - return true; + if ($this->typeAcceptingValue($subType, $value)) { + return $subType; } } - return false; + return null; + } + + if ($type instanceof CompositeTraversableType || $type instanceof ShapedArrayType) { + return null; + } + + if ($type->accepts($value)) { + return $type; } - return ! $type instanceof CompositeTraversableType - && ! $type instanceof ShapedArrayType - && $type->accepts($value); + return null; } } diff --git a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php b/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php deleted file mode 100644 index 5eb70e07..00000000 --- a/src/Mapper/Tree/Builder/FilteredObjectNodeBuilder.php +++ /dev/null @@ -1,68 +0,0 @@ -describeArguments(), $shell); - - $children = $this->children($shell, $arguments, $rootBuilder); - - $object = $this->buildObject($builder, $children); - - return $arguments->hadSingleArgument() - ? TreeNode::flattenedBranch($shell, $object, $children[0]) - : TreeNode::branch($shell, $object, $children); - } - - /** - * @return array - */ - private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array - { - $children = []; - - foreach ($arguments as $argument) { - $name = $argument->name(); - $type = $argument->type(); - $attributes = $argument->attributes(); - - $child = $shell->child($name, $type, $attributes); - - if ($arguments->hasValue($name)) { - $child = $child->withValue($arguments->getValue($name)); - } - - $children[] = $rootBuilder->build($child); - } - - return $children; - } - - /** - * @param TreeNode[] $children - */ - private function buildObject(ObjectBuilder $builder, array $children): ?object - { - $arguments = []; - - foreach ($children as $child) { - if (! $child->isValid()) { - return null; - } - - $arguments[$child->name()] = $child->value(); - } - - return $builder->build($arguments); - } -} diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php index 73c9b7e2..17b5a449 100644 --- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php +++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php @@ -8,8 +8,7 @@ use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; use CuyZ\Valinor\Mapper\Object\Arguments; use CuyZ\Valinor\Mapper\Object\ArgumentsValues; -use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; -use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder; +use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; use CuyZ\Valinor\Mapper\Tree\Exception\CannotInferFinalClass; use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveObjectType; use CuyZ\Valinor\Mapper\Tree\Exception\InterfaceHasBothConstructorAndInfer; @@ -27,8 +26,6 @@ public function __construct( private NodeBuilder $delegate, private ObjectImplementations $implementations, private ClassDefinitionRepository $classDefinitionRepository, - private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, private FunctionsContainer $constructors, ) {} @@ -69,7 +66,13 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode throw new CannotInferFinalClass($type, $function); } - $children = $this->children($shell, $arguments, $rootBuilder); + $argumentsValues = ArgumentsValues::forInterface($arguments, $shell); + + if ($argumentsValues->hasInvalidValue()) { + throw new InvalidSource($shell->value(), $arguments); + } + + $children = $this->children($shell, $argumentsValues, $rootBuilder); $values = []; @@ -87,12 +90,10 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode throw UserlandError::from($exception); } - $class = $this->classDefinitionRepository->for($classType); - $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class)); + $shell = $shell->withType($classType); + $shell = $shell->withAllowedSuperfluousKeys($arguments->names()); - $shell = $this->transformSourceForClass($shell, $arguments, $objectBuilder->describeArguments()); - - return $this->filteredObjectNodeBuilder->build($objectBuilder, $shell, $rootBuilder); + return $this->delegate->build($shell, $rootBuilder); } private function constructorRegisteredFor(Type $type): bool @@ -106,40 +107,11 @@ private function constructorRegisteredFor(Type $type): bool return false; } - private function transformSourceForClass(Shell $shell, Arguments $interfaceArguments, Arguments $classArguments): Shell - { - $value = $shell->value(); - - if (! is_array($value)) { - return $shell; - } - - foreach ($interfaceArguments as $argument) { - $name = $argument->name(); - - if (array_key_exists($name, $value) && ! $classArguments->has($name)) { - unset($value[$name]); - } - } - - if (count($classArguments) === 1 && count($value) === 1) { - $name = $classArguments->at(0)->name(); - - if (array_key_exists($name, $value)) { - $value = $value[$name]; - } - } - - return $shell->withValue($value); - } - /** * @return array */ - private function children(Shell $shell, Arguments $arguments, RootNodeBuilder $rootBuilder): array + private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array { - $arguments = ArgumentsValues::forInterface($arguments, $shell); - $children = []; foreach ($arguments as $argument) { diff --git a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php index 007de11a..88455bb0 100644 --- a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php @@ -5,13 +5,16 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; +use CuyZ\Valinor\Mapper\Object\ArgumentsValues; +use CuyZ\Valinor\Mapper\Object\Exception\CannotFindObjectBuilder; +use CuyZ\Valinor\Mapper\Object\Exception\InvalidSource; use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; -use CuyZ\Valinor\Mapper\Object\FilteredObjectBuilder; +use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use CuyZ\Valinor\Mapper\Tree\Shell; - use CuyZ\Valinor\Type\ObjectType; use function assert; +use function count; /** @internal */ final class ObjectNodeBuilder implements NodeBuilder @@ -19,7 +22,6 @@ final class ObjectNodeBuilder implements NodeBuilder public function __construct( private ClassDefinitionRepository $classDefinitionRepository, private ObjectBuilderFactory $objectBuilderFactory, - private FilteredObjectNodeBuilder $filteredObjectNodeBuilder, ) {} public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode @@ -34,8 +36,77 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode } $class = $this->classDefinitionRepository->for($type); - $objectBuilder = FilteredObjectBuilder::from($shell->value(), ...$this->objectBuilderFactory->for($class)); + $builders = $this->objectBuilderFactory->for($class); + + foreach ($builders as $builder) { + $argumentsValues = ArgumentsValues::forClass($builder->describeArguments(), $shell); + + if ($argumentsValues->hasInvalidValue()) { + if (count($builders) === 1) { + return TreeNode::error($shell, new InvalidSource($shell->value(), $builder->describeArguments())); + } + + continue; + } + + $children = $this->children($shell, $argumentsValues, $rootBuilder); + + $object = $this->buildObject($builder, $children); + + if ($argumentsValues->hadSingleArgument()) { + $node = TreeNode::flattenedBranch($shell, $object, $children[0]); + } else { + $node = TreeNode::branch($shell, $object, $children); + $node = $node->checkUnexpectedKeys(); + } + + if ($node->isValid() || count($builders) === 1) { + return $node; + } + } + + throw new CannotFindObjectBuilder($builders); + } + + /** + * @return list + */ + private function children(Shell $shell, ArgumentsValues $arguments, RootNodeBuilder $rootBuilder): array + { + $children = []; + + foreach ($arguments as $argument) { + $name = $argument->name(); + $type = $argument->type(); + $attributes = $argument->attributes(); + + $child = $shell->child($name, $type, $attributes); + + if ($arguments->hasValue($name)) { + $child = $child->withValue($arguments->getValue($name)); + } + + $children[] = $rootBuilder->build($child); + } + + return $children; + } + + /** + * @param list $children + */ + private function buildObject(ObjectBuilder $builder, array $children): ?object + { + $arguments = []; + + foreach ($children as $child) { + if (! $child->isValid()) { + return null; + } + + $arguments[$child->name()] = $child->value(); + } - return $this->filteredObjectNodeBuilder->build($objectBuilder, $shell, $rootBuilder); + return $builder->build($arguments); } } diff --git a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php index e270f36a..fb0448ff 100644 --- a/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php @@ -30,7 +30,10 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode $array = $this->buildArray($children); - return TreeNode::branch($shell, $array, $children); + $node = TreeNode::branch($shell, $array, $children); + $node = $node->checkUnexpectedKeys(); + + return $node; } /** diff --git a/src/Mapper/Tree/Builder/TreeNode.php b/src/Mapper/Tree/Builder/TreeNode.php index 3b37d5ee..afc72587 100644 --- a/src/Mapper/Tree/Builder/TreeNode.php +++ b/src/Mapper/Tree/Builder/TreeNode.php @@ -12,9 +12,10 @@ use CuyZ\Valinor\Type\Type; use Throwable; +use function array_diff; +use function array_keys; use function array_map; use function assert; -use function count; use function is_array; /** @internal */ @@ -130,6 +131,23 @@ public function node(): Node return $this->buildNode($this); } + public function checkUnexpectedKeys(): self + { + $value = $this->shell->value(); + + if ($this->shell->allowSuperfluousKeys() || ! is_array($value)) { + return $this; + } + + $diff = array_diff(array_keys($value), array_keys($this->children), $this->shell->allowedSuperfluousKeys()); + + if ($diff !== []) { + return $this->withMessage(new UnexpectedKeysInSource($value, $this->children)); + } + + return $this; + } + private function check(): void { foreach ($this->children as $child) { @@ -138,17 +156,8 @@ private function check(): void } } - $value = $this->shell->value(); $type = $this->shell->type(); - if (! $this->shell->allowSuperfluousKeys() - && is_array($value) - && count($value) > count($this->children) - ) { - $this->valid = false; - $this->messages[] = new UnexpectedKeysInSource($value, $this->children); - } - if ($this->valid && ! $type->accepts($this->value)) { $this->valid = false; $this->messages[] = new InvalidNodeValue($type); diff --git a/src/Mapper/Tree/Builder/UnionNodeBuilder.php b/src/Mapper/Tree/Builder/UnionNodeBuilder.php index 4126135b..845d5df9 100644 --- a/src/Mapper/Tree/Builder/UnionNodeBuilder.php +++ b/src/Mapper/Tree/Builder/UnionNodeBuilder.php @@ -8,18 +8,17 @@ use CuyZ\Valinor\Mapper\Tree\Exception\TooManyResolvedTypesFromUnion; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\ClassType; -use CuyZ\Valinor\Type\FloatType; -use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\ScalarType; -use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Types\InterfaceType; use CuyZ\Valinor\Type\Types\NullType; use CuyZ\Valinor\Type\Types\ShapedArrayType; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\TypeHelper; use function count; use function krsort; use function reset; +use function usort; /** @internal */ final class UnionNodeBuilder implements NodeBuilder @@ -91,28 +90,11 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode return $first[0]; } } elseif ($scalars !== []) { - // Sorting the scalar types by priority: int, float, string, bool. - $sorted = []; - - foreach ($scalars as $node) { - if ($node->type() instanceof IntegerType) { - $sorted[IntegerType::class] = $node; - } elseif ($node->type() instanceof FloatType) { - $sorted[FloatType::class] = $node; - } elseif ($node->type() instanceof StringType) { - $sorted[StringType::class] = $node; - } - } - - if (isset($sorted[IntegerType::class])) { - return $sorted[IntegerType::class]; - } elseif (isset($sorted[FloatType::class])) { - return $sorted[FloatType::class]; - } elseif (isset($sorted[StringType::class])) { - return $sorted[StringType::class]; - } + usort( + $scalars, + fn (TreeNode $a, TreeNode $b): int => TypeHelper::typePriority($b->type()) <=> TypeHelper::typePriority($a->type()), + ); - // @infection-ignore-all / We know this is a boolean, so we don't need to mutate the index return $scalars[0]; } diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index 32c2d131..cec31b4d 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -32,6 +32,9 @@ final class Shell private self $parent; + /** @var list */ + private array $allowedSuperfluousKeys = []; + private function __construct(Settings $settings, Type $type) { if ($type instanceof UnresolvableType) { @@ -135,6 +138,25 @@ public function attributes(): Attributes return $this->attributes ?? Attributes::empty(); } + /** + * @param list $allowedSuperfluousKeys + */ + public function withAllowedSuperfluousKeys(array $allowedSuperfluousKeys): self + { + $clone = clone $this; + $clone->allowedSuperfluousKeys = $allowedSuperfluousKeys; + + return $clone; + } + + /** + * @return list + */ + public function allowedSuperfluousKeys(): array + { + return $this->allowedSuperfluousKeys; + } + public function path(): string { if (! isset($this->parent)) { diff --git a/src/Utility/TypeHelper.php b/src/Utility/TypeHelper.php index 262ca036..51024413 100644 --- a/src/Utility/TypeHelper.php +++ b/src/Utility/TypeHelper.php @@ -6,9 +6,13 @@ use CuyZ\Valinor\Mapper\Object\Argument; use CuyZ\Valinor\Mapper\Object\Arguments; +use CuyZ\Valinor\Type\BooleanType; use CuyZ\Valinor\Type\CompositeType; use CuyZ\Valinor\Type\FixedType; +use CuyZ\Valinor\Type\FloatType; +use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\ObjectType; +use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\EnumType; use CuyZ\Valinor\Type\Types\MixedType; @@ -17,6 +21,20 @@ /** @internal */ final class TypeHelper { + /** + * Sorting the scalar types by priority: int, float, string, bool. + */ + public static function typePriority(Type $type): int + { + return match (true) { + $type instanceof IntegerType => 4, + $type instanceof FloatType => 3, + $type instanceof StringType => 2, + $type instanceof BooleanType => 1, + default => 0, + }; + } + public static function dump(Type $type, bool $surround = true): string { if ($type instanceof EnumType) { @@ -51,7 +69,7 @@ function (Argument $argument) { return $argument->isRequired() ? "$name: $signature" : "$name?: $signature"; }, - [...$arguments] + [...$arguments], ); return '`array{' . implode(', ', $parameters) . '}`'; diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index 4e157da8..9282923f 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -20,6 +20,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; final class ConstructorRegistrationMappingTest extends IntegrationTestCase @@ -288,26 +289,6 @@ public function test_registered_native_constructor_is_called_if_registered_and_o self::assertSame(1337, $result->bar); } - public function test_registered_constructor_is_used_when_not_the_first_nor_last_one(): void - { - $object = new stdClass(); - - try { - $result = $this->mapperBuilder() - ->registerConstructor(fn (): DateTime => new DateTime()) - // This constructor is surrounded by other ones to ensure it is - // still used correctly. - ->registerConstructor(fn (): stdClass => $object) - ->registerConstructor(fn (): DateTimeImmutable => new DateTimeImmutable()) - ->mapper() - ->map(stdClass::class, []); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame($object, $result); - } - public function test_registered_constructor_with_one_argument_is_used(): void { try { @@ -353,102 +334,6 @@ public function test_registered_constructor_with_several_arguments_is_used(): vo self::assertSame(1337.404, $result->float); } - public function test_registered_constructors_for_same_class_are_filtered_correctly(): void - { - $mapper = $this->mapperBuilder() - // Basic constructor - ->registerConstructor(function (string $foo): stdClass { - $class = new stdClass(); - $class->foo = $foo; - - return $class; - }) - // Constructor with two parameters - ->registerConstructor(function (string $foo, int $bar): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - - return $class; - }) - // Constructor with optional parameter - ->registerConstructor(function (string $foo, int $bar, float $baz, string $fiz = 'fiz'): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - $class->baz = $baz; - $class->fiz = $fiz; - - return $class; - }) - ->mapper(); - - try { - $resultA = $mapper->map(stdClass::class, 'foo'); - - $resultB = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 42, - ]); - - $resultC = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 42, - 'baz' => 1337.404, - ]); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame('foo', $resultA->foo); - - self::assertSame('foo', $resultB->foo); - self::assertSame(42, $resultB->bar); - - self::assertSame('foo', $resultC->foo); - self::assertSame(42, $resultC->bar); - self::assertSame(1337.404, $resultC->baz); - self::assertSame('fiz', $resultC->fiz); - } - - public function test_several_constructors_with_same_arguments_number_are_filtered_correctly(): void - { - $mapper = $this->mapperBuilder() - ->registerConstructor(function (string $foo, string $bar): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->bar = $bar; - - return $class; - }) - ->registerConstructor(function (string $foo, string $baz): stdClass { - $class = new stdClass(); - $class->foo = $foo; - $class->baz = $baz; - - return $class; - })->mapper(); - - try { - $resultA = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'bar' => 'bar', - ]); - - $resultB = $mapper->map(stdClass::class, [ - 'foo' => 'foo', - 'baz' => 'baz', - ]); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame('foo', $resultA->foo); - self::assertSame('bar', $resultA->bar); - self::assertSame('foo', $resultB->foo); - self::assertSame('baz', $resultB->baz); - } - public function test_inherited_static_constructor_is_used_to_map_child_class(): void { $class = (new class () { @@ -475,11 +360,222 @@ public function test_inherited_static_constructor_is_used_to_map_child_class(): self::assertSame(1337, $result->someOtherChild->bar); } + /** + * @param list $constructors + * @param array $data + */ + #[DataProvider('constructors_are_sorted_and_filtered_correctly_data_provider')] + public function test_constructors_are_sorted_and_filtered_correctly(array $constructors, array $data): void + { + $mapperBuilder = $this->mapperBuilder()->registerConstructor(...$constructors); + + try { + foreach ($data as $value) { + $result = $mapperBuilder->mapper()->map(stdClass::class, $value['value']); + + self::assertSame($value['expected'], $result); + + // Also testing with allowed superfluous keys to be sure that + // constructors with fewer arguments are taken into account but + //filtered correctly. + $result = $mapperBuilder->allowSuperfluousKeys()->mapper()->map(stdClass::class, $value['value']); + + self::assertSame($value['expected'], $result); + } + } catch (MappingError $error) { + $this->mappingFail($error); + } + } + + public static function constructors_are_sorted_and_filtered_correctly_data_provider(): iterable + { + $resultA = new stdClass(); + $resultB = new stdClass(); + $resultC = new stdClass(); + + yield 'constructor is used when surrounded by other constructors' => [ + 'constructors' => [ + fn (): DateTime => new DateTime(), + // This constructor is surrounded by other ones to ensure it is + // still used correctly. + fn (): stdClass => $resultA, + fn (): DateTimeImmutable => new DateTimeImmutable(), + ], + 'data' => [ + [ + 'value' => [], + 'expected' => $resultA, + ], + ], + ]; + + yield 'constructors for same class are sorted properly' => [ + 'constructors' => [ + // Basic constructor + fn (string $foo): stdClass => $resultA, + // Constructor with two parameters + fn (string $foo, int $bar): stdClass => $resultB, + // Constructor with optional parameter + fn (string $foo, int $bar, float $baz, string $fiz = 'fiz'): stdClass => $resultC, + ], + 'data' => [ + 'string source' => [ + 'value' => 'foo', + 'expected' => $resultA, + ], + 'foo and bar values' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 42, + ], + 'expected' => $resultB, + ], + 'foo and bar and baz values' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 42, + 'baz' => 1337.0, + ], + 'expected' => $resultC, + ], + ], + ]; + + yield 'constructors for same class with same arguments number but different types' => [ + 'constructors' => [ + fn (string $foo, string $bar): stdClass => $resultA, + fn (string $foo, string $fiz): stdClass => $resultB, + ], + 'data' => [ + 'foo and bar' => [ + 'value' => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + 'expected' => $resultA, + ], + 'foo and fiz' => [ + 'value' => [ + 'foo' => 'foo', + 'fiz' => 'fiz', + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same parameter name but different types' => [ + 'constructors' => [ + fn (string $value): stdClass => $resultA, + fn (float $value): stdClass => $resultB, + fn (int $value): stdClass => $resultC, + ], + 'data' => [ + 'string source' => [ + 'value' => 'foo', + 'expected' => $resultA, + ], + 'float source' => [ + 'value' => 404.0, + 'expected' => $resultB, + ], + 'integer source' => [ + 'value' => 1337, + 'expected' => $resultC, + ], + ], + ]; + + yield 'constructors with same named parameter use integer over float' => [ + 'constructors' => [ + fn (float $value): stdClass => $resultA, + fn (int $value): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => 1337, + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same named parameters names use integer over float' => [ + 'constructors' => [ + fn (float $valueA, float $valueB): stdClass => $resultA, + fn (int $valueA, int $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337, + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructors with same parameter name but second one is either float or integer' => [ + 'constructors' => [ + fn (int $valueA, float $valueB): stdClass => $resultA, + fn (int $valueA, int $valueB): stdClass => $resultB, + ], + 'data' => [ + 'integer and float' => [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337.0, + ], + 'expected' => $resultA, + ], + 'integer and integer' => [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 1337, + ], + 'expected' => $resultB, + ], + ], + ]; + + yield 'constructor with non scalar argument has priority over those with scalar (non scalar constructor is registered first)' => [ + 'constructors' => [ + fn (int $valueA, SimpleObject $valueB): stdClass => $resultA, + fn (int $valueA, string $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 'foo', + ], + 'expected' => $resultA, + ], + ], + ]; + + yield 'constructor with non scalar argument has priority over those with scalar (non scalar constructor is registered last)' => [ + 'constructors' => [ + fn (int $valueA, string $valueB): stdClass => $resultA, + fn (int $valueA, SimpleObject $valueB): stdClass => $resultB, + ], + 'data' => [ + [ + 'value' => [ + 'valueA' => 42, + 'valueB' => 'foo', + ], + 'expected' => $resultB, + ], + ], + ]; + } + public function test_identical_registered_constructors_with_no_argument_throws_exception(): void { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./'); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); $this->mapperBuilder() ->registerConstructor( @@ -495,12 +591,27 @@ public function test_identical_registered_constructors_with_one_argument_throws_ { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./'); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); $this->mapperBuilder() ->registerConstructor( fn (int $int): stdClass => new stdClass(), - fn (float $float): stdClass => new stdClass(), + fn (int $int): stdClass => new stdClass(), + ) + ->mapper() + ->map(stdClass::class, []); + } + + public function test_constructors_with_colliding_arguments_throws_exception(): void + { + $this->expectException(ObjectBuildersCollision::class); + $this->expectExceptionCode(1654955787); + $this->expectExceptionMessageMatches('/A type collision was detected between the constructors `Closure .*` and `Closure .*`\./'); + + $this->mapperBuilder() + ->registerConstructor( + fn (int $valueA, float $valueB): stdClass => new stdClass(), + fn (float $valueA, int $valueB): stdClass => new stdClass(), ) ->mapper() ->map(stdClass::class, []); @@ -510,7 +621,7 @@ public function test_identical_registered_constructors_with_several_argument_thr { $this->expectException(ObjectBuildersCollision::class); $this->expectExceptionCode(1654955787); - $this->expectExceptionMessage('A collision was detected between the following constructors of the class `stdClass`: `CuyZ\Valinor\Tests\Integration\Mapping\constructorA()`, `CuyZ\Valinor\Tests\Integration\Mapping\constructorB()`.'); + $this->expectExceptionMessage('A type collision was detected between the constructors `CuyZ\Valinor\Tests\Integration\Mapping\constructorA()` and `CuyZ\Valinor\Tests\Integration\Mapping\constructorB()`.'); $this->mapperBuilder() ->registerConstructor( diff --git a/tests/Unit/Mapper/Tree/ShellTest.php b/tests/Unit/Mapper/Tree/ShellTest.php index 96cb70b0..a8b243c1 100644 --- a/tests/Unit/Mapper/Tree/ShellTest.php +++ b/tests/Unit/Mapper/Tree/ShellTest.php @@ -42,6 +42,15 @@ public function test_change_type_changes_type(): void self::assertSame($typeB, $shellB->type()); } + public function test_allows_superfluous_keys(): void + { + $shellA = Shell::root(new Settings(), new FakeType(), []); + $shellB = $shellA->withAllowedSuperfluousKeys(['foo', 'bar']); + + self::assertNotSame($shellA, $shellB); + self::assertSame(['foo', 'bar'], $shellB->allowedSuperfluousKeys()); + } + public function test_change_value_changes_value(): void { $valueA = 'foo'; diff --git a/tests/Unit/Utility/TypeHelperTest.php b/tests/Unit/Utility/TypeHelperTest.php index f509b1cd..75a8b5f9 100644 --- a/tests/Unit/Utility/TypeHelperTest.php +++ b/tests/Unit/Utility/TypeHelperTest.php @@ -9,12 +9,26 @@ use CuyZ\Valinor\Tests\Fake\Definition\FakeParameterDefinition; use CuyZ\Valinor\Tests\Fake\Type\FakeObjectType; use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Type\Types\ArrayType; +use CuyZ\Valinor\Type\Types\NativeBooleanType; +use CuyZ\Valinor\Type\Types\NativeFloatType; +use CuyZ\Valinor\Type\Types\NativeIntegerType; +use CuyZ\Valinor\Type\Types\NativeStringType; use CuyZ\Valinor\Type\Types\UnionType; use CuyZ\Valinor\Utility\TypeHelper; use PHPUnit\Framework\TestCase; final class TypeHelperTest extends TestCase { + public function test_types_have_correct_priorities(): void + { + self::assertSame(4, TypeHelper::typePriority(NativeIntegerType::get())); + self::assertSame(3, TypeHelper::typePriority(NativeFloatType::get())); + self::assertSame(2, TypeHelper::typePriority(NativeStringType::get())); + self::assertSame(1, TypeHelper::typePriority(NativeBooleanType::get())); + self::assertSame(0, TypeHelper::typePriority(ArrayType::native())); + } + public function test_arguments_dump_is_correct(): void { $typeA = FakeType::permissive(); From e1142fec1d2980a528d260e318ee0ca4c2657766 Mon Sep 17 00:00:00 2001 From: Marco Pivetta Date: Mon, 2 Sep 2024 14:44:59 +0200 Subject: [PATCH 07/12] test: add test for non-intersecting hashmap type constructors In this test, Valinor considers two disjoint input types as colliding. Similar to https://github.com/CuyZ/Valinor/pull/487, this scenario was detected while trying to map multiple constructors for XML structures that may present different data depending on singular/plural entries found: ```xml 123 456 789 ``` In #487, we attempted to map a single constructor using `array{foo: T|list}`, while in this patch, we found the issue because we attempted to attack the problem by declaring separate constructors that would work on `array{foo: T}` and `array{foo: list}` disjointly, but failed to do so due to aggressive collision detection logic. Initially discovered by @Tigerman55 --- .../ConstructorRegistrationMappingTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index 9282923f..402e745a 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -633,6 +633,33 @@ public function test_identical_registered_constructors_with_several_argument_thr ->map(stdClass::class, []); } + public function test_non_intersecting_hashmap_type_constructors_do_not_lead_to_collisions(): void + { + $mapper = $this->mapperBuilder() + ->registerConstructor( + /** @param array{key: SimpleObject} $input */ + static fn (array $input): stdClass => (object)['single-item' => $input], + /** @param array{key: list} $input */ + static fn (array $input): stdClass => (object)['multiple-items' => $input], + ) + ->mapper(); + + $hello = new SimpleObject(); + $world = new SimpleObject(); + + $hello->value = 'hello'; + $world->value = 'world'; + + try { + self::assertEquals( + (object) ['multiple-items' => ['key' => [$hello, $world]]], + $mapper->map(stdClass::class, ['key' => ['hello', 'world']]) + ); + } catch (MappingError $error) { + $this->mappingFail($error); + } + } + public function test_source_not_matching_registered_constructors_throws_exception(): void { try { From 4e0bc8b6a9ca4ce5350a8206f7c8d81664806a01 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Mon, 2 Sep 2024 14:56:25 +0200 Subject: [PATCH 08/12] release: version 1.13.0 --- docs/pages/project/changelog.md | 1 + .../pages/project/changelog/version-1.13.0.md | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/pages/project/changelog/version-1.13.0.md diff --git a/docs/pages/project/changelog.md b/docs/pages/project/changelog.md index e888fae4..3ef66227 100644 --- a/docs/pages/project/changelog.md +++ b/docs/pages/project/changelog.md @@ -9,6 +9,7 @@ Below are listed the changelogs for all released versions of the library. ## Version 1 +- [`1.13.0` — 2nd of September 2024](changelog/version-1.13.0.md) - [`1.12.0` — 4th of April 2024](changelog/version-1.12.0.md) - [`1.11.0` — 27th of March 2024](changelog/version-1.11.0.md) - [`1.10.0` — 12th of March 2024](changelog/version-1.10.0.md) diff --git a/docs/pages/project/changelog/version-1.13.0.md b/docs/pages/project/changelog/version-1.13.0.md new file mode 100644 index 00000000..d15fd06c --- /dev/null +++ b/docs/pages/project/changelog/version-1.13.0.md @@ -0,0 +1,114 @@ +# Changelog 1.13.0 — 2nd of September 2024 + +!!! info inline end "[See release on GitHub]" + [See release on GitHub]: https://github.com/CuyZ/Valinor/releases/tag/1.13.0 + +## Notable changes + +**Microseconds support for timestamp format** + +Prior to this patch, this would require a custom constructor in the form of: + +```php +static fn(float | int $timestamp): DateTimeImmutable => new + DateTimeImmutable(sprintf("@%d", $timestamp)), +``` + +This bypasses the datetime format support of Valinor entirely. This is required +because the library does not support floats as valid `DateTimeInterface` input +values. + +This commit adds support for floats and registers `timestamp.microseconds` +(`U.u`) as a valid default format. + +**Support for `value-of` type** + +This type can be used as follows: + +```php +enum Suit: string +{ + case Hearts = 'H'; + case Diamonds = 'D'; + case Clubs = 'C'; + case Spades = 'S'; +} + +$suit = (new \CuyZ\Valinor\MapperBuilder()) + ->mapper() + ->map('value-of', 'D'); + +// $suit === 'D' +``` + +**Object constructors parameters types inferring improvements** + +The collision system that checks object constructors parameters types is now way +more clever, as it no longer checks for parameters' names only. Types are now +also checked, and only true collision will be detected, for instance when two +constructors share a parameter with the same name and type. + +Note that when two parameters share the same name, the following type priority +operates: + +1. Non-scalar type +2. Integer type +3. Float type +4. String type +5. Boolean type + +With this change, the code below is now valid: + +```php +final readonly class Money +{ + private function __construct( + public int $value, + ) {} + + #[\CuyZ\Valinor\Mapper\Object\Constructor] + public static function fromInt(int $value): self + { + return new self($value); + } + + #[\CuyZ\Valinor\Mapper\Object\Constructor] + public static function fromString(string $value): self + { + if (! preg_match('/^\d+€$/', $value)) { + throw new \InvalidArgumentException('Invalid money format'); + } + + return new self((int)rtrim($value, '€')); + } +} + +$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper(); + +$mapper->map(Money::class, 42); // ✅ +$mapper->map(Money::class, '42€'); // ✅ +``` + +### Features + +* Add microseconds support to timestamp format ([02bd2e](https://github.com/CuyZ/Valinor/commit/02bd2e5e0f0e7d4daf234852464085bcdd1a0eb2)) +* Add support for `value-of` type ([b1017c](https://github.com/CuyZ/Valinor/commit/b1017ce55729f0698c7629d57a3d3a30c0f9bff3)) +* Improve object constructors parameters types inferring ([2150dc](https://github.com/CuyZ/Valinor/commit/2150dcad4ce821bfe36c3718346ccc412e37832a)) + +### Bug Fixes + +* Allow any constant in class constant type ([694275](https://github.com/CuyZ/Valinor/commit/6942755865f91c80af8ea97fde2faa390478a6b8)) +* Allow docblock for transformer callable type ([69e0e3](https://github.com/CuyZ/Valinor/commit/69e0e3a5f1de6a5eedcfa4125d8639be91f0c303)) +* Do not override invalid variadic parameter type ([c5860f](https://github.com/CuyZ/Valinor/commit/c5860f0e5b3f59f49900bfbb20ca4493916eca7a)) +* Handle interface generics ([40e6fa](https://github.com/CuyZ/Valinor/commit/40e6fa340819961068b8be178e312a99c06cede2)) +* Handle iterable objects as iterable during normalization ([436e3c](https://github.com/CuyZ/Valinor/commit/436e3c25532d5cf396b00354ec5459e812c2953e)) +* Properly format empty object with JSON normalizer ([ba22b5](https://github.com/CuyZ/Valinor/commit/ba22b5233e80f0ffbbe9591a5099b9dd62715eb8)) +* Properly handle nested local type aliases ([127839](https://github.com/CuyZ/Valinor/commit/1278392757a4e9dc9eee2ab642c5700e83ccf982)) + +### Other + +* Exclude unneeded attributes in class/function definitions ([1803d0](https://github.com/CuyZ/Valinor/commit/1803d094f08b256c64535f4f86e32ab35a07bbf1)) +* Improve mapping performance for nullable union type ([6fad94](https://github.com/CuyZ/Valinor/commit/6fad94a46785dfb853c11c241e3f60bcf6a85ede)) +* Move "float type accepting integer value" logic in `Shell` ([047953](https://github.com/CuyZ/Valinor/commit/0479532fbc96fca35dcbfb4c1f5a9ef63e7625c5)) +* Move setting values in shell ([84b1ff](https://github.com/CuyZ/Valinor/commit/84b1ffbc8190a709d752a9882f71f6f419ad0434)) +* Reorganize type resolver services ([86fb7b](https://github.com/CuyZ/Valinor/commit/86fb7b6303b15b54da6ac02ca8a7008b23c8bcff)) From 304db395bf1b16d1b8c4db3278f38fcac3f020d1 Mon Sep 17 00:00:00 2001 From: Filippo Tessarotto Date: Mon, 16 Sep 2024 19:05:38 +0200 Subject: [PATCH 09/12] misc: change implicitly nullable parameter types --- src/Mapper/Tree/Shell.php | 2 +- src/Type/Types/ClassStringType.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index cec31b4d..150083cd 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -53,7 +53,7 @@ public static function root( return (new self($settings, $type))->withValue($value); } - public function child(string $name, Type $type, Attributes $attributes = null): self + public function child(string $name, Type $type, ?Attributes $attributes = null): self { $instance = new self($this->settings, $type); $instance->name = $name; diff --git a/src/Type/Types/ClassStringType.php b/src/Type/Types/ClassStringType.php index e1404e55..d715d646 100644 --- a/src/Type/Types/ClassStringType.php +++ b/src/Type/Types/ClassStringType.php @@ -27,7 +27,7 @@ final class ClassStringType implements StringType, CompositeType private string $signature; - public function __construct(ObjectType|UnionType $subType = null) + public function __construct(ObjectType|UnionType|null $subType = null) { if ($subType instanceof UnionType) { foreach ($subType->types() as $type) { From f3e8c1e09c0b947693cd40941d2ae5ab9b31bbd9 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Mon, 16 Sep 2024 20:26:41 +0300 Subject: [PATCH 10/12] feat: add support for `JSON_FORCE_OBJECT` option in JSON normalizer ```php (new \CuyZ\Valinor\MapperBuilder()) ->normalizer(Format::json()) ->withOptions(JSON_FORCE_OBJECT) ->normalize(['foo', 'bar']); // {"0":"foo","1":"bar"} ``` --- src/Normalizer/Formatter/JsonFormatter.php | 8 +++++-- src/Normalizer/JsonNormalizer.php | 4 +++- .../Integration/Normalizer/NormalizerTest.php | 21 +++++++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/Normalizer/Formatter/JsonFormatter.php b/src/Normalizer/Formatter/JsonFormatter.php index 6e46220d..e7dfe298 100644 --- a/src/Normalizer/Formatter/JsonFormatter.php +++ b/src/Normalizer/Formatter/JsonFormatter.php @@ -17,6 +17,7 @@ use function is_scalar; use function json_encode; +use const JSON_FORCE_OBJECT; use const JSON_THROW_ON_ERROR; /** @internal */ @@ -54,8 +55,11 @@ public function format(mixed $value): void // afterward, this leads to a JSON array being written, while it // should have been an object. This is a trade-off we accept, // considering most generators starting at 0 are actually lists. - $isList = ($value instanceof Generator && $value->key() === 0) - || (is_array($value) && array_is_list($value)); + $isList = ! ($this->jsonEncodingOptions & JSON_FORCE_OBJECT) + && ( + ($value instanceof Generator && $value->key() === 0) + || (is_array($value) && array_is_list($value)) + ); $isFirst = true; diff --git a/src/Normalizer/JsonNormalizer.php b/src/Normalizer/JsonNormalizer.php index 872955ed..3ccb8702 100644 --- a/src/Normalizer/JsonNormalizer.php +++ b/src/Normalizer/JsonNormalizer.php @@ -14,6 +14,7 @@ use function is_resource; use function stream_get_contents; +use const JSON_FORCE_OBJECT; use const JSON_HEX_AMP; use const JSON_HEX_APOS; use const JSON_HEX_QUOT; @@ -34,7 +35,8 @@ */ final class JsonNormalizer implements Normalizer { - private const ACCEPTABLE_JSON_OPTIONS = JSON_HEX_QUOT + private const ACCEPTABLE_JSON_OPTIONS = JSON_FORCE_OBJECT + | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS diff --git a/tests/Integration/Normalizer/NormalizerTest.php b/tests/Integration/Normalizer/NormalizerTest.php index 80bbb59e..6599fd70 100644 --- a/tests/Integration/Normalizer/NormalizerTest.php +++ b/tests/Integration/Normalizer/NormalizerTest.php @@ -30,6 +30,7 @@ use function array_merge; +use const JSON_FORCE_OBJECT; use const JSON_HEX_TAG; use const JSON_THROW_ON_ERROR; @@ -194,6 +195,21 @@ public static function normalize_basic_values_yields_expected_output_data_provid 'expected json' => '{"foo":"foo","bar":"bar"}', ]; + yield 'list' => [ + 'input' => ['foo', 'bar'], + 'expected array' => ['foo', 'bar'], + 'expected json' => '["foo","bar"]', + ]; + + yield 'list kept as object in json' => [ + 'input' => ['foo', 'bar'], + 'expected array' => ['foo', 'bar'], + 'expected json' => '{"0":"foo","1":"bar"}', + [], + [], + JSON_FORCE_OBJECT + ]; + yield 'ArrayObject' => [ 'input' => new ArrayObject(['foo' => 'foo', 'bar' => 'bar']), 'expected array' => [ @@ -1148,16 +1164,13 @@ public function test_json_transformer_will_always_throw_on_error(): void public function test_json_transformer_only_accepts_acceptable_json_options(): void { - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT); - self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PARTIAL_OUTPUT_ON_ERROR); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PRETTY_PRINT); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); - $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT); + $normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT); self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer)); } } From b9c6add856ba6b2ee2034deaaee312a99159e885 Mon Sep 17 00:00:00 2001 From: Joas Schilling <213943+nickvergessen@users.noreply.github.com> Date: Mon, 16 Sep 2024 19:45:03 +0200 Subject: [PATCH 11/12] misc: fix typo in property type annotation --- src/Type/Parser/Lexer/TokenizedAnnotation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Parser/Lexer/TokenizedAnnotation.php b/src/Type/Parser/Lexer/TokenizedAnnotation.php index 4e4c403c..0aa15f99 100644 --- a/src/Type/Parser/Lexer/TokenizedAnnotation.php +++ b/src/Type/Parser/Lexer/TokenizedAnnotation.php @@ -13,7 +13,7 @@ final class TokenizedAnnotation public function __construct( /** @var non-empty-string */ private string $name, - /** @var non-empty-list> */ + /** @var non-empty-list */ private array $tokens, ) {} From 8742b273f1bdd6e7037f1398b9d8822508ffda63 Mon Sep 17 00:00:00 2001 From: NanoSector Date: Mon, 16 Sep 2024 20:57:14 +0200 Subject: [PATCH 12/12] fix: handle float type casting properly Regression fix for 0479532fbc96fca35dcbfb4c1f5a9ef63e7625c5 --- src/Mapper/Tree/Shell.php | 24 ++++++++++++------- .../Integration/Mapping/UnionMappingTest.php | 9 +++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php index 150083cd..10831820 100644 --- a/src/Mapper/Tree/Shell.php +++ b/src/Mapper/Tree/Shell.php @@ -80,6 +80,7 @@ public function withType(Type $newType): self { $clone = clone $this; $clone->type = $newType; + $clone->value = self::castCompatibleValue($newType, $this->value); return $clone; } @@ -91,17 +92,9 @@ public function type(): Type public function withValue(mixed $value): self { - // When the value is an integer and the type is a float, the value is - // cast to float, to follow the rule of PHP regarding acceptance of an - // integer value in a float type. Note that PHPStan/Psalm analysis - // applies the same rule. - if ($this->type instanceof FloatType && is_int($value)) { - $value = (float)$value; - } - $clone = clone $this; $clone->hasValue = true; - $clone->value = $value; + $clone->value = self::castCompatibleValue($clone->type, $value); return $clone; } @@ -173,4 +166,17 @@ public function path(): string return implode('.', $path); } + + private static function castCompatibleValue(Type $type, mixed $value): mixed + { + // When the value is an integer and the type is a float, the value is + // cast to float, to follow the rule of PHP regarding acceptance of an + // integer value in a float type. Note that PHPStan/Psalm analysis + // applies the same rule. + if ($type instanceof FloatType && is_int($value)) { + return (float)$value; + } + + return $value; + } } diff --git a/tests/Integration/Mapping/UnionMappingTest.php b/tests/Integration/Mapping/UnionMappingTest.php index 8612d507..e840a98c 100644 --- a/tests/Integration/Mapping/UnionMappingTest.php +++ b/tests/Integration/Mapping/UnionMappingTest.php @@ -64,6 +64,15 @@ public static function union_mapping_works_properly_data_provider(): iterable 'assertion' => fn (mixed $result) => self::assertNull($result), ]; + yield 'nullable float with integer value' => [ + 'type' => 'float|null', + 'source' => 42, + 'assertion' => function (mixed $result) { + self::assertIsFloat($result); + self::assertEquals(42.0, $result); + }, + ]; + yield 'string or list of string, with string' => [ 'type' => 'string|list', 'source' => 'foo',