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
15 changes: 15 additions & 0 deletions src/Command/ConfigInitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ private static function getTemplate(): string
// \Acme\Domain\TypePHPExtension::class,
],

/*
|--------------------------------------------------------------------------
| Array Validation Strategy
|--------------------------------------------------------------------------
| Controls how collections (list<T>, array<K, V>, 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 = ...)
Expand Down
2 changes: 1 addition & 1 deletion src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -870,4 +870,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

return $node;
}
}
}
2 changes: 1 addition & 1 deletion src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public static function checkParams(
}

$contract = ContractParser::parse($effectiveFunction);

if (! $contract['hasParamContract']) {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Internal/Checker/ReturnChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,4 @@ private static function resolveTemplateConditional(

return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry);
}
}
}
23 changes: 23 additions & 0 deletions src/Internal/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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();
Expand All @@ -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 = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full';
}
}
57 changes: 36 additions & 21 deletions src/Validator/ArrayShapeValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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());
}
}
}

Expand Down
79 changes: 73 additions & 6 deletions src/Validator/ArrayValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,21 @@
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[]).
* Supports both exhaustive O(n) verification and Beartype-style hybrid O(1) sampling.
*
* @internal
*/
final class ArrayValidator implements TypeValidatorInterface
{
/**
* 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.
*/
private const HYBRID_SAMPLE_THRESHOLD = 64;

public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
{
if (! \is_array($value) && ! ($value instanceof Traversable)) {
Expand All @@ -37,6 +36,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 && Config::isArrayValidationHybrid()) {
return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count);
}

foreach ($value as $k => $v) {
$err = $registry->validate($v, $arrayNode->type, '');
if ($err !== null) {
$keyStr = (string) $k;

return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage());
}
}

return null;
}

foreach ($value as $k => $v) {
$err = $registry->validate($v, $arrayNode->type, '');
if ($err !== null) {
Expand All @@ -48,4 +69,50 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

return null;
}
}

/**
* @param array<mixed> $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 = (string) $k;

return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage());
}
}

return null;
}
}
Loading
Loading