Skip to content

Commit a644b4b

Browse files
committed
Enhance ArrayValidator to support Traversable types; add comprehensive tests for Traversable and Generator handling
1 parent 7a21ea1 commit a644b4b

2 files changed

Lines changed: 125 additions & 3 deletions

File tree

src/Validator/ArrayValidator.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,39 @@
44

55
namespace TypePHP\Validator;
66

7+
use Generator;
78
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
89
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
10+
use Traversable;
911
use TypePHP\Internal\ErrorFactory;
1012
use TypePHP\Internal\ErrorMessage;
1113
use TypePHP\Internal\TypeFormatter;
1214

1315
/**
14-
* @internal Class for validating array types like array<int, string>.
16+
* Validates array and Traversable collection instances against ArrayTypeNode ASTs (Type[]).
17+
*
18+
* @internal
1519
*/
1620
final class ArrayValidator implements TypeValidatorInterface
1721
{
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+
*/
1827
public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
1928
{
20-
if (! \is_array($value)) {
29+
if (! \is_array($value) && ! ($value instanceof Traversable)) {
2130
return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value) . ' given');
2231
}
2332

2433
/** @var ArrayTypeNode $arrayNode */
2534
$arrayNode = $node;
2635

36+
if ($value instanceof Generator) {
37+
return null;
38+
}
39+
2740
foreach ($value as $k => $v) {
2841
$err = $registry->validate($v, $arrayNode->type, $context . '[' . $k . ']');
2942
if ($err !== null) {
@@ -33,4 +46,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
3346

3447
return null;
3548
}
36-
}
49+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Tests\Fixtures\Domain\Car;
6+
use TypePHP\Tests\Fixtures\Domain\Dog;
7+
8+
/**
9+
* 1. Function with @param string[] and native `iterable` typehint
10+
*
11+
* @param string[] $extensions
12+
*/
13+
function testTraversableToStringArray(iterable $extensions): array
14+
{
15+
$result = [];
16+
foreach ($extensions as $ext) {
17+
$result[] = $ext;
18+
}
19+
20+
return $result;
21+
}
22+
23+
/**
24+
* 2. Function with @param Dog[] and native `Traversable` typehint
25+
*
26+
* @param Dog[] $dogs
27+
*/
28+
function testTraversableToDogArray(Traversable $dogs): int
29+
{
30+
return iterator_count($dogs);
31+
}
32+
33+
/**
34+
* 3. Function with @param positive-int[] and un-typehinted parameter
35+
*
36+
* @param positive-int[] $numbers
37+
*/
38+
function testTraversableToPositiveIntArray($numbers): int
39+
{
40+
$count = 0;
41+
foreach ($numbers as $num) {
42+
$count++;
43+
}
44+
45+
return $count;
46+
}
47+
48+
describe('Traversable & Generator Support for Array Types (Type[])', function () {
49+
test('accepts ArrayIterator instance matching string[] contract', function () {
50+
$iterator = new ArrayIterator(['ext1', 'ext2', 'ext3']);
51+
52+
expect(testTraversableToStringArray($iterator))->toBe(['ext1', 'ext2', 'ext3']);
53+
});
54+
55+
test('accepts Generator instance yielding valid string[] elements', function () {
56+
$generator = (function () {
57+
yield 'foo';
58+
yield 'bar';
59+
})();
60+
61+
expect(testTraversableToStringArray($generator))->toBe(['foo', 'bar']);
62+
});
63+
64+
test('throws TypeError when Traversable yields an element violating string[] contract', function () {
65+
$badIterator = new ArrayIterator(['ext1', 12345, 'ext3']);
66+
67+
expect(fn () => testTraversableToStringArray($badIterator))
68+
->toThrow(TypeError::class, '[1] must be of type string')
69+
;
70+
});
71+
72+
test('accepts Traversable instance matching object Dog[] contract', function () {
73+
$dogIterator = new ArrayIterator([new Dog(), new Dog()]);
74+
75+
expect(testTraversableToDogArray($dogIterator))->toBe(2);
76+
});
77+
78+
test('throws TypeError when Traversable contains an object violating Dog[] contract', function () {
79+
$badDogIterator = new ArrayIterator([new Dog(), new Car()]);
80+
81+
expect(fn () => testTraversableToDogArray($badDogIterator))
82+
->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog')
83+
;
84+
});
85+
86+
test('accepts Traversable for un-typehinted parameter with positive-int[] docblock', function () {
87+
$numbers = new ArrayIterator([10, 20, 30]);
88+
89+
expect(testTraversableToPositiveIntArray($numbers))->toBe(3);
90+
});
91+
92+
test('throws TypeError when Traversable yields negative integer for positive-int[] docblock', function () {
93+
$badNumbers = new ArrayIterator([10, -5, 30]);
94+
95+
expect(fn () => testTraversableToPositiveIntArray($badNumbers))
96+
->toThrow(TypeError::class, 'positive-int')
97+
;
98+
});
99+
100+
test('rejects non-array and non-traversable values (e.g. string or plain stdClass)', function () {
101+
expect(fn () => testTraversableToPositiveIntArray('not_iterable'))
102+
->toThrow(TypeError::class, 'must be of type array')
103+
;
104+
105+
expect(fn () => testTraversableToPositiveIntArray(new stdClass()))
106+
->toThrow(TypeError::class, 'must be of type array')
107+
;
108+
});
109+
});

0 commit comments

Comments
 (0)