diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md index 5eaf445c6b8a0..fa847b44c0adb 100644 --- a/src/Symfony/Component/TypeInfo/CHANGELOG.md +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Deprecate constructing a `CollectionType` instance as a list that is not an array * Deprecate the third `$asList` argument of `TypeFactoryTrait::iterable()`, use `TypeFactoryTrait::list()` instead * Add type alias support in `TypeContext` and `StringTypeResolver` + * Add `CollectionType::mergeCollectionValueTypes()` method 7.2 --- diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php index fdc2a215f63be..fa0be0c7efdc3 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php @@ -134,4 +134,26 @@ public function testCannotCreateIterableList() $this->expectUserDeprecationMessage('Since symfony/type-info 7.3: Creating a "Symfony\Component\TypeInfo\Type\CollectionType" that is a list and not an array is deprecated and will throw a "Symfony\Component\TypeInfo\Exception\InvalidArgumentException" in 8.0.'); new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ITERABLE), Type::bool()), isList: true); } + + public function testMergeCollectionValueTypes() + { + $this->assertEquals(Type::int(), CollectionType::mergeCollectionValueTypes([Type::int()])); + $this->assertEquals(Type::union(Type::int(), Type::string()), CollectionType::mergeCollectionValueTypes([Type::int(), Type::string()])); + + $this->assertEquals(Type::mixed(), CollectionType::mergeCollectionValueTypes([Type::int(), Type::mixed()])); + + $this->assertEquals(Type::union(Type::int(), Type::true()), CollectionType::mergeCollectionValueTypes([Type::int(), Type::true()])); + $this->assertEquals(Type::bool(), CollectionType::mergeCollectionValueTypes([Type::true(), Type::false(), Type::true()])); + + $this->assertEquals(Type::union(Type::object(\Stringable::class), Type::object(\Countable::class)), CollectionType::mergeCollectionValueTypes([Type::object(\Stringable::class), Type::object(\Countable::class)])); + $this->assertEquals(Type::object(), CollectionType::mergeCollectionValueTypes([Type::object(\Stringable::class), Type::object()])); + } + + public function testCannotMergeEmptyCollectionValueTypes() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The $types cannot be empty.'); + + CollectionType::mergeCollectionValueTypes([]); + } } diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php index e62e881f7ceef..800120cbe00d8 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -227,6 +227,7 @@ public static function createFromValueProvider(): iterable // object yield [Type::object(\DateTimeImmutable::class), new \DateTimeImmutable()]; yield [Type::object(), new \stdClass()]; + yield [Type::list(Type::object()), [new \stdClass(), new \DateTimeImmutable()]]; // collection $arrayAccess = new class implements \ArrayAccess { diff --git a/src/Symfony/Component/TypeInfo/Type/CollectionType.php b/src/Symfony/Component/TypeInfo/Type/CollectionType.php index 127345b52fa9f..b1f6f6c36d834 100644 --- a/src/Symfony/Component/TypeInfo/Type/CollectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -52,6 +52,59 @@ public function __construct( } } + /** + * @param array $types + */ + public static function mergeCollectionValueTypes(array $types): Type + { + if (!$types) { + throw new InvalidArgumentException('The $types cannot be empty.'); + } + + $normalizedTypes = []; + $boolTypes = []; + $objectTypes = []; + + foreach ($types as $t) { + // cannot create an union with a standalone type + if ($t->isIdentifiedBy(TypeIdentifier::MIXED)) { + return Type::mixed(); + } + + if ($t->isIdentifiedBy(TypeIdentifier::TRUE, TypeIdentifier::FALSE, TypeIdentifier::BOOL)) { + $boolTypes[] = $t; + + continue; + } + + if ($t->isIdentifiedBy(TypeIdentifier::OBJECT)) { + $objectTypes[] = $t; + + continue; + } + + $normalizedTypes[] = $t; + } + + $boolTypes = array_unique($boolTypes); + $objectTypes = array_unique($objectTypes); + + // cannot create an union with either "true" and "false", "bool" must be used instead + if ($boolTypes) { + $normalizedTypes[] = \count($boolTypes) > 1 ? Type::bool() : $boolTypes[0]; + } + + // cannot create a union with either "object" and a class name, "object" must be used instead + if ($objectTypes) { + $hasBuiltinObjectType = array_filter($objectTypes, static fn (Type $t): bool => $t->isSatisfiedBy(static fn (Type $t): bool => $t instanceof BuiltinType)); + $normalizedTypes = [...$normalizedTypes, ...($hasBuiltinObjectType ? [Type::object()] : $objectTypes)]; + } + + $normalizedTypes = array_values(array_unique($normalizedTypes)); + + return \count($normalizedTypes) > 1 ? self::union(...$normalizedTypes) : $normalizedTypes[0]; + } + public function getWrappedType(): Type { return $this->type; diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index 382644dd5e38c..ec29e9cdbd91a 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -391,45 +391,25 @@ public static function fromValue(mixed $value): Type /** @var list|BuiltinType> $keyTypes */ $keyTypes = []; - /** @var list $valueTypes */ + /** @var list $valueTypes */ $valueTypes = []; $i = 0; foreach ($value as $k => $v) { $keyTypes[] = self::fromValue($k); - $keyTypes = array_unique($keyTypes); - $valueTypes[] = self::fromValue($v); - $valueTypes = array_unique($valueTypes); } - if ([] !== $keyTypes) { - $keyTypes = array_values($keyTypes); + if ($keyTypes) { + $keyTypes = array_values(array_unique($keyTypes)); $keyType = \count($keyTypes) > 1 ? self::union(...$keyTypes) : $keyTypes[0]; - - $valueType = null; - foreach ($valueTypes as &$v) { - if ($v->isIdentifiedBy(TypeIdentifier::MIXED)) { - $valueType = Type::mixed(); - - break; - } - - if ($v->isIdentifiedBy(TypeIdentifier::TRUE, TypeIdentifier::FALSE)) { - $v = Type::bool(); - } - } - - if (!$valueType) { - $valueTypes = array_values(array_unique($valueTypes)); - $valueType = \count($valueTypes) > 1 ? self::union(...$valueTypes) : $valueTypes[0]; - } } else { $keyType = Type::union(Type::int(), Type::string()); - $valueType = Type::mixed(); } + $valueType = $valueTypes ? CollectionType::mergeCollectionValueTypes($valueTypes) : Type::mixed(); + return self::collection($type, $valueType, $keyType, \is_array($value) && array_is_list($value)); }