diff --git a/docs/supported-types/arrays-and-shapes.md b/docs/supported-types/arrays-and-shapes.md index 457619d..34d9a61 100644 --- a/docs/supported-types/arrays-and-shapes.md +++ b/docs/supported-types/arrays-and-shapes.md @@ -239,6 +239,146 @@ processTuple([-5, 'success']); // Throws: TypeError: processTuple(): Argument $tuple['0'] must be of type positive-int ``` +Here is the updated documentation with a special, dedicated section for **`key-of`** and **`value-of`** inside `docs/supported-types/arrays-and-shapes.md`. + +--- +## Key & Value Extraction (`key-of` & `value-of`) + +TypePHP supports dynamically restricting function parameters, return types, property writes, or array shape fields to the keys or values of an array constant, an array shape, or a PHP 8.1 Backed Enum using `key-of` and `value-of` type operators. + +> **Performance & Visibility:** TypePHP caches array and enum extractions in static memory, guaranteeing **$O(1)$ constant lookup times** during execution. Furthermore, it uses Reflection to safely bypass PHP visibility restrictions, allowing you to reference `private` or `protected` class constants (e.g., `key-of`) in docblocks without throwing runtime errors. + +| Annotation | Supported Targets `T` | Validation Rule | +| :--- | :--- | :--- | +| **`key-of`** | Array Constant, Array Shape, Enum | Validates that the value matches a valid **array key** or **Enum case name** (e.g., `'Active'`). | +| **`value-of`** | Array Constant, Backed Enum | Validates that the value matches a valid **array value** or **Enum backing value** (e.g., `'active'`). | + +--- + +### 1. Extracting from Class Constants + +Extract allowed keys or values directly from `public`, `protected`, or `private` class constant arrays: + +```php + 'PDO\MySQL\Driver', + 'pdo_sqlite' => 'PDO\SQLite\Driver', + ]; + + /** + * @param key-of $driverKey + * @param value-of $driverClass + */ + public function connect(string $driverKey, string $driverClass): void + { + // ... + } +} + +$manager = new DriverManager(); + +// Valid Call +$manager->connect('pdo_mysql', 'PDO\MySQL\Driver'); + +// Invalid Driver Key +$manager->connect('pdo_pgsql', 'PDO\MySQL\Driver'); +// Throws: TypeError: Argument $driverKey must be a key of App\Database\DriverManager::DRIVER_MAP, string 'pdo_pgsql' given + +// Invalid Driver Class Value +$manager->connect('pdo_mysql', 'PDO\PgSQL\Driver'); +// Throws: TypeError: Argument $driverClass must be a value of App\Database\DriverManager::DRIVER_MAP +``` + +--- + +### 2. Extracting from Enums + +For Enums, `key-of` strictly validates case **names**, while `value-of` strictly validates **backing values**: + +```php +enum StatusEnum: string +{ + case Active = 'active'; + case Pending = 'pending'; +} + +/** + * @param key-of $caseName // Expects: 'Active' | 'Pending' + * @param value-of $caseValue // Expects: 'active' | 'pending' + */ +function setStatus(string $caseName, string $caseValue): void +{ + // ... +} + +// Valid Call +setStatus('Active', 'active'); + +// Invalid Case Name (Passing backing value 'active' where case name 'Active' was expected) +setStatus('active', 'active'); +// Throws: TypeError: Argument $caseName must be a key of enum StatusEnum + +// Invalid Backing Value +setStatus('Active', 'archived'); +// Throws: TypeError: Argument $caseValue must be a value of enum StatusEnum +``` + +--- + +### 3. Inline Array Shapes & Type Aliases (`@phpstan-type`) + +`key-of` and `value-of` can be used directly on inline array shapes or nested deeply inside `@phpstan-type` / `@psalm-type` aliases: + +```php +namespace App\Services; + +use App\Database\DriverManager; + +/** + * Type alias extracting keys and values from external class constants + * + * @phpstan-type ConnectionParams array{ + * driver: key-of, + * driverClass?: value-of + * } + */ +class ConnectionService +{ + /** + * @param ConnectionParams $params + * @param key-of $shapeKey + */ + public function configure(array $params, string $shapeKey): void + { + // ... + } +} + +$service = new ConnectionService(); + +// Valid Call +$service->configure(['driver' => 'pdo_mysql'], 'id'); + +// Invalid Nested Driver Key inside Type Alias +$service->configure(['driver' => 'pdo_pgsql'], 'id'); +// Throws: TypeError: Argument $params['driver'] must be a key of App\Database\DriverManager::DRIVER_MAP + +// Invalid Direct Shape Key ('invalid' is neither 'id' nor 'name') +$service->configure(['driver' => 'pdo_mysql'], 'invalid'); +// Throws: TypeError: Argument $shapeKey must be a key of the specified array shape +``` + --- ## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`) diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 51d909f..e507061 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -7,6 +7,9 @@ use PhpParser\Node\Stmt; use PhpParser\ParserFactory; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -15,6 +18,8 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -107,7 +112,17 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof ConstTypeNode) { if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $resolvedClass = self::resolveFqcn($node->constExpr->className, $ref); + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); } @@ -129,6 +144,41 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct ); } + if ($node instanceof ArrayShapeNode) { + $items = array_map(function ($item) use ($context, $thisObj) { + return new ArrayShapeItemNode( + $item->keyName, + $item->optional, + self::resolve($item->valueType, $context, $thisObj) + ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); + } else { + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $context, $thisObj) : null; + $unsealedValue = self::resolve($node->unsealedType->valueType, $context, $thisObj); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + } + + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + } + + if ($node instanceof ObjectShapeNode) { + $items = array_map(function ($item) use ($context, $thisObj) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolve($item->valueType, $context, $thisObj) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + if ($node instanceof CallableTypeNode) { $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) { return new CallableTypeParameterNode( @@ -198,7 +248,14 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode if ($node instanceof ConstTypeNode) { if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $resolvedClass = self::resolveFqcnForFile($node->constExpr->className, $file); + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'parent') { + $resolvedClass = $className; + } else { + $resolvedClass = self::resolveFqcnForFile($className, $file); + } return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); } @@ -220,6 +277,41 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } + if ($node instanceof ArrayShapeNode) { + $items = array_map(function ($item) use ($file) { + return new ArrayShapeItemNode( + $item->keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) + ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); + } else { + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null; + $unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + } + + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + } + + if ($node instanceof ObjectShapeNode) { + $items = array_map(function ($item) use ($file) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + if ($node instanceof CallableTypeNode) { $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { return new CallableTypeParameterNode( diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index f7ebb83..0825d7a 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -4,6 +4,11 @@ namespace TypePHP\Validator; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -19,8 +24,20 @@ final class GenericValidator implements TypeValidatorInterface { /** - * Validates a value against a GenericTypeNode AST. + * @var array */ + private static array $constantCache = []; + + /** + * @var array> + */ + private static array $enumKeyCache = []; + + /** + * @var array> + */ + private static array $enumValueCache = []; + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { /** @var GenericTypeNode $genericNode */ @@ -32,10 +49,161 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'class-string' => $this->validateClassString($value, $genericNode, $context), 'list', 'non-empty-list', 'non-empty-array-list' => $this->validateList($value, $genericNode, $context, $registry), 'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry), + 'key-of' => $this->validateKeyOf($value, $genericNode, $context), + 'value-of' => $this->validateValueOf($value, $genericNode, $context), default => $this->validateObjectGeneric($value, $genericNode, $context), }; } + /** + * Validates key-of generic structures with O(1) in-memory caching. + * + * Execution Flow: + * 1. Array Constants: If T is a class constant (e.g., self::DRIVER_MAP), it safely reflects the + * target class to bypass visibility restrictions (private/protected), caches the array in memory, + * and verifies that the provided value exists as a key in that array. + * 2. Enums: If T is an Enum identifier, it extracts and caches the enum case names, then verifies + * that the provided value matches a valid case name. + * 3. Array Shapes: If T is an inline array shape (e.g., array{id: int, name: string}), it verifies + * that the provided value exists as one of the key names in the shape. + * 4. Fallback: Returns null gracefully for unresolvable or unsupported structures. + */ + private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + { + $targetType = $node->genericTypes[0] ?? null; + + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { + $constExpr = $targetType->constExpr; + $fqcn = $constExpr->className; + $constName = $constExpr->name; + $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + + if (!\array_key_exists($cacheKey, self::$constantCache)) { + $constValue = false; + if ($fqcn !== '') { + if (class_exists($fqcn) || interface_exists($fqcn)) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + } + } catch (\ReflectionException $e) { + } + } + } else { + if (\defined($constName)) { + $constValue = \constant($constName); + } + } + self::$constantCache[$cacheKey] = $constValue; + } + + $constValue = self::$constantCache[$cacheKey]; + + if (\is_array($constValue)) { + if ((!\is_int($value) && !\is_string($value)) || !\array_key_exists($value, $constValue)) { + return ErrorFactory::createError($context . " must be a key of $cacheKey, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + return null; + } + } elseif ($targetType instanceof IdentifierTypeNode) { + $enumClass = $targetType->name; + if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass)) { + if (!isset(self::$enumKeyCache[$enumClass])) { + self::$enumKeyCache[$enumClass] = array_map(fn($case) => $case->name, $enumClass::cases()); + } + + if (!\in_array($value, self::$enumKeyCache[$enumClass], true)) { + return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + return null; + } + } elseif ($targetType instanceof ArrayShapeNode) { + $validKeys = []; + foreach ($targetType->items as $item) { + if ($item->keyName instanceof ConstExprStringNode) { + $validKeys[] = $item->keyName->value; + } elseif ($item->keyName instanceof IdentifierTypeNode) { + $validKeys[] = $item->keyName->name; + } elseif ($item->keyName instanceof ConstExprIntegerNode) { + $validKeys[] = (int) $item->keyName->value; + } + } + + if (!\in_array($value, $validKeys, true)) { + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + return null; + } + + return null; + } + + /** + * Validates value-of generic structures with O(1) in-memory caching. + * + * Execution Flow: + * 1. Array Constants: If T is a class constant (e.g., self::DRIVER_MAP), it safely reflects the + * target class to bypass visibility restrictions (private/protected), caches the array in memory, + * and verifies that the provided value exists as a value in that array. + * 2. Enums: If T is a Backed Enum identifier, it extracts and caches the enum case backing values, + * then verifies that the provided value matches a valid case value. + * 3. Fallback: Returns null gracefully for unresolvable or unsupported structures. + */ + private function validateValueOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + { + $targetType = $node->genericTypes[0] ?? null; + + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { + $constExpr = $targetType->constExpr; + $fqcn = $constExpr->className; + $constName = $constExpr->name; + $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + + if (!\array_key_exists($cacheKey, self::$constantCache)) { + $constValue = false; + if ($fqcn !== '') { + if (class_exists($fqcn) || interface_exists($fqcn)) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + } + } catch (\ReflectionException $e) { + } + } + } else { + if (\defined($constName)) { + $constValue = \constant($constName); + } + } + self::$constantCache[$cacheKey] = $constValue; + } + + $constValue = self::$constantCache[$cacheKey]; + + if (\is_array($constValue)) { + if (!\in_array($value, $constValue, true)) { + return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + return null; + } + } elseif ($targetType instanceof IdentifierTypeNode) { + $enumClass = $targetType->name; + if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass) && is_subclass_of($enumClass, \BackedEnum::class)) { + if (!isset(self::$enumValueCache[$enumClass])) { + self::$enumValueCache[$enumClass] = array_map(fn($case) => $case->value, $enumClass::cases()); + } + + if (!\in_array($value, self::$enumValueCache[$enumClass], true)) { + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + return null; + } + } + + return null; + } /** * Validates integer ranges (e.g. int<1, 100> or int). */ diff --git a/tests/Fixtures/Types/DatabaseDriverMap.php b/tests/Fixtures/Types/DatabaseDriverMap.php new file mode 100644 index 0000000..d7fa025 --- /dev/null +++ b/tests/Fixtures/Types/DatabaseDriverMap.php @@ -0,0 +1,63 @@ + 'PDO\MySQL\Driver', + 'pdo_sqlite' => 'PDO\SQLite\Driver', + ]; + + public const PUBLIC_MAP = [ + 'read' => 1, + 'write' => 2, + ]; + + /** + * @param key-of $driver + */ + public static function checkStaticDriverKey(string $driver): string + { + return $driver; + } + + /** + * @param value-of $driverClass + */ + public static function checkStaticDriverValue(string $driverClass): string + { + return $driverClass; + } + + /** + * @param key-of $driver + */ + public function checkInstanceDriverKey(string $driver): string + { + return $driver; + } + + /** + * @param key-of $action + */ + private function checkPrivateMethodKey(string $action): string + { + return $action; + } + + public function proxyPrivateMethod(string $action): string + { + return $this->checkPrivateMethodKey($action); + } + + /** + * @param key-of $key + */ + public static function checkArrayShapeKey(string $key): string + { + return $key; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Types/DoctrineLikeConnection.php b/tests/Fixtures/Types/DoctrineLikeConnection.php new file mode 100644 index 0000000..4ab2621 --- /dev/null +++ b/tests/Fixtures/Types/DoctrineLikeConnection.php @@ -0,0 +1,37 @@ +, + * driverClass?: value-of + * } + * @phpstan-type LocalParams array{ + * action: key-of + * } + */ +class DoctrineLikeConnection +{ + /** + * @param ConnectionParams $params + */ + public function connect(array $params): bool + { + return true; + } + + private const LOCAL_ACTIONS = ['start' => 1, 'stop' => 0]; + + /** + * @param LocalParams $params + */ + public function localAction(array $params): bool + { + return true; + } +} diff --git a/tests/TypeChecking/KeyOfValueOfTest.php b/tests/TypeChecking/KeyOfValueOfTest.php new file mode 100644 index 0000000..caf6ceb --- /dev/null +++ b/tests/TypeChecking/KeyOfValueOfTest.php @@ -0,0 +1,150 @@ + $action + */ +function testExternalPublicConstKey(string $action): string +{ + return $action; +} + +/** + * @param key-of $statusName + */ +function testEnumKeyOf(string $statusName): string +{ + return $statusName; +} + +/** + * @param value-of $statusValue + */ +function testEnumValueOf(string $statusValue): string +{ + return $statusValue; +} + +describe('key-of and value-of Annotations', function () { + + describe('Array Constants (e.g. self::DRIVER_MAP)', function () { + + test('accepts valid key-of on a private constant from a static method', function () { + expect(DatabaseDriverMap::checkStaticDriverKey('pdo_mysql'))->toBe('pdo_mysql'); + expect(DatabaseDriverMap::checkStaticDriverKey('pdo_sqlite'))->toBe('pdo_sqlite'); + }); + + test('throws TypeError on invalid key-of on a private constant from a static method', function () { + expect(fn () => DatabaseDriverMap::checkStaticDriverKey('pdo_pgsql')) + ->toThrow(TypeError::class, 'must be a key of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP'); + }); + + test('accepts valid key-of on a private constant from an instance method', function () { + $dbMap = new DatabaseDriverMap(); + expect($dbMap->checkInstanceDriverKey('pdo_mysql'))->toBe('pdo_mysql'); + }); + + test('throws TypeError on invalid key-of on a public constant from a private method', function () { + $dbMap = new DatabaseDriverMap(); + + expect($dbMap->proxyPrivateMethod('read'))->toBe('read'); + + expect(fn () => $dbMap->proxyPrivateMethod('delete')) + ->toThrow(TypeError::class, 'must be a key of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::PUBLIC_MAP'); + }); + + test('accepts valid key-of on a public constant from an external function', function () { + expect(testExternalPublicConstKey('write'))->toBe('write'); + + expect(fn () => testExternalPublicConstKey('execute')) + ->toThrow(TypeError::class, 'must be a key of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::PUBLIC_MAP'); + }); + + test('accepts valid value-of on a private constant array', function () { + expect(DatabaseDriverMap::checkStaticDriverValue('PDO\MySQL\Driver'))->toBe('PDO\MySQL\Driver'); + }); + + test('throws TypeError on invalid value-of on a private constant array', function () { + expect(fn () => DatabaseDriverMap::checkStaticDriverValue('PDO\PgSQL\Driver')) + ->toThrow(TypeError::class, 'must be a value of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP'); + }); + + }); + + describe('Enums (e.g. StatusEnum)', function () { + + test('key-of strictly checks against the Enum CASE NAMES', function () { + expect(testEnumKeyOf('Active'))->toBe('Active'); + expect(testEnumKeyOf('Pending'))->toBe('Pending'); + + expect(fn () => testEnumKeyOf('Archived')) + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Types\StatusEnum'); + + expect(fn () => testEnumKeyOf('active')) + ->toThrow(TypeError::class, "must be a key of enum TypePHP\Tests\Fixtures\Types\StatusEnum, string 'active' given"); + }); + + test('value-of strictly checks against the Enum BACKING VALUES', function () { + expect(testEnumValueOf('active'))->toBe('active'); + expect(testEnumValueOf('pending'))->toBe('pending'); + + expect(fn () => testEnumValueOf('archived')) + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Types\StatusEnum'); + + expect(fn () => testEnumValueOf('Active')) + ->toThrow(TypeError::class, "must be a value of enum TypePHP\Tests\Fixtures\Types\StatusEnum, string 'Active' given"); + }); + + }); + + describe('Array Shapes (e.g. key-of)', function () { + + test('accepts valid string keys of an inline array shape', function () { + expect(DatabaseDriverMap::checkArrayShapeKey('id'))->toBe('id'); + expect(DatabaseDriverMap::checkArrayShapeKey('name'))->toBe('name'); + }); + + test('throws TypeError on invalid string key of an inline array shape', function () { + expect(fn () => DatabaseDriverMap::checkArrayShapeKey('invalid_key')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape'); + }); + + }); + + describe('Type Aliases (@phpstan-type nested shapes)', function () { + + test('validates key-of and value-of correctly when deeply nested inside an array shape type alias', function () { + $conn = new DoctrineLikeConnection(); + + expect($conn->connect([ + 'driver' => 'pdo_mysql', + 'driverClass' => 'PDO\MySQL\Driver' + ]))->toBeTrue(); + + expect(fn () => $conn->connect(['driver' => 'pdo_pgsql'])) + ->toThrow(TypeError::class, "['driver'] must be a key of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP"); + + expect(fn () => $conn->connect([ + 'driver' => 'pdo_mysql', + 'driverClass' => 'PDO\PgSQL\Driver' + ]))->toThrow(TypeError::class, "['driverClass'] must be a value of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP"); + }); + + test('validates key-of with self:: reference natively inside a local type alias', function () { + $conn = new DoctrineLikeConnection(); + + expect($conn->localAction(['action' => 'start']))->toBeTrue(); + + expect(fn () => $conn->localAction(['action' => 'pause'])) + ->toThrow(TypeError::class, "['action'] must be a key of TypePHP\Tests\Fixtures\Types\DoctrineLikeConnection::LOCAL_ACTIONS"); + }); + + }); +}); \ No newline at end of file