From 8f1837411ece84a5a54a4c537aaf112570329ba3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 23 Aug 2026 20:05:18 +0800 Subject: [PATCH 1/4] Refactor validators to implement hybrid sampling for large collections; improve error handling and code clarity --- src/Contract/ContractParser.php | 2 +- src/Internal/Checker/ParamChecker.php | 2 +- src/Internal/Checker/ReturnChecker.php | 2 +- src/Validator/ArrayShapeValidator.php | 57 ++++++---- src/Validator/ArrayValidator.php | 73 ++++++++++++- src/Validator/GenericValidator.php | 140 +++++++++++++++++++------ 6 files changed, 218 insertions(+), 58 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 3064a01..a12e4be 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -870,4 +870,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 7b19f69..96b0b3e 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -61,7 +61,7 @@ public static function checkParams( } $contract = ContractParser::parse($effectiveFunction); - + if (! $contract['hasParamContract']) { return null; } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index e4f5a60..a048936 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -350,4 +350,4 @@ private static function resolveTemplateConditional( return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); } -} \ No newline at end of file +} diff --git a/src/Validator/ArrayShapeValidator.php b/src/Validator/ArrayShapeValidator.php index db82e0b..b8db790 100644 --- a/src/Validator/ArrayShapeValidator.php +++ b/src/Validator/ArrayShapeValidator.php @@ -24,11 +24,13 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value) . ' given'); } - /** @var ArrayShapeNode $node */ + /** @var ArrayShapeNode $shapeNode */ + $shapeNode = $node; $knownKeys = []; $nextAutoIndex = 0; + $matchedKeysCount = 0; - foreach ($node->items as $item) { + foreach ($shapeNode->items as $item) { $key = null; if ($item->keyName instanceof ConstExprStringNode) { @@ -55,37 +57,50 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali continue; } - $err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']"); + $matchedKeysCount++; + + $err = $registry->validate($value[$key], $item->valueType, ''); if ($err !== null) { - return $err; + return ErrorFactory::createError($context . "['" . $key . "']" . $err->getMessage()); } } - $extraKeys = array_diff_key($value, $knownKeys); + $valueCount = \count($value); - if (\count($extraKeys) > 0) { - if ($node->sealed) { - $firstExtraKey = (string) array_key_first($extraKeys); + if ($valueCount === $matchedKeysCount) { + return null; + } - return ErrorFactory::createError($context . " contains unsealed unexpected key '$firstExtraKey'"); + if ($shapeNode->sealed) { + foreach ($value as $k => $_) { + if (! isset($knownKeys[$k])) { + return ErrorFactory::createError($context . " contains unsealed unexpected key '{$k}'"); + } } - if ($node->unsealedType !== null) { - $unsealedKeyType = $node->unsealedType->keyType; - $unsealedValueType = $node->unsealedType->valueType; + return null; + } - foreach ($extraKeys as $k => $v) { - if ($unsealedKeyType !== null) { - $err = $registry->validate($k, $unsealedKeyType, $context . " extra key '$k'"); - if ($err !== null) { - return $err; - } - } - $err = $registry->validate($v, $unsealedValueType, $context . "['$k']"); + if ($shapeNode->unsealedType !== null) { + $unsealedKeyType = $shapeNode->unsealedType->keyType; + $unsealedValueType = $shapeNode->unsealedType->valueType; + + foreach ($value as $k => $v) { + if (isset($knownKeys[$k])) { + continue; + } + + if ($unsealedKeyType !== null) { + $err = $registry->validate($k, $unsealedKeyType, ''); if ($err !== null) { - return $err; + return ErrorFactory::createError($context . " extra key '{$k}'" . $err->getMessage()); } } + + $err = $registry->validate($v, $unsealedValueType, ''); + if ($err !== null) { + return ErrorFactory::createError($context . "['{$k}']" . $err->getMessage()); + } } } diff --git a/src/Validator/ArrayValidator.php b/src/Validator/ArrayValidator.php index d32b1a9..9a277c4 100644 --- a/src/Validator/ArrayValidator.php +++ b/src/Validator/ArrayValidator.php @@ -14,11 +14,14 @@ /** * Validates array and Traversable collection instances against ArrayTypeNode ASTs (Type[]). + * Implements a Beartype-inspired hybrid O(1) sampling algorithm for large collections. * * @internal */ final class ArrayValidator implements TypeValidatorInterface { + private const HYBRID_SAMPLE_THRESHOLD = 64; + /** * Validates an array or Traversable collection against an ArrayTypeNode (Type[]). * Accepts native arrays and Traversable objects (e.g. ArrayIterator, Symfony RewindableGenerator). @@ -37,6 +40,28 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } + if (\is_array($value)) { + $count = \count($value); + if ($count === 0) { + return null; + } + + if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count); + } + + foreach ($value as $k => $v) { + $err = $registry->validate($v, $arrayNode->type, ''); + if ($err !== null) { + $keyStr = (\is_scalar($k) || $k === null) ? (string) $k : get_debug_type($k); + + return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); + } + } + + return null; + } + foreach ($value as $k => $v) { $err = $registry->validate($v, $arrayNode->type, ''); if ($err !== null) { @@ -48,4 +73,50 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} + + /** + * @param array $value + */ + private function validateArrayHybrid( + array $value, + ArrayTypeNode $arrayNode, + string $context, + TypeValidatorRegistry $registry, + int $count + ): ?ErrorMessage { + if (array_is_list($value)) { + $sampleIndices = [0, $count - 1]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleIndices[] = mt_rand(1, $count - 2); + } + + foreach ($sampleIndices as $idx) { + $err = $registry->validate($value[$idx], $arrayNode->type, ''); + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $idx . ']' . $err->getMessage()); + } + } + + return null; + } + + $keys = array_keys($value); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + } + + foreach ($sampleKeys as $k) { + $err = $registry->validate($value[$k], $arrayNode->type, ''); + if ($err !== null) { + $keyStr = (\is_scalar($k) || $k === null) ? (string) $k : get_debug_type($k); + + return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index 0f0f4e9..1f6ae14 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -23,6 +23,8 @@ */ final class GenericValidator implements TypeValidatorInterface { + private const HYBRID_SAMPLE_THRESHOLD = 64; + /** * @var array */ @@ -359,6 +361,7 @@ private function validateClassString(mixed $value, GenericTypeNode $node, string /** * Validates sequential list structures (e.g. list or non-empty-list). + * Uses Beartype-style O(1) hybrid sampling for large lists (> 64 items). */ private function validateList(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { @@ -368,24 +371,47 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return ErrorFactory::createError($context . ' must be a list, ' . TypeFormatter::formatGivenValue($value) . ' given'); } - if (str_contains($baseType, 'non-empty') && \count($value) === 0) { + $count = \count($value); + + if (str_contains($baseType, 'non-empty') && $count === 0) { return ErrorFactory::createError($context . ' must be a non-empty list, empty array given'); } $valueTypeNode = $node->genericTypes[0] ?? null; - if ($valueTypeNode !== null) { - foreach ($value as $k => $v) { - if ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { - $err = $this->validateObjectGeneric($v, $valueTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } else { - $err = $registry->validate($v, $valueTypeNode, ''); + if ($valueTypeNode !== null && $count > 0) { + $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + + // O(1) Beartype Hybrid Sampling for large lists (> 64 items) + if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + $sampleIndices = [0, $count - 1]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleIndices[] = mt_rand(1, $count - 2); + } + + foreach ($sampleIndices as $k) { + $v = $value[$k]; + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valueTypeNode, '') + : $registry->validate($v, $valueTypeNode, ''); + if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); } } + + return null; + } + + // Full O(n) scan for small/medium lists (<= 64 items) + foreach ($value as $k => $v) { + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valueTypeNode, '') + : $registry->validate($v, $valueTypeNode, ''); + + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + } } } @@ -394,6 +420,7 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte /** * Validates key-value array structures (e.g. array). + * Uses Beartype-style O(1) hybrid sampling for large maps (> 64 items). */ private function validateArray(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { @@ -407,56 +434,103 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont return null; } - if (str_contains($baseType, 'non-empty') && \count($value) === 0) { + $count = \count($value); + + if (str_contains($baseType, 'non-empty') && $count === 0) { return ErrorFactory::createError($context . ' must be a non-empty array, empty array given'); } + if ($count === 0) { + return null; + } + $typesCount = \count($node->genericTypes); if ($typesCount === 1) { $valTypeNode = $node->genericTypes[0]; - foreach ($value as $k => $v) { - if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { - $err = $this->validateObjectGeneric($v, $valTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } else { - $err = $registry->validate($v, $valTypeNode, ''); + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + + if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + $keys = array_keys($value); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + } + + foreach ($sampleKeys as $k) { + $v = $value[$k]; + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); + if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); } } + + return null; + } + + foreach ($value as $k => $v) { + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); + + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + } } } elseif ($typesCount >= 2) { $keyTypeNode = $node->genericTypes[0]; $valTypeNode = $node->genericTypes[1]; - foreach ($value as $k => $v) { - $err = $registry->validate($k, $keyTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . ' key' . $err->getMessage()); + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + + if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + $keys = array_keys($value); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; } - if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { - $err = $this->validateObjectGeneric($v, $valTypeNode, ''); + foreach ($sampleKeys as $k) { + $err = $registry->validate($k, $keyTypeNode, ''); if ($err !== null) { - return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + return ErrorFactory::createError($context . ' key' . $err->getMessage()); } - } else { - $err = $registry->validate($v, $valTypeNode, ''); + + $v = $value[$k]; + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); + if ($err !== null) { return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); } } + + return null; + } + + foreach ($value as $k => $v) { + $err = $registry->validate($k, $keyTypeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($context . ' key' . $err->getMessage()); + } + + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); + + if ($err !== null) { + return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + } } } return null; } - /** - * Validates object generic instances and binds template parameters. - * Gracefully ignores generic annotations with invalid class syntax (e.g. custom-generic). - */ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { if (! ClassNameValidator::isValid($node->type->name)) { @@ -473,4 +547,4 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } -} +} \ No newline at end of file From 2e5b601565f0b18c7798afd2f157caad34a80944 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 23 Aug 2026 21:30:14 +0800 Subject: [PATCH 2/4] make array optmization configurable --- src/Command/ConfigInitCommand.php | 15 +++++++++++++++ src/Internal/Config.php | 23 +++++++++++++++++++++++ src/Validator/ArrayValidator.php | 9 +++++---- src/Validator/GenericValidator.php | 14 +++++--------- typephp.php | 15 +++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/Command/ConfigInitCommand.php b/src/Command/ConfigInitCommand.php index bcf4630..1b27028 100644 --- a/src/Command/ConfigInitCommand.php +++ b/src/Command/ConfigInitCommand.php @@ -110,6 +110,21 @@ private static function getTemplate(): string // \Acme\Domain\TypePHPExtension::class, ], + /* + |-------------------------------------------------------------------------- + | Array Validation Strategy + |-------------------------------------------------------------------------- + | Controls how collections (list, array, Type[]) are verified: + | + | - 'full' : (Default / Strict) 100% exhaustive scan. Checks every single + | item in every array, guaranteeing every single offending item + | is caught without exception. + | + | - 'hybrid' : (Beartype O(1) Mode) Fast boundary + random sampling on + | arrays > 64 items. Ideal for massive production datasets. + */ + 'array_validation' => 'full', + /* |-------------------------------------------------------------------------- | Inline Variable Validation (@var $x = ...) diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 197af99..5bd9221 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -42,6 +42,8 @@ final class Config private static bool $respectIgnoreTags = true; + private static string $arrayValidation = 'full'; + public static function isEnabled(): bool { if (self::$cachedConfig === null) { @@ -96,6 +98,24 @@ public static function isRespectIgnoreTagsEnabled(): bool return self::$respectIgnoreTags; } + public static function isArrayValidationHybrid(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$arrayValidation === 'hybrid'; + } + + public static function getArrayValidationStrategy(): string + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$arrayValidation; + } + /** * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. * Caches the result in memory so the search happens exactly once. @@ -158,6 +178,7 @@ public static function get(): array 'magic_properties' => true, 'magic_methods' => true, 'respect_ignore_tags' => true, + 'array_validation' => 'full', 'cache' => true, 'cache_dir' => null, 'inline_vars' => [ @@ -232,6 +253,7 @@ public static function reset(): void self::$magicProperties = true; self::$magicMethods = true; self::$respectIgnoreTags = true; + self::$arrayValidation = 'full'; ContractParser::reset(); ParamChecker::reset(); @@ -256,5 +278,6 @@ private static function syncFlags(array $config): void self::$magicProperties = (bool) ($config['magic_properties'] ?? true); self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); + self::$arrayValidation = (string) ($config['array_validation'] ?? 'full'); } } diff --git a/src/Validator/ArrayValidator.php b/src/Validator/ArrayValidator.php index 9a277c4..17cf2c7 100644 --- a/src/Validator/ArrayValidator.php +++ b/src/Validator/ArrayValidator.php @@ -8,19 +8,20 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use Traversable; +use TypePHP\Internal\Config; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\ErrorMessage; use TypePHP\Internal\TypeFormatter; /** * Validates array and Traversable collection instances against ArrayTypeNode ASTs (Type[]). - * Implements a Beartype-inspired hybrid O(1) sampling algorithm for large collections. + * Supports both exhaustive O(n) verification and Beartype-style hybrid O(1) sampling. * * @internal */ final class ArrayValidator implements TypeValidatorInterface { - private const HYBRID_SAMPLE_THRESHOLD = 64; + private const HYBRID_SAMPLE_THRESHOLD = 128; /** * Validates an array or Traversable collection against an ArrayTypeNode (Type[]). @@ -46,7 +47,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count); } @@ -119,4 +120,4 @@ private function validateArrayHybrid( return null; } -} \ No newline at end of file +} diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index 1f6ae14..e7f2898 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Internal\ClassNameValidator; +use TypePHP\Internal\Config; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\ErrorMessage; use TypePHP\Internal\RuntimeTypeChecker; @@ -361,7 +362,6 @@ private function validateClassString(mixed $value, GenericTypeNode $node, string /** * Validates sequential list structures (e.g. list or non-empty-list). - * Uses Beartype-style O(1) hybrid sampling for large lists (> 64 items). */ private function validateList(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { @@ -381,8 +381,7 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte if ($valueTypeNode !== null && $count > 0) { $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - // O(1) Beartype Hybrid Sampling for large lists (> 64 items) - if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $sampleIndices = [0, $count - 1]; $samplesToTake = min(3, $count - 2); for ($i = 0; $i < $samplesToTake; $i++) { @@ -403,7 +402,6 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return null; } - // Full O(n) scan for small/medium lists (<= 64 items) foreach ($value as $k => $v) { $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valueTypeNode, '') @@ -420,7 +418,6 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte /** * Validates key-value array structures (e.g. array). - * Uses Beartype-style O(1) hybrid sampling for large maps (> 64 items). */ private function validateArray(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { @@ -448,8 +445,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont if ($typesCount === 1) { $valTypeNode = $node->genericTypes[0]; $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - - if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); $sampleKeys = [$keys[0], $keys[$count - 1]]; $samplesToTake = min(3, $count - 2); @@ -485,7 +481,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont $valTypeNode = $node->genericTypes[1]; $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - if ($count > self::HYBRID_SAMPLE_THRESHOLD) { + if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); $sampleKeys = [$keys[0], $keys[$count - 1]]; $samplesToTake = min(3, $count - 2); @@ -547,4 +543,4 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } -} \ No newline at end of file +} diff --git a/typephp.php b/typephp.php index f5d8a62..0a5de16 100644 --- a/typephp.php +++ b/typephp.php @@ -67,6 +67,21 @@ // \Acme\Domain\TypePHPExtension::class, ], + /* + |-------------------------------------------------------------------------- + | Array Validation Strategy + |-------------------------------------------------------------------------- + | Controls how collections (list, array, Type[]) are verified: + | + | - 'full' : (Default / Strict) 100% exhaustive scan. Checks every single + | item in every array, guaranteeing every single offending item + | is caught without exception. + | + | - 'hybrid' : (Beartype O(1) Mode) Fast boundary + random sampling on + | arrays > 64 items. Ideal for massive production datasets. + */ + 'array_validation' => 'hybrid', + /* |-------------------------------------------------------------------------- | Inline Variable Validation (@var $x = ...) From 3502a44c3b80a545699c450a08e01171db3dbee4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 23 Aug 2026 21:39:13 +0800 Subject: [PATCH 3/4] improve vaidator test coverage --- tests/Unit/ValidatorsTest.php | 587 ++++++++++++++++++++++------------ 1 file changed, 377 insertions(+), 210 deletions(-) diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php index 5062bf5..390f550 100644 --- a/tests/Unit/ValidatorsTest.php +++ b/tests/Unit/ValidatorsTest.php @@ -9,6 +9,20 @@ use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; use TypePHP\Internal\ErrorMessage; +use TypePHP\Tests\Fixtures\Domain\Car; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Enums\Suit; +use TypePHP\Tests\Fixtures\Enums\TransactionStatus; +use TypePHP\Tests\Fixtures\Generics\Producer; +use TypePHP\Tests\Fixtures\Readonly\UninitializedReadonlyContainer; +use TypePHP\Tests\Fixtures\Types\ArrayAccessOnly; +use TypePHP\Tests\Fixtures\Types\BitmaskFlags; +use TypePHP\Tests\Fixtures\Types\CountableArrayAccess; +use TypePHP\Tests\Fixtures\Types\CountableOnly; +use TypePHP\Tests\Fixtures\Types\DatabaseDriverMap; +use TypePHP\Tests\Fixtures\Types\StatusEnum; +use TypePHP\Tests\Fixtures\Types\UserObjectShape; +use TypePHP\Tests\Fixtures\Types\WildcardConstantFixture; use TypePHP\Validator\TypeValidatorRegistry; beforeEach(function () { @@ -29,300 +43,453 @@ function parseType(string $typeString, Lexer $lexer, TypeParser $typeParser): Ty describe('IdentifierValidator', function () { test('validates basic primitives', function () { $intNode = parseType('int', $this->lexer, $this->typeParser); - expect($this->registry->validate(10, $intNode, 'arg'))->toBeNull(); - expect($this->registry->validate('hello', $intNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(10, $intNode, 'arg'))->toBeNull() + ->and($this->registry->validate('hello', $intNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; $stringNode = parseType('string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $stringNode, 'arg'))->toBeNull(); - expect($this->registry->validate(123, $stringNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate('hello', $stringNode, 'arg'))->toBeNull() + ->and($this->registry->validate(123, $stringNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $boolNode = parseType('bool', $this->lexer, $this->typeParser); + expect($this->registry->validate(true, $boolNode, 'arg'))->toBeNull() + ->and($this->registry->validate(false, $boolNode, 'arg'))->toBeNull() + ->and($this->registry->validate('true', $boolNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $floatNode = parseType('float', $this->lexer, $this->typeParser); + expect($this->registry->validate(12.34, $floatNode, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $floatNode, 'arg'))->toBeNull() // Int coerced to float + ->and($this->registry->validate('not_float', $floatNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('validates special string types', function () { - $nonEmpty = parseType('non-empty-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $nonEmpty, 'arg'))->toBeNull(); - expect($this->registry->validate('', $nonEmpty, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $numericStr = parseType('numeric-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('123.45', $numericStr, 'arg'))->toBeNull(); - expect($this->registry->validate('not_a_number', $numericStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $lowerStr = parseType('lowercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $lowerStr, 'arg'))->toBeNull(); - expect($this->registry->validate('Hello', $lowerStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); - - test('validates int ranges and constraints', function () { + test('validates int refinements (positive, negative, non-positive, non-negative, non-zero, unsigned)', function () { $posInt = parseType('positive-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(5, $posInt, 'arg'))->toBeNull(); - expect($this->registry->validate(-5, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(5, $posInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(-5, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; $negInt = parseType('negative-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(-5, $negInt, 'arg'))->toBeNull(); - expect($this->registry->validate(5, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(-5, $negInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(5, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonPosInt = parseType('non-positive-int', $this->lexer, $this->typeParser); + expect($this->registry->validate(0, $nonPosInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-5, $nonPosInt, 'arg'))->toBeNull() + ->and($this->registry->validate(5, $nonPosInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonNegInt = parseType('non-negative-int', $this->lexer, $this->typeParser); + expect($this->registry->validate(0, $nonNegInt, 'arg'))->toBeNull() + ->and($this->registry->validate(5, $nonNegInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-5, $nonNegInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonZeroInt = parseType('non-zero-int', $this->lexer, $this->typeParser); + expect($this->registry->validate(1, $nonZeroInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $nonZeroInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $nonZeroInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $unsignedInt = parseType('unsigned-int', $this->lexer, $this->typeParser); + expect($this->registry->validate(0, $unsignedInt, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $unsignedInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $unsignedInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('validates truthy and falsy', function () { - $truthy = parseType('truthy', $this->lexer, $this->typeParser); - expect($this->registry->validate('true', $truthy, 'arg'))->toBeNull(); - expect($this->registry->validate(0, $truthy, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates float refinements (positive, negative, non-positive, non-negative, non-zero)', function () { + $posFloat = parseType('positive-float', $this->lexer, $this->typeParser); + expect($this->registry->validate(12.34, $posFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-12.34, $posFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $negFloat = parseType('negative-float', $this->lexer, $this->typeParser); + expect($this->registry->validate(-5.5, $negFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(5.5, $negFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonPosFloat = parseType('non-positive-float', $this->lexer, $this->typeParser); + expect($this->registry->validate(0.0, $nonPosFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-5.5, $nonPosFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(5.5, $nonPosFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonNegFloat = parseType('non-negative-float', $this->lexer, $this->typeParser); + expect($this->registry->validate(0.0, $nonNegFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(5.5, $nonNegFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-5.5, $nonNegFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $falsy = parseType('falsy', $this->lexer, $this->typeParser); - expect($this->registry->validate(false, $falsy, 'arg'))->toBeNull(); - expect($this->registry->validate('hello', $falsy, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $nonZeroFloat = parseType('non-zero-float', $this->lexer, $this->typeParser); + expect($this->registry->validate(1.5, $nonZeroFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(0.0, $nonZeroFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: validates class-string against interfaces, traits, and enums', function () { - $classString = parseType('class-string', $this->lexer, $this->typeParser); + test('validates string refinements', function () { + $nonEmpty = parseType('non-empty-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('hello', $nonEmpty, 'arg'))->toBeNull() + ->and($this->registry->validate('', $nonEmpty, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(DateTimeInterface::class, $classString, 'arg'))->toBeNull(); - expect($this->registry->validate('NonExistentClass12345', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(123, $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + $numericStr = parseType('numeric-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('123.45', $numericStr, 'arg'))->toBeNull() + ->and($this->registry->validate('not_a_number', $numericStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - test('edge case: validates callable-string', function () { - $callableStr = parseType('callable-string', $this->lexer, $this->typeParser); + $lowerStr = parseType('lowercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('hello', $lowerStr, 'arg'))->toBeNull() + ->and($this->registry->validate('Hello', $lowerStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyLower = parseType('non-empty-lowercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('hello', $nonEmptyLower, 'arg'))->toBeNull() + ->and($this->registry->validate('', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('Hello', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $upperStr = parseType('uppercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('USD', $upperStr, 'arg'))->toBeNull() + ->and($this->registry->validate('', $upperStr, 'arg'))->toBeNull() + ->and($this->registry->validate('Usd', $upperStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyUpper = parseType('non-empty-uppercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('EUR', $nonEmptyUpper, 'arg'))->toBeNull() + ->and($this->registry->validate('', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('eur', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $arrayKey = parseType('array-key', $this->lexer, $this->typeParser); + expect($this->registry->validate(123, $arrayKey, 'arg'))->toBeNull() + ->and($this->registry->validate('key_1', $arrayKey, 'arg'))->toBeNull() + ->and($this->registry->validate(true, $arrayKey, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $literalStr = parseType('literal-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('any_string', $literalStr, 'arg'))->toBeNull() + ->and($this->registry->validate(123, $literalStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate('strlen', $callableStr, 'arg'))->toBeNull(); - expect($this->registry->validate('non_existent_function_abc_123', $callableStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $truthyStr = parseType('truthy-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('hello', $truthyStr, 'arg'))->toBeNull() + ->and($this->registry->validate('0', $truthyStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('', $truthyStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: validates boundary conditions for positive, negative, and non-zero ints', function () { - $posInt = parseType('positive-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates pseudo-types (mixed, scalar, void, never, truthy, falsy, numeric, resources)', function () { + $mixed = parseType('mixed', $this->lexer, $this->typeParser); + expect($this->registry->validate(123, $mixed, 'arg'))->toBeNull() + ->and($this->registry->validate(null, $mixed, 'arg'))->toBeNull() + ->and($this->registry->validate(new stdClass(), $mixed, 'arg'))->toBeNull() + ; - $nonZero = parseType('non-zero-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $nonZero, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(-1, $nonZero, 'arg'))->toBeNull(); - expect($this->registry->validate(1, $nonZero, 'arg'))->toBeNull(); - }); + $scalar = parseType('scalar', $this->lexer, $this->typeParser); + expect($this->registry->validate(123, $scalar, 'arg'))->toBeNull() + ->and($this->registry->validate('hello', $scalar, 'arg'))->toBeNull() + ->and($this->registry->validate(true, $scalar, 'arg'))->toBeNull() + ->and($this->registry->validate([], $scalar, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - test('validates interface-string, trait-string, and enum-string', function () { - $interfaceStr = parseType('interface-string', $this->lexer, $this->typeParser); - expect($this->registry->validate(DateTimeInterface::class, $interfaceStr, 'arg'))->toBeNull(); - expect($this->registry->validate(stdClass::class, $interfaceStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $void = parseType('void', $this->lexer, $this->typeParser); + expect($this->registry->validate(null, $void, 'arg'))->toBeNull() + ->and($this->registry->validate(123, $void, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $enumStr = parseType('enum-string', $this->lexer, $this->typeParser); - expect($this->registry->validate(TypePHP\Tests\Fixtures\Types\StatusEnum::class, $enumStr, 'arg'))->toBeNull(); - expect($this->registry->validate(stdClass::class, $enumStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + $never = parseType('never', $this->lexer, $this->typeParser); + expect($this->registry->validate('returned', $never, 'arg'))->toBeInstanceOf(ErrorMessage::class); - test('validates float refinements (positive-float, negative-float, non-zero-float)', function () { - $posFloat = parseType('positive-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(12.34, $posFloat, 'arg'))->toBeNull(); - expect($this->registry->validate(-12.34, $posFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $truthy = parseType('truthy', $this->lexer, $this->typeParser); + expect($this->registry->validate('true', $truthy, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $truthy, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $nonZeroFloat = parseType('non-zero-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(0.0, $nonZeroFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(1.5, $nonZeroFloat, 'arg'))->toBeNull(); + $falsy = parseType('falsy', $this->lexer, $this->typeParser); + expect($this->registry->validate(false, $falsy, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $falsy, 'arg'))->toBeNull() + ->and($this->registry->validate('hello', $falsy, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $numeric = parseType('numeric', $this->lexer, $this->typeParser); + expect($this->registry->validate(10, $numeric, 'arg'))->toBeNull() + ->and($this->registry->validate('10.5', $numeric, 'arg'))->toBeNull() + ->and($this->registry->validate('not_numeric', $numeric, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $res = fopen('php://memory', 'r+'); + $openResource = parseType('open-resource', $this->lexer, $this->typeParser); + expect($this->registry->validate($res, $openResource, 'arg'))->toBeNull(); + fclose($res); + + $closedResource = parseType('closed-resource', $this->lexer, $this->typeParser); + expect($this->registry->validate($res, $closedResource, 'arg'))->toBeNull(); }); - test('validates truthy-string and never return type', function () { - $truthyStr = parseType('truthy-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $truthyStr, 'arg'))->toBeNull(); - expect($this->registry->validate('0', $truthyStr, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates string class type identifiers (class-string, interface-string, trait-string, enum-string, callable-string)', function () { + $classString = parseType('class-string', $this->lexer, $this->typeParser); + expect($this->registry->validate(DateTimeInterface::class, $classString, 'arg'))->toBeNull() + ->and($this->registry->validate(Dog::class, $classString, 'arg'))->toBeNull() + ->and($this->registry->validate('NonExistentClass123', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $neverNode = parseType('never', $this->lexer, $this->typeParser); - expect($this->registry->validate('returned_value', $neverNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + $ifaceString = parseType('interface-string', $this->lexer, $this->typeParser); + expect($this->registry->validate(DateTimeInterface::class, $ifaceString, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $ifaceString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - test('validates array-key pseudo-type (int|string)', function () { - $arrayKeyNode = parseType('array-key', $this->lexer, $this->typeParser); + $enumString = parseType('enum-string', $this->lexer, $this->typeParser); + expect($this->registry->validate(StatusEnum::class, $enumString, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $enumString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(123, $arrayKeyNode, 'arg'))->toBeNull(); - expect($this->registry->validate('custom_key', $arrayKeyNode, 'arg'))->toBeNull(); - expect($this->registry->validate(true, $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate([], $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $callableStr = parseType('callable-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('strlen', $callableStr, 'arg'))->toBeNull() + ->and($this->registry->validate('non_existent_func_123', $callableStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('validates uppercase-string and non-empty-uppercase-string', function () { - $uppercase = parseType('uppercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('USD', $uppercase, 'arg'))->toBeNull(); - expect($this->registry->validate('hello', $uppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates class instances and ignores invalid custom syntax with hyphens', function () { + $dogNode = parseType(Dog::class, $this->lexer, $this->typeParser); + expect($this->registry->validate(new Dog(), $dogNode, 'arg'))->toBeNull() + ->and($this->registry->validate(new Car(), $dogNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $nonEmptyUppercase = parseType('non-empty-uppercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('EUR', $nonEmptyUppercase, 'arg'))->toBeNull(); - expect($this->registry->validate('', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate('eur', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $hyphenSyntax = parseType('custom-type-with-hyphens', $this->lexer, $this->typeParser); + expect($this->registry->validate('anything', $hyphenSyntax, 'arg'))->toBeNull(); // Gracefully ignored! }); }); describe('ConstValidator', function () { - test('validates string and integer literals', function () { + test('validates string, integer, bool, and null literals', function () { $strLiteral = parseType("'active'", $this->lexer, $this->typeParser); - expect($this->registry->validate('active', $strLiteral, 'arg'))->toBeNull(); - expect($this->registry->validate('inactive', $strLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $intLiteral = parseType('42', $this->lexer, $this->typeParser); - expect($this->registry->validate(42, $intLiteral, 'arg'))->toBeNull(); - expect($this->registry->validate(100, $intLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + expect($this->registry->validate('active', $strLiteral, 'arg'))->toBeNull() + ->and($this->registry->validate('inactive', $strLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - test('edge case: strict type matching for constant literals', function () { $intLiteral = parseType('42', $this->lexer, $this->typeParser); - expect($this->registry->validate('42', $intLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(42, $intLiteral, 'arg'))->toBeNull() + ->and($this->registry->validate(100, $intLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; $trueLiteral = parseType('true', $this->lexer, $this->typeParser); - expect($this->registry->validate(1, $trueLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(true, $trueLiteral, 'arg'))->toBeNull(); + expect($this->registry->validate(true, $trueLiteral, 'arg'))->toBeNull() + ->and($this->registry->validate(1, $trueLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; $nullLiteral = parseType('null', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $nullLiteral, 'arg'))->toBeNull(); - expect($this->registry->validate(false, $nullLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(null, $nullLiteral, 'arg'))->toBeNull() + ->and($this->registry->validate(false, $nullLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); -}); -describe('ArrayShapeValidator', function () { - test('checks required and optional keys', function () { - $shape = parseType('array{id: int, name: string, email?: string}', $this->lexer, $this->typeParser); + test('validates float literals and handles IEEE 754 precision', function () { + $floatLiteral = parseType('12.34', $this->lexer, $this->typeParser); + expect($this->registry->validate(12.34, $floatLiteral, 'arg'))->toBeNull() + ->and($this->registry->validate(12.35, $floatLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(['id' => 1, 'name' => 'Alice', 'email' => 'alice@test.com'], $shape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 1, 'name' => 'Alice'], $shape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 1], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(['id' => 'not_an_int', 'name' => 'Alice'], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $precisionLiteral = parseType('0.3', $this->lexer, $this->typeParser); + $sum = 0.1 + 0.2; // Evaluates to 0.30000000000000004 in IEEE 754 + expect($this->registry->validate($sum, $precisionLiteral, 'arg'))->toBeNull(); }); - test('edge case: sealed shapes reject unexpected extra keys', function () { - $sealedShape = parseType('array{id: int}', $this->lexer, $this->typeParser); - - expect($this->registry->validate(['id' => 1], $sealedShape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 1, 'extra' => 'value'], $sealedShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates wildcard class constant patterns', function () { + $wildcardType = parseType(WildcardConstantFixture::class . '::VERSION_SELECTION_*', $this->lexer, $this->typeParser); + expect($this->registry->validate('all', $wildcardType, 'arg'))->toBeNull() + ->and($this->registry->validate('blue-green', $wildcardType, 'arg'))->toBeNull() + ->and($this->registry->validate('internal-mode', $wildcardType, 'arg'))->toBeNull() + ->and($this->registry->validate('invalid', $wildcardType, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); +}); - test('edge case: unsealed shapes allow extra keys matching unsealed type', function () { - $unsealedShape = parseType('array{id: int, ...}', $this->lexer, $this->typeParser); - - expect($this->registry->validate(['id' => 1, 'role' => 'admin'], $unsealedShape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 1, 'role' => 999], $unsealedShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); +describe('GenericValidator', function () { + test('validates int range bounds', function () { + $range = parseType('int<1, 10>', $this->lexer, $this->typeParser); + expect($this->registry->validate(5, $range, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $range, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(15, $range, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - test('edge case: nested array shapes', function () { - $nestedShape = parseType('array{user: array{id: int, name: string}}', $this->lexer, $this->typeParser); + $minRange = parseType('int', $this->lexer, $this->typeParser); + expect($this->registry->validate(-99999, $minRange, 'arg'))->toBeNull() + ->and($this->registry->validate(101, $minRange, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(['user' => ['id' => 1, 'name' => 'Alice']], $nestedShape, 'arg'))->toBeNull(); - expect($this->registry->validate(['user' => ['id' => 'invalid']], $nestedShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $maxRange = parseType('int<0, max>', $this->lexer, $this->typeParser); + expect($this->registry->validate(999999, $maxRange, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $maxRange, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); -}); - -describe('GenericValidator', function () { - test('checks int range bounds int<1, 10>', function () { - $rangeNode = parseType('int<1, 10>', $this->lexer, $this->typeParser); - expect($this->registry->validate(5, $rangeNode, 'arg'))->toBeNull(); - expect($this->registry->validate(0, $rangeNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(15, $rangeNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates class-string with bounds', function () { + $classStringBound = parseType('class-string<' . DateTimeInterface::class . '>', $this->lexer, $this->typeParser); + expect($this->registry->validate(DateTime::class, $classStringBound, 'arg'))->toBeNull() + ->and($this->registry->validate(DateTimeImmutable::class, $classStringBound, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $classStringBound, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('checks list and array', function () { - $listNode = parseType('list', $this->lexer, $this->typeParser); + test('validates key-of on Constants, Enums, and Shapes', function () { + $constKeyOf = parseType('key-of<' . DatabaseDriverMap::class . '::PUBLIC_MAP>', $this->lexer, $this->typeParser); + expect($this->registry->validate('read', $constKeyOf, 'arg'))->toBeNull() + ->and($this->registry->validate('write', $constKeyOf, 'arg'))->toBeNull() + ->and($this->registry->validate('invalid', $constKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $enumKeyOf = parseType('key-of<' . Suit::class . '>', $this->lexer, $this->typeParser); + expect($this->registry->validate('Hearts', $enumKeyOf, 'arg'))->toBeNull() + ->and($this->registry->validate('invalid_case', $enumKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $shapeKeyOf = parseType('key-of', $this->lexer, $this->typeParser); + expect($this->registry->validate('id', $shapeKeyOf, 'arg'))->toBeNull() + ->and($this->registry->validate('name', $shapeKeyOf, 'arg'))->toBeNull() + ->and($this->registry->validate('missing_key', $shapeKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); - expect($this->registry->validate(['a', 'b', 'c'], $listNode, 'arg'))->toBeNull(); - expect($this->registry->validate(['a' => 'b'], $listNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates value-of on Constants, BackedEnums, and UnitEnums', function () { + $constValueOf = parseType('value-of<' . DatabaseDriverMap::class . '::PUBLIC_MAP>', $this->lexer, $this->typeParser); + expect($this->registry->validate(1, $constValueOf, 'arg'))->toBeNull() + ->and($this->registry->validate(99, $constValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - $assocArrayNode = parseType('array', $this->lexer, $this->typeParser); + $enumValueOf = parseType('value-of<' . TransactionStatus::class . '>', $this->lexer, $this->typeParser); + expect($this->registry->validate(1, $enumValueOf, 'arg'))->toBeNull() + ->and($this->registry->validate(99, $enumValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(['age' => 30, 'score' => 100], $assocArrayNode, 'arg'))->toBeNull(); - expect($this->registry->validate(['age' => 'thirty'], $assocArrayNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $unitEnumValueOf = parseType('value-of<' . Suit::class . '>', $this->lexer, $this->typeParser); + expect($this->registry->validate('Hearts', $unitEnumValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class); // UnitEnums have no backing values }); - test('edge case: int range bounds with min, max, and wildcard *', function () { - $minRange = parseType('int', $this->lexer, $this->typeParser); - expect($this->registry->validate(-99999, $minRange, 'arg'))->toBeNull(); - expect($this->registry->validate(100, $minRange, 'arg'))->toBeNull(); - expect($this->registry->validate(101, $minRange, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $maxRange = parseType('int<0, max>', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $maxRange, 'arg'))->toBeNull(); - expect($this->registry->validate(999999, $maxRange, 'arg'))->toBeNull(); - expect($this->registry->validate(-1, $maxRange, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates int-mask and int-mask-of bitmasks', function () { + $intMask = parseType('int-mask<1, 2, 4>', $this->lexer, $this->typeParser); + expect($this->registry->validate(0, $intMask, 'arg'))->toBeNull() + ->and($this->registry->validate(1, $intMask, 'arg'))->toBeNull() + ->and($this->registry->validate(3, $intMask, 'arg'))->toBeNull() + ->and($this->registry->validate(8, $intMask, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $intMaskOf = parseType('int-mask-of<' . BitmaskFlags::class . '::FLAG_*>', $this->lexer, $this->typeParser); + expect($this->registry->validate(3, $intMaskOf, 'arg'))->toBeNull() + ->and($this->registry->validate(16, $intMaskOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: non-empty-list rejects empty array', function () { - $nonEmptyList = parseType('non-empty-list', $this->lexer, $this->typeParser); - - expect($this->registry->validate([10, 20], $nonEmptyList, 'arg'))->toBeNull(); - expect($this->registry->validate([], $nonEmptyList, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates generic lists and key-value arrays', function () { + $list = parseType('list', $this->lexer, $this->typeParser); + expect($this->registry->validate([1, 2, 3], $list, 'arg'))->toBeNull() + ->and($this->registry->validate(['key' => 1], $list, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $map = parseType('array', $this->lexer, $this->typeParser); + expect($this->registry->validate(['a' => 10], $map, 'arg'))->toBeNull() + ->and($this->registry->validate([0 => 10], $map, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(['a' => 'invalid'], $map, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: nested generic arrays array>', function () { - $nestedGeneric = parseType('array>', $this->lexer, $this->typeParser); - - expect($this->registry->validate(['scores' => [10, 20, 30]], $nestedGeneric, 'arg'))->toBeNull(); - expect($this->registry->validate(['scores' => ['a' => 10]], $nestedGeneric, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates object generics (Producer)', function () { + $producerDog = parseType(Producer::class . '<' . Dog::class . '>', $this->lexer, $this->typeParser); + expect($this->registry->validate(new Producer(new Dog()), $producerDog, 'arg'))->toBeNull() + ->and($this->registry->validate(new Producer(new Car()), $producerDog, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('not_an_object', $producerDog, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); }); -describe('NullableValidator', function () { - test('handles null and wrapped types', function () { - $nullableInt = parseType('?int', $this->lexer, $this->typeParser); +describe('ArrayShapeValidator & ObjectShapeValidator', function () { + test('validates array shapes with required, optional, sealed, and unsealed keys', function () { + $shape = parseType('array{id: int, name: string, active?: bool}', $this->lexer, $this->typeParser); + expect($this->registry->validate(['id' => 1, 'name' => 'Alice', 'active' => true], $shape, 'arg'))->toBeNull() + ->and($this->registry->validate(['id' => 1, 'name' => 'Alice'], $shape, 'arg'))->toBeNull() + ->and($this->registry->validate(['id' => 1], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(['id' => 1, 'name' => 'Alice', 'extra' => 1], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->registry->validate(null, $nullableInt, 'arg'))->toBeNull(); - expect($this->registry->validate(100, $nullableInt, 'arg'))->toBeNull(); - expect($this->registry->validate('string', $nullableInt, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $unsealedShape = parseType('array{id: int, ...}', $this->lexer, $this->typeParser); + expect($this->registry->validate(['id' => 1, 'role' => 'admin'], $unsealedShape, 'arg'))->toBeNull() + ->and($this->registry->validate(['id' => 1, 'role' => 999], $unsealedShape, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: nullable array shape ?array{id: int}', function () { - $nullableShape = parseType('?array{id: int}', $this->lexer, $this->typeParser); + test('validates object shapes on stdClass (fast-path) and custom objects', function () { + $objShape = parseType('object{id: int, name: string}', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $nullableShape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 10], $nullableShape, 'arg'))->toBeNull(); - expect($this->registry->validate(['id' => 'invalid'], $nullableShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); -}); + $std = new stdClass(); + $std->id = 1; + $std->name = 'Alice'; + expect($this->registry->validate($std, $objShape, 'arg'))->toBeNull(); -describe('UnionValidator', function () { - test('accepts valid choices and rejects invalid choices', function () { - $union = parseType('int|string', $this->lexer, $this->typeParser); + $badStd = new stdClass(); + $badStd->id = 1; + expect($this->registry->validate($badStd, $objShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - expect($this->registry->validate(10, $union, 'arg'))->toBeNull(); - expect($this->registry->validate('hello', $union, 'arg'))->toBeNull(); - expect($this->registry->validate(true, $union, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + $custom = new UserObjectShape(1, 'Alice'); + expect($this->registry->validate($custom, $objShape, 'arg'))->toBeNull(); - test('edge case: literal string union active|pending|closed', function () { - $enumUnion = parseType("'active'|'pending'|'closed'", $this->lexer, $this->typeParser); - - expect($this->registry->validate('active', $enumUnion, 'arg'))->toBeNull(); - expect($this->registry->validate('pending', $enumUnion, 'arg'))->toBeNull(); - expect($this->registry->validate('archived', $enumUnion, 'arg'))->toBeInstanceOf(ErrorMessage::class); + $uninit = new UninitializedReadonlyContainer(); + expect($this->registry->validate($uninit, $objShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); }); }); -describe('IntersectionValidator', function () { - test('requires value to satisfy all types', function () { - $intersection = parseType('Countable&ArrayAccess', $this->lexer, $this->typeParser); +describe('ArrayValidator, UnionValidator, NullableValidator & IntersectionValidator', function () { + test('validates typed arrays (Type[])', function () { + $intArray = parseType('int[]', $this->lexer, $this->typeParser); + expect($this->registry->validate([1, 2, 3], $intArray, 'arg'))->toBeNull() + ->and($this->registry->validate([], $intArray, 'arg'))->toBeNull() + ->and($this->registry->validate([1, 'bad', 3], $intArray, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('not_array', $intArray, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); - $validObj = new ArrayObject(); - $invalidObj = new stdClass(); + test('validates nullable types (?Type)', function () { + $nullableInt = parseType('?int', $this->lexer, $this->typeParser); + expect($this->registry->validate(null, $nullableInt, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $nullableInt, 'arg'))->toBeNull() + ->and($this->registry->validate('str', $nullableInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); - expect($this->registry->validate($validObj, $intersection, 'arg'))->toBeNull(); - expect($this->registry->validate($invalidObj, $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class); + test('validates union types with deep error bubbling', function () { + $union = parseType('int|string', $this->lexer, $this->typeParser); + expect($this->registry->validate(10, $union, 'arg'))->toBeNull() + ->and($this->registry->validate('str', $union, 'arg'))->toBeNull() + ->and($this->registry->validate(true, $union, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $deepUnion = parseType('array{id: int, tags: list}|null', $this->lexer, $this->typeParser); + expect($this->registry->validate(null, $deepUnion, 'arg'))->toBeNull() + ->and($this->registry->validate(['id' => 10, 'tags' => ['a', 'b']], $deepUnion, 'arg'))->toBeNull() + ->and($this->registry->validate(['id' => 10, 'tags' => ['a', 123]], $deepUnion, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('edge case: object failing one interface in intersection', function () { + test('validates intersection types', function () { $intersection = parseType('Countable&ArrayAccess', $this->lexer, $this->typeParser); - - $countableOnly = new class () implements Countable { - public function count(): int - { - return 0; - } - }; - - expect($this->registry->validate($countableOnly, $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate(new CountableArrayAccess(), $intersection, 'arg'))->toBeNull() + ->and($this->registry->validate(new CountableOnly(), $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(new ArrayAccessOnly(), $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; }); -}); -describe('ArrayValidator', function () { - test('checks array element types', function () { - $intArray = parseType('int[]', $this->lexer, $this->typeParser); - - expect($this->registry->validate([1, 2, 3], $intArray, 'arg'))->toBeNull(); - expect($this->registry->validate([1, 'invalid_string', 3], $intArray, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); + test('memoizes object validations in TypeValidatorRegistry', function () { + $dog = new Dog(); + $dogType = parseType(Dog::class, $this->lexer, $this->typeParser); - test('edge case: empty array is valid for typed array', function () { - $intArray = parseType('int[]', $this->lexer, $this->typeParser); + expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); // Validated and memoized + expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); // O(1) cache hit - expect($this->registry->validate([], $intArray, 'arg'))->toBeNull(); + TypeValidatorRegistry::reset(); + expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); }); }); From 4ae4fba93588dfa328bcf8a4fb8fb54cd04da0c3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 23 Aug 2026 21:43:42 +0800 Subject: [PATCH 4/4] Fix php stan errors --- src/Internal/Config.php | 2 +- src/Validator/ArrayValidator.php | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 5bd9221..5867be5 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -278,6 +278,6 @@ private static function syncFlags(array $config): void self::$magicProperties = (bool) ($config['magic_properties'] ?? true); self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); - self::$arrayValidation = (string) ($config['array_validation'] ?? 'full'); + self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full'; } } diff --git a/src/Validator/ArrayValidator.php b/src/Validator/ArrayValidator.php index 17cf2c7..d3ad077 100644 --- a/src/Validator/ArrayValidator.php +++ b/src/Validator/ArrayValidator.php @@ -21,13 +21,8 @@ */ final class ArrayValidator implements TypeValidatorInterface { - private const HYBRID_SAMPLE_THRESHOLD = 128; + private const HYBRID_SAMPLE_THRESHOLD = 64; - /** - * Validates an array or Traversable collection against an ArrayTypeNode (Type[]). - * Accepts native arrays and Traversable objects (e.g. ArrayIterator, Symfony RewindableGenerator). - * Bypasses eager iteration on Generator instances to prevent premature generator closure. - */ public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { if (! \is_array($value) && ! ($value instanceof Traversable)) { @@ -54,7 +49,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali foreach ($value as $k => $v) { $err = $registry->validate($v, $arrayNode->type, ''); if ($err !== null) { - $keyStr = (\is_scalar($k) || $k === null) ? (string) $k : get_debug_type($k); + $keyStr = (string) $k; return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); } @@ -112,7 +107,7 @@ private function validateArrayHybrid( foreach ($sampleKeys as $k) { $err = $registry->validate($value[$k], $arrayNode->type, ''); if ($err !== null) { - $keyStr = (\is_scalar($k) || $k === null) ? (string) $k : get_debug_type($k); + $keyStr = (string) $k; return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); } @@ -120,4 +115,4 @@ private function validateArrayHybrid( return null; } -} +} \ No newline at end of file