Skip to content

Commit 792e155

Browse files
authored
Array performance optimization (#44)
* Refactor validators to implement hybrid sampling for large collections; improve error handling and code clarity * make array optmization configurable * improve vaidator test coverage * Fix php stan errors
1 parent 456eaf8 commit 792e155

10 files changed

Lines changed: 644 additions & 272 deletions

File tree

src/Command/ConfigInitCommand.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ private static function getTemplate(): string
110110
// \Acme\Domain\TypePHPExtension::class,
111111
],
112112
113+
/*
114+
|--------------------------------------------------------------------------
115+
| Array Validation Strategy
116+
|--------------------------------------------------------------------------
117+
| Controls how collections (list<T>, array<K, V>, Type[]) are verified:
118+
|
119+
| - 'full' : (Default / Strict) 100% exhaustive scan. Checks every single
120+
| item in every array, guaranteeing every single offending item
121+
| is caught without exception.
122+
|
123+
| - 'hybrid' : (Beartype O(1) Mode) Fast boundary + random sampling on
124+
| arrays > 64 items. Ideal for massive production datasets.
125+
*/
126+
'array_validation' => 'full',
127+
113128
/*
114129
|--------------------------------------------------------------------------
115130
| Inline Variable Validation (@var $x = ...)

src/Contract/ContractParser.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,4 +870,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
870870

871871
return $node;
872872
}
873-
}
873+
}

src/Internal/Checker/ParamChecker.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public static function checkParams(
6161
}
6262

6363
$contract = ContractParser::parse($effectiveFunction);
64-
64+
6565
if (! $contract['hasParamContract']) {
6666
return null;
6767
}

src/Internal/Checker/ReturnChecker.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,4 +350,4 @@ private static function resolveTemplateConditional(
350350

351351
return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry);
352352
}
353-
}
353+
}

src/Internal/Config.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ final class Config
4242

4343
private static bool $respectIgnoreTags = true;
4444

45+
private static string $arrayValidation = 'full';
46+
4547
public static function isEnabled(): bool
4648
{
4749
if (self::$cachedConfig === null) {
@@ -96,6 +98,24 @@ public static function isRespectIgnoreTagsEnabled(): bool
9698
return self::$respectIgnoreTags;
9799
}
98100

101+
public static function isArrayValidationHybrid(): bool
102+
{
103+
if (self::$cachedConfig === null) {
104+
self::get();
105+
}
106+
107+
return self::$arrayValidation === 'hybrid';
108+
}
109+
110+
public static function getArrayValidationStrategy(): string
111+
{
112+
if (self::$cachedConfig === null) {
113+
self::get();
114+
}
115+
116+
return self::$arrayValidation;
117+
}
118+
99119
/**
100120
* Locates the project root directory by searching upwards for vendor/autoload.php or composer.json.
101121
* Caches the result in memory so the search happens exactly once.
@@ -158,6 +178,7 @@ public static function get(): array
158178
'magic_properties' => true,
159179
'magic_methods' => true,
160180
'respect_ignore_tags' => true,
181+
'array_validation' => 'full',
161182
'cache' => true,
162183
'cache_dir' => null,
163184
'inline_vars' => [
@@ -232,6 +253,7 @@ public static function reset(): void
232253
self::$magicProperties = true;
233254
self::$magicMethods = true;
234255
self::$respectIgnoreTags = true;
256+
self::$arrayValidation = 'full';
235257

236258
ContractParser::reset();
237259
ParamChecker::reset();
@@ -256,5 +278,6 @@ private static function syncFlags(array $config): void
256278
self::$magicProperties = (bool) ($config['magic_properties'] ?? true);
257279
self::$magicMethods = (bool) ($config['magic_methods'] ?? true);
258280
self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true);
281+
self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full';
259282
}
260283
}

src/Validator/ArrayShapeValidator.php

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
2424
return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value) . ' given');
2525
}
2626

27-
/** @var ArrayShapeNode $node */
27+
/** @var ArrayShapeNode $shapeNode */
28+
$shapeNode = $node;
2829
$knownKeys = [];
2930
$nextAutoIndex = 0;
31+
$matchedKeysCount = 0;
3032

31-
foreach ($node->items as $item) {
33+
foreach ($shapeNode->items as $item) {
3234
$key = null;
3335

3436
if ($item->keyName instanceof ConstExprStringNode) {
@@ -55,37 +57,50 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
5557
continue;
5658
}
5759

58-
$err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']");
60+
$matchedKeysCount++;
61+
62+
$err = $registry->validate($value[$key], $item->valueType, '');
5963
if ($err !== null) {
60-
return $err;
64+
return ErrorFactory::createError($context . "['" . $key . "']" . $err->getMessage());
6165
}
6266
}
6367

64-
$extraKeys = array_diff_key($value, $knownKeys);
68+
$valueCount = \count($value);
6569

66-
if (\count($extraKeys) > 0) {
67-
if ($node->sealed) {
68-
$firstExtraKey = (string) array_key_first($extraKeys);
70+
if ($valueCount === $matchedKeysCount) {
71+
return null;
72+
}
6973

70-
return ErrorFactory::createError($context . " contains unsealed unexpected key '$firstExtraKey'");
74+
if ($shapeNode->sealed) {
75+
foreach ($value as $k => $_) {
76+
if (! isset($knownKeys[$k])) {
77+
return ErrorFactory::createError($context . " contains unsealed unexpected key '{$k}'");
78+
}
7179
}
7280

73-
if ($node->unsealedType !== null) {
74-
$unsealedKeyType = $node->unsealedType->keyType;
75-
$unsealedValueType = $node->unsealedType->valueType;
81+
return null;
82+
}
7683

77-
foreach ($extraKeys as $k => $v) {
78-
if ($unsealedKeyType !== null) {
79-
$err = $registry->validate($k, $unsealedKeyType, $context . " extra key '$k'");
80-
if ($err !== null) {
81-
return $err;
82-
}
83-
}
84-
$err = $registry->validate($v, $unsealedValueType, $context . "['$k']");
84+
if ($shapeNode->unsealedType !== null) {
85+
$unsealedKeyType = $shapeNode->unsealedType->keyType;
86+
$unsealedValueType = $shapeNode->unsealedType->valueType;
87+
88+
foreach ($value as $k => $v) {
89+
if (isset($knownKeys[$k])) {
90+
continue;
91+
}
92+
93+
if ($unsealedKeyType !== null) {
94+
$err = $registry->validate($k, $unsealedKeyType, '');
8595
if ($err !== null) {
86-
return $err;
96+
return ErrorFactory::createError($context . " extra key '{$k}'" . $err->getMessage());
8797
}
8898
}
99+
100+
$err = $registry->validate($v, $unsealedValueType, '');
101+
if ($err !== null) {
102+
return ErrorFactory::createError($context . "['{$k}']" . $err->getMessage());
103+
}
89104
}
90105
}
91106

src/Validator/ArrayValidator.php

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,21 @@
88
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
99
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1010
use Traversable;
11+
use TypePHP\Internal\Config;
1112
use TypePHP\Internal\ErrorFactory;
1213
use TypePHP\Internal\ErrorMessage;
1314
use TypePHP\Internal\TypeFormatter;
1415

1516
/**
1617
* Validates array and Traversable collection instances against ArrayTypeNode ASTs (Type[]).
18+
* Supports both exhaustive O(n) verification and Beartype-style hybrid O(1) sampling.
1719
*
1820
* @internal
1921
*/
2022
final class ArrayValidator implements TypeValidatorInterface
2123
{
22-
/**
23-
* Validates an array or Traversable collection against an ArrayTypeNode (Type[]).
24-
* Accepts native arrays and Traversable objects (e.g. ArrayIterator, Symfony RewindableGenerator).
25-
* Bypasses eager iteration on Generator instances to prevent premature generator closure.
26-
*/
24+
private const HYBRID_SAMPLE_THRESHOLD = 64;
25+
2726
public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
2827
{
2928
if (! \is_array($value) && ! ($value instanceof Traversable)) {
@@ -37,6 +36,28 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
3736
return null;
3837
}
3938

39+
if (\is_array($value)) {
40+
$count = \count($value);
41+
if ($count === 0) {
42+
return null;
43+
}
44+
45+
if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) {
46+
return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count);
47+
}
48+
49+
foreach ($value as $k => $v) {
50+
$err = $registry->validate($v, $arrayNode->type, '');
51+
if ($err !== null) {
52+
$keyStr = (string) $k;
53+
54+
return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage());
55+
}
56+
}
57+
58+
return null;
59+
}
60+
4061
foreach ($value as $k => $v) {
4162
$err = $registry->validate($v, $arrayNode->type, '');
4263
if ($err !== null) {
@@ -48,4 +69,50 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
4869

4970
return null;
5071
}
51-
}
72+
73+
/**
74+
* @param array<mixed> $value
75+
*/
76+
private function validateArrayHybrid(
77+
array $value,
78+
ArrayTypeNode $arrayNode,
79+
string $context,
80+
TypeValidatorRegistry $registry,
81+
int $count
82+
): ?ErrorMessage {
83+
if (array_is_list($value)) {
84+
$sampleIndices = [0, $count - 1];
85+
$samplesToTake = min(3, $count - 2);
86+
for ($i = 0; $i < $samplesToTake; $i++) {
87+
$sampleIndices[] = mt_rand(1, $count - 2);
88+
}
89+
90+
foreach ($sampleIndices as $idx) {
91+
$err = $registry->validate($value[$idx], $arrayNode->type, '');
92+
if ($err !== null) {
93+
return ErrorFactory::createError($context . '[' . $idx . ']' . $err->getMessage());
94+
}
95+
}
96+
97+
return null;
98+
}
99+
100+
$keys = array_keys($value);
101+
$sampleKeys = [$keys[0], $keys[$count - 1]];
102+
$samplesToTake = min(3, $count - 2);
103+
for ($i = 0; $i < $samplesToTake; $i++) {
104+
$sampleKeys[] = $keys[mt_rand(1, $count - 2)];
105+
}
106+
107+
foreach ($sampleKeys as $k) {
108+
$err = $registry->validate($value[$k], $arrayNode->type, '');
109+
if ($err !== null) {
110+
$keyStr = (string) $k;
111+
112+
return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage());
113+
}
114+
}
115+
116+
return null;
117+
}
118+
}

0 commit comments

Comments
 (0)