8000 [TypeInfo] Add `ArrayShapeType::$sealed` by mtarld · Pull Request #59981 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[TypeInfo] Add ArrayShapeType::$sealed #59981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

8000

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/Symfony/Component/TypeInfo/Tests/Type/ArrayShapeTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{0: string, 1: ?Type, 2: ?Type}>
*/
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([
Expand Down Expand Up @@ -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()
Expand All @@ -94,5 +131,19 @@ public function testToString()
'bar' => ['type' => Type::string(), 'optional' => true],
]);
$thi 10000 s->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, ...<int, string>}", (string) $type);
}
}
10 changes: 10 additions & 0 deletions src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...<int, string>}'];
yield [Type::arrayShape(['foo' => Type::bool()], extraValueType: Type::int()), 'array{foo: bool, ...<int>}'];

// object shape
yield [Type::object(), 'object{foo: true, bar: false}'];
Expand Down
42 changes: 38 additions & 4 deletions src/Symfony/Component/TypeInfo/Type/ArrayShapeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -31,8 +32,11 @@ final class ArrayShapeType extends CollectionType
/**
* @param array<array{type: Type, optional: bool}> $shape
*/
public function __construct(array $shape)
{
public function __construct(
array $shape,
private readonly ?Type $extraKeyType = null,
private readonly ?Type $extraValueType = null,
) {
$keyTypes = [];
$valueTypes = [];

Expand All @@ -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));
}
}

/**
Expand All @@ -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)) {
Expand All @@ -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;
}
}
Expand All @@ -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));
}
}
15 changes: 12 additions & 3 deletions src/Symfony/Component/TypeInfo/TypeFactoryTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,22 @@ public static function dict(?Type $value = null): CollectionType
/**
* @param array<array{type: Type, optional?: bool}|Type> $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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
0