diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md index fa847b44c0adb..15af56fb00e4e 100644 --- a/src/Symfony/Component/TypeInfo/CHANGELOG.md +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Deprecate the third `$asList` argument of `TypeFactoryTrait::iterable()`, use `TypeFactoryTrait::list()` instead * Add type alias support in `TypeContext` and `StringTypeResolver` * Add `CollectionType::mergeCollectionValueTypes()` method + * Add `ArrayShapeType` to represent the exact shape of an array 7.2 --- diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php new file mode 100644 index 0000000000000..e54c832afd2e0 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php @@ -0,0 +1,98 @@ + + * + * 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\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; + +class ArrayShapeTypeTest extends TestCase +{ + public function testGetCollectionKeyType() + { + $type = new ArrayShapeType([ + 1 => ['type' => Type::bool(), 'optional' => false], + ]); + $this->assertEquals(Type::int(), $type->getCollectionKeyType()); + + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::bool(), 'optional' => false], + ]); + $this->assertEquals(Type::string(), $type->getCollectionKeyType()); + + $type = new ArrayShapeType([ + 1 => ['type' => Type::bool(), 'optional' => false], + 'foo' => ['type' => Type::bool(), 'optional' => false], + ]); + $this->assertEquals(Type::union(Type::int(), Type::string()), $type->getCollectionKeyType()); + } + + public function testGetCollectionValueType() + { + $type = new ArrayShapeType([ + 1 => ['type' => Type::bool(), 'optional' => false], + ]); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::bool(), 'optional' => false], + 'bar' => ['type' => Type::int(), 'optional' => false], + ]); + $this->assertEquals(Type::union(Type::int(), Type::bool()), $type->getCollectionValueType()); + + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::bool(), 'optional' => false], + 'bar' => ['type' => Type::nullable(Type::string()), 'optional' => false], + ]); + $this->assertEquals(Type::nullable(Type::union(Type::bool(), Type::string())), $type->getCollectionValueType()); + + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::true(), 'optional' => false], + 'bar' => ['type' => Type::false(), 'optional' => false], + ]); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + } + + public function testAccepts() + { + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::bool(), 'optional' => false], + 'bar' => ['type' => Type::string(), 'optional' => true], + ]); + + $this->assertFalse($type->accepts('string')); + $this->assertFalse($type->accepts([])); + $this->assertFalse($type->accepts(['foo' => 'string'])); + $this->assertFalse($type->accepts(['foo' => true, 'other' => 'string'])); + + $this->assertTrue($type->accepts(['foo' => true])); + $this->assertTrue($type->accepts(['foo' => true, 'bar' => 'string'])); + } + + public function testToString() + { + $type = new ArrayShapeType([1 => ['type' => Type::bool(), 'optional' => false]]); + $this->assertSame('array{1: bool}', (string) $type); + + $type = new ArrayShapeType([ + 2 => ['type' => Type::int(), 'optional' => true], + 1 => ['type' => Type::bool(), 'optional' => false], + ]); + $this->assertSame('array{1: bool, 2?: int}', (string) $type); + + $type = new ArrayShapeType([ + 'foo' => ['type' => Type::bool(), 'optional' => false], + 'bar' => ['type' => Type::string(), 'optional' => true], + ]); + $this->assertSame("array{'bar'?: string, 'foo': bool}", (string) $type); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php index b4f43d24468e4..7f5520cc7d01a 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -16,6 +16,7 @@ use Symfony\Component\TypeInfo\Tests\Fixtures\DummyBackedEnum; use Symfony\Component\TypeInfo\Tests\Fixtures\DummyEnum; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\BackedEnumType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -205,6 +206,12 @@ public function testCreateNullable() ); } + public function testCreateArrayShape() + { + $this->assertEquals(new ArrayShapeType(['foo' => ['type' => Type::bool(), 'optional' => true]]), Type::arrayShape(['foo' => ['type' => Type::bool(), 'optional' => true]])); + $this->assertEquals(new ArrayShapeType(['foo' => ['type' => Type::bool(), 'optional' => false]]), Type::arrayShape(['foo' => Type::bool()])); + } + /** * @dataProvider createFromValueProvider */ diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php index bbc1ffc93b738..b2db7660f9026 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -74,7 +74,8 @@ public static function resolveDataProvider(): iterable yield [Type::list(Type::bool()), 'bool[]']; // array shape - yield [Type::array(), 'array{0: true, 1: false}']; + yield [Type::arrayShape(['foo' => Type::true(), 1 => Type::false()]), 'array{foo: true, 1: false}']; + yield [Type::arrayShape(['foo' => ['type' => Type::bool(), 'optional' => true]]), 'array{foo?: bool}']; // object shape yield [Type::object(), 'object{foo: true, bar: false}']; diff --git a/src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php b/src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php new file mode 100644 index 0000000000000..2c3819cc56dfd --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php @@ -0,0 +1,110 @@ + + * + * 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; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Represents the exact shape of an array. + * + * @author Mathias Arlaud + * + * @extends CollectionType>> + */ +final class ArrayShapeType extends CollectionType +{ + /** + * @var array + */ + private readonly array $shape; + + /** + * @param array $shape + */ + public function __construct(array $shape) + { + $keyTypes = []; + $valueTypes = []; + + foreach ($shape as $k => $v) { + $keyTypes[] = self::fromValue($k); + $valueTypes[] = $v['type']; + } + + if ($keyTypes) { + $keyTypes = array_values(array_unique($keyTypes)); + $keyType = \count($keyTypes) > 1 ? self::union(...$keyTypes) : $keyTypes[0]; + } else { + $keyType = Type::union(Type::int(), Type::string()); + } + + $valueType = $valueTypes ? CollectionType::mergeCollectionValueTypes($valueTypes) : Type::mixed(); + + parent::__construct(self::generic(self::builtin(TypeIdentifier::ARRAY), $keyType, $valueType)); + + $sortedShape = $shape; + ksort($sortedShape); + + $this->shape = $sortedShape; + } + + /** + * @return array + */ + public function getShape(): array + { + return $this->shape; + } + + public function accepts(mixed $value): bool + { + if (!\is_array($value)) { + return false; + } + + foreach ($this->shape as $key => $shapeValue) { + if (!($shapeValue['optional'] ?? false) && !\array_key_exists($key, $value)) { + return false; + } + } + + foreach ($value as $key => $itemValue) { + $valueType = $this->shape[$key]['type'] ?? false; + if (!$valueType) { + return false; + } + + if (!$valueType->accepts($itemValue)) { + return false; + } + } + + return true; + } + + public function __toString(): string + { + $items = []; + + foreach ($this->shape as $key => $value) { + $itemKey = \is_int($key) ? (string) $key : \sprintf("'%s'", $key); + if ($value['optional'] ?? false) { + $itemKey = \sprintf('%s?', $itemKey); + } + + $items[] = \sprintf('%s: %s', $itemKey, $value['type']); + } + + return \sprintf('array{%s}', implode(', ', $items)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/CollectionType.php b/src/Symfony/Component/TypeInfo/Type/CollectionType.php index b1f6f6c36d834..579d6d358cc6d 100644 --- a/src/Symfony/Component/TypeInfo/Type/CollectionType.php +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -25,7 +25,7 @@ * * @implements WrappingTypeInterface */ -final class CollectionType extends Type implements WrappingTypeInterface +class CollectionType extends Type implements WrappingTypeInterface { /** * @param T $type diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index 48127ddb54382..4fe2c7beb1609 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -11,6 +11,7 @@ namespace Symfony\Component\TypeInfo; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\BackedEnumType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -194,6 +195,18 @@ public static function dict(?Type $value = null): CollectionType return self::array($value, self::string()); } + /** + * @param array $shape + */ + public static function arrayShape(array $shape): ArrayShapeType + { + return new ArrayShapeType(array_map(static function (array|Type $item): array { + return $item instanceof Type + ? ['type' => $item, 'optional' => false] + : ['type' => $item['type'], 'optional' => $item['optional'] ?? false]; + }, $shape)); + } + /** * @template T of class-string * diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php index 5cd0819bd8b76..ff31b711389e6 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -102,7 +102,15 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ } if ($node instanceof ArrayShapeNode) { - return Type::array(); + $shape = []; + foreach ($node->items as $item) { + $shape[(string) $item->keyName] = [ + 'type' => $this->getTypeFromNode($item->valueType, $typeContext), + 'optional' => $item->optional, + ]; + } + + return Type::arrayShape($shape); } if ($node instanceof ObjectShapeNode) {