Skip to content
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
36 changes: 21 additions & 15 deletions src/Validator/GenericValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

namespace TypePHP\Validator;

use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
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;
Expand All @@ -29,7 +29,7 @@ final class GenericValidator implements TypeValidatorInterface
private static array $constantCache = [];

/**
* @var array<string, array<int, string>>
* @var array<string, array<int, string>>
*/
private static array $enumKeyCache = [];

Expand Down Expand Up @@ -78,7 +78,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont
$constName = $constExpr->name;
$cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName;

if (!\array_key_exists($cacheKey, self::$constantCache)) {
if (! \array_key_exists($cacheKey, self::$constantCache)) {
$constValue = false;
if ($fqcn !== '') {
if (class_exists($fqcn) || interface_exists($fqcn)) {
Expand All @@ -101,21 +101,23 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont
$constValue = self::$constantCache[$cacheKey];

if (\is_array($constValue)) {
if ((!\is_int($value) && !\is_string($value)) || !\array_key_exists($value, $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 (! isset(self::$enumKeyCache[$enumClass])) {
self::$enumKeyCache[$enumClass] = array_map(fn ($case) => $case->name, $enumClass::cases());
}

if (!\in_array($value, self::$enumKeyCache[$enumClass], true)) {
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) {
Expand All @@ -130,9 +132,10 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont
}
}

if (!\in_array($value, $validKeys, true)) {
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;
}

Expand All @@ -146,7 +149,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont
* 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,
* 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.
*/
Expand All @@ -160,7 +163,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co
$constName = $constExpr->name;
$cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName;

if (!\array_key_exists($cacheKey, self::$constantCache)) {
if (! \array_key_exists($cacheKey, self::$constantCache)) {
$constValue = false;
if ($fqcn !== '') {
if (class_exists($fqcn) || interface_exists($fqcn)) {
Expand All @@ -183,27 +186,30 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co
$constValue = self::$constantCache[$cacheKey];

if (\is_array($constValue)) {
if (!\in_array($value, $constValue, true)) {
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 (! isset(self::$enumValueCache[$enumClass])) {
self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases());
}

if (!\in_array($value, self::$enumValueCache[$enumClass], true)) {
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<min, max>).
*/
Expand Down
16 changes: 15 additions & 1 deletion src/Validator/IdentifierValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use TypePHP\Internal\ClassNameValidator;
use TypePHP\Internal\ErrorFactory;
use TypePHP\Internal\ErrorMessage;
use TypePHP\Internal\TypeFormatter;
Expand Down Expand Up @@ -71,7 +72,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
'open-resource' => \is_resource($value),
'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)',

default => \is_object($value) && is_a($value, $identifierNode->name),
default => $this->validateClassOrIgnore($value, $identifierNode->name),
};

if (! $ok) {
Expand All @@ -80,4 +81,17 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

return null;
}

/**
* Enforces strict object/class checks for valid PHP class identifiers (e.g. User, NonExistentClass),
* but gracefully ignores invalid class syntax (e.g. madeup-type, custom-tag-name).
*/
private function validateClassOrIgnore(mixed $value, string $name): bool
{
if (! ClassNameValidator::isValid($name)) {
return true;
}

return \is_object($value) && is_a($value, $name);
}
}
6 changes: 3 additions & 3 deletions tests/Fixtures/Types/DatabaseDriverMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
class DatabaseDriverMap
{
private const DRIVER_MAP = [
'pdo_mysql' => 'PDO\MySQL\Driver',
'pdo_mysql' => 'PDO\MySQL\Driver',
'pdo_sqlite' => 'PDO\SQLite\Driver',
];

public const PUBLIC_MAP = [
'read' => 1,
'read' => 1,
'write' => 2,
];

Expand Down Expand Up @@ -60,4 +60,4 @@ public static function checkArrayShapeKey(string $key): string
{
return $key;
}
}
}
39 changes: 39 additions & 0 deletions tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

/**
* Custom annotation with invalid class syntax (contains hyphens)
*
* @param madeup-type $specialType
*/
function testUnsupportedTypeSyntax(string $specialType): string
{
return $specialType;
}

/**
* Valid PHP class name syntax for a class that does not exist at runtime
*
* @param NonExistentClass $param
*/
function testNonExistentClassType(mixed $param): mixed
{
return $param;
}

test('ignores custom unsupported type syntax with hyphens gracefully', function () {
$result = testUnsupportedTypeSyntax('special-type');

expect($result)->toBe('special-type');
});

test('strictly validates valid class syntax even if class does not exist at runtime', function () {
expect(fn () => testNonExistentClassType('hello'))
->toThrow(TypeError::class, 'must be of type NonExistentClass, string \'hello\' given')
;

expect(fn () => testNonExistentClassType(new stdClass()))
->toThrow(TypeError::class, 'must be of type NonExistentClass, stdClass given')
;
});
42 changes: 26 additions & 16 deletions tests/TypeChecking/KeyOfValueOfTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ function testEnumValueOf(string $statusValue): string

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');
->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 () {
Expand All @@ -53,18 +54,20 @@ function testEnumValueOf(string $statusValue): string

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');
->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');
->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 () {
Expand All @@ -73,7 +76,8 @@ function testEnumValueOf(string $statusValue): string

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');
->toThrow(TypeError::class, 'must be a value of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP')
;
});

});
Expand All @@ -85,21 +89,25 @@ function testEnumValueOf(string $statusValue): string
expect(testEnumKeyOf('Pending'))->toBe('Pending');

expect(fn () => testEnumKeyOf('Archived'))
->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Types\StatusEnum');

->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");
->toThrow(TypeError::class, "must be a key of enum TypePHP\Tests\Fixtures\Types\StatusEnum, string 'active' given")
;
});

test('value-of<Enum> 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');

->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");
->toThrow(TypeError::class, "must be a value of enum TypePHP\Tests\Fixtures\Types\StatusEnum, string 'Active' given")
;
});

});
Expand All @@ -113,7 +121,8 @@ function testEnumValueOf(string $statusValue): string

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');
->toThrow(TypeError::class, 'must be a key of the specified array shape')
;
});

});
Expand All @@ -125,15 +134,16 @@ function testEnumValueOf(string $statusValue): string

expect($conn->connect([
'driver' => 'pdo_mysql',
'driverClass' => 'PDO\MySQL\Driver'
'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");
->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'
'driverClass' => 'PDO\PgSQL\Driver',
]))->toThrow(TypeError::class, "['driverClass'] must be a value of TypePHP\Tests\Fixtures\Types\DatabaseDriverMap::DRIVER_MAP");
});

Expand All @@ -147,4 +157,4 @@ function testEnumValueOf(string $statusValue): string
});

});
});
});
Loading