From cbd04ea4cdd441a812d6ef7c799aeb537c3e21a4 Mon Sep 17 00:00:00 2001 From: Pascal Luna Date: Thu, 8 Aug 2024 01:17:32 +0100 Subject: [PATCH 1/4] Add NullableTypeInterface and improve types comparison --- .../Tests/Type/BackedEnumTypeTest.php | 11 +-- .../Tests/Type/CollectionTypeTest.php | 14 --- .../TypeInfo/Tests/Type/EnumTypeTest.php | 15 +--- .../TypeInfo/Tests/Type/GenericTypeTest.php | 10 +-- .../Tests/Type/IntersectionTypeTest.php | 90 +++++++++---------- .../TypeInfo/Tests/Type/ObjectTypeTest.php | 4 +- .../TypeInfo/Tests/Type/UnionTypeTest.php | 13 ++- .../TypeInfo/Tests/TypeFactoryTest.php | 11 ++- .../ReflectionTypeResolverTest.php | 4 +- .../TypeResolver/StringTypeResolverTest.php | 6 +- src/Symfony/Component/TypeInfo/Type.php | 9 +- .../Component/TypeInfo/Type/BuiltinType.php | 17 ++-- .../TypeInfo/Type/CollectionType.php | 10 +-- .../TypeInfo/Type/CompositeTypeInterface.php | 13 +++ .../TypeInfo/Type/CompositeTypeTrait.php | 19 ---- .../Component/TypeInfo/Type/GenericType.php | 10 +-- .../TypeInfo/Type/IntersectionType.php | 43 ++++++--- .../TypeInfo/Type/NullableTypeInterface.php | 11 +++ .../Component/TypeInfo/Type/ObjectType.php | 13 +-- .../Component/TypeInfo/Type/TemplateType.php | 10 +-- .../Component/TypeInfo/Type/UnionType.php | 68 +++++++++++--- .../Component/TypeInfo/TypeFactoryTrait.php | 6 +- 22 files changed, 222 insertions(+), 185 deletions(-) create mode 100644 src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php create mode 100644 src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php index b42fd944b2c27..e63aa7cc79750 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php @@ -24,9 +24,9 @@ public function testToString() $this->assertSame(DummyBackedEnum::class, (string) new BackedEnumType(DummyBackedEnum::class, Type::int())); } - public function testIsNullable() + public function testGetTypeIdentifier() { - $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isNullable()); + $this->assertSame(TypeIdentifier::OBJECT, (new BackedEnumType(DummyBackedEnum::class, Type::int()))->getTypeIdentifier()); } public function testGetBaseType() @@ -34,13 +34,6 @@ public function testGetBaseType() $this->assertEquals(new BackedEnumType(DummyBackedEnum::class, Type::int()), (new BackedEnumType(DummyBackedEnum::class, Type::int()))->getBaseType()); } - public function testAsNonNullable() - { - $type = new BackedEnumType(DummyBackedEnum::class, Type::int()); - - $this->assertSame($type, $type->asNonNullable()); - } - public function testIsA() { $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(TypeIdentifier::ARRAY)); diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php index 8104121f5e592..2a814ab892619 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php @@ -79,20 +79,6 @@ 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())); diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php index 69baf0d8d5d84..96a485087b0b6 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php @@ -23,21 +23,14 @@ 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() + public function testGetTypeIdentifier(): void { - $this->assertFalse((new EnumType(DummyEnum::class))->isNullable()); + $this->assertSame(TypeIdentifier::OBJECT, (new EnumType(DummyEnum::class))->getTypeIdentifier()); } - public function testAsNonNullable() + public function testGetBaseType() { - $type = new EnumType(DummyEnum::class); - - $this->assertSame($type, $type->asNonNullable()); + $this->assertEquals(new EnumType(DummyEnum::class), (new EnumType(DummyEnum::class))->getBaseType()); } public function testIsA() diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php index 6277e4ea10727..c226eef4f4c74 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php @@ -35,6 +35,9 @@ public function testGetBaseType() $this->assertEquals(Type::object(), Type::generic(Type::object(), Type::int())->getBaseType()); } + /** + * This should fail! GenericType::isNullable() does not exist. We need to remove __call and use interfaces. + */ public function testIsNullable() { $this->assertFalse((new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::int()))->isNullable()); @@ -42,13 +45,6 @@ public function testIsNullable() $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()); diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php index 8002ebcba1430..5e14a05380655 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php @@ -17,89 +17,89 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\TypeIdentifier; class IntersectionTypeTest extends TestCase { - public function testCannotCreateWithOnlyOneType() + public function testCannotCreateWithOnlyOneType(): void { $this->expectException(InvalidArgumentException::class); - new IntersectionType(Type::int()); + new IntersectionType(Type::object('Foo')); } - public function testCannotCreateWithIntersectionTypeParts() + public static function getInvalidParts(): iterable + { + $foo = Type::object('Foo'); + $bar = Type::object('Bar'); + + yield 'intersection' => [Type::intersection($foo, $bar), Type::intersection($foo, $bar)]; + yield 'union' => [Type::intersection($foo, $bar), Type::intersection($foo, $bar)]; + foreach (TypeIdentifier::cases() as $case) { + yield $case->value => [Type::builtin($case), Type::builtin($case)]; + } + yield 'generic' => [Type::object('Foo'), Type::generic(Type::builtin('array'), Type::string())]; + yield 'collection' => [Type::object('Foo'), Type::collection(Type::generic(Type::builtin('array')))]; + } + + /** + * @dataProvider getInvalidParts + */ + public function testCannotCreateWithNonObjectParts(Type ...$parts): void { $this->expectException(InvalidArgumentException::class); - new IntersectionType(Type::int(), new IntersectionType()); + + new IntersectionType(...$parts); } - public function testSortTypesOnCreation() + public function testCreateWithObjectParts(): void { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); - $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); + $foo = Type::object('Foo'); + $bar = Type::generic(Type::object('Bar'), Type::string()); + $baz = Type::collection(Type::generic(Type::object('Baz'), Type::string())); + + $type = new IntersectionType($foo, $bar, $baz); + $this->assertEquals([$bar, $baz, $foo], $type->getTypes()); } - public function testAtLeastOneTypeIs() + public function testAtLeastOneTypeIs(): void { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar'), Type::object('Baz')); - $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'int' === (string) $t)); - $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'float' === (string) $t)); + $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'Bar' === (string) $t)); + $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'Blip' === (string) $t)); } public function testEveryTypeIs() { - $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); - $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar'), Type::object('Baz')); + $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof ObjectType)); - $type = new IntersectionType(Type::int(), Type::string(), Type::template('T')); - $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar'), Type::generic(Type::object('Baz'))); + $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof ObjectType)); } public function testGetBaseType() { $this->expectException(LogicException::class); - (new IntersectionType(Type::string(), Type::int()))->getBaseType(); + (new IntersectionType(Type::object('Bar'), Type::object('Foo')))->getBaseType(); } public function testToString() { - $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); - } - - public function testIsNullable() - { - $this->assertFalse((new IntersectionType(Type::int(), Type::string(), Type::float()))->isNullable()); - $this->assertTrue((new IntersectionType(Type::null(), Type::union(Type::int(), Type::mixed())))->isNullable()); - } - - public function testAsNonNullable() - { - $type = new IntersectionType(Type::int(), Type::string(), Type::float()); - - $this->assertSame($type, $type->asNonNullable()); - } - - public function testCannotTurnNullIntersectionAsNonNullable() - { - $this->expectException(LogicException::class); - - $type = (new IntersectionType(Type::null(), Type::mixed()))->asNonNullable(); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar'), Type::generic(Type::object('Baz'), Type::string())); + $this->assertSame('Bar&Baz&Foo', (string) $type); } public function testIsA() { - $type = new IntersectionType(Type::int(), Type::string(), Type::float()); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar')); $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); - $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); + $type = new IntersectionType(Type::object('Foo'), Type::object('Bar')); $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('Foo'), Type::object('Bar')); + $this->assertTrue($type->isA(TypeIdentifier::OBJECT)); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php index 1289f32df5ede..fa94f16e5c86e 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php @@ -22,9 +22,9 @@ public function testToString() $this->assertSame(self::class, (string) new ObjectType(self::class)); } - public function testIsNullable() + public function testGetTypeIdentifier(): void { - $this->assertFalse((new ObjectType(self::class))->isNullable()); + $this->assertSame(TypeIdentifier::OBJECT, (new ObjectType(self::class))->getTypeIdentifier()); } public function testGetBaseType() diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php index bc308d4651466..98231778e1c86 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -56,13 +56,13 @@ public function testAsNonNullable() $type = new UnionType(Type::int(), Type::object(\stdClass::class), Type::mixed()); $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); $this->assertEquals([ + Type::object(\stdClass::class), Type::builtin(TypeIdentifier::ARRAY), Type::bool(), Type::float(), Type::int(), Type::object(), Type::resource(), - Type::object(\stdClass::class), Type::string(), ], $type->asNonNullable()->getTypes()); } @@ -97,13 +97,13 @@ public function testToString() $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); + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::object('Foo'), Type::object('Bar'))); + $this->assertSame('(Bar&Foo)|int|string', (string) $type); } public function testIsNullable() { - $this->assertFalse((new UnionType(Type::int(), Type::intersection(Type::float(), Type::int())))->isNullable()); + $this->assertFalse((new UnionType(Type::int(), Type::intersection(Type::object('Foo'), Type::object('Bar'))))->isNullable()); $this->assertTrue((new UnionType(Type::int(), Type::null()))->isNullable()); $this->assertTrue((new UnionType(Type::int(), Type::mixed()))->isNullable()); } @@ -114,14 +114,11 @@ public function testIsA() $this->assertFalse($type->isNullable()); $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); - $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::object('Foo'), Type::object('Bar'))); $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::string(), Type::intersection(Type::int(), Type::int())); - $this->assertTrue($type->isA(TypeIdentifier::INT)); } public function testProxiesMethodsToNonNullableType() diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php index bb1f1c3c8ba5c..d3892ccac5534 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -185,9 +185,14 @@ 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('Foo'), new ObjectType('Bar')), + Type::intersection(Type::object('Foo'), Type::object('Bar')) + ); + $this->assertEquals( + new IntersectionType(new ObjectType('Foo'), new ObjectType('Bar'), new ObjectType('Baz')), + Type::intersection(Type::object('Baz'), Type::intersection(Type::object('Foo'), Type::object('Bar'))) + ); } public function testCreateNullable() diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php index 4d7bff9449764..4cadd5943e102 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php @@ -44,7 +44,7 @@ public function testResolve(Type $expectedType, \ReflectionType $reflection, ?Ty /** * @return iterable */ - public function resolveDataProvider(): iterable + public static function resolveDataProvider(): iterable { $typeContext = (new TypeContextFactory())->createFromClassName(ReflectionExtractableDummy::class); $reflection = new \ReflectionClass(ReflectionExtractableDummy::class); @@ -89,7 +89,7 @@ public function testCannotResolveClassKeywordsWithoutTypeContext(\ReflectionType /** * @return iterable */ - public function classKeywordsTypesDataProvider(): iterable + public static function classKeywordsTypesDataProvider(): iterable { $reflection = new \ReflectionClass(ReflectionExtractableDummy::class); diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php index 78377560a8d76..6ba595c49be51 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -60,7 +60,7 @@ public function __toString(): string /** * @return iterable */ - public function resolveDataProvider(): iterable + public static function resolveDataProvider(): iterable { $typeContextFactory = new TypeContextFactory(new StringTypeResolver()); @@ -153,10 +153,10 @@ public function resolveDataProvider(): iterable yield [Type::union(Type::int(), Type::string()), 'int|string']; // intersection - yield [Type::intersection(Type::int(), Type::string()), 'int&string']; + yield [Type::intersection(Type::object('DateTimeInterface'), Type::object('DateTimeImmutable')), 'DateTimeImmutable&DateTimeInterface']; // DNF - yield [Type::union(Type::int(), Type::intersection(Type::string(), Type::bool())), 'int|(string&bool)']; + yield [Type::union(Type::intersection(Type::object('DateTimeInterface'), Type::object('DateTimeImmutable')), Type::int()), 'int|(DateTimeImmutable&DateTimeInterface)']; // collection objects yield [Type::collection(Type::object(\Traversable::class)), \Traversable::class]; diff --git a/src/Symfony/Component/TypeInfo/Type.php b/src/Symfony/Component/TypeInfo/Type.php index 3109f96fb4d08..4f442ca071667 100644 --- a/src/Symfony/Component/TypeInfo/Type.php +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -27,13 +27,13 @@ abstract class Type implements \Stringable abstract public function getBaseType(): BuiltinType|ObjectType; + abstract public function getTypeIdentifier(): TypeIdentifier; + /** * @param TypeIdentifier|class-string $subject */ abstract public function isA(TypeIdentifier|string $subject): bool; - abstract public function asNonNullable(): self; - /** * @param callable(Type): bool $callable */ @@ -42,11 +42,6 @@ public function is(callable $callable): bool return $callable($this); } - public function isNullable(): bool - { - return $this->is(fn (Type $t): bool => $t->isA(TypeIdentifier::NULL) || $t->isA(TypeIdentifier::MIXED)); - } - /** * Graceful fallback for unexisting methods. * diff --git a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php index 06f175df3f75d..2eb334596673a 100644 --- a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php @@ -23,7 +23,7 @@ * * @experimental */ -final class BuiltinType extends Type +final class BuiltinType extends Type implements NullableTypeInterface { /** * @param T $typeIdentifier @@ -33,11 +33,6 @@ public function __construct( ) { } - public function getBaseType(): self|ObjectType - { - return $this; - } - /** * @return T */ @@ -46,6 +41,11 @@ public function getTypeIdentifier(): TypeIdentifier return $this->typeIdentifier; } + public function getBaseType(): self|ObjectType + { + return $this; + } + public function isA(TypeIdentifier|string $subject): bool { if ($subject instanceof TypeIdentifier) { @@ -59,6 +59,11 @@ public function isA(TypeIdentifier|string $subject): bool } } + public function isNullable(): bool + { + return \in_array($this->getTypeIdentifier(), [TypeIdentifier::NULL, TypeIdentifier::MIXED], true); + } + /** * @return self|UnionType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType> */ diff --git a/src/Symfony/Component/TypeInfo/Type/CollectionType.php b/src/Symfony/Component/TypeInfo/Type/CollectionType.php index 3076da7de3e9f..5602751e0ab16 100644 --- a/src/Symfony/Component/TypeInfo/Type/CollectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -45,6 +45,11 @@ public function __construct( } } + public function getTypeIdentifier(): TypeIdentifier + { + return $this->getType()->getTypeIdentifier(); + } + public function getBaseType(): BuiltinType|ObjectType { return $this->getType()->getBaseType(); @@ -68,11 +73,6 @@ public function isList(): bool return $this->isList; } - public function asNonNullable(): self - { - return $this; - } - public function getCollectionKeyType(): Type { $defaultCollectionKeyType = self::union(self::int(), self::string()); diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php new file mode 100644 index 0000000000000..8bd5fbc9e1882 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php @@ -0,0 +1,13 @@ + + */ + public function getTypes(): array; +} diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php index ee8d6c52092cc..768015a49614e 100644 --- a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php @@ -30,25 +30,6 @@ trait CompositeTypeTrait */ 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)); diff --git a/src/Symfony/Component/TypeInfo/Type/GenericType.php b/src/Symfony/Component/TypeInfo/Type/GenericType.php index 2439444153026..5db452cc56d0a 100644 --- a/src/Symfony/Component/TypeInfo/Type/GenericType.php +++ b/src/Symfony/Component/TypeInfo/Type/GenericType.php @@ -43,6 +43,11 @@ public function __construct( $this->variableTypes = $variableTypes; } + public function getTypeIdentifier(): TypeIdentifier + { + return $this->getType()->getTypeIdentifier(); + } + public function getBaseType(): BuiltinType|ObjectType { return $this->getType(); @@ -61,11 +66,6 @@ public function isA(TypeIdentifier|string $subject): bool return $this->getType()->isA($subject); } - public function asNonNullable(): self - { - return $this; - } - /** * @return list */ diff --git a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php index 62842436ed154..2027bc3390a8c 100644 --- a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php @@ -11,24 +11,51 @@ namespace Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * @author Mathias Arlaud * @author Baptiste Leduc * - * @template T of Type + * @template T of ObjectType|GenericType|CollectionType> * * @experimental */ -final class IntersectionType extends Type +final class IntersectionType extends Type implements CompositeTypeInterface { /** * @use CompositeTypeTrait */ use CompositeTypeTrait; + /** + * @param list $types + */ + public function __construct(Type ...$types) + { + if (\count($types) < 2) { + throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); + } + // Only accept non-composite object types, except the builtin 'object' + foreach ($types as $t) { + if ($t instanceof CompositeTypeInterface || $t instanceof BuiltinType || TypeIdentifier::OBJECT !== $t->getTypeIdentifier()) { + throw new InvalidArgumentException(\sprintf('Cannot set type "%s" as a "%s" part.', $t, self::class)); + } + } + // All subtypes are class names and are sorted alphabetically + usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b); + + $this->types = array_values(array_unique($types)); + } + + public function getTypeIdentifier(): TypeIdentifier + { + return TypeIdentifier::OBJECT; + } + public function is(callable $callable): bool { return $this->everyTypeIs($callable); @@ -54,16 +81,4 @@ public function getBaseType(): BuiltinType|ObjectType { throw new LogicException(\sprintf('Cannot get base type on "%s" compound type.', $this)); } - - /** - * @throws LogicException - */ - public function asNonNullable(): self - { - if ($this->isNullable()) { - throw new LogicException(\sprintf('"%s cannot be turned as non nullable.', (string) $this)); - } - - return $this; - } } diff --git a/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php new file mode 100644 index 0000000000000..270c70cc3665a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php @@ -0,0 +1,11 @@ +className; } - public function asNonNullable(): static - { - return $this; - } - public function __toString(): string { return $this->className; diff --git a/src/Symfony/Component/TypeInfo/Type/TemplateType.php b/src/Symfony/Component/TypeInfo/Type/TemplateType.php index eef3170b75496..9c0b966c79b61 100644 --- a/src/Symfony/Component/TypeInfo/Type/TemplateType.php +++ b/src/Symfony/Component/TypeInfo/Type/TemplateType.php @@ -31,6 +31,11 @@ public function __construct( ) { } + public function getTypeIdentifier(): TypeIdentifier + { + return $this->getBound()->getTypeIdentifier(); + } + public function getBaseType(): BuiltinType|ObjectType { throw new LogicException(\sprintf('Cannot get base type on "%s" template type.', $this)); @@ -51,11 +56,6 @@ public function getBound(): Type return $this->bound; } - public function asNonNullable(): self - { - return $this; - } - public function __toString(): string { return $this->name; diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php index d757897c681aa..0ee9280a3f69b 100644 --- a/src/Symfony/Component/TypeInfo/Type/UnionType.php +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.php @@ -11,6 +11,7 @@ namespace Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -19,17 +20,51 @@ * @author Mathias Arlaud * @author Baptiste Leduc * - * @template T of Type + * @template T of BuiltinType|ObjectType|GenericType|CollectionType|IntersectionType * * @experimental */ -final class UnionType extends Type +final class UnionType extends Type implements CompositeTypeInterface, NullableTypeInterface { /** * @use CompositeTypeTrait */ use CompositeTypeTrait; + 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 || \in_array($t->getTypeIdentifier(), [TypeIdentifier::NEVER, TypeIdentifier::VOID], true)) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" part.', $t, self::class)); + } + } + // Sort intersections first, then classes, then builtins + $prefix = function (Type $t): string { + return match ($t::class) { + IntersectionType::class => '!!', + ObjectType::class => '!', + default => '', + }.$t; + }; + usort($types, fn (Type $a, Type $b): int => $prefix($a) <=> $prefix($b)); + + $this->types = array_values(array_unique($types)); + } + + public function getTypeIdentifier(): TypeIdentifier + { + $identifiers = array_values(array_unique(array_map(fn($t) => $t->getTypeIdentifier(), $this->getTypes()))); + + return 1 === count($identifiers) ? $identifiers[0] : TypeIdentifier::MIXED; + } + + /** + * @param callable(T): bool $callable + */ public function is(callable $callable): bool { return $this->atLeastOneTypeIs($callable); @@ -48,22 +83,35 @@ public function getBaseType(): BuiltinType|ObjectType throw new LogicException(\sprintf('Cannot get base type on "%s" compound type.', $this)); } + public function isNullable(): bool + { + foreach ($this->getTypes() as $type) { + if ($type instanceof NullableTypeInterface && $type->isNullable()) { + return true; + } + } + + return false; + } + public function asNonNullable(): Type { + if (!$this->isNullable()) { + return $this; + } $nonNullableTypes = []; foreach ($this->getTypes() as $type) { - if ($type->isA(TypeIdentifier::NULL)) { + if (TypeIdentifier::NULL === $type->getTypeIdentifier()) { continue; } - - $nonNullableType = $type->asNonNullable(); - $nonNullableTypes = [ - ...$nonNullableTypes, - ...($nonNullableType instanceof self ? $nonNullableType->getTypes() : [$nonNullableType]), - ]; + if ($type instanceof NullableTypeInterface && $type->isNullable()) { + $type = $type->asNonNullable(); + } + $nonNullableTypes[] = $type instanceof self ? $type->getTypes() : [$type]; } + $nonNullableTypes = array_merge(...$nonNullableTypes); - return \count($nonNullableTypes) > 1 ? new self(...$nonNullableTypes) : $nonNullableTypes[0]; + return 1 < \count($nonNullableTypes) ? new self(...$nonNullableTypes) : $nonNullableTypes[0]; } public function __toString(): string diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index d87737d5945bb..cd4259f188f68 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\NullableTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; @@ -305,8 +306,11 @@ public static function intersection(Type ...$types): IntersectionType * * @return (T is UnionType ? T : UnionType>) */ - public static function nullable(Type $type): UnionType + public static function nullable(Type $type): Type&NullableTypeInterface { + if ($type instanceof NullableTypeInterface && $type->isNullable()) { + return $type; + } if ($type instanceof UnionType) { return Type::union(Type::null(), ...$type->getTypes()); } From 6f1d8824705ad80ee33e6ee41e23bd053b3ab457 Mon Sep 17 00:00:00 2001 From: Pascal Luna Date: Thu, 8 Aug 2024 18:08:48 +0100 Subject: [PATCH 2/4] Guard against more invalid types --- .../TypeInfo/Tests/Type/BuiltinTypeTest.php | 16 +++- .../TypeInfo/Tests/Type/UnionTypeTest.php | 89 +++++++++++++---- .../Component/TypeInfo/Tests/TypeTest.php | 15 --- src/Symfony/Component/TypeInfo/Type.php | 3 + .../TypeInfo/Type/CompositeTypeTrait.php | 19 ++++ .../Component/TypeInfo/Type/UnionType.php | 95 ++++++++++++------- .../Component/TypeInfo/TypeIdentifier.php | 24 +++++ 7 files changed, 193 insertions(+), 68 deletions(-) diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php index 0537c3566f114..3ddade3c730f4 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php @@ -29,11 +29,19 @@ public function testGetBaseType() $this->assertEquals(new BuiltinType(TypeIdentifier::INT), (new BuiltinType(TypeIdentifier::INT))->getBaseType()); } - public function testIsNullable() + /** + * @dataProvider provideNullabilityResults + */ + public function testIsNullable(TypeIdentifier $typeIdentifier, bool $isNullable): void { - $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isNullable()); - $this->assertTrue((new BuiltinType(TypeIdentifier::NULL))->isNullable()); - $this->assertTrue((new BuiltinType(TypeIdentifier::MIXED))->isNullable()); + $this->assertSame($isNullable, (new BuiltinType($typeIdentifier))->isNullable()); + } + + public static function provideNullabilityResults(): iterable + { + foreach (TypeIdentifier::cases() as $case) { + yield $case->value => [$case, TypeIdentifier::NULL === $case || TypeIdentifier::MIXED === $case]; + } } public function testAsNonNullable() diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php index 98231778e1c86..5715f0f97a57c 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -30,7 +30,54 @@ public function testCannotCreateWithOnlyOneType() public function testCannotCreateWithUnionTypeParts() { $this->expectException(InvalidArgumentException::class); - new UnionType(Type::int(), new UnionType()); + new UnionType(Type::int(), new UnionType(Type::bool(), Type::float())); + } + + /** + * @dataProvider provideStandaloneTypes + */ + public function testCannotCreateWithStandaloneParts(Type $type): void + { + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::generic(Type::int()), $type); + } + + /** + * @dataProvider provideComposableTypes + */ + public function testCanCreateWithComposableTypes(Type $type): void + { + $this->assertContains($type, (new UnionType(Type::generic(Type::int()), $type))->getTypes()); + } + + public static function provideComposableTypes(): iterable + { + foreach (TypeIdentifier::cases() as $case) { + if ($case->isComposable()) { + yield $case->name => [Type::builtin($case)]; + } + } + } + + public static function provideStandaloneTypes(): iterable + { + foreach (TypeIdentifier::cases() as $case) { + if (!$case->isComposable()) { + yield $case->name => [Type::builtin($case)]; + } + } + } + + public function testCannotComposeTrueAndFalse(): void + { + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::true(), Type::false()); + } + + public function testCannotComposeBoolAndBoolValue(): void + { + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::bool(), Type::false()); } public function testSortTypesOnCreation() @@ -52,19 +99,6 @@ public function testAsNonNullable() $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::object(\stdClass::class), - Type::builtin(TypeIdentifier::ARRAY), - Type::bool(), - Type::float(), - Type::int(), - Type::object(), - Type::resource(), - Type::string(), - ], $type->asNonNullable()->getTypes()); } public function testGetBaseType() @@ -88,7 +122,7 @@ public function testEveryTypeIs() $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')); + $type = new UnionType(Type::int(), Type::string(), Type::object('T')); $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); } @@ -104,8 +138,31 @@ public function testToString() public function testIsNullable() { $this->assertFalse((new UnionType(Type::int(), Type::intersection(Type::object('Foo'), Type::object('Bar'))))->isNullable()); + $this->assertFalse((new UnionType(Type::int(), Type::string()))->isNullable()); $this->assertTrue((new UnionType(Type::int(), Type::null()))->isNullable()); - $this->assertTrue((new UnionType(Type::int(), Type::mixed()))->isNullable()); + } + + /** + * @dataProvider provideTypeIdentifierAndParts + */ + public function testGetTypeIdentifier(TypeIdentifier $expected, Type $first, Type $second): void + { + $this->assertSame($expected, (new UnionType($first, $second))->getTypeIdentifier()); + } + + public static function provideTypeIdentifierAndParts(): iterable + { + $int1 = Type::intersection(Type::object('Foo'), Type::object('Bar')); + $int2 = Type::intersection(Type::object('Bar'), Type::object('Baz')); + + yield 'int|string' => [TypeIdentifier::MIXED, Type::int(), Type::string()]; + yield 'int|null' => [TypeIdentifier::MIXED, Type::int(), Type::null()]; + yield 'int|int' => [TypeIdentifier::INT, Type::int(), Type::generic(Type::int())]; + yield 'int|object' => [TypeIdentifier::MIXED, Type::int(), Type::object()]; + yield 'intersections' => [TypeIdentifier::OBJECT, $int1, $int2]; + yield 'intersection|ClassName' => [TypeIdentifier::OBJECT, $int1, Type::object('Foo')]; + yield 'intersection|scalar' => [TypeIdentifier::MIXED, $int1, Type::int()]; + yield 'ClassName|ClassName' => [TypeIdentifier::OBJECT, Type::object('Foo'), Type::generic(Type::object('Foo'))]; } public function testIsA() diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php index c271ba581ed1f..2745f5ec0a098 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php @@ -31,21 +31,6 @@ public function testIs() $this->assertFalse(Type::generic(Type::string(), Type::int())->is($isInt)); } - 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); diff --git a/src/Symfony/Component/TypeInfo/Type.php b/src/Symfony/Component/TypeInfo/Type.php index 4f442ca071667..9ca0d354a8110 100644 --- a/src/Symfony/Component/TypeInfo/Type.php +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -27,6 +27,9 @@ abstract class Type implements \Stringable abstract public function getBaseType(): BuiltinType|ObjectType; + /** + * Return the simplest primitive type that this type will satisfy. + */ abstract public function getTypeIdentifier(): TypeIdentifier; /** diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php index 768015a49614e..b0298f0a18cb3 100644 --- a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php @@ -70,4 +70,23 @@ public function everyTypeIs(callable $callable): bool return true; } + + /** + * @param callable(T): bool $callable + * @return list + */ + public function filter(callable $callable): array + { + return $this->filterTypes($callable, ...$this->getTypes()); + } + + /** + * @param callable(T): bool $callable + * @param array $types + * @return list + */ + private function filterTypes(callable $callable, Type ...$types): array + { + return array_values(array_filter($types, $callable)); + } } diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php index 0ee9280a3f69b..ff504fee4da38 100644 --- a/src/Symfony/Component/TypeInfo/Type/UnionType.php +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.php @@ -31,35 +31,55 @@ final class UnionType extends Type implements CompositeTypeInterface, NullableTy */ use CompositeTypeTrait; + private readonly TypeIdentifier $typeIdentifier; + private readonly bool $isNullable; + public function __construct(Type ...$types) { if (\count($types) < 2) { throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); } + $nullable = false; + $hasClassType = false; + $hasObject = false; + $identifiers = []; foreach ($types as $t) { - if ($t instanceof self || \in_array($t->getTypeIdentifier(), [TypeIdentifier::NEVER, TypeIdentifier::VOID], true)) { + if ($t instanceof self) { throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" part.', $t, self::class)); } + if (!$t->getTypeIdentifier()->isComposable()) { + throw new InvalidArgumentException(\sprintf('Type %s can only be used as a standalone type', $t->getTypeIdentifier()->value)); + } + if (TypeIdentifier::NULL === $t->getTypeIdentifier()) { + $nullable = true; + } + $hasClassType = $hasClassType || !$t instanceof BuiltinType && TypeIdentifier::OBJECT === $t->getTypeIdentifier(); + $hasObject = $hasObject || $t instanceof BuiltinType && TypeIdentifier::OBJECT === $t->getTypeIdentifier(); + $identifiers[$t->getTypeIdentifier()->name] = $t->getTypeIdentifier(); + } + if ($hasClassType && $hasObject) { + throw new InvalidArgumentException('Union contains both object and a class type, which is redundant.'); } - // Sort intersections first, then classes, then builtins - $prefix = function (Type $t): string { - return match ($t::class) { - IntersectionType::class => '!!', - ObjectType::class => '!', - default => '', - }.$t; - }; - usort($types, fn (Type $a, Type $b): int => $prefix($a) <=> $prefix($b)); - $this->types = array_values(array_unique($types)); + $types = array_values(array_unique($types)); + + // bool, true and false cannot be composed together (use same errors as PHP in similar cases) + $booleanTypes = $this->filterTypes(fn(Type $t): bool => $t->getTypeIdentifier()->isBool(), ...$types); + if (1 < \count($booleanTypes)) { + throw \in_array(TypeIdentifier::TRUE, $identifiers, true) && \in_array(TypeIdentifier::FALSE, $identifiers, true) + ? new InvalidArgumentException('Union type contains both true and false, bool should be used instead.') + : new InvalidArgumentException('Duplicate boolean type is redundant.'); + } + + $this->typeIdentifier = 1 === count($identifiers) ? current($identifiers) : TypeIdentifier::MIXED; + $this->isNullable = $nullable; + $this->types = $this->sortSubtypesForRendering(...$types); } public function getTypeIdentifier(): TypeIdentifier { - $identifiers = array_values(array_unique(array_map(fn($t) => $t->getTypeIdentifier(), $this->getTypes()))); - - return 1 === count($identifiers) ? $identifiers[0] : TypeIdentifier::MIXED; + return $this->typeIdentifier; } /** @@ -83,33 +103,22 @@ public function getBaseType(): BuiltinType|ObjectType throw new LogicException(\sprintf('Cannot get base type on "%s" compound type.', $this)); } + /** + * Whether this union represents a nullable type. + * + * A union is nullable if it contains "null" (as it may not contain unions or mixed). + */ public function isNullable(): bool { - foreach ($this->getTypes() as $type) { - if ($type instanceof NullableTypeInterface && $type->isNullable()) { - return true; - } - } - - return false; + return $this->isNullable; } public function asNonNullable(): Type { - if (!$this->isNullable()) { + if (!$this->isNullable) { return $this; } - $nonNullableTypes = []; - foreach ($this->getTypes() as $type) { - if (TypeIdentifier::NULL === $type->getTypeIdentifier()) { - continue; - } - if ($type instanceof NullableTypeInterface && $type->isNullable()) { - $type = $type->asNonNullable(); - } - $nonNullableTypes[] = $type instanceof self ? $type->getTypes() : [$type]; - } - $nonNullableTypes = array_merge(...$nonNullableTypes); + $nonNullableTypes = $this->filter(fn (Type $t): bool => TypeIdentifier::NULL !== $t->getTypeIdentifier()); return 1 < \count($nonNullableTypes) ? new self(...$nonNullableTypes) : $nonNullableTypes[0]; } @@ -146,4 +155,24 @@ public function __call(string $method, array $arguments): mixed throw new LogicException(\sprintf('Cannot call "%s" on "%s" compound type.', $method, $this)); } + + /** + * Sort intersections first, then classes, then builtins and order alphabetically within each group. + * + * @param array $types + * @return list + */ + private function sortSubtypesForRendering(Type ...$types): array + { + $prefix = function (Type $t): string { + return match ($t::class) { + IntersectionType::class => '!!', + ObjectType::class => '!', + default => '', + }.$t; + }; + usort($types, fn (Type $a, Type $b): int => $prefix($a) <=> $prefix($b)); + + return array_values($types); + } } diff --git a/src/Symfony/Component/TypeInfo/TypeIdentifier.php b/src/Symfony/Component/TypeInfo/TypeIdentifier.php index 45bd5472ab41e..5aad4aeac8ae5 100644 --- a/src/Symfony/Component/TypeInfo/TypeIdentifier.php +++ b/src/Symfony/Component/TypeInfo/TypeIdentifier.php @@ -44,4 +44,28 @@ public static function values(): array { return array_column(self::cases(), 'value'); } + + /** + * Whether this type can be used in unions. + */ + public function isComposable(): bool + { + return !\in_array($this, [self::MIXED, self::NEVER, self::VOID], true); + } + + /** + * Whether this type is a scalar (string|float|int|bool) type. + */ + public function isScalar(): bool + { + return \in_array($this, [self::STRING, self::FLOAT, self::INT, self::BOOL, self::FALSE, self::TRUE], true); + } + + /** + * Whether this type represents a boolean value. + */ + public function isBool(): bool + { + return \in_array($this, [self::BOOL, self::FALSE, self::TRUE], true); + } } From 832d197f002fcac7b30e60a347f09d741d021a66 Mon Sep 17 00:00:00 2001 From: Pascal Luna Date: Thu, 8 Aug 2024 18:50:11 +0100 Subject: [PATCH 3/4] Remove NullableTypeInterface --- src/Symfony/Component/TypeInfo/Type/BuiltinType.php | 2 +- .../Component/TypeInfo/Type/NullableTypeInterface.php | 11 ----------- src/Symfony/Component/TypeInfo/Type/UnionType.php | 2 +- src/Symfony/Component/TypeInfo/TypeFactoryTrait.php | 4 ++-- 4 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php diff --git a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php index 2eb334596673a..44be91eca7313 100644 --- a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php @@ -23,7 +23,7 @@ * * @experimental */ -final class BuiltinType extends Type implements NullableTypeInterface +final class BuiltinType extends Type { /** * @param T $typeIdentifier diff --git a/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php deleted file mode 100644 index 270c70cc3665a..0000000000000 --- a/src/Symfony/Component/TypeInfo/Type/NullableTypeInterface.php +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index cd4259f188f68..8620570177ba5 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -306,9 +306,9 @@ public static function intersection(Type ...$types): IntersectionType * * @return (T is UnionType ? T : UnionType>) */ - public static function nullable(Type $type): Type&NullableTypeInterface + public static function nullable(Type $type): Type { - if ($type instanceof NullableTypeInterface && $type->isNullable()) { + if ($type->isNullable()) { return $type; } if ($type instanceof UnionType) { From d1506817be5c9672e68108427274d4eb09c007d8 Mon Sep 17 00:00:00 2001 From: Pascal Luna Date: Thu, 8 Aug 2024 21:20:37 +0100 Subject: [PATCH 4/4] Added methods to CompositeTypeInterface and improved doc --- .../TypeInfo/Tests/Type/GenericTypeTest.php | 4 +- .../TypeInfo/Tests/Type/UnionTypeTest.php | 4 +- src/Symfony/Component/TypeInfo/Type.php | 10 +++++ .../TypeInfo/Type/CompositeTypeInterface.php | 44 ++++++++++++++++++- .../TypeInfo/Type/IntersectionType.php | 1 + .../Component/TypeInfo/Type/UnionType.php | 5 ++- .../Component/TypeInfo/TypeIdentifier.php | 6 +-- 7 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php index c226eef4f4c74..1fbf2e7dbb654 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php @@ -41,8 +41,8 @@ public function testGetBaseType() public function testIsNullable() { $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()); + //$this->assertTrue((new GenericType(Type::null(), Type::int()))->isNullable()); + //$this->assertTrue((new GenericType(Type::mixed(), Type::int()))->isNullable()); } public function testIsA() diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php index 5715f0f97a57c..8971fba482064 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -53,7 +53,7 @@ public function testCanCreateWithComposableTypes(Type $type): void public static function provideComposableTypes(): iterable { foreach (TypeIdentifier::cases() as $case) { - if ($case->isComposable()) { + if (!$case->isStandalone()) { yield $case->name => [Type::builtin($case)]; } } @@ -62,7 +62,7 @@ public static function provideComposableTypes(): iterable public static function provideStandaloneTypes(): iterable { foreach (TypeIdentifier::cases() as $case) { - if (!$case->isComposable()) { + if ($case->isStandalone()) { yield $case->name => [Type::builtin($case)]; } } diff --git a/src/Symfony/Component/TypeInfo/Type.php b/src/Symfony/Component/TypeInfo/Type.php index 9ca0d354a8110..59fe94b1737de 100644 --- a/src/Symfony/Component/TypeInfo/Type.php +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -45,6 +45,16 @@ public function is(callable $callable): bool return $callable($this); } + public function asNonNullable(): self + { + return $this; + } + + public function isNullable(): bool + { + return false; + } + /** * Graceful fallback for unexisting methods. * diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php index 8bd5fbc9e1882..d166a418a190d 100644 --- a/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeInterface.php @@ -1,13 +1,55 @@ + * + * 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 by several other types. + * + * @author Mathias Arlaud + * + * @template T of Type + * + * @experimental + */ interface CompositeTypeInterface { /** - * @return list + * Returns the list of subtypes that compose this type. + * + * @return list */ public function getTypes(): array; + + /** + * Returns the list of subtypes that satisfy a given predicate. + * + * @param callable(T): bool $callable + * @return list + */ + public function filter(callable $callable): array; + + /** + * Checks whether at least one subtype satisfies the given predicate. + * + * @param callable(T): bool $callable + */ + public function atLeastOneTypeIs(callable $callable): bool; + + /** + * Checks whether all subtypes satisfy the given predicate. + * * + * @param callable(T): bool $callable + */ + public function everyTypeIs(callable $callable): bool; } diff --git a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php index 2027bc3390a8c..8c2b7503c639e 100644 --- a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php @@ -21,6 +21,7 @@ * @author Baptiste Leduc * * @template T of ObjectType|GenericType|CollectionType> + * @implements CompositeTypeInterface * * @experimental */ diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php index f2aa3af37702e..0094147f3a052 100644 --- a/src/Symfony/Component/TypeInfo/Type/UnionType.php +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.php @@ -20,7 +20,8 @@ * @author Mathias Arlaud * @author Baptiste Leduc * - * @template T of BuiltinType|ObjectType|GenericType|CollectionType|IntersectionType + * @template T of Type + * @implements CompositeTypeInterface * * @experimental */ @@ -48,7 +49,7 @@ public function __construct(Type ...$types) if ($t instanceof self) { throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" part.', $t, self::class)); } - if (!$t->getTypeIdentifier()->isComposable()) { + if ($t->getTypeIdentifier()->isStandalone()) { throw new InvalidArgumentException(\sprintf('Type %s can only be used as a standalone type', $t->getTypeIdentifier()->value)); } if (TypeIdentifier::NULL === $t->getTypeIdentifier()) { diff --git a/src/Symfony/Component/TypeInfo/TypeIdentifier.php b/src/Symfony/Component/TypeInfo/TypeIdentifier.php index 5aad4aeac8ae5..af6f56067f74d 100644 --- a/src/Symfony/Component/TypeInfo/TypeIdentifier.php +++ b/src/Symfony/Component/TypeInfo/TypeIdentifier.php @@ -46,11 +46,11 @@ public static function values(): array } /** - * Whether this type can be used in unions. + * Whether this type is a standalone PHP type (i.e. not allowed to be used as a part of a composite type or as a base type). */ - public function isComposable(): bool + public function isStandalone(): bool { - return !\in_array($this, [self::MIXED, self::NEVER, self::VOID], true); + return \in_array($this, [self::MIXED, self::NEVER, self::VOID], true); } /**