From 12af3ed1d396b1dbf81b11a7ad5a0e205b47e74a Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Fri, 18 Aug 2023 17:52:02 +0200 Subject: [PATCH] feat: allow any string or integer in array key The following array key types are now accepted and will work properly with the mapper: ```php $mapper->map("array<'foo'|'bar', string>", ['foo' => 'foo']); $mapper->map('array<42|1337, string>', [42 => 'foo']); $mapper->map('array', [42 => 'foo']); $mapper->map('array', [-42 => 'foo']); $mapper->map('array, string>', [42 => 'foo']); $mapper->map('array', ['foo' => 'foo']); $mapper->map('array', ['SomeClass' => 'foo']); ``` --- docs/pages/usage/type-reference.md | 12 + src/Mapper/Tree/Builder/ArrayNodeBuilder.php | 9 +- .../Exception/Iterable/InvalidArrayKey.php | 15 +- src/Type/Parser/Lexer/Token/ArrayToken.php | 16 +- src/Type/Types/ArrayKeyType.php | 52 +++-- .../Type/Parser/Lexer/NativeLexerTest.php | 4 +- .../Mapping/Other/ArrayMappingTest.php | 205 ++++++++++++++++++ .../Other/ArrayOfScalarMappingTest.php | 25 --- .../Tree/Builder/ArrayNodeBuilderTest.php | 19 +- tests/Unit/Type/Types/ArrayKeyTypeTest.php | 6 +- 10 files changed, 267 insertions(+), 96 deletions(-) create mode 100644 tests/Integration/Mapping/Other/ArrayMappingTest.php delete mode 100644 tests/Integration/Mapping/Other/ArrayOfScalarMappingTest.php diff --git a/docs/pages/usage/type-reference.md b/docs/pages/usage/type-reference.md index 585a209b..68932cc5 100644 --- a/docs/pages/usage/type-reference.md +++ b/docs/pages/usage/type-reference.md @@ -96,6 +96,18 @@ final class SomeClass /** @var array */ private array $arrayOfClassWithIntegerKeys, + /** @var array */ + private array $arrayOfClassWithNonEmptyStringKeys, + + /** @var array<'foo'|'bar', string> */ + private array $arrayOfClassWithStringValueKeys, + + /** @var array<42|1337, string> */ + private array $arrayOfClassWithIntegerValueKeys, + + /** @var array */ + private array $arrayOfClassWithPositiveIntegerValueKeys, + /** @var non-empty-array */ private array $nonEmptyArrayOfStrings, diff --git a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php index bcacaca5..2ab1340a 100644 --- a/src/Mapper/Tree/Builder/ArrayNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ArrayNodeBuilder.php @@ -54,12 +54,13 @@ private function children(CompositeTraversableType $type, Shell $shell, RootNode $children = []; foreach ($values as $key => $value) { + $child = $shell->child((string)$key, $subType); + if (! $keyType->accepts($key)) { - throw new InvalidTraversableKey($key, $keyType); + $children[$key] = TreeNode::error($child, new InvalidTraversableKey($key, $keyType)); + } else { + $children[$key] = $rootBuilder->build($child->withValue($value)); } - - $child = $shell->child((string)$key, $subType)->withValue($value); - $children[$key] = $rootBuilder->build($child); } return $children; diff --git a/src/Type/Parser/Exception/Iterable/InvalidArrayKey.php b/src/Type/Parser/Exception/Iterable/InvalidArrayKey.php index 243687c2..a94089aa 100644 --- a/src/Type/Parser/Exception/Iterable/InvalidArrayKey.php +++ b/src/Type/Parser/Exception/Iterable/InvalidArrayKey.php @@ -6,26 +6,15 @@ use CuyZ\Valinor\Type\Parser\Exception\InvalidType; use CuyZ\Valinor\Type\Type; -use CuyZ\Valinor\Type\Types\ArrayType; -use CuyZ\Valinor\Type\Types\NonEmptyArrayType; use RuntimeException; /** @internal */ final class InvalidArrayKey extends RuntimeException implements InvalidType { - /** - * @param class-string $arrayType - */ - public function __construct(string $arrayType, Type $keyType, Type $subType) + public function __construct(Type $keyType) { - $signature = "array<{$keyType->toString()}, {$subType->toString()}>"; - - if ($arrayType === NonEmptyArrayType::class) { - $signature = "non-empty-array<{$keyType->toString()}, {$subType->toString()}>"; - } - parent::__construct( - "Invalid key type `{$keyType->toString()}` for `$signature`. It must be one of `array-key`, `int` or `string`.", + "Invalid array key type `{$keyType->toString()}`, it must be a valid string or integer.", 1604335007 ); } diff --git a/src/Type/Parser/Lexer/Token/ArrayToken.php b/src/Type/Parser/Lexer/Token/ArrayToken.php index d5d07a09..8d1563f0 100644 --- a/src/Type/Parser/Lexer/Token/ArrayToken.php +++ b/src/Type/Parser/Lexer/Token/ArrayToken.php @@ -5,17 +5,14 @@ namespace CuyZ\Valinor\Type\Parser\Lexer\Token; use CuyZ\Valinor\Type\CompositeTraversableType; -use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ArrayClosingBracketMissing; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ArrayCommaMissing; -use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayClosingBracketMissing; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayColonTokenMissing; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayCommaMissing; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementTypeMissing; use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayEmptyElements; use CuyZ\Valinor\Type\Parser\Lexer\TokenStream; -use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\Types\ArrayType; @@ -84,17 +81,10 @@ private function arrayType(TokenStream $stream): CompositeTraversableType throw new ArrayCommaMissing($this->arrayType, $type); } + $keyType = ArrayKeyType::from($type); $subType = $stream->read(); - if ($type instanceof ArrayKeyType) { - $arrayType = new ($this->arrayType)($type, $subType); - } elseif ($type instanceof IntegerType) { - $arrayType = new ($this->arrayType)(ArrayKeyType::integer(), $subType); - } elseif ($type instanceof StringType) { - $arrayType = new ($this->arrayType)(ArrayKeyType::string(), $subType); - } else { - throw new InvalidArrayKey($this->arrayType, $type, $subType); - } + $arrayType = new ($this->arrayType)($keyType, $subType); if ($stream->done() || ! $stream->forward() instanceof ClosingBracketToken) { throw new ArrayClosingBracketMissing($arrayType); @@ -138,7 +128,7 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType } if ($stream->done()) { - $elements[] = new ShapedArrayElement(new StringValueType((string)$index), $type); + $elements[] = new ShapedArrayElement(new IntegerValueType($index), $type); throw new ShapedArrayClosingBracketMissing($elements); } diff --git a/src/Type/Types/ArrayKeyType.php b/src/Type/Types/ArrayKeyType.php index 2655d3b4..dd0bd847 100644 --- a/src/Type/Types/ArrayKeyType.php +++ b/src/Type/Types/ArrayKeyType.php @@ -4,7 +4,9 @@ namespace CuyZ\Valinor\Type\Types; +use CuyZ\Valinor\Type\CombiningType; use CuyZ\Valinor\Type\IntegerType; +use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey; use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; @@ -13,32 +15,34 @@ /** @internal */ final class ArrayKeyType implements Type { + private static self $default; + private static self $integer; private static self $string; - private static self $integerAndString; - - /** @var array */ + /** @var array */ private array $types; private string $signature; - /** - * @codeCoverageIgnore - * @infection-ignore-all - */ - private function __construct(IntegerType|StringType ...$types) + private function __construct(Type $type) { - $this->types = $types; - $this->signature = count($this->types) === 1 - ? $this->types[0]->toString() - : 'array-key'; + $this->signature = $type->toString(); + $this->types = $type instanceof CombiningType + ? [...$type->types()] + : [$type]; + + foreach ($this->types as $subType) { + if (! $subType instanceof IntegerType && ! $subType instanceof StringType) { + throw new InvalidArrayKey($subType); + } + } } public static function default(): self { - return self::$integerAndString ??= new self(NativeIntegerType::get(), NativeStringType::get()); + return self::$default ??= new self(new UnionType(NativeIntegerType::get(), NativeStringType::get())); } public static function integer(): self @@ -51,16 +55,24 @@ public static function string(): self return self::$string ??= new self(NativeStringType::get()); } - public function accepts(mixed $value): bool + public static function from(Type $type): ?self { - // If an array key can be evaluated as an integer, it will always be - // cast to an integer, even if the actual key is a string. - if (is_int($value)) { - return true; - } + return match (true) { + $type instanceof self => $type, + $type instanceof NativeIntegerType => self::integer(), + $type instanceof NativeStringType => self::string(), + default => new self($type), + }; + } + public function accepts(mixed $value): bool + { foreach ($this->types as $type) { - if ($type->accepts($value)) { + // If an array key can be evaluated as an integer, it will always be + // cast to an integer, even if the actual key is a string. + if (is_int($value) && $type instanceof NativeStringType) { + return true; + } elseif ($type->accepts($value)) { return true; } } diff --git a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php index 105d8ea3..79854a82 100644 --- a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php +++ b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php @@ -1040,7 +1040,7 @@ public function test_invalid_array_key_throws_exception(): void { $this->expectException(InvalidArrayKey::class); $this->expectExceptionCode(1604335007); - $this->expectExceptionMessage('Invalid key type `float` for `array`. It must be one of `array-key`, `int` or `string`.'); + $this->expectExceptionMessage('Invalid array key type `float`, it must be a valid string or integer.'); $this->parser->parse('array'); } @@ -1049,7 +1049,7 @@ public function test_invalid_non_empty_array_key_throws_exception(): void { $this->expectException(InvalidArrayKey::class); $this->expectExceptionCode(1604335007); - $this->expectExceptionMessage('Invalid key type `float` for `non-empty-array`. It must be one of `array-key`, `int` or `string`.'); + $this->expectExceptionMessage('Invalid array key type `float`, it must be a valid string or integer.'); $this->parser->parse('non-empty-array'); } diff --git a/tests/Integration/Mapping/Other/ArrayMappingTest.php b/tests/Integration/Mapping/Other/ArrayMappingTest.php new file mode 100644 index 00000000..e4659c1e --- /dev/null +++ b/tests/Integration/Mapping/Other/ArrayMappingTest.php @@ -0,0 +1,205 @@ +mapper()->map('string[]', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_union_string_key_works_properly(): void + { + $source = ['foo' => 'foo', 'bar' => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map("array<'foo'|'bar', string>", $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_union_integer_key_works_properly(): void + { + $source = [42 => 'foo', 1337 => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map('array<42|1337, string>', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_positive_integer_key_works_properly(): void + { + $source = [42 => 'foo', 1337 => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_negative_integer_key_works_properly(): void + { + $source = [-42 => 'foo', -1337 => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_integer_range_key_works_properly(): void + { + $source = [-42 => 'foo', 42 => 'foo', 1337 => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map('array, string>', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_non_empty_string_key_works_properly(): void + { + $source = ['foo' => 'foo', 'bar' => 'bar']; + + try { + $result = (new MapperBuilder())->mapper()->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_map_to_array_with_class_string_key_works_properly(): void + { + $source = [stdClass::class => 'foo']; + + try { + $result = (new MapperBuilder())->mapper()->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($source, $result); + } + + public function test_value_with_invalid_integer_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array', ['foo' => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['foo']->messages()[0]; + + self::assertSame("Key 'foo' does not match type `int`.", (string)$error); + } + } + + public function test_value_with_invalid_positive_integer_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array', [-42 => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()[-42]->messages()[0]; + + self::assertSame("Key -42 does not match type `positive-int`.", (string)$error); + } + } + + public function test_value_with_invalid_negative_integer_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array', [42 => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()[42]->messages()[0]; + + self::assertSame("Key 42 does not match type `negative-int`.", (string)$error); + } + } + + public function test_value_with_invalid_integer_range_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array, string>', [-404 => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()[-404]->messages()[0]; + + self::assertSame("Key -404 does not match type `int<-42, 1337>`.", (string)$error); + } + } + + public function test_value_with_invalid_union_string_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map("array<'foo'|'bar', string>", ['baz' => 'baz']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['baz']->messages()[0]; + + self::assertSame("Key 'baz' does not match type `'foo'|'bar'`.", (string)$error); + } + } + + public function test_value_with_invalid_union_integer_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array<42|1337, string>', [404 => 'baz']); + } catch (MappingError $exception) { + $error = $exception->node()->children()[404]->messages()[0]; + + self::assertSame("Key 404 does not match type `42|1337`.", (string)$error); + } + } + + public function test_value_with_invalid_non_empty_string_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array', ['' => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['']->messages()[0]; + + self::assertSame("Key '' does not match type `non-empty-string`.", (string)$error); + } + } + + public function test_value_with_invalid_class_string_key_type_throws_exception(): void + { + try { + (new MapperBuilder())->mapper()->map('array', ['foo bar' => 'foo']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['foo bar']->messages()[0]; + + self::assertSame("Key 'foo bar' does not match type `class-string`.", (string)$error); + } + } +} diff --git a/tests/Integration/Mapping/Other/ArrayOfScalarMappingTest.php b/tests/Integration/Mapping/Other/ArrayOfScalarMappingTest.php deleted file mode 100644 index 1d70ec9d..00000000 --- a/tests/Integration/Mapping/Other/ArrayOfScalarMappingTest.php +++ /dev/null @@ -1,25 +0,0 @@ -mapper()->map('string[]', $source); - } catch (MappingError $error) { - $this->mappingFail($error); - } - - self::assertSame($source, $result); - } -} diff --git a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php index 773ae7ac..d5510b5a 100644 --- a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php @@ -5,14 +5,11 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; -use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; -use CuyZ\Valinor\Mapper\Tree\Exception\InvalidTraversableKey; +use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; -use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\Types\ArrayType; -use CuyZ\Valinor\Type\Types\NativeStringType; use PHPUnit\Framework\TestCase; final class ArrayNodeBuilderTest extends TestCase @@ -31,18 +28,4 @@ public function test_invalid_type_fails_assertion(): void (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new(new FakeType())); } - - public function test_invalid_source_key_throws_exception(): void - { - $this->expectException(InvalidTraversableKey::class); - $this->expectExceptionCode(1630946163); - $this->expectExceptionMessage("Key 'foo' does not match type `int`."); - - $type = new ArrayType(ArrayKeyType::integer(), NativeStringType::get()); - $value = [ - 'foo' => 'key is not ok', - ]; - - (new RootNodeBuilder(new ArrayNodeBuilder(true)))->build(FakeShell::new($type, $value)); - } } diff --git a/tests/Unit/Type/Types/ArrayKeyTypeTest.php b/tests/Unit/Type/Types/ArrayKeyTypeTest.php index 176f60c0..e48bbade 100644 --- a/tests/Unit/Type/Types/ArrayKeyTypeTest.php +++ b/tests/Unit/Type/Types/ArrayKeyTypeTest.php @@ -7,6 +7,8 @@ use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\Types\MixedType; +use CuyZ\Valinor\Type\Types\NativeIntegerType; +use CuyZ\Valinor\Type\Types\NativeStringType; use PHPUnit\Framework\TestCase; use stdClass; @@ -17,11 +19,13 @@ public function test_instances_are_memoized(): void self::assertSame(ArrayKeyType::default(), ArrayKeyType::default()); self::assertSame(ArrayKeyType::integer(), ArrayKeyType::integer()); self::assertSame(ArrayKeyType::string(), ArrayKeyType::string()); + self::assertSame(ArrayKeyType::integer(), ArrayKeyType::from(new NativeIntegerType())); + self::assertSame(ArrayKeyType::string(), ArrayKeyType::from(new NativeStringType())); } public function test_string_values_are_correct(): void { - self::assertSame('array-key', ArrayKeyType::default()->toString()); + self::assertSame('int|string', ArrayKeyType::default()->toString()); self::assertSame('int', ArrayKeyType::integer()->toString()); self::assertSame('string', ArrayKeyType::string()->toString()); }