diff --git a/README.md b/README.md index 707b18c..76ba5da 100644 --- a/README.md +++ b/README.md @@ -776,7 +776,7 @@ final class SomeClass public function __construct( private int|string $simpleUnion, - /** @var class-string|class-string */ + /** @var class-string */ private string $unionOfClassString, /** @var array */ diff --git a/src/Type/Parser/Lexer/Token/ClassStringToken.php b/src/Type/Parser/Lexer/Token/ClassStringToken.php index 2cc8c13..387000d 100644 --- a/src/Type/Parser/Lexer/Token/ClassStringToken.php +++ b/src/Type/Parser/Lexer/Token/ClassStringToken.php @@ -10,6 +10,7 @@ use CuyZ\Valinor\Type\Parser\Exception\Scalar\InvalidClassStringSubType; use CuyZ\Valinor\Type\Parser\Lexer\TokenStream; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\ClassStringType; +use CuyZ\Valinor\Type\Types\UnionType; use CuyZ\Valinor\Utility\IsSingleton; /** @internal */ @@ -27,7 +28,7 @@ final class ClassStringToken implements TraversingToken $type = $stream->read(); - if (! $type instanceof ObjectType) { + if (! $type instanceof ObjectType && ! $type instanceof UnionType) { throw new InvalidClassStringSubType($type); } diff --git a/src/Type/Types/ClassStringType.php b/src/Type/Types/ClassStringType.php index bbf6c75..182d306 100644 --- a/src/Type/Types/ClassStringType.php +++ b/src/Type/Types/ClassStringType.php @@ -9,6 +9,7 @@ use CuyZ\Valinor\Type\StringType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\Exception\CannotCastValue; use CuyZ\Valinor\Type\Types\Exception\InvalidClassString; +use CuyZ\Valinor\Type\Types\Exception\InvalidUnionOfClassString; use Stringable; use function class_exists; @@ -18,12 +19,24 @@ use function is_string; /** @api */ final class ClassStringType implements StringType { - private ?ObjectType $subType; + /** @var ObjectType|UnionType|null */ + private ?Type $subType; private string $signature; - public function __construct(ObjectType $subType = null) + /** + * @param ObjectType|UnionType $subType + */ + public function __construct(Type $subType = null) { + if ($subType instanceof UnionType) { + foreach ($subType->types() as $type) { + if (! $type instanceof ObjectType) { + throw new InvalidUnionOfClassString($subType); + } + } + } + $this->subType = $subType; $this->signature = $this->subType ? "class-string<$this->subType>" @@ -44,7 +57,18 @@ final class ClassStringType implements StringType return true; } - return is_a($value, $this->subType->className(), true); + if ($this->subType instanceof ObjectType) { + return is_a($value, $this->subType->className(), true); + } + + foreach ($this->subType->types() as $type) { + /** @var ObjectType $type */ + if (is_a($value, $type->className(), true)) { + return true; + } + } + + return false; } public function matches(Type $other): bool @@ -89,18 +113,17 @@ final class ClassStringType implements StringType $value = (string)$value; // @phpstan-ignore-line - if (! $this->subType) { - return $value; + if (! $this->accepts($value)) { + throw new InvalidClassString($value, $this->subType); } - if (is_a($value, $this->subType->className(), true)) { - return $value; - } - - throw new InvalidClassString($value, $this->subType); + return $value; } - public function subType(): ?ObjectType + /** + * @return ObjectType|UnionType|null + */ + public function subType(): ?Type { return $this->subType; } diff --git a/src/Type/Types/Exception/InvalidClassString.php b/src/Type/Types/Exception/InvalidClassString.php index 59aa5cf..5357795 100644 --- a/src/Type/Types/Exception/InvalidClassString.php +++ b/src/Type/Types/Exception/InvalidClassString.php @@ -4,17 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Type\ObjectType; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Type\Types\UnionType; use LogicException; +use function count; +use function implode; + /** @api */ final class InvalidClassString extends LogicException implements CastError { - public function __construct(string $raw, Type $type) + /** + * @param ObjectType|UnionType|null $type + */ + public function __construct(string $raw, ?Type $type) { - parent::__construct( - "Invalid class string `$raw`, it must be a subtype of `$type`.", - 1608132562 - ); + $types = []; + + if ($type instanceof ObjectType) { + $types = [$type]; + } elseif ($type instanceof UnionType) { + $types = $type->types(); + } + + $message = "Invalid class string `$raw`."; + + if (count($types) > 1) { + $message = "Invalid class string `$raw`, it must be one of `" . implode('`, `', $types) . "`."; + } elseif (count($types) === 1) { + $message = "Invalid class string `$raw`, it must be a subtype of `$type`."; + } + + parent::__construct($message, 1608132562); } } diff --git a/src/Type/Types/Exception/InvalidUnionOfClassString.php b/src/Type/Types/Exception/InvalidUnionOfClassString.php new file mode 100644 index 0000000..b7696b9 --- /dev/null +++ b/src/Type/Types/Exception/InvalidUnionOfClassString.php @@ -0,0 +1,21 @@ + 'class-string', 'type' => ClassStringType::class, ], + 'Class string of union' => [ + 'raw' => 'class-string', + 'transformed' => 'class-string', + 'type' => ClassStringType::class, + ], 'Class name' => [ 'raw' => stdClass::class, 'transformed' => stdClass::class, diff --git a/tests/Unit/Type/Types/ClassStringTypeTest.php b/tests/Unit/Type/Types/ClassStringTypeTest.php index 4b37399..7f614a5 100644 --- a/tests/Unit/Type/Types/ClassStringTypeTest.php +++ b/tests/Unit/Type/Types/ClassStringTypeTest.php @@ -7,6 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Type\Types; use CuyZ\Valinor\Tests\Fake\Type\FakeObjectType; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Tests\Fixture\Object\StringableObject; +use CuyZ\Valinor\Type\Types\Exception\InvalidUnionOfClassString; use CuyZ\Valinor\Type\Types\NativeStringType; use CuyZ\Valinor\Type\Types\ClassStringType; use CuyZ\Valinor\Type\Types\Exception\CannotCastValue; @@ -22,13 +23,31 @@ use stdClass; final class ClassStringTypeTest extends TestCase { - public function test_subtype_can_be_retrieved(): void + public function test_string_subtype_can_be_retrieved(): void { $subType = new FakeObjectType(); self::assertSame($subType, (new ClassStringType($subType))->subType()); } + public function test_union_of_string_subtype_can_be_retrieved(): void + { + $subType = new UnionType(new FakeObjectType(), new FakeObjectType()); + + self::assertSame($subType, (new ClassStringType($subType))->subType()); + } + + public function test_union_with_invalid_type_throws_exception(): void + { + $type = new UnionType(new FakeObjectType(), new FakeType()); + + $this->expectException(InvalidUnionOfClassString::class); + $this->expectExceptionCode(1648830951); + $this->expectExceptionMessage("Type `$type` contains invalid class string element(s)."); + + new ClassStringType($type); + } + public function test_accepts_correct_values(): void { $classStringType = new ClassStringType(); @@ -62,6 +81,26 @@ final class ClassStringTypeTest extends TestCase self::assertFalse($classStringType->accepts(stdClass::class)); } + public function test_accepts_correct_values_with_union_sub_type(): void + { + $type = new UnionType(new FakeObjectType(DateTimeInterface::class), new FakeObjectType(stdClass::class)); + $classStringType = new ClassStringType($type); + + self::assertTrue($classStringType->accepts(DateTime::class)); + self::assertTrue($classStringType->accepts(DateTimeImmutable::class)); + self::assertTrue($classStringType->accepts(DateTimeInterface::class)); + + self::assertTrue($classStringType->accepts(stdClass::class)); + } + + public function test_does_not_accept_incorrect_values_with_union_sub_type(): void + { + $unionType = new UnionType(new FakeObjectType(DateTime::class), new FakeObjectType(stdClass::class)); + $classStringType = new ClassStringType($unionType); + + self::assertFalse($classStringType->accepts(DateTimeImmutable::class)); + } + public function test_can_cast_stringable_value(): void { self::assertTrue((new ClassStringType())->canCast('foo')); @@ -111,6 +150,17 @@ final class ClassStringTypeTest extends TestCase } public function test_cast_invalid_class_string_throws_exception(): void + { + $classStringObject = new StringableObject('foo'); + + $this->expectException(InvalidClassString::class); + $this->expectExceptionCode(1608132562); + $this->expectExceptionMessage("Invalid class string `foo`."); + + (new ClassStringType())->cast($classStringObject); + } + + public function test_cast_invalid_class_string_of_object_type_throws_exception(): void { $objectType = new FakeObjectType(); $classStringObject = new StringableObject(DateTimeInterface::class); @@ -122,6 +172,18 @@ final class ClassStringTypeTest extends TestCase (new ClassStringType($objectType))->cast($classStringObject); } + public function test_cast_invalid_class_string_of_union_type_throws_exception(): void + { + $unionType = new UnionType(new FakeObjectType(DateTime::class), new FakeObjectType(stdClass::class)); + $classStringObject = new StringableObject(DateTimeInterface::class); + + $this->expectException(InvalidClassString::class); + $this->expectExceptionCode(1608132562); + $this->expectExceptionMessage("Invalid class string `DateTimeInterface`, it must be one of `DateTime`, `stdClass`."); + + (new ClassStringType($unionType))->cast($classStringObject); + } + public function test_string_value_is_correct(): void { $objectType = new FakeObjectType();