diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php index e54c832afd2e0..d68b7bcfe544b 100644 --- a/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php @@ -12,11 +12,37 @@ 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\ArrayShapeType; class ArrayShapeTypeTest extends TestCase { + /** + * @dataProvider cannotConstructWithInvalidExtraDataProvider + */ + public function testCannotConstructWithInvalidExtra(string $expectedMessage, ?Type $extraKeyType, ?Type $extraValueType) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + new ArrayShapeType( + shape: [1 => ['type' => Type::bool(), 'optional' => false]], + extraKeyType: $extraKeyType, + extraValueType: $extraValueType, + ); + } + + /** + * @return iterable + */ + public static function cannotConstructWithInvalidExtraDataProvider(): iterable + { + yield ['You must provide as value for "$extraValueType" when "$extraKeyType" is provided.', Type::string(), null]; + yield ['You must provide as value for "$extraKeyType" when "$extraValueType" is provided.', null, Type::string()]; + yield ['"float" is not a valid array key type.', Type::float(), Type::string()]; + } + public function testGetCollectionKeyType() { $type = new ArrayShapeType([ @@ -76,6 +102,17 @@ public function testAccepts() $this->assertTrue($type->accepts(['foo' => true])); $this->assertTrue($type->accepts(['foo' => true, 'bar' => 'string'])); + + $type = new ArrayShapeType( + shape: ['foo' => ['type' => Type::bool()]], + extraKeyType: Type::string(), + extraValueType: Type::string(), + ); + + $this->assertTrue($type->accepts(['foo' => true, 'other' => 'string'])); + $this->assertTrue($type->accepts(['other' => 'string', 'foo' => true])); + $this->assertFalse($type->accepts(['other' => 1, 'foo' => true])); + $this->assertFalse($type->accepts(['other' => 'string', 'foo' => 'foo'])); } public function testToString() @@ -94,5 +131,19 @@ public function testToString() 'bar' => ['type' => Type::string(), 'optional' => true], ]); $this->assertSame("array{'bar'?: string, 'foo': bool}", (string) $type); + + $type = new ArrayShapeType( + shape: ['foo' => ['type' => Type::bool()]], + extraKeyType: Type::union(Type::int(), Type::string()), + extraValueType: Type::mixed(), + ); + $this->assertSame("array{'foo': bool, ...}", (string) $type); + + $type = new ArrayShapeType( + shape: ['foo' => ['type' => Type::bool()]], + extraKeyType: Type::int(), + extraValueType: Type::string(), + ); + $this->assertSame("array{'foo': bool, ...}", (string) $type); } } diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php index 7f5520cc7d01a..65a33739bf0fb 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -210,6 +210,16 @@ 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()])); + $this->assertEquals(new ArrayShapeType( + shape: ['foo' => ['type' => Type::bool(), 'optional' => false]], + extraKeyType: Type::union(Type::int(), Type::string()), + extraValueType: Type::mixed(), + ), Type::arrayShape(['foo' => Type::bool()], sealed: false)); + $this->assertEquals(new ArrayShapeType( + shape: ['foo' => ['type' => Type::bool(), 'optional' => false]], + extraKeyType: Type::string(), + extraValueType: Type::bool(), + ), Type::arrayShape(['foo' => Type::bool()], extraKeyType: Type::string(), extraValueType: Type::bool())); } /** diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php index b2db7660f9026..21abd8d72c283 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -76,6 +76,9 @@ public static function resolveDataProvider(): iterable // array shape 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}']; + yield [Type::arrayShape(['foo' => Type::bool()], sealed: false), 'array{foo: bool, ...}']; + yield [Type::arrayShape(['foo' => Type::bool()], extraKeyType: Type::int(), extraValueType: Type::string()), 'array{foo: bool, ...}']; + yield [Type::arrayShape(['foo' => Type::bool()], extraValueType: Type::int()), '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 index 2c3819cc56dfd..504a59ac619ba 100644 --- a/src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php +++ b/src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php @@ -11,6 +11,7 @@ namespace Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -31,8 +32,11 @@ final class ArrayShapeType extends CollectionType /** * @param array $shape */ - public function __construct(array $shape) - { + public function __construct( + array $shape, + private readonly ?Type $extraKeyType = null, + private readonly ?Type $extraValueType = null, + ) { $keyTypes = []; $valueTypes = []; @@ -56,6 +60,14 @@ public function __construct(array $shape) ksort($sortedShape); $this->shape = $sortedShape; + + if ($this->extraKeyType xor $this->extraValueType) { + throw new InvalidArgumentException(\sprintf('You must provide a value for "$%s" when "$%s" is provided.', $this->extraKeyType ? 'extraValueType' : 'extraKeyType', $this->extraKeyType ? 'extraKeyType' : 'extraValueType')); + } + + if ($extraKeyType && !$extraKeyType->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::STRING)) { + throw new InvalidArgumentException(\sprintf('"%s" is not a valid array key type.', (string) $extraKeyType)); + } } /** @@ -66,6 +78,21 @@ public function getShape(): array return $this->shape; } + public function isSealed(): bool + { + return null === $this->extraValueType; + } + + public function getExtraKeyType(): ?Type + { + return $this->extraKeyType; + } + + public function getExtraValueType(): ?Type + { + return $this->extraKeyType; + } + public function accepts(mixed $value): bool { if (!\is_array($value)) { @@ -80,11 +107,12 @@ public function accepts(mixed $value): bool foreach ($value as $key => $itemValue) { $valueType = $this->shape[$key]['type'] ?? false; - if (!$valueType) { + + if ($valueType && !$valueType->accepts($itemValue)) { return false; } - if (!$valueType->accepts($itemValue)) { + if (!$valueType && ($this->isSealed() || !$this->extraKeyType->accepts($key) || !$this->extraValueType->accepts($itemValue))) { return false; } } @@ -105,6 +133,12 @@ public function __toString(): string $items[] = \sprintf('%s: %s', $itemKey, $value['type']); } + if (!$this->isSealed()) { + $items[] = $this->extraKeyType->isIdentifiedBy(TypeIdentifier::INT) && $this->extraKeyType->isIdentifiedBy(TypeIdentifier::STRING) && $this->extraValueType->isIdentifiedBy(TypeIdentifier::MIXED) + ? '...' + : \sprintf('...<%s, %s>', $this->extraKeyType, $this->extraValueType); + } + return \sprintf('array{%s}', implode(', ', $items)); } } diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index 4fe2c7beb1609..125b3702016fb 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -198,13 +198,22 @@ public static function dict(?Type $value = null): CollectionType /** * @param array $shape */ - public static function arrayShape(array $shape): ArrayShapeType + public static function arrayShape(array $shape, bool $sealed = true, ?Type $extraKeyType = null, ?Type $extraValueType = null): ArrayShapeType { - return new ArrayShapeType(array_map(static function (array|Type $item): array { + $shape = array_map(static function (array|Type $item): array { return $item instanceof Type ? ['type' => $item, 'optional' => false] : ['type' => $item['type'], 'optional' => $item['optional'] ?? false]; - }, $shape)); + }, $shape); + + if ($extraKeyType || $extraValueType) { + $sealed = false; + } + + $extraKeyType ??= !$sealed ? Type::union(Type::int(), Type::string()) : null; + $extraValueType ??= !$sealed ? Type::mixed() : null; + + return new ArrayShapeType($shape, $extraKeyType, $extraValueType); } /** diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php index ff31b711389e6..244563f602f7d 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -110,7 +110,12 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ ]; } - return Type::arrayShape($shape); + return Type::arrayShape( + $shape, + $node->sealed, + $node->unsealedType?->keyType ? $this->getTypeFromNode($node->unsealedType->keyType, $typeContext) : null, + $node->unsealedType?->valueType ? $this->getTypeFromNode($node->unsealedType->valueType, $typeContext) : null, + ); } if ($node instanceof ObjectShapeNode) {