From c1eeb868ed73df855c5c1151dedcfa2008733bf7 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Wed, 27 Mar 2024 21:08:58 +0100 Subject: [PATCH] feat: allow mapping to `array-key` type --- src/Type/CombiningType.php | 4 +- .../Parser/Lexer/Token/ClassNameToken.php | 3 +- src/Type/Types/ArrayKeyType.php | 56 ++++++++++++-- src/Type/Types/IntersectionType.php | 11 +-- src/Type/Types/UnionType.php | 30 ++++---- .../Type/Parser/Lexer/GenericLexerTest.php | 2 +- .../Object/ScalarValuesMappingTest.php | 29 +++++++- .../Other/FlexibleCastingMappingTest.php | 11 +++ tests/Unit/Type/Types/ArrayKeyTypeTest.php | 73 ++++++++++++++++++- .../Unit/Type/Types/IntersectionTypeTest.php | 10 ++- .../Type/Types/UndefinedObjectTypeTest.php | 2 +- tests/Unit/Type/Types/UnionTypeTest.php | 2 +- 12 files changed, 198 insertions(+), 35 deletions(-) diff --git a/src/Type/CombiningType.php b/src/Type/CombiningType.php index a659fde2..8dbd7f98 100644 --- a/src/Type/CombiningType.php +++ b/src/Type/CombiningType.php @@ -10,7 +10,7 @@ interface CombiningType extends CompositeType public function isMatchedBy(Type $other): bool; /** - * @return Type[] + * @return non-empty-list */ - public function types(): iterable; + public function types(): array; } diff --git a/src/Type/Parser/Lexer/Token/ClassNameToken.php b/src/Type/Parser/Lexer/Token/ClassNameToken.php index 94f7f724..d1a5e0ab 100644 --- a/src/Type/Parser/Lexer/Token/ClassNameToken.php +++ b/src/Type/Parser/Lexer/Token/ClassNameToken.php @@ -25,6 +25,7 @@ use function array_keys; use function array_map; use function array_shift; +use function array_values; use function count; use function explode; @@ -101,7 +102,7 @@ private function classConstant(TokenStream $stream): ?Type $cases = array_map(static fn ($value) => ValueTypeFactory::from($value), $cases); if (count($cases) > 1) { - return new UnionType(...$cases); + return new UnionType(...array_values($cases)); } return reset($cases); diff --git a/src/Type/Types/ArrayKeyType.php b/src/Type/Types/ArrayKeyType.php index dd0bd847..3adc944a 100644 --- a/src/Type/Types/ArrayKeyType.php +++ b/src/Type/Types/ArrayKeyType.php @@ -4,16 +4,19 @@ namespace CuyZ\Valinor\Type\Types; -use CuyZ\Valinor\Type\CombiningType; +use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage; +use CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder; use CuyZ\Valinor\Type\IntegerType; use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey; +use CuyZ\Valinor\Type\ScalarType; use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; +use LogicException; use function is_int; /** @internal */ -final class ArrayKeyType implements Type +final class ArrayKeyType implements ScalarType { private static self $default; @@ -21,28 +24,36 @@ final class ArrayKeyType implements Type private static self $string; - /** @var array */ + /** @var non-empty-list */ private array $types; private string $signature; private function __construct(Type $type) { - $this->signature = $type->toString(); - $this->types = $type instanceof CombiningType + $types = $type instanceof UnionType ? [...$type->types()] : [$type]; - foreach ($this->types as $subType) { + foreach ($types as $subType) { if (! $subType instanceof IntegerType && ! $subType instanceof StringType) { throw new InvalidArrayKey($subType); } } + + /** @var non-empty-list $types */ + $this->types = $types; + $this->signature = $type->toString(); } public static function default(): self { - return self::$default ??= new self(new UnionType(NativeIntegerType::get(), NativeStringType::get())); + if (!isset(self::$default)) { + self::$default = new self(new UnionType(NativeIntegerType::get(), NativeStringType::get())); + self::$default->signature = 'array-key'; + } + + return self::$default; } public static function integer(): self @@ -86,6 +97,10 @@ public function matches(Type $other): bool return true; } + if ($other instanceof UnionType) { + return $this->isMatchedBy($other); + } + if (! $other instanceof self) { return false; } @@ -114,6 +129,33 @@ public function isMatchedBy(Type $other): bool return false; } + public function canCast(mixed $value): bool + { + foreach ($this->types as $type) { + if ($type->canCast($value)) { + return true; + } + } + + return false; + } + + public function cast(mixed $value): string|int + { + foreach ($this->types as $type) { + if ($type->canCast($value)) { + return $type->cast($value); + } + } + + throw new LogicException(); + } + + public function errorMessage(): ErrorMessage + { + return MessageBuilder::newError('Value {source_value} is not a valid array key.')->build(); + } + public function toString(): string { return $this->signature; diff --git a/src/Type/Types/IntersectionType.php b/src/Type/Types/IntersectionType.php index 61724eba..9b220df9 100644 --- a/src/Type/Types/IntersectionType.php +++ b/src/Type/Types/IntersectionType.php @@ -9,20 +9,21 @@ use CuyZ\Valinor\Type\CompositeType; use CuyZ\Valinor\Type\Type; +use function array_values; use function implode; /** @internal */ final class IntersectionType implements CombiningType { - /** @var ObjectType[] */ + /** @var non-empty-list */ private array $types; private string $signature; - public function __construct(ObjectType ...$types) + public function __construct(ObjectType $type, ObjectType $otherType, ObjectType ...$otherTypes) { - $this->types = $types; - $this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $types)); + $this->types = [$type, $otherType, ...array_values($otherTypes)]; + $this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $this->types)); } public function accepts(mixed $value): bool @@ -82,7 +83,7 @@ public function traverse(): array } /** - * @return ObjectType[] + * @return non-empty-list */ public function types(): array { diff --git a/src/Type/Types/UnionType.php b/src/Type/Types/UnionType.php index c66ce2bc..5ed460dd 100644 --- a/src/Type/Types/UnionType.php +++ b/src/Type/Types/UnionType.php @@ -15,30 +15,34 @@ /** @internal */ final class UnionType implements CombiningType { - /** @var Type[] */ - private array $types = []; + /** @var non-empty-list */ + private array $types; private string $signature; - public function __construct(Type ...$types) + public function __construct(Type $type, Type $otherType, Type ...$otherTypes) { - $this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $types)); + $types = [$type, $otherType, ...$otherTypes]; + $filteredTypes = []; - foreach ($types as $type) { - if ($type instanceof self) { - foreach ($type->types as $subType) { - $this->types[] = $subType; + foreach ($types as $subType) { + if ($subType instanceof self) { + foreach ($subType->types as $anotherSubType) { + $filteredTypes[] = $anotherSubType; } continue; } - if ($type instanceof MixedType) { + if ($subType instanceof MixedType) { throw new ForbiddenMixedType(); } - $this->types[] = $type; + $filteredTypes[] = $subType; } + + $this->types = $filteredTypes; + $this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $this->types)); } public function accepts(mixed $value): bool @@ -65,12 +69,12 @@ public function matches(Type $other): bool } foreach ($this->types as $type) { - if (! $type->matches($other)) { - return false; + if ($type->matches($other)) { + return true; } } - return true; + return false; } public function isMatchedBy(Type $other): bool diff --git a/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php b/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php index 94b3779f..cf3937c2 100644 --- a/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php +++ b/tests/Functional/Type/Parser/Lexer/GenericLexerTest.php @@ -208,7 +208,7 @@ public function test_generic_with_non_matching_array_key_type_for_template_throw $this->expectException(InvalidAssignedGeneric::class); $this->expectExceptionCode(1604613633); - $this->expectExceptionMessage("The generic `bool` is not a subtype of `int|string` for the template `Template` of the class `$className`."); + $this->expectExceptionMessage("The generic `bool` is not a subtype of `array-key` for the template `Template` of the class `$className`."); $this->parser->parse("$className"); } diff --git a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php index e9ba4bf4..47555218 100644 --- a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php @@ -45,6 +45,8 @@ public function test_values_are_mapped_properly(): void 'classString' => self::class, 'classStringOfDateTime' => DateTimeImmutable::class, 'classStringOfAlias' => stdClass::class, + 'arrayKeyWithString' => 'foo', + 'arrayKeyWithInteger' => 42, ]; foreach ([ScalarValues::class, ScalarValuesWithConstructor::class] as $class) { @@ -81,6 +83,8 @@ public function test_values_are_mapped_properly(): void self::assertSame(self::class, $result->classString); self::assertSame(DateTimeImmutable::class, $result->classStringOfDateTime); self::assertSame(stdClass::class, $result->classStringOfAlias); + self::assertSame('foo', $result->arrayKeyWithString); + self::assertSame(42, $result->arrayKeyWithInteger); } } @@ -94,6 +98,17 @@ public function test_value_with_invalid_type_throws_exception(): void self::assertSame('Value object(stdClass) is not a valid string.', (string)$error); } } + + public function test_invalid_array_key_throws_exception(): void + { + try { + $this->mapperBuilder()->mapper()->map('array-key', new stdClass()); + } catch (MappingError $exception) { + $error = $exception->node()->messages()[0]; + + self::assertSame('Value object(stdClass) is not a valid array key.', (string)$error); + } + } } class ScalarValues @@ -173,6 +188,12 @@ class ScalarValues /** @var class-string */ public string $classStringOfAlias; + + /** @var array-key */ + public string|int $arrayKeyWithString; + + /** @var array-key */ + public string|int $arrayKeyWithInteger; } class ScalarValuesWithConstructor extends ScalarValues @@ -200,6 +221,8 @@ class ScalarValuesWithConstructor extends ScalarValues * @param class-string $classString * @param class-string $classStringOfDateTime * @param class-string $classStringOfAlias + * @param array-key $arrayKeyWithString + * @param array-key $arrayKeyWithInteger */ public function __construct( bool $boolean, @@ -228,7 +251,9 @@ public function __construct( string $stringValueContainingSpecialCharsWithDoubleQuote, string $classString, string $classStringOfDateTime, - string $classStringOfAlias + string $classStringOfAlias, + int|string $arrayKeyWithString, + int|string $arrayKeyWithInteger, ) { $this->boolean = $boolean; $this->float = $float; @@ -257,5 +282,7 @@ public function __construct( $this->classString = $classString; $this->classStringOfDateTime = $classStringOfDateTime; $this->classStringOfAlias = $classStringOfAlias; + $this->arrayKeyWithString = $arrayKeyWithString; + $this->arrayKeyWithInteger = $arrayKeyWithInteger; } } diff --git a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php index 021e2be3..e8f36eba 100644 --- a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php +++ b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php @@ -326,6 +326,17 @@ public function test_source_value_is_casted_when_other_type_cannot_be_casted(): self::assertSame('foo', $result); } + + public function test_source_can_be_casted_to_array_key(): void + { + try { + $result = $this->mapper->map('array-key', new StringableObject('foo')); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame('foo', $result); + } } interface SomeInterfaceForClassWithNoProperties {} diff --git a/tests/Unit/Type/Types/ArrayKeyTypeTest.php b/tests/Unit/Type/Types/ArrayKeyTypeTest.php index e48bbade..050591ad 100644 --- a/tests/Unit/Type/Types/ArrayKeyTypeTest.php +++ b/tests/Unit/Type/Types/ArrayKeyTypeTest.php @@ -5,10 +5,12 @@ namespace CuyZ\Valinor\Tests\Unit\Type\Types; use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Tests\Fixture\Object\StringableObject; 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 LogicException; use PHPUnit\Framework\TestCase; use stdClass; @@ -25,7 +27,7 @@ public function test_instances_are_memoized(): void public function test_string_values_are_correct(): void { - self::assertSame('int|string', ArrayKeyType::default()->toString()); + self::assertSame('array-key', ArrayKeyType::default()->toString()); self::assertSame('int', ArrayKeyType::integer()->toString()); self::assertSame('string', ArrayKeyType::string()->toString()); } @@ -55,6 +57,75 @@ public function test_does_not_accept_incorrect_values(): void self::assertFalse(ArrayKeyType::default()->accepts(new stdClass())); } + public function test_default_array_key_can_cast_numeric_and_string_value(): void + { + self::assertTrue(ArrayKeyType::default()->canCast(42.1337)); + self::assertTrue(ArrayKeyType::default()->canCast(404)); + self::assertTrue(ArrayKeyType::default()->canCast('foo')); + self::assertTrue(ArrayKeyType::default()->canCast(new StringableObject('foo'))); + } + + public function test_default_array_key_cannot_cast_other_values(): void + { + self::assertFalse(ArrayKeyType::default()->canCast(null)); + self::assertFalse(ArrayKeyType::default()->canCast(['foo' => 'bar'])); + self::assertFalse(ArrayKeyType::default()->canCast(false)); + self::assertFalse(ArrayKeyType::default()->canCast(new stdClass())); + } + + public function test_integer_array_key_can_cast_numeric_value(): void + { + self::assertTrue(ArrayKeyType::integer()->canCast(42)); + } + + public function test_integer_array_key_cannot_cast_other_values(): void + { + self::assertFalse(ArrayKeyType::integer()->canCast(null)); + self::assertFalse(ArrayKeyType::integer()->canCast(42.1337)); + self::assertFalse(ArrayKeyType::integer()->canCast(['foo' => 'bar'])); + self::assertFalse(ArrayKeyType::integer()->canCast('Schwifty!')); + self::assertFalse(ArrayKeyType::integer()->canCast(false)); + self::assertFalse(ArrayKeyType::integer()->canCast(new stdClass())); + } + + public function test_string_array_key_can_cast_numeric_and_string_value(): void + { + self::assertTrue(ArrayKeyType::string()->canCast(42.1337)); + self::assertTrue(ArrayKeyType::string()->canCast(404)); + self::assertTrue(ArrayKeyType::string()->canCast('foo')); + self::assertTrue(ArrayKeyType::string()->canCast(new StringableObject('foo'))); + } + + public function test_string_array_key_cannot_cast_other_values(): void + { + self::assertFalse(ArrayKeyType::string()->canCast(null)); + self::assertFalse(ArrayKeyType::string()->canCast(['foo' => 'bar'])); + self::assertFalse(ArrayKeyType::string()->canCast(false)); + self::assertFalse(ArrayKeyType::string()->canCast(new stdClass())); + } + + public function test_cast_value_yields_correct_result(): void + { + self::assertSame(42, ArrayKeyType::default()->cast(42)); + self::assertSame('42.1337', ArrayKeyType::default()->cast(42.1337)); + self::assertSame('foo', ArrayKeyType::default()->cast('foo')); + self::assertSame('foo', ArrayKeyType::default()->cast(new StringableObject('foo'))); + + self::assertSame(42, ArrayKeyType::integer()->cast(42)); + + self::assertSame('42', ArrayKeyType::string()->cast(42)); + self::assertSame('42.1337', ArrayKeyType::string()->cast(42.1337)); + self::assertSame('foo', ArrayKeyType::string()->cast('foo')); + self::assertSame('foo', ArrayKeyType::string()->cast(new StringableObject('foo'))); + } + + public function test_cast_invalid_value_throw_exception(): void + { + $this->expectException(LogicException::class); + + ArrayKeyType::default()->cast(new stdClass()); + } + public function test_matches_each_others(): void { $arrayKeyDefault = ArrayKeyType::default(); diff --git a/tests/Unit/Type/Types/IntersectionTypeTest.php b/tests/Unit/Type/Types/IntersectionTypeTest.php index 7281931a..5f41767a 100644 --- a/tests/Unit/Type/Types/IntersectionTypeTest.php +++ b/tests/Unit/Type/Types/IntersectionTypeTest.php @@ -20,8 +20,14 @@ public function test_types_can_be_retrieved(): void $typeA = new FakeObjectType(); $typeB = new FakeObjectType(); $typeC = new FakeObjectType(); - - $types = (new IntersectionType($typeA, $typeB, $typeC))->types(); + $typeD = new FakeObjectType(); + + $types = (new IntersectionType( + $typeA, + $typeB, + // Putting those in associative array on purpose + ...['C' => $typeC, 'D' => $typeD], + ))->types(); self::assertSame($typeA, $types[0]); self::assertSame($typeB, $types[1]); diff --git a/tests/Unit/Type/Types/UndefinedObjectTypeTest.php b/tests/Unit/Type/Types/UndefinedObjectTypeTest.php index 9fad82a5..d908fc89 100644 --- a/tests/Unit/Type/Types/UndefinedObjectTypeTest.php +++ b/tests/Unit/Type/Types/UndefinedObjectTypeTest.php @@ -79,7 +79,7 @@ public function test_does_not_match_union_containing_invalid_type(): void public function test_matches_intersection_type(): void { - self::assertTrue($this->undefinedObjectType->matches(new IntersectionType())); + self::assertTrue($this->undefinedObjectType->matches(new IntersectionType(new FakeObjectType(), new FakeObjectType()))); } public function test_matches_mixed_type(): void diff --git a/tests/Unit/Type/Types/UnionTypeTest.php b/tests/Unit/Type/Types/UnionTypeTest.php index a4443373..2face8aa 100644 --- a/tests/Unit/Type/Types/UnionTypeTest.php +++ b/tests/Unit/Type/Types/UnionTypeTest.php @@ -133,7 +133,7 @@ public function test_does_not_match_other_not_matching_union(): void $typeC = new FakeType(); $unionTypeA = new UnionType($typeA, $typeB, $typeC); - $unionTypeB = new UnionType($typeB); + $unionTypeB = new UnionType($typeB, $typeC); self::assertFalse($unionTypeA->matches($unionTypeB)); }