diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index 8c1ca761f7800..72a9624b4534c 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -39,7 +39,7 @@ "symfony/security-core": "^6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.2", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index af83a9a13f403..fd058671cfc55 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -64,7 +64,7 @@ "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", "symfony/twig-bundle": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.2", "symfony/validator": "^6.4|^7.0", "symfony/workflow": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php index 7d72f9c274618..9612b1bdb86f7 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php @@ -693,7 +693,7 @@ public static function typeWithCustomPrefixesProvider(): iterable yield ['f', Type::list(Type::object(\DateTimeImmutable::class))]; yield ['g', Type::nullable(Type::array())]; yield ['h', Type::nullable(Type::string())]; - yield ['i', Type::union(Type::int(), Type::string(), Type::null())]; + yield ['i', Type::nullable(Type::union(Type::int(), Type::string()))]; yield ['j', Type::nullable(Type::object(\DateTimeImmutable::class))]; yield ['nullableCollectionOfNonNullableElements', Type::nullable(Type::list(Type::int()))]; yield ['nonNullableCollectionOfNullableElements', Type::list(Type::nullable(Type::int()))]; diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php index 109d54f0898cf..369d9ddba8448 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php @@ -869,7 +869,7 @@ public function testPseudoTypes(string $property, ?Type $type) public static function pseudoTypesProvider(): iterable { yield ['classString', Type::string()]; - yield ['classStringGeneric', Type::generic(Type::string(), Type::object(\stdClass::class))]; + yield ['classStringGeneric', Type::string()]; yield ['htmlEscapedString', Type::string()]; yield ['lowercaseString', Type::string()]; yield ['nonEmptyLowercaseString', Type::string()]; diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php index e0d7367208a3f..d50df598b929a 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php @@ -771,7 +771,7 @@ public static function php80TypesProvider(): iterable yield ['foo', Type::nullable(Type::array())]; yield ['bar', Type::nullable(Type::int())]; yield ['timeout', Type::union(Type::int(), Type::float())]; - yield ['optional', Type::union(Type::nullable(Type::int()), Type::nullable(Type::float()))]; + yield ['optional', Type::nullable(Type::union(Type::float(), Type::int()))]; yield ['string', Type::union(Type::string(), Type::object(\Stringable::class))]; yield ['payload', Type::mixed()]; yield ['data', Type::mixed()]; diff --git a/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php b/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php index 65b53977df7cf..9ded32755a47c 100644 --- a/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php +++ b/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php @@ -128,7 +128,9 @@ public function getType(DocType $varType): ?Type $nullable = true; } - return $this->createType($varType, $nullable); + $type = $this->createType($varType); + + return $nullable ? Type::nullable($type) : $type; } $varTypes = []; @@ -156,8 +158,7 @@ public function getType(DocType $varType): ?Type $unionTypes = []; foreach ($varTypes as $varType) { - $t = $this->createType($varType, $nullable); - if (null !== $t) { + if (null !== $t = $this->createType($varType)) { $unionTypes[] = $t; } } @@ -183,7 +184,7 @@ private function createLegacyType(DocType $type, bool $nullable): ?LegacyType [$phpType, $class] = $this->getPhpTypeAndClass((string) $fqsen); - $collection = \is_a($class, \Traversable::class, true) || \is_a($class, \ArrayAccess::class, true); + $collection = is_a($class, \Traversable::class, true) || is_a($class, \ArrayAccess::class, true); // it's safer to fall back to other extractors if the generic type is too abstract if (!$collection && !class_exists($class)) { @@ -238,7 +239,7 @@ private function createLegacyType(DocType $type, bool $nullable): ?LegacyType /** * Creates a {@see Type} from a PHPDoc type. */ - private function createType(DocType $docType, bool $nullable): ?Type + private function createType(DocType $docType): ?Type { $docTypeString = (string) $docType; @@ -262,9 +263,8 @@ private function createType(DocType $docType, bool $nullable): ?Type } $type = null !== $class ? Type::object($class) : Type::builtin($phpType); - $type = Type::collection($type, ...$variableTypes); - return $nullable ? Type::nullable($type) : $type; + return Type::collection($type, ...$variableTypes); } if (!$docTypeString) { @@ -277,9 +277,8 @@ private function createType(DocType $docType, bool $nullable): ?Type if (str_starts_with($docTypeString, 'list<') && $docType instanceof Array_) { $collectionValueType = $this->getType($docType->getValueType()); - $type = Type::list($collectionValueType); - return $nullable ? Type::nullable($type) : $type; + return Type::list($collectionValueType); } if (str_starts_with($docTypeString, 'array<') && $docType instanceof Array_) { @@ -288,16 +287,14 @@ private function createType(DocType $docType, bool $nullable): ?Type $collectionKeyType = $this->getType($docType->getKeyType()); $collectionValueType = $this->getType($docType->getValueType()); - $type = Type::array($collectionValueType, $collectionKeyType); - - return $nullable ? Type::nullable($type) : $type; + return Type::array($collectionValueType, $collectionKeyType); } if ($docType instanceof PseudoType) { if ($docType->underlyingType() instanceof Integer) { - return $nullable ? Type::nullable(Type::int()) : Type::int(); + return Type::int(); } elseif ($docType->underlyingType() instanceof String_) { - return $nullable ? Type::nullable(Type::string()) : Type::string(); + return Type::string(); } } @@ -314,12 +311,10 @@ private function createType(DocType $docType, bool $nullable): ?Type [$phpType, $class] = $this->getPhpTypeAndClass($docTypeString); if ('array' === $docTypeString) { - return $nullable ? Type::nullable(Type::array()) : Type::array(); + return Type::array(); } - $type = null !== $class ? Type::object($class) : Type::builtin($phpType); - - return $nullable ? Type::nullable($type) : $type; + return null !== $class ? Type::object($class) : Type::builtin($phpType); } private function normalizeType(string $docType): string diff --git a/src/Symfony/Component/PropertyInfo/composer.json b/src/Symfony/Component/PropertyInfo/composer.json index 2e468e654c97f..e29a21a294549 100644 --- a/src/Symfony/Component/PropertyInfo/composer.json +++ b/src/Symfony/Component/PropertyInfo/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.2", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "^7.2" }, "require-dev": { "symfony/serializer": "^6.4|^7.0", diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 9db2412980930..82aaa290d64e4 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -32,12 +32,14 @@ use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\TypeInfo\Exception\LogicException as TypeInfoLogicException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; /** @@ -644,11 +646,9 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass private function validateAndDenormalize(Type $type, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed { $expectedTypes = []; - $isUnionType = $type->asNonNullable() instanceof UnionType; $e = null; $extraAttributesException = null; $missingConstructorArgumentsException = null; - $isNullable = false; $types = match (true) { $type instanceof IntersectionType => throw new LogicException('Unable to handle intersection type.'), @@ -667,11 +667,13 @@ private function validateAndDenormalize(Type $type, string $currentClass, string $collectionValueType = $t->getCollectionValueType(); } - $t = $t->getBaseType(); + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } // Fix a collection that contains the only one element // This is special to xml format only - if ('xml' === $format && $collectionValueType && !$collectionValueType->isA(TypeIdentifier::MIXED) && (!\is_array($data) || !\is_int(key($data)))) { + if ('xml' === $format && $collectionValueType && !$collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED) && (!\is_array($data) || !\is_int(key($data)))) { $data = [$data]; } @@ -694,8 +696,6 @@ private function validateAndDenormalize(Type $type, string $currentClass, string if (TypeIdentifier::STRING === $typeIdentifier) { return ''; } - - $isNullable = $isNullable ?: $type->isNullable(); } switch ($typeIdentifier) { @@ -731,10 +731,9 @@ private function validateAndDenormalize(Type $type, string $currentClass, string } if ($collectionValueType) { - try { - $collectionValueBaseType = $collectionValueType->getBaseType(); - } catch (TypeInfoLogicException) { - $collectionValueBaseType = Type::mixed(); + $collectionValueBaseType = $collectionValueType; + while ($collectionValueBaseType instanceof WrappingTypeInterface) { + $collectionValueBaseType = $collectionValueBaseType->getWrappedType(); } if ($collectionValueBaseType instanceof ObjectType) { @@ -742,15 +741,25 @@ private function validateAndDenormalize(Type $type, string $currentClass, string $class = $collectionValueBaseType->getClassName().'[]'; $context['key_type'] = $collectionKeyType; $context['value_type'] = $collectionValueType; - } elseif (TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()) { + } elseif ($collectionValueBaseType instanceof BuiltinType && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()) { // get inner type for any nested array $innerType = $collectionValueType; + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } // note that it will break for any other builtinType $dimensions = '[]'; while ($innerType instanceof CollectionType) { $dimensions .= '[]'; $innerType = $innerType->getCollectionValueType(); + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } + } + + while ($innerType instanceof WrappingTypeInterface) { + $innerType = $innerType->getWrappedType(); } if ($innerType instanceof ObjectType) { @@ -832,17 +841,17 @@ private function validateAndDenormalize(Type $type, string $currentClass, string return $data; } } catch (NotNormalizableValueException|InvalidArgumentException $e) { - if (!$isUnionType && !$isNullable) { + if (!$type instanceof UnionType) { throw $e; } } catch (ExtraAttributesException $e) { - if (!$isUnionType && !$isNullable) { + if (!$type instanceof UnionType) { throw $e; } $extraAttributesException ??= $e; } catch (MissingConstructorArgumentsException $e) { - if (!$isUnionType && !$isNullable) { + if (!$type instanceof UnionType) { throw $e; } @@ -862,7 +871,7 @@ private function validateAndDenormalize(Type $type, string $currentClass, string throw $missingConstructorArgumentsException; } - if (!$isUnionType && $e) { + if ($e && !($type instanceof UnionType && !$type instanceof NullableType)) { throw $e; } diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index 94de2de345127..08fae04df8557 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -16,7 +16,9 @@ use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Denormalizes arrays of objects. @@ -54,7 +56,10 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $typeIdentifiers = []; if (null !== $keyType = ($context['key_type'] ?? null)) { if ($keyType instanceof Type) { - $typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); + /** @var list|BuiltinType> */ + $keyTypes = $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]; + + $typeIdentifiers = array_map(fn (BuiltinType $t): string => $t->getTypeIdentifier()->value, $keyTypes); } else { $typeIdentifiers = array_map(fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]); } diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index 8691e22400c02..bb325dfefa379 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -38,7 +38,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1.5", + "symfony/type-info": "^7.2", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -51,7 +51,7 @@ "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", - "symfony/type-info": "<7.1.5", + "symfony/type-info": "<7.2", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4" diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md index c98ffeb4ac107..6accd579f6e7d 100644 --- a/src/Symfony/Component/TypeInfo/CHANGELOG.md +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -4,6 +4,13 @@ CHANGELOG 7.2 --- + * Add construction validation for `BackedEnumType`, `CollectionType`, `GenericType`, `IntersectionType`, and `UnionType` + * Add `TypeIdentifier::isStandalone()`, `TypeIdentifier::isScalar()`, and `TypeIdentifier::isBool()` methods + * Add `WrappingTypeInterface` and `CompositeTypeInterface` type interfaces + * Add `NullableType` type class + * Rename `Type::isA()` to `Type::isIdentifiedBy()` and `Type::is()` to `Type::isSatisfiedBy()` + * Remove `Type::getBaseType()`, `Type::asNonNullable()` and `Type::__call()` methods + * Remove `CompositeTypeTrait` * Add `PhpDocAwareReflectionTypeResolver` resolver 7.1 diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php index b42fd944b2c27..a794835ff965e 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php @@ -12,42 +12,21 @@ namespace Symfony\Component\TypeInfo\Tests\Type; use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Tests\Fixtures\DummyBackedEnum; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BackedEnumType; -use Symfony\Component\TypeInfo\TypeIdentifier; class BackedEnumTypeTest extends TestCase { - public function testToString() - { - $this->assertSame(DummyBackedEnum::class, (string) new BackedEnumType(DummyBackedEnum::class, Type::int())); - } - - public function testIsNullable() - { - $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isNullable()); - } - - public function testGetBaseType() + public function testCannotCreateInvalidBackingBuiltinType() { - $this->assertEquals(new BackedEnumType(DummyBackedEnum::class, Type::int()), (new BackedEnumType(DummyBackedEnum::class, Type::int()))->getBaseType()); + $this->expectException(InvalidArgumentException::class); + new BackedEnumType(DummyBackedEnum::class, Type::bool()); } - public function testAsNonNullable() - { - $type = new BackedEnumType(DummyBackedEnum::class, Type::int()); - - $this->assertSame($type, $type->asNonNullable()); - } - - public function testIsA() + public function testToString() { - $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(TypeIdentifier::ARRAY)); - $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(TypeIdentifier::OBJECT)); - $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(self::class)); - $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(DummyBackedEnum::class)); - $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(\BackedEnum::class)); - $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(\UnitEnum::class)); + $this->assertSame(DummyBackedEnum::class, (string) new BackedEnumType(DummyBackedEnum::class, Type::int())); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php index 0537c3566f114..e27d44ad6539f 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php @@ -12,8 +12,6 @@ namespace Symfony\Component\TypeInfo\Tests\Type; use PHPUnit\Framework\TestCase; -use Symfony\Component\TypeInfo\Exception\LogicException; -use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -24,50 +22,21 @@ public function testToString() $this->assertSame('int', (string) new BuiltinType(TypeIdentifier::INT)); } - public function testGetBaseType() + public function testIsIdentifiedBy() { - $this->assertEquals(new BuiltinType(TypeIdentifier::INT), (new BuiltinType(TypeIdentifier::INT))->getBaseType()); + $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy(TypeIdentifier::ARRAY)); + $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy(TypeIdentifier::INT)); + + $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy('array')); + $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy('int')); + + $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy('string', 'int')); } public function testIsNullable() { - $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isNullable()); $this->assertTrue((new BuiltinType(TypeIdentifier::NULL))->isNullable()); $this->assertTrue((new BuiltinType(TypeIdentifier::MIXED))->isNullable()); - } - - public function testAsNonNullable() - { - $type = new BuiltinType(TypeIdentifier::INT); - - $this->assertSame($type, $type->asNonNullable()); - $this->assertEquals( - Type::union( - new BuiltinType(TypeIdentifier::OBJECT), - new BuiltinType(TypeIdentifier::RESOURCE), - new BuiltinType(TypeIdentifier::ARRAY), - new BuiltinType(TypeIdentifier::STRING), - new BuiltinType(TypeIdentifier::FLOAT), - new BuiltinType(TypeIdentifier::INT), - new BuiltinType(TypeIdentifier::BOOL), - ), - Type::nullable(new BuiltinType(TypeIdentifier::MIXED))->asNonNullable() - ); - } - - public function testCannotTurnNullAsNonNullable() - { - $this->expectException(LogicException::class); - - (new BuiltinType(TypeIdentifier::NULL))->asNonNullable(); - } - - public function testIsA() - { - $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isA(TypeIdentifier::ARRAY)); - $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isA(TypeIdentifier::INT)); - $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isA('array')); - $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isA('int')); - $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isA(self::class)); + $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isNullable()); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php index 8104121f5e592..e488457988224 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php @@ -20,6 +20,12 @@ class CollectionTypeTest extends TestCase { + public function testCannotCreateInvalidBuiltinType() + { + $this->expectException(InvalidArgumentException::class); + new CollectionType(Type::int()); + } + public function testCanOnlyConstructListWithIntKeyType() { new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::bool()), isList: true); @@ -62,6 +68,15 @@ public function testGetCollectionValueType() $this->assertEquals(Type::bool(), $type->getCollectionValueType()); } + public function testWrappedTypeIsSatisfiedBy() + { + $type = new CollectionType(Type::builtin(TypeIdentifier::ARRAY)); + $this->assertTrue($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'array' === (string) $t)); + + $type = new CollectionType(Type::builtin(TypeIdentifier::ITERABLE)); + $this->assertFalse($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'array' === (string) $t)); + } + public function testToString() { $type = new CollectionType(Type::builtin(TypeIdentifier::ITERABLE)); @@ -73,45 +88,4 @@ public function testToString() $type = new CollectionType(new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); $this->assertEquals('array', (string) $type); } - - public function testGetBaseType() - { - $this->assertEquals(Type::int(), Type::collection(Type::generic(Type::int(), Type::string()))->getBaseType()); - } - - public function testIsNullable() - { - $this->assertFalse((new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int())))->isNullable()); - $this->assertTrue((new CollectionType(Type::generic(Type::null(), Type::int())))->isNullable()); - $this->assertTrue((new CollectionType(Type::generic(Type::mixed(), Type::int())))->isNullable()); - } - - public function testAsNonNullable() - { - $type = new CollectionType(Type::builtin(TypeIdentifier::ITERABLE)); - - $this->assertSame($type, $type->asNonNullable()); - } - - public function testIsA() - { - $type = new CollectionType(new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); - - $this->assertTrue($type->isA(TypeIdentifier::ARRAY)); - $this->assertFalse($type->isA(TypeIdentifier::STRING)); - $this->assertFalse($type->isA(TypeIdentifier::INT)); - $this->assertFalse($type->isA(self::class)); - - $type = new CollectionType(new GenericType(Type::object(self::class), Type::string(), Type::bool())); - - $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); - $this->assertTrue($type->isA(TypeIdentifier::OBJECT)); - $this->assertTrue($type->isA(self::class)); - } - - public function testProxiesMethodsToBaseType() - { - $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); - $this->assertEquals([Type::string(), Type::bool()], $type->getVariableTypes()); - } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php index 69baf0d8d5d84..33a14ea2f21e1 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php @@ -14,7 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\TypeInfo\Tests\Fixtures\DummyEnum; use Symfony\Component\TypeInfo\Type\EnumType; -use Symfony\Component\TypeInfo\TypeIdentifier; class EnumTypeTest extends TestCase { @@ -22,30 +21,4 @@ public function testToString() { $this->assertSame(DummyEnum::class, (string) new EnumType(DummyEnum::class)); } - - public function testGetBaseType() - { - $this->assertEquals(new EnumType(DummyEnum::class), (new EnumType(DummyEnum::class))->getBaseType()); - } - - public function testIsNullable() - { - $this->assertFalse((new EnumType(DummyEnum::class))->isNullable()); - } - - public function testAsNonNullable() - { - $type = new EnumType(DummyEnum::class); - - $this->assertSame($type, $type->asNonNullable()); - } - - public function testIsA() - { - $this->assertFalse((new EnumType(DummyEnum::class))->isA(TypeIdentifier::ARRAY)); - $this->assertTrue((new EnumType(DummyEnum::class))->isA(TypeIdentifier::OBJECT)); - $this->assertTrue((new EnumType(DummyEnum::class))->isA(DummyEnum::class)); - $this->assertTrue((new EnumType(DummyEnum::class))->isA(\UnitEnum::class)); - $this->assertFalse((new EnumType(DummyEnum::class))->isA(\BackedEnum::class)); - } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php index 6277e4ea10727..08e00bb729699 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php @@ -12,12 +12,19 @@ namespace Symfony\Component\TypeInfo\Tests\Type; use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\TypeIdentifier; class GenericTypeTest extends TestCase { + public function testCannotCreateInvalidBuiltinType() + { + $this->expectException(InvalidArgumentException::class); + new GenericType(Type::int(), Type::string()); + } + public function testToString() { $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::bool()); @@ -30,42 +37,12 @@ public function testToString() $this->assertEquals(\sprintf('%s', self::class), (string) $type); } - public function testGetBaseType() - { - $this->assertEquals(Type::object(), Type::generic(Type::object(), Type::int())->getBaseType()); - } - - public function testIsNullable() + public function testWrappedTypeIsSatisfiedBy() { - $this->assertFalse((new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::int()))->isNullable()); - $this->assertTrue((new GenericType(Type::null(), Type::int()))->isNullable()); - $this->assertTrue((new GenericType(Type::mixed(), Type::int()))->isNullable()); - } - - public function testAsNonNullable() - { - $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::int()); - - $this->assertSame($type, $type->asNonNullable()); - } - - public function testIsA() - { - $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool()); - $this->assertTrue($type->isA(TypeIdentifier::ARRAY)); - $this->assertFalse($type->isA(TypeIdentifier::STRING)); - $this->assertFalse($type->isA(self::class)); - - $type = new GenericType(Type::object(self::class), Type::union(Type::bool(), Type::string()), Type::int(), Type::float()); - $this->assertTrue($type->isA(TypeIdentifier::OBJECT)); - $this->assertFalse($type->isA(TypeIdentifier::INT)); - $this->assertFalse($type->isA(TypeIdentifier::STRING)); - $this->assertTrue($type->isA(self::class)); - } + $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::bool()); + $this->assertTrue($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'array' === (string) $t)); - public function testProxiesMethodsToBaseType() - { - $type = new GenericType(Type::object(self::class), Type::float()); - $this->assertSame(self::class, $type->getClassName()); + $type = new GenericType(Type::builtin(TypeIdentifier::ITERABLE), Type::bool()); + $this->assertFalse($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'array' === (string) $t)); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php index 8002ebcba1430..c77d850158044 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php @@ -13,93 +13,68 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; -use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\IntersectionType; -use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; class IntersectionTypeTest extends TestCase { public function testCannotCreateWithOnlyOneType() { $this->expectException(InvalidArgumentException::class); - new IntersectionType(Type::int()); + new IntersectionType(Type::object(\DateTime::class)); } - public function testCannotCreateWithIntersectionTypeParts() + public function testCannotCreateWithUnionTypePart() { $this->expectException(InvalidArgumentException::class); - new IntersectionType(Type::int(), new IntersectionType()); + new IntersectionType(Type::object(\DateTime::class), new UnionType(Type::int(), Type::string())); } - public function testSortTypesOnCreation() - { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); - $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); - } - - public function testAtLeastOneTypeIs() - { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); - - $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'int' === (string) $t)); - $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'float' === (string) $t)); - } - - public function testEveryTypeIs() + public function testCannotCreateWithIntersectionTypePart() { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); - $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); - - $type = new IntersectionType(Type::int(), Type::string(), Type::template('T')); - $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + $this->expectException(InvalidArgumentException::class); + new IntersectionType(Type::object(\DateTime::class), new IntersectionType(Type::object(\DateTime::class), Type::object(\Iterator::class))); } - public function testGetBaseType() + public function testCannotCreateWithNonObjectTypePart() { - $this->expectException(LogicException::class); - (new IntersectionType(Type::string(), Type::int()))->getBaseType(); + $this->expectException(InvalidArgumentException::class); + new IntersectionType(Type::object(\DateTime::class), Type::int()); } - public function testToString() + public function testCannotCreateWithNullableTypePart() { - $type = new IntersectionType(Type::int(), Type::string(), Type::float()); - $this->assertSame('float&int&string', (string) $type); - - $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); - $this->assertSame('(bool|float)&int&string', (string) $type); + $this->expectException(InvalidArgumentException::class); + new IntersectionType(Type::object(\DateTime::class), Type::nullable(Type::object(\Stringable::class))); } - public function testIsNullable() + public function testCanCreateWithWrappingTypes() { - $this->assertFalse((new IntersectionType(Type::int(), Type::string(), Type::float()))->isNullable()); - $this->assertTrue((new IntersectionType(Type::null(), Type::union(Type::int(), Type::mixed())))->isNullable()); + new IntersectionType(Type::collection(Type::object(\Iterator::class)), Type::generic(Type::object(\Iterator::class))); + // no assertion. this method just asserts that no exception is thrown + $this->addToAssertionCount(1); } - public function testAsNonNullable() + public function testSortTypesOnCreation() { - $type = new IntersectionType(Type::int(), Type::string(), Type::float()); - - $this->assertSame($type, $type->asNonNullable()); + $type = new IntersectionType(Type::object(\DateTime::class), Type::object(\Iterator::class), Type::object(\Stringable::class)); + $this->assertEquals([Type::object(\DateTime::class), Type::object(\Iterator::class), Type::object(\Stringable::class)], $type->getTypes()); } - public function testCannotTurnNullIntersectionAsNonNullable() + public function testComposedTypesAreSatisfiedBy() { - $this->expectException(LogicException::class); + $type = new IntersectionType(Type::object(\Iterator::class), Type::object(\Stringable::class)); + $this->assertTrue($type->composedTypesAreSatisfiedBy(static fn (Type $t): bool => $t instanceof ObjectType)); - $type = (new IntersectionType(Type::null(), Type::mixed()))->asNonNullable(); + $type = new IntersectionType(Type::object(\Iterator::class), Type::object(\Stringable::class)); + $this->assertFalse($type->composedTypesAreSatisfiedBy(static fn (ObjectType $t): bool => \Iterator::class === $t->getClassName())); } - public function testIsA() + public function testToString() { - $type = new IntersectionType(Type::int(), Type::string(), Type::float()); - $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); - - $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); - $this->assertFalse($type->isA(TypeIdentifier::INT)); - - $type = new IntersectionType(Type::int(), Type::union(Type::int(), Type::int())); - $this->assertTrue($type->isA(TypeIdentifier::INT)); + $type = new IntersectionType(Type::object(\DateTime::class), Type::object(\Iterator::class), Type::object(\Stringable::class)); + $this->assertSame(\sprintf('%s&%s&%s', \DateTime::class, \Iterator::class, \Stringable::class), (string) $type); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/NullableTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/NullableTypeTest.php new file mode 100644 index 0000000000000..ad56707761e5c --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/NullableTypeTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\NullableType; + +class NullableTypeTest extends TestCase +{ + public function testCannotCreateWithNullableType() + { + $this->expectException(InvalidArgumentException::class); + new NullableType(Type::null()); + } + + public function testNullPartIsAdded() + { + $type = new NullableType(Type::int()); + $this->assertEquals([Type::int(), Type::null()], $type->getTypes()); + + $type = new NullableType(Type::union(Type::int(), Type::string())); + $this->assertEquals([Type::int(), Type::null(), Type::string()], $type->getTypes()); + } + + public function testWrappedTypeIsSatisfiedBy() + { + $type = new NullableType(Type::int()); + $this->assertTrue($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'int' === (string) $t)); + + $type = new NullableType(Type::string()); + $this->assertFalse($type->wrappedTypeIsSatisfiedBy(static fn (Type $t): bool => 'int' === (string) $t)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php index 1289f32df5ede..be38c033b0a88 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php @@ -22,21 +22,17 @@ public function testToString() $this->assertSame(self::class, (string) new ObjectType(self::class)); } - public function testIsNullable() + public function testIsIdentifiedBy() { - $this->assertFalse((new ObjectType(self::class))->isNullable()); - } + $this->assertFalse((new ObjectType(self::class))->isIdentifiedBy(TypeIdentifier::ARRAY)); + $this->assertTrue((new ObjectType(self::class))->isIdentifiedBy(TypeIdentifier::OBJECT)); - public function testGetBaseType() - { - $this->assertEquals(new ObjectType(self::class), (new ObjectType(self::class))->getBaseType()); - } + $this->assertFalse((new ObjectType(self::class))->isIdentifiedBy('array')); + $this->assertTrue((new ObjectType(self::class))->isIdentifiedBy('object')); - public function testIsA() - { - $this->assertFalse((new ObjectType(self::class))->isA(TypeIdentifier::ARRAY)); - $this->assertTrue((new ObjectType(self::class))->isA(TypeIdentifier::OBJECT)); - $this->assertTrue((new ObjectType(self::class))->isA(self::class)); - $this->assertFalse((new ObjectType(self::class))->isA(\stdClass::class)); + $this->assertTrue((new ObjectType(self::class))->isIdentifiedBy(self::class)); + $this->assertFalse((new ObjectType(self::class))->isIdentifiedBy(\stdClass::class)); + + $this->assertTrue((new ObjectType(self::class))->isIdentifiedBy('array', 'object')); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php index bc308d4651466..f5763f93f41f4 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -13,11 +13,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; -use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\TypeIdentifier; class UnionTypeTest extends TestCase { @@ -27,119 +26,63 @@ public function testCannotCreateWithOnlyOneType() new UnionType(Type::int()); } - public function testCannotCreateWithUnionTypeParts() + public function testCannotCreateWithUnionTypePart() { $this->expectException(InvalidArgumentException::class); new UnionType(Type::int(), new UnionType()); } - public function testSortTypesOnCreation() + public function testCannotCreateWithNullPart() { - $type = new UnionType(Type::int(), Type::string(), Type::bool()); - $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); - } - - public function testAsNonNullable() - { - $type = new UnionType(Type::int(), Type::string(), Type::bool()); - $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); - $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->asNonNullable()->getTypes()); - - $type = new UnionType(Type::int(), Type::string(), Type::null()); - $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); - $this->assertEquals([Type::int(), Type::string()], $type->asNonNullable()->getTypes()); - - $type = new UnionType(Type::int(), Type::null()); - $this->assertInstanceOf(BuiltinType::class, $type->asNonNullable()); - $this->assertEquals(Type::int(), $type->asNonNullable()); - - $type = new UnionType(Type::int(), Type::object(\stdClass::class), Type::mixed()); - $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); - $this->assertEquals([ - Type::builtin(TypeIdentifier::ARRAY), - Type::bool(), - Type::float(), - Type::int(), - Type::object(), - Type::resource(), - Type::object(\stdClass::class), - Type::string(), - ], $type->asNonNullable()->getTypes()); + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::int(), Type::null()); } - public function testGetBaseType() + public function testCannotCreateWithStandaloneTypePart() { - $this->assertEquals(Type::string(), (new UnionType(Type::string(), Type::null()))->getBaseType()); - - $this->expectException(LogicException::class); - (new UnionType(Type::string(), Type::int(), Type::null()))->getBaseType(); + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::int(), Type::mixed()); } - public function testAtLeastOneTypeIs() + public function testCannotCreateWithTrueAndFalseTypeParts() { - $type = new UnionType(Type::int(), Type::string(), Type::bool()); - - $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'int' === (string) $t)); - $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'float' === (string) $t)); + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::true(), Type::false()); } - public function testEveryTypeIs() + public function testCannotCreateWithMultipleBooleanTypeParts() { - $type = new UnionType(Type::int(), Type::string(), Type::bool()); - $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); - - $type = new UnionType(Type::int(), Type::string(), Type::template('T')); - $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::true(), Type::bool()); } - public function testToString() + public function testCannotCreateWithBuiltinObjectAndClassTypeParts() { - $type = new UnionType(Type::int(), Type::string(), Type::float()); - $this->assertSame('float|int|string', (string) $type); - - $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); - $this->assertSame('(bool&float)|int|string', (string) $type); + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::object(), Type::object(\DateTime::class)); } - public function testIsNullable() + public function testSortTypesOnCreation() { - $this->assertFalse((new UnionType(Type::int(), Type::intersection(Type::float(), Type::int())))->isNullable()); - $this->assertTrue((new UnionType(Type::int(), Type::null()))->isNullable()); - $this->assertTrue((new UnionType(Type::int(), Type::mixed()))->isNullable()); + $type = new UnionType(Type::int(), Type::string(), Type::bool()); + $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); } - public function testIsA() + public function testComposedTypesAreSatisfiedBy() { - $type = new UnionType(Type::int(), Type::string(), Type::float()); - $this->assertFalse($type->isNullable()); - $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); - - $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); - $this->assertTrue($type->isA(TypeIdentifier::INT)); - $this->assertTrue($type->isA(TypeIdentifier::STRING)); - $this->assertFalse($type->isA(TypeIdentifier::FLOAT)); - $this->assertFalse($type->isA(TypeIdentifier::BOOL)); + $type = new UnionType(Type::object(\Iterator::class), Type::int()); + $this->assertTrue($type->composedTypesAreSatisfiedBy(static fn (Type $t): bool => $t instanceof BuiltinType)); - $type = new UnionType(Type::string(), Type::intersection(Type::int(), Type::int())); - $this->assertTrue($type->isA(TypeIdentifier::INT)); + $type = new UnionType(Type::int(), Type::string()); + $this->assertFalse($type->composedTypesAreSatisfiedBy(static fn (Type $t): bool => $t instanceof ObjectType)); } - public function testProxiesMethodsToNonNullableType() + public function testToString() { - $this->assertEquals(Type::string(), (new UnionType(Type::list(Type::string()), Type::null()))->getCollectionValueType()); - - try { - (new UnionType(Type::int(), Type::null()))->getCollectionValueType(); - $this->fail(); - } catch (LogicException) { - $this->addToAssertionCount(1); - } + $type = new UnionType(Type::int(), Type::string(), Type::float()); + $this->assertSame('float|int|string', (string) $type); - try { - (new UnionType(Type::list(Type::string()), Type::string()))->getCollectionValueType(); - $this->fail(); - } catch (LogicException) { - $this->addToAssertionCount(1); - } + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::object(\DateTime::class), Type::object(\Iterator::class))); + $this->assertSame(\sprintf('(%s&%s)|int|string', \DateTime::class, \Iterator::class), (string) $type); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php index bb1f1c3c8ba5c..9e8796d09b3c4 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -21,6 +21,7 @@ use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; @@ -185,22 +186,22 @@ public function testCreateUnion() public function testCreateIntersection() { - $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new ObjectType(self::class)), Type::intersection(Type::int(), Type::object(self::class))); - $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::intersection(Type::int(), Type::string(), Type::int())); - $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::intersection(Type::int(), Type::intersection(Type::int(), Type::string()))); + $this->assertEquals(new IntersectionType(new ObjectType(\DateTime::class), new ObjectType(self::class)), Type::intersection(Type::object(\DateTime::class), Type::object(self::class))); + $this->assertEquals(new IntersectionType(new ObjectType(\DateTime::class), new ObjectType(self::class)), Type::intersection(Type::object(\DateTime::class), Type::object(self::class), Type::object(self::class))); + $this->assertEquals(new IntersectionType(new ObjectType(\DateTime::class), new ObjectType(self::class)), Type::intersection(Type::object(\DateTime::class), Type::intersection(Type::object(\DateTime::class), Type::object(self::class)))); } public function testCreateNullable() { - $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::NULL)), Type::nullable(Type::int())); - $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::NULL)), Type::nullable(Type::nullable(Type::int()))); + $this->assertEquals(new NullableType(new BuiltinType(TypeIdentifier::INT)), Type::nullable(Type::int())); + $this->assertEquals(new NullableType(new BuiltinType(TypeIdentifier::INT)), Type::nullable(Type::nullable(Type::int()))); $this->assertEquals( - new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING), new BuiltinType(TypeIdentifier::NULL)), + new NullableType(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING))), Type::nullable(Type::union(Type::int(), Type::string())), ); $this->assertEquals( - new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING), new BuiltinType(TypeIdentifier::NULL)), + new NullableType(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING))), Type::nullable(Type::union(Type::int(), Type::string(), Type::null())), ); } diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php index 22812267b6466..b0ad600085e14 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -145,18 +145,18 @@ public static function resolveDataProvider(): iterable yield [Type::nullable(Type::int()), '?int']; // generic - yield [Type::generic(Type::object(), Type::string(), Type::bool()), 'object']; - yield [Type::generic(Type::object(), Type::generic(Type::string(), Type::bool())), 'object>']; + yield [Type::generic(Type::object(\DateTime::class), Type::string(), Type::bool()), \DateTime::class.'']; + yield [Type::generic(Type::object(\DateTime::class), Type::generic(Type::object(\Stringable::class), Type::bool())), \sprintf('%s<%s>', \DateTime::class, \Stringable::class)]; yield [Type::int(), 'int<0, 100>']; // union yield [Type::union(Type::int(), Type::string()), 'int|string']; // intersection - yield [Type::intersection(Type::int(), Type::string()), 'int&string']; + yield [Type::intersection(Type::object(\DateTime::class), Type::object(\Stringable::class)), \DateTime::class.'&'.\Stringable::class]; // DNF - yield [Type::union(Type::int(), Type::intersection(Type::string(), Type::bool())), 'int|(string&bool)']; + yield [Type::union(Type::int(), Type::intersection(Type::object(\DateTime::class), Type::object(\Stringable::class))), \sprintf('int|(%s&%s)', \DateTime::class, \Stringable::class)]; // collection objects yield [Type::collection(Type::object(\Traversable::class)), \Traversable::class]; diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php index c271ba581ed1f..6d60b5dc21eca 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php @@ -12,23 +12,18 @@ namespace Symfony\Component\TypeInfo\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; class TypeTest extends TestCase { - public function testIs() + public function testIsIdentifiedBy() { - $isInt = fn (Type $t) => TypeIdentifier::INT === $t->getBaseType()->getTypeIdentifier(); - - $this->assertTrue(Type::int()->is($isInt)); - $this->assertTrue(Type::union(Type::string(), Type::int())->is($isInt)); - $this->assertTrue(Type::generic(Type::int(), Type::string())->is($isInt)); - - $this->assertFalse(Type::string()->is($isInt)); - $this->assertFalse(Type::union(Type::string(), Type::float())->is($isInt)); - $this->assertFalse(Type::generic(Type::string(), Type::int())->is($isInt)); + $this->assertTrue(Type::intersection(Type::object(\Iterator::class), Type::object(\Stringable::class))->isIdentifiedBy(TypeIdentifier::OBJECT)); + $this->assertTrue(Type::union(Type::int(), Type::string())->isIdentifiedBy(TypeIdentifier::INT)); + $this->assertTrue(Type::collection(Type::object(\Iterator::class))->isIdentifiedBy(TypeIdentifier::OBJECT)); + $this->assertTrue(Type::generic(Type::object(\Iterator::class), Type::string())->isIdentifiedBy(TypeIdentifier::OBJECT)); + $this->assertTrue(Type::nullable(Type::union(Type::collection(Type::object(\Iterator::class)), Type::string()))->isIdentifiedBy(TypeIdentifier::OBJECT)); } public function testIsNullable() @@ -36,25 +31,7 @@ public function testIsNullable() $this->assertTrue(Type::null()->isNullable()); $this->assertTrue(Type::mixed()->isNullable()); $this->assertTrue(Type::nullable(Type::int())->isNullable()); - $this->assertTrue(Type::union(Type::int(), Type::null())->isNullable()); - $this->assertTrue(Type::union(Type::int(), Type::mixed())->isNullable()); - $this->assertTrue(Type::generic(Type::null(), Type::string())->isNullable()); $this->assertFalse(Type::int()->isNullable()); - $this->assertFalse(Type::union(Type::int(), Type::string())->isNullable()); - $this->assertFalse(Type::generic(Type::int(), Type::nullable(Type::string()))->isNullable()); - $this->assertFalse(Type::generic(Type::int(), Type::mixed())->isNullable()); - } - - public function testCannotGetBaseTypeOnCompoundType() - { - $this->expectException(LogicException::class); - Type::union(Type::int(), Type::string())->getBaseType(); - } - - public function testThrowsOnUnexistingMethod() - { - $this->expectException(LogicException::class); - Type::int()->unexistingMethod(); } } diff --git a/src/Symfony/Component/TypeInfo/Type.php b/src/Symfony/Component/TypeInfo/Type.php index 3109f96fb4d08..7a5363039d5e7 100644 --- a/src/Symfony/Component/TypeInfo/Type.php +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -11,9 +11,8 @@ namespace Symfony\Component\TypeInfo; -use Symfony\Component\TypeInfo\Exception\LogicException; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * @author Mathias Arlaud @@ -25,35 +24,38 @@ abstract class Type implements \Stringable { use TypeFactoryTrait; - abstract public function getBaseType(): BuiltinType|ObjectType; - /** - * @param TypeIdentifier|class-string $subject + * Tells if the type is satisfied by the $specification callable. + * + * @param callable(self): bool $specification */ - abstract public function isA(TypeIdentifier|string $subject): bool; - - abstract public function asNonNullable(): self; + public function isSatisfiedBy(callable $specification): bool + { + return $specification($this); + } /** - * @param callable(Type): bool $callable + * Tells if the type (or one of its wrapped/composed parts) is identified by one of the $identifiers. */ - public function is(callable $callable): bool + public function isIdentifiedBy(TypeIdentifier|string ...$identifiers): bool { - return $callable($this); - } + $specification = static function (Type $type) use (&$specification, $identifiers): bool { + if ($type instanceof WrappingTypeInterface) { + return $type->wrappedTypeIsSatisfiedBy($specification); + } - public function isNullable(): bool - { - return $this->is(fn (Type $t): bool => $t->isA(TypeIdentifier::NULL) || $t->isA(TypeIdentifier::MIXED)); + if ($type instanceof CompositeTypeInterface) { + return $type->composedTypesAreSatisfiedBy($specification); + } + + return $type->isIdentifiedBy(...$identifiers); + }; + + return $this->isSatisfiedBy($specification); } - /** - * Graceful fallback for unexisting methods. - * - * @param list $arguments - */ - public function __call(string $method, array $arguments): mixed + public function isNullable(): bool { - throw new LogicException(\sprintf('Cannot call "%s" on "%s" type.', $method, $this)); + return false; } } diff --git a/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php b/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php index 32ec3b6c96dc7..ad37c63a966bd 100644 --- a/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php +++ b/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php @@ -11,6 +11,7 @@ namespace Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\TypeIdentifier; /** @@ -34,6 +35,10 @@ public function __construct( string $className, private readonly BuiltinType $backingType, ) { + if (TypeIdentifier::INT !== $backingType->getTypeIdentifier() && TypeIdentifier::STRING !== $backingType->getTypeIdentifier()) { + throw new InvalidArgumentException(\sprintf('Cannot create "%s" with "%s" backing type.', self::class, $backingType)); + } + parent::__construct($className); } diff --git a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php index 06f175df3f75d..68fcd832846af 100644 --- a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php @@ -11,7 +11,6 @@ namespace Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -33,11 +32,6 @@ public function __construct( ) { } - public function getBaseType(): self|ObjectType - { - return $this; - } - /** * @return T */ @@ -46,43 +40,28 @@ public function getTypeIdentifier(): TypeIdentifier return $this->typeIdentifier; } - public function isA(TypeIdentifier|string $subject): bool + public function isIdentifiedBy(TypeIdentifier|string ...$identifiers): bool { - if ($subject instanceof TypeIdentifier) { - return $this->getTypeIdentifier() === $subject; - } + foreach ($identifiers as $identifier) { + if (\is_string($identifier)) { + try { + $identifier = TypeIdentifier::from($identifier); + } catch (\ValueError) { + continue; + } + } - try { - return TypeIdentifier::from($subject) === $this->getTypeIdentifier(); - } catch (\ValueError) { - return false; + if ($identifier === $this->typeIdentifier) { + return true; + } } + + return false; } - /** - * @return self|UnionType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType> - */ - public function asNonNullable(): self|UnionType + public function isNullable(): bool { - if (TypeIdentifier::NULL === $this->typeIdentifier) { - throw new LogicException('"null" cannot be turned as non nullable.'); - } - - // "mixed" is an alias of "object|resource|array|string|float|int|bool|null" - // therefore, its non-nullable version is "object|resource|array|string|float|int|bool" - if (TypeIdentifier::MIXED === $this->typeIdentifier) { - return new UnionType( - new self(TypeIdentifier::OBJECT), - new self(TypeIdentifier::RESOURCE), - new self(TypeIdentifier::ARRAY), - new self(TypeIdentifier::STRING), - new self(TypeIdentifier::FLOAT), - new self(TypeIdentifier::INT), - new self(TypeIdentifier::BOOL), - ); - } - - return $this; + return \in_array($this->typeIdentifier, [TypeIdentifier::NULL, TypeIdentifier::MIXED]); } public function __toString(): string diff --git a/src/Symfony/Component/TypeInfo/Type/CollectionType.php b/src/Symfony/Component/TypeInfo/Type/CollectionType.php index 3076da7de3e9f..081dd4f3a1fa8 100644 --- a/src/Symfony/Component/TypeInfo/Type/CollectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -18,16 +18,16 @@ /** * Represents a key/value collection type. * - * It proxies every method to the main type and adds methods related to key and value types. - * * @author Mathias Arlaud * @author Baptiste Leduc * * @template T of BuiltinType|BuiltinType|ObjectType|GenericType * + * @implements WrappingTypeInterface + * * @experimental */ -final class CollectionType extends Type +final class CollectionType extends Type implements WrappingTypeInterface { /** * @param T $type @@ -36,6 +36,10 @@ public function __construct( private readonly BuiltinType|ObjectType|GenericType $type, private readonly bool $isList = false, ) { + if ($type instanceof BuiltinType && TypeIdentifier::ARRAY !== $type->getTypeIdentifier() && TypeIdentifier::ITERABLE !== $type->getTypeIdentifier()) { + throw new InvalidArgumentException(\sprintf('Cannot create "%s" with "%s" type.', self::class, $type)); + } + if ($this->isList()) { $keyType = $this->getCollectionKeyType(); @@ -45,34 +49,16 @@ public function __construct( } } - public function getBaseType(): BuiltinType|ObjectType - { - return $this->getType()->getBaseType(); - } - - /** - * @return T - */ - public function getType(): BuiltinType|ObjectType|GenericType + public function getWrappedType(): Type { return $this->type; } - public function isA(TypeIdentifier|string $subject): bool - { - return $this->getType()->isA($subject); - } - public function isList(): bool { return $this->isList; } - public function asNonNullable(): self - { - return $this; - } - public function getCollectionKeyType(): Type { $defaultCollectionKeyType = self::union(self::int(), self::string()); @@ -103,18 +89,13 @@ public function getCollectionValueType(): Type return $defaultCollectionValueType; } - public function __toString(): string + public function wrappedTypeIsSatisfiedBy(callable $specification): bool { - return (string) $this->type; + return $this->getWrappedType()->isSatisfiedBy($specification); } - /** - * Proxies all method calls to the original type. - * - * @param list $arguments - */ - public function __call(string $method, array $arguments): mixed + public function __toString(): string { - return $this->type->{$method}(...$arguments); + return (string) $this->type; } } diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php new file mode 100644 index 0000000000000..407ee8a354792 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Type; + +use Symfony\Component\TypeInfo\Type; + +/** + * Represents a type composed of several other types. + * + * @author Mathias Arlaud + * + * @template T of Type + * + * @experimental + */ +interface CompositeTypeInterface +{ + /** + * @return list + */ + public function getTypes(): array; + + /** + * @param callable(Type): bool $specification + */ + public function composedTypesAreSatisfiedBy(callable $specification): bool; +} diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php deleted file mode 100644 index ee8d6c52092cc..0000000000000 --- a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\TypeInfo\Type; - -use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\TypeIdentifier; - -/** - * @author Mathias Arlaud - * @author Baptiste Leduc - * - * @internal - * - * @template T of Type - */ -trait CompositeTypeTrait -{ - /** - * @var list - */ - private readonly array $types; - - /** - * @param list $types - */ - public function __construct(Type ...$types) - { - if (\count($types) < 2) { - throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); - } - - foreach ($types as $t) { - if ($t instanceof self) { - throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%1$s" part.', self::class)); - } - } - - usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b); - $this->types = array_values(array_unique($types)); - } - - public function isA(TypeIdentifier|string $subject): bool - { - return $this->is(fn (Type $type) => $type->isA($subject)); - } - - /** - * @return list - */ - public function getTypes(): array - { - return $this->types; - } - - /** - * @param callable(T): bool $callable - */ - public function atLeastOneTypeIs(callable $callable): bool - { - foreach ($this->types as $t) { - if ($callable($t)) { - return true; - } - } - - return false; - } - - /** - * @param callable(T): bool $callable - */ - public function everyTypeIs(callable $callable): bool - { - foreach ($this->types as $t) { - if (!$callable($t)) { - return false; - } - } - - return true; - } -} diff --git a/src/Symfony/Component/TypeInfo/Type/GenericType.php b/src/Symfony/Component/TypeInfo/Type/GenericType.php index 5fa53ad4a32cf..afa6da09938bf 100644 --- a/src/Symfony/Component/TypeInfo/Type/GenericType.php +++ b/src/Symfony/Component/TypeInfo/Type/GenericType.php @@ -11,22 +11,23 @@ namespace Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; /** * Represents a generic type, which is a type that holds variable parts. * - * It proxies every method to the main type and adds methods related to variable types. - * * @author Mathias Arlaud * @author Baptiste Leduc * * @template T of BuiltinType|BuiltinType|ObjectType * + * @implements WrappingTypeInterface + * * @experimental */ -final class GenericType extends Type +final class GenericType extends Type implements WrappingTypeInterface { /** * @var list @@ -40,32 +41,18 @@ public function __construct( private readonly BuiltinType|ObjectType $type, Type ...$variableTypes, ) { - $this->variableTypes = $variableTypes; - } + if ($type instanceof BuiltinType && TypeIdentifier::ARRAY !== $type->getTypeIdentifier() && TypeIdentifier::ITERABLE !== $type->getTypeIdentifier()) { + throw new InvalidArgumentException(\sprintf('Cannot create "%s" with "%s" type.', self::class, $type)); + } - public function getBaseType(): BuiltinType|ObjectType - { - return $this->getType(); + $this->variableTypes = $variableTypes; } - /** - * @return T - */ - public function getType(): BuiltinType|ObjectType + public function getWrappedType(): Type { return $this->type; } - public function isA(TypeIdentifier|string $subject): bool - { - return $this->getType()->isA($subject); - } - - public function asNonNullable(): self - { - return $this; - } - /** * @return list */ @@ -74,6 +61,11 @@ public function getVariableTypes(): array return $this->variableTypes; } + public function wrappedTypeIsSatisfiedBy(callable $specification): bool + { + return $this->getWrappedType()->isSatisfiedBy($specification); + } + public function __toString(): string { $typeString = (string) $this->type; @@ -87,14 +79,4 @@ public function __toString(): string return $typeString.'<'.$variableTypesString.'>'; } - - /** - * Proxies all method calls to the original type. - * - * @param list $arguments - */ - public function __call(string $method, array $arguments): mixed - { - return $this->type->{$method}(...$arguments); - } } diff --git a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php index fa5ffbe5a796a..0c6fbfd363d91 100644 --- a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php @@ -11,59 +11,82 @@ namespace Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; /** * @author Mathias Arlaud * @author Baptiste Leduc * - * @template T of Type + * @template T of ObjectType|GenericType|CollectionType> + * + * @implements CompositeTypeInterface * * @experimental */ -final class IntersectionType extends Type +final class IntersectionType extends Type implements CompositeTypeInterface { /** - * @use CompositeTypeTrait + * @var list */ - use CompositeTypeTrait; + private readonly array $types; - public function is(callable $callable): bool + /** + * @param list $types + */ + public function __construct(Type ...$types) { - return $this->everyTypeIs($callable); - } + if (\count($types) < 2) { + throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); + } - public function __toString(): string - { - $string = ''; - $glue = ''; + foreach ($types as $type) { + if ($type instanceof CompositeTypeInterface || $type instanceof NullableType) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" part.', $type, self::class)); + } - foreach ($this->types as $t) { - $string .= $glue.($t instanceof UnionType ? '('.$t.')' : $t); - $glue = '&'; + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if (!$type instanceof ObjectType) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" part.', $type, self::class)); + } } - return $string; + usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b); + $this->types = array_values(array_unique($types)); } /** - * @throws LogicException + * @return list */ - public function getBaseType(): BuiltinType|ObjectType + public function getTypes(): array { - throw new LogicException(\sprintf('Cannot get base type on "%s" compound type.', $this)); + return $this->types; } - /** - * @throws LogicException - */ - public function asNonNullable(): self + public function composedTypesAreSatisfiedBy(callable $specification): bool { - if ($this->isNullable()) { - throw new LogicException(\sprintf('"%s cannot be turned as non nullable.', (string) $this)); + foreach ($this->types as $type) { + if (!$type->isSatisfiedBy($specification)) { + return false; + } } - return $this; + return true; + } + + public function __toString(): string + { + $string = ''; + $glue = ''; + + foreach ($this->types as $t) { + $string .= $glue.($t instanceof CompositeTypeInterface ? '('.$t.')' : $t); + $glue = '&'; + } + + return $string; } } diff --git a/src/Symfony/Component/TypeInfo/Type/NullableType.php b/src/Symfony/Component/TypeInfo/Type/NullableType.php new file mode 100644 index 0000000000000..d5725dccdb85f --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/NullableType.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Type; + +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @author Mathias Arlaud + * + * @template T of Type + * + * @extends UnionType> + * + * @implements WrappingTypeInterface + * + * @experimental + */ +final class NullableType extends UnionType implements WrappingTypeInterface +{ + /** + * @param T $type + */ + public function __construct( + private readonly Type $type, + ) { + if ($type->isNullable()) { + throw new InvalidArgumentException(\sprintf('Cannot create a "%s" with "%s" because it is already nullable.', self::class, $type)); + } + + if ($type instanceof UnionType) { + parent::__construct(Type::null(), ...$type->getTypes()); + + return; + } + + parent::__construct(Type::null(), $type); + } + + public function getWrappedType(): Type + { + return $this->type; + } + + public function wrappedTypeIsSatisfiedBy(callable $specification): bool + { + return $this->getWrappedType()->isSatisfiedBy($specification); + } + + public function isNullable(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/ObjectType.php b/src/Symfony/Component/TypeInfo/Type/ObjectType.php index 5d35278380510..c12e37c5c00d5 100644 --- a/src/Symfony/Component/TypeInfo/Type/ObjectType.php +++ b/src/Symfony/Component/TypeInfo/Type/ObjectType.php @@ -32,25 +32,11 @@ public function __construct( ) { } - public function getBaseType(): BuiltinType|self - { - return $this; - } - public function getTypeIdentifier(): TypeIdentifier { return TypeIdentifier::OBJECT; } - public function isA(TypeIdentifier|string $subject): bool - { - if ($subject instanceof TypeIdentifier) { - return $this->getTypeIdentifier() === $subject; - } - - return is_a($this->getClassName(), $subject, allow_string: true); - } - /** * @return T */ @@ -59,9 +45,27 @@ public function getClassName(): string return $this->className; } - public function asNonNullable(): static + public function isIdentifiedBy(TypeIdentifier|string ...$identifiers): bool { - return $this; + foreach ($identifiers as $identifier) { + if ($identifier instanceof TypeIdentifier) { + if (TypeIdentifier::OBJECT === $identifier) { + return true; + } + + continue; + } + + if (TypeIdentifier::OBJECT->value === $identifier) { + return true; + } + + if (is_a($this->className, $identifier, allow_string: true)) { + return true; + } + } + + return false; } public function __toString(): string diff --git a/src/Symfony/Component/TypeInfo/Type/TemplateType.php b/src/Symfony/Component/TypeInfo/Type/TemplateType.php index 902dd89a87ef3..3aba9be1bb0f9 100644 --- a/src/Symfony/Component/TypeInfo/Type/TemplateType.php +++ b/src/Symfony/Component/TypeInfo/Type/TemplateType.php @@ -12,7 +12,6 @@ namespace Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\TypeIdentifier; /** * Represents a template placeholder, such as "T" in "Collection". @@ -20,39 +19,44 @@ * @author Mathias Arlaud * @author Baptiste Leduc * + * @template T of Type + * + * @implements WrappingTypeInterface + * * @experimental */ -final class TemplateType extends Type +final class TemplateType extends Type implements WrappingTypeInterface { + /** + * @param T $bound + */ public function __construct( private readonly string $name, private readonly Type $bound, ) { } - public function getBaseType(): BuiltinType|ObjectType - { - return $this->bound->getBaseType(); - } - - public function isA(TypeIdentifier|string $subject): bool - { - return false; - } - public function getName(): string { return $this->name; } + /** + * @return T + */ public function getBound(): Type { return $this->bound; } - public function asNonNullable(): self + public function getWrappedType(): Type + { + return $this->bound; + } + + public function wrappedTypeIsSatisfiedBy(callable $specification): bool { - return $this; + return $this->getWrappedType()->isSatisfiedBy($specification); } public function __toString(): string diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php index 6e23108f4c664..138b84e050d79 100644 --- a/src/Symfony/Component/TypeInfo/Type/UnionType.php +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.php @@ -11,7 +11,7 @@ namespace Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -21,49 +21,80 @@ * * @template T of Type * + * @implements CompositeTypeInterface + * * @experimental */ -final class UnionType extends Type +class UnionType extends Type implements CompositeTypeInterface { /** - * @use CompositeTypeTrait + * @var list */ - use CompositeTypeTrait; + private readonly array $types; - public function is(callable $callable): bool + /** + * @param list $types + */ + public function __construct(Type ...$types) { - return $this->atLeastOneTypeIs($callable); + if (\count($types) < 2) { + throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); + } + + foreach ($types as $type) { + if ($type instanceof self) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%1$s" part.', self::class)); + } + + if ($type instanceof BuiltinType) { + if ($type->getTypeIdentifier() === TypeIdentifier::NULL && !is_a(static::class, NullableType::class, allow_string: true)) { + throw new InvalidArgumentException(\sprintf('Cannot create union with "null", please use "%s" instead.', NullableType::class)); + } + + if ($type->getTypeIdentifier()->isStandalone()) { + throw new InvalidArgumentException(\sprintf('Cannot create union with "%s" standalone type.', $type)); + } + } + } + + usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b); + $this->types = array_values(array_unique($types)); + + $builtinTypesIdentifiers = array_map( + fn (BuiltinType $t): TypeIdentifier => $t->getTypeIdentifier(), + array_filter($this->types, fn (Type $t): bool => $t instanceof BuiltinType), + ); + + if ((\in_array(TypeIdentifier::TRUE, $builtinTypesIdentifiers, true) || \in_array(TypeIdentifier::FALSE, $builtinTypesIdentifiers, true)) && \in_array(TypeIdentifier::BOOL, $builtinTypesIdentifiers, true)) { + throw new InvalidArgumentException('Cannot create union with redundant boolean type.'); + } + + if (\in_array(TypeIdentifier::TRUE, $builtinTypesIdentifiers, true) && \in_array(TypeIdentifier::FALSE, $builtinTypesIdentifiers, true)) { + throw new InvalidArgumentException('Cannot create union with both "true" and "false", "bool" should be used instead.'); + } + + if (\in_array(TypeIdentifier::OBJECT, $builtinTypesIdentifiers, true) && \count(array_filter($this->types, fn (Type $t): bool => $t instanceof ObjectType))) { + throw new InvalidArgumentException('Cannot create union with both "object" and class type.'); + } } /** - * @throws LogicException + * @return list */ - public function getBaseType(): BuiltinType|ObjectType + public function getTypes(): array { - $nonNullableType = $this->asNonNullable(); - if (!$nonNullableType instanceof self) { - return $nonNullableType->getBaseType(); - } - - throw new LogicException(\sprintf('Cannot get base type on "%s" compound type.', $this)); + return $this->types; } - public function asNonNullable(): Type + public function composedTypesAreSatisfiedBy(callable $specification): bool { - $nonNullableTypes = []; - foreach ($this->getTypes() as $type) { - if ($type->isA(TypeIdentifier::NULL)) { - continue; + foreach ($this->types as $type) { + if ($type->isSatisfiedBy($specification)) { + return true; } - - $nonNullableType = $type->asNonNullable(); - $nonNullableTypes = [ - ...$nonNullableTypes, - ...($nonNullableType instanceof self ? $nonNullableType->getTypes() : [$nonNullableType]), - ]; } - return \count($nonNullableTypes) > 1 ? new self(...$nonNullableTypes) : $nonNullableTypes[0]; + return false; } public function __toString(): string @@ -72,30 +103,10 @@ public function __toString(): string $glue = ''; foreach ($this->types as $t) { - $string .= $glue.($t instanceof IntersectionType ? '('.$t.')' : $t); + $string .= $glue.($t instanceof CompositeTypeInterface ? '('.$t.')' : $t); $glue = '|'; } return $string; } - - /** - * Proxies all method calls to the original non-nullable type. - * - * @param list $arguments - */ - public function __call(string $method, array $arguments): mixed - { - $nonNullableType = $this->asNonNullable(); - - if (!$nonNullableType instanceof self) { - if (!method_exists($nonNullableType, $method)) { - throw new LogicException(\sprintf('Method "%s" doesn\'t exist on "%s" type.', $method, $nonNullableType)); - } - - return $nonNullableType->{$method}(...$arguments); - } - - throw new LogicException(\sprintf('Cannot call "%s" on "%s" compound type.', $method, $this)); - } } diff --git a/src/Symfony/Component/TypeInfo/Type/WrappingTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/WrappingTypeInterface.php new file mode 100644 index 0000000000000..292b5f5ce091f --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/WrappingTypeInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Type; + +use Symfony\Component\TypeInfo\Type; + +/** + * Represents a type wrapping another type. + * + * @author Mathias Arlaud + * + * @template T of Type + * + * @experimental + */ +interface WrappingTypeInterface +{ + /** + * @return T + */ + public function getWrappedType(): Type; + + /** + * @param callable(Type): bool $specification + */ + public function wrappedTypeIsSatisfiedBy(callable $specification): bool; +} diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index d87737d5945bb..0fae03dd3ef9c 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -17,6 +17,7 @@ use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; @@ -234,11 +235,18 @@ public static function enum(string $className, ?BuiltinType $backingType = null) * * @return GenericType */ - public static function generic(Type $mainType, Type ...$variableTypes): GenericType + public static function generic(BuiltinType|ObjectType $mainType, Type ...$variableTypes): GenericType { return new GenericType($mainType, ...$variableTypes); } + /** + * @template T of Type + * + * @param T|null $bound + * + * @return ($bound is null ? TemplateType> : TemplateType) + */ public static function template(string $name, ?Type $bound = null): TemplateType { return new TemplateType($name, $bound ?? Type::mixed()); @@ -249,34 +257,55 @@ public static function template(string $name, ?Type $bound = null): TemplateType * * @param list $types * - * @return UnionType + * @return UnionType|NullableType */ public static function union(Type ...$types): UnionType { /** @var list $unionTypes */ $unionTypes = []; + $nullableUnion = false; + $isNullable = fn (Type $type): bool => $type instanceof BuiltinType && TypeIdentifier::NULL === $type->getTypeIdentifier(); + foreach ($types as $type) { - if (!$type instanceof UnionType) { - $unionTypes[] = $type; + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $unionType) { + if ($isNullable($type)) { + $nullableUnion = true; + + continue; + } + + $unionTypes[] = $unionType; + } continue; } - foreach ($type->getTypes() as $unionType) { - $unionTypes[] = $unionType; + if ($isNullable($type)) { + $nullableUnion = true; + + continue; } + + $unionTypes[] = $type; + } + + if (1 === \count($unionTypes)) { + return self::nullable($unionTypes[0]); } - return new UnionType(...$unionTypes); + $unionType = new UnionType(...$unionTypes); + + return $nullableUnion ? self::nullable($unionType) : $unionType; } /** - * @template T of Type + * @template T of ObjectType|GenericType|CollectionType> * - * @param list $types + * @param list> $types * - * @return IntersectionType + * @return IntersectionType */ public static function intersection(Type ...$types): IntersectionType { @@ -303,14 +332,14 @@ public static function intersection(Type ...$types): IntersectionType * * @param T $type * - * @return (T is UnionType ? T : UnionType>) + * @return ($type is NullableType ? T : NullableType) */ - public static function nullable(Type $type): UnionType + public static function nullable(Type $type): NullableType { - if ($type instanceof UnionType) { - return Type::union(Type::null(), ...$type->getTypes()); + if ($type instanceof NullableType) { + return $type; } - return Type::union($type, Type::null()); + return new NullableType($type); } } diff --git a/src/Symfony/Component/TypeInfo/TypeIdentifier.php b/src/Symfony/Component/TypeInfo/TypeIdentifier.php index 45bd5472ab41e..18844052564fd 100644 --- a/src/Symfony/Component/TypeInfo/TypeIdentifier.php +++ b/src/Symfony/Component/TypeInfo/TypeIdentifier.php @@ -44,4 +44,19 @@ public static function values(): array { return array_column(self::cases(), 'value'); } + + public function isStandalone(): bool + { + return \in_array($this, [self::MIXED, self::NEVER, self::VOID], true); + } + + public function isScalar(): bool + { + return \in_array($this, [self::STRING, self::FLOAT, self::INT, self::BOOL, self::FALSE, self::TRUE], true); + } + + public function isBool(): bool + { + return \in_array($this, [self::BOOL, self::FALSE, self::TRUE], true); + } } diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php index 793eb394e9df0..737d62e3ac418 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -37,9 +37,9 @@ use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Exception\UnsupportedException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\GenericType; -use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\TypeContext\TypeContext; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -84,6 +84,8 @@ public function resolve(mixed $subject, ?TypeContext $typeContext = null): Type private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Type { + $typeIsCollectionObject = fn (Type $type): bool => $type->isIdentifiedBy(\Traversable::class) || $type->isIdentifiedBy(\ArrayAccess::class); + if ($node instanceof CallableTypeNode) { return Type::callable(); } @@ -161,7 +163,7 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ default => $this->resolveCustomIdentifier($node->name, $typeContext), }; - if ($type instanceof ObjectType && (is_a($type->getClassName(), \Traversable::class, true) || is_a($type->getClassName(), \ArrayAccess::class, true))) { + if ($typeIsCollectionObject($type)) { return Type::collection($type); } @@ -176,7 +178,7 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ $type = $this->getTypeFromNode($node->type, $typeContext); // handle integer ranges as simple integers - if ($type->isA(TypeIdentifier::INT)) { + if ($type->isIdentifiedBy(TypeIdentifier::INT)) { return $type; } @@ -185,10 +187,10 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ if ($type instanceof CollectionType) { $asList = $type->isList(); $keyType = $type->getCollectionKeyType(); + $type = $type->getWrappedType(); - $type = $type->getType(); if ($type instanceof GenericType) { - $type = $type->getType(); + $type = $type->getWrappedType(); } if (1 === \count($variableTypes)) { @@ -198,7 +200,7 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ } } - if ($type instanceof ObjectType && (is_a($type->getClassName(), \Traversable::class, true) || is_a($type->getClassName(), \ArrayAccess::class, true))) { + if ($typeIsCollectionObject($type)) { return match (\count($variableTypes)) { 1 => Type::collection($type, $variableTypes[0]), 2 => Type::collection($type, $variableTypes[1], $variableTypes[0]), @@ -206,6 +208,10 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ }; } + if ($type instanceof BuiltinType && $type->getTypeIdentifier() !== TypeIdentifier::ARRAY && $type->getTypeIdentifier() !== TypeIdentifier::ITERABLE) { + return $type; + } + return Type::generic($type, ...$variableTypes); } diff --git a/src/Symfony/Component/TypeInfo/composer.json b/src/Symfony/Component/TypeInfo/composer.json index 54b14975d9f49..ba2ad4d96a8a2 100644 --- a/src/Symfony/Component/TypeInfo/composer.json +++ b/src/Symfony/Component/TypeInfo/composer.json @@ -31,12 +31,14 @@ "require-dev": { "phpstan/phpdoc-parser": "^1.0", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0" + "symfony/property-info": "^7.2" }, "conflict": { "phpstan/phpdoc-parser": "<1.0", "symfony/dependency-injection": "<6.4", - "symfony/property-info": "<6.4" + "symfony/property-info": "<7.2", + "symfony/serializer": "<7.2", + "symfony/validator": "<7.2" }, "autoload": { "psr-4": { "Symfony\\Component\\TypeInfo\\": "" }, diff --git a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php index 39a00792ae19b..335d10e659874 100644 --- a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php +++ b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php @@ -16,11 +16,13 @@ use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as PropertyInfoType; use Symfony\Component\TypeInfo\Type as TypeInfoType; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; @@ -139,11 +141,10 @@ public function loadClassMetadata(ClassMetadata $metadata): bool } $type = $types; - $nullable = false; + $nullable = $type->isNullable(); - if ($type instanceof UnionType && $type->isNullable()) { - $nullable = true; - $type = $type->asNonNullable(); + if ($type instanceof NullableType) { + $type = $type->getWrappedType(); } if ($type instanceof CollectionType) { @@ -193,18 +194,27 @@ private function getTypeConstraintLegacy(string $builtinType, PropertyInfoType $ private function getTypeConstraint(TypeInfoType $type): ?Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return ($type->isA(TypeIdentifier::INT) || $type->isA(TypeIdentifier::FLOAT) || $type->isA(TypeIdentifier::STRING) || $type->isA(TypeIdentifier::BOOL)) ? new Type(['type' => 'scalar']) : null; + if ($type instanceof CompositeTypeInterface) { + return $type->isIdentifiedBy( + TypeIdentifier::INT, + TypeIdentifier::FLOAT, + TypeIdentifier::STRING, + TypeIdentifier::BOOL, + TypeIdentifier::TRUE, + TypeIdentifier::FALSE, + ) ? new Type(['type' => 'scalar']) : null; } - $baseType = $type->getBaseType(); + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } - if ($baseType instanceof ObjectType) { - return new Type(['type' => $baseType->getClassName()]); + if ($type instanceof ObjectType) { + return new Type(['type' => $type->getClassName()]); } - if (TypeIdentifier::MIXED !== $baseType->getTypeIdentifier()) { - return new Type(['type' => $baseType->getTypeIdentifier()->value]); + if ($type instanceof BuiltinType && TypeIdentifier::MIXED !== $type->getTypeIdentifier()) { + return new Type(['type' => $type->getTypeIdentifier()->value]); } return null; diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index 5177d37d2955a..aacf40731c27a 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -39,7 +39,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation": "^6.4.3|^7.0.3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.2", "egulias/email-validator": "^2.1.10|^3|^4" }, "conflict": {