Skip to content

Commit 7de5e49

Browse files
authored
Enhance documentation for iterators and generators, add ConcreteFileCollection and PluginConfiguration classes, and implement tests for concrete iterable property assignments (#33)
1 parent 5eb9e6f commit 7de5e49

6 files changed

Lines changed: 265 additions & 21 deletions

File tree

docs/supported-types/iterators-and-generators.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,56 @@ When an iterable or generator is passed into a function accepting `Traversable<K
1414

1515
---
1616

17+
## Generic Streams vs. Concrete Collection Classes (Zero Proxy Overhead)
18+
19+
A critical architectural design in TypePHP is distinguishing between **abstract generic streams** and **concrete collection classes**:
20+
21+
### 1. Abstract Generic Streams (`iterable<T>`, `Traversable<K, V>`, `Generator<K, V>`)
22+
When a method or parameter specifies an abstract stream keyword with inner type constraints:
23+
```php
24+
/**
25+
* Abstract stream contract -> Wrapped in IteratorProxy
26+
*
27+
* @param Traversable<string, positive-int> $scores
28+
*/
29+
public function processScores(Traversable $scores): void
30+
```
31+
Because `Traversable` is a general interface with no methods of its own, TypePHP wraps it in a lazy `IteratorProxy` to validate yielded items and keys during iteration.
32+
33+
### 2. Concrete Collection Classes (`FileCollection`, `ArrayCollection`, `OrderList`)
34+
When a method parameter, property, or return value is a concrete class (even if that class implements `\IteratorAggregate` or `\Traversable`):
35+
```php
36+
class StorefrontConfig
37+
{
38+
// Native PHP class type hint
39+
protected FileCollection $styleFiles;
40+
41+
public function setStyleFiles(FileCollection $styleFiles): void
42+
{
43+
$this->styleFiles = $styleFiles; // Preserved as raw FileCollection!
44+
}
45+
}
46+
```
47+
48+
TypePHP leaves concrete collections **100% unwrapped as raw PHP objects**:
49+
50+
* **Preserves Native PHP Nominal Type Hints:** Prevents fatal PHP engine errors (e.g. `Cannot assign IteratorProxy to property ...::$styleFiles of type FileCollection`).
51+
* **Preserves Custom Domain Methods & State:** Custom business methods (like `$files->getPublicUrls()` or `$files->filterByExtension()`) and private properties remain directly accessible.
52+
* **Direct `\WeakMap` Enforcement:** For generic concrete classes (like `ArrayCollection<int, Animal>`), TypePHP tracks generic template bindings directly in `\WeakMap` memory, running validation rules inside `$collection->add()` and `$collection->set()` without needing any wrapper proxy!
53+
* **Zero Allocation Overhead:** Eliminates proxy object allocations and garbage collection pressure, allowing concrete collections to run at native C-level speed.
54+
55+
### Summary: When TypePHP Wraps vs. Leaves Unwrapped
56+
57+
| Type Annotation | Object Passed | Action Taken | Why |
58+
| :--- | :--- | :---: | :--- |
59+
| `Traversable<string, positive-int>` | Any Iterator / Generator | **Wrapped in `IteratorProxy`** | Abstract stream; needs lazy item validation during iteration. |
60+
| `iterable<User>` | Any Array / Traversable | **Wrapped in `IteratorProxy`** | Abstract stream; validates items on-the-fly. |
61+
| `FileCollection` | `new FileCollection()` | **Unwrapped (Raw Object)** | Concrete class; preserves native PHP type hints and domain methods. |
62+
| `ArrayCollection<int, Animal>` | `new ArrayCollection()` | **Unwrapped (Raw Object)** | Generic state is tracked directly via `\WeakMap` inside `add()`/`set()`. |
63+
| Un-annotated (`mixed $items`) | Any Iterator | **Unwrapped (Raw Object)** | No type contracts to enforce; zero overhead. |
64+
65+
---
66+
1767
## Traversable & Iterator Contracts (`Traversable<K, V>`)
1868

1969
Validate keys and values on any `Traversable` or `ArrayIterator` instance:
@@ -346,3 +396,4 @@ function processCollection(Traversable $collection): void
346396

347397
processCollection(new NestedCollection());
348398
```
399+
```

src/Internal/Visitor/FunctionContractInjector.php

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $n
7474
return false;
7575
}
7676

77-
$visitor = new class () extends NodeVisitorAbstract {
77+
$visitor = new class() extends NodeVisitorAbstract {
7878
public bool $isGen = false;
7979

8080
public function enterNode(Node $n): ?int
@@ -182,7 +182,7 @@ private static function buildParamInjections(
182182
}
183183
}
184184

185-
if ($isClassMethod || str_contains($docText, 'iterable') || str_contains($docText, 'Traversable') || str_contains($docText, 'Generator') || str_contains($docText, 'Iterator')) {
185+
if (str_contains($docText, 'iterable') || str_contains($docText, 'Traversable') || str_contains($docText, 'Generator') || str_contains($docText, 'Iterator')) {
186186
foreach ($node->params as $param) {
187187
if ($param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) {
188188
$paramName = $param->var->name;
@@ -217,10 +217,8 @@ private static function buildParamInjections(
217217
private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array
218218
{
219219
$traverser = new NodeTraverser();
220-
$traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract {
221-
public function __construct(private Node\Expr $thisArg)
222-
{
223-
}
220+
$traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract {
221+
public function __construct(private Node\Expr $thisArg) {}
224222

225223
public function enterNode(Node $n): int|Node|null
226224
{
@@ -344,12 +342,11 @@ public function enterNode(Node $n): int|Node|null
344342
private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid): array
345343
{
346344
$traverser = new NodeTraverser();
347-
$traverser->addVisitor(new class ($thisArg, $isNativeVoid) extends NodeVisitorAbstract {
345+
$traverser->addVisitor(new class($thisArg, $isNativeVoid) extends NodeVisitorAbstract {
348346
public function __construct(
349347
private Node\Expr $thisArg,
350348
private bool $isNativeVoid
351-
) {
352-
}
349+
) {}
353350

354351
public function enterNode(Node $n): int|array|null
355352
{

src/Wrapper/IterableWrapper.php

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,33 +37,43 @@ public static function wrap(string $function, string $paramName, mixed $iterable
3737

3838
$contract = ContractParser::parse($function);
3939
$typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null);
40+
41+
// If no docblock contract exists, never wrap!
42+
if ($typeNode === null) {
43+
return $iterable;
44+
}
45+
4046
$aliases = $contract['aliases'] ?? [];
4147
$templates = $contract['templates'] ?? [];
4248

4349
$thisObj = \is_object($thisOrClass) ? $thisOrClass : null;
4450
$boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates);
4551

46-
if ($typeNode !== null && (\count($boundTemplates) > 0 || \count($templates) > 0)) {
52+
if (\count($boundTemplates) > 0 || \count($templates) > 0) {
4753
$typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $templates);
4854
$typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj);
4955
}
5056

51-
if ($typeNode !== null) {
52-
$baseName = '';
53-
if ($typeNode instanceof IdentifierTypeNode) {
54-
$baseName = strtolower(ltrim($typeNode->name, '\\'));
55-
} elseif ($typeNode instanceof GenericTypeNode) {
56-
$baseName = strtolower(ltrim($typeNode->type->name, '\\'));
57-
}
57+
// Only wrap generic iterable keywords, never concrete classes like FileCollection
58+
$baseName = '';
59+
if ($typeNode instanceof IdentifierTypeNode) {
60+
$baseName = strtolower(ltrim($typeNode->name, '\\'));
61+
} elseif ($typeNode instanceof GenericTypeNode) {
62+
$baseName = strtolower(ltrim($typeNode->type->name, '\\'));
63+
}
5864

59-
$standardIterables = ['iterable', 'traversable', 'iterator', 'generator', 'iteratoraggregate', 'array'];
60-
if ($baseName !== '' && ! \in_array($baseName, $standardIterables, true)) {
61-
return $iterable;
62-
}
65+
$standardIterables = ['iterable', 'traversable', 'iterator', 'generator'];
66+
if (! \in_array($baseName, $standardIterables, true)) {
67+
return $iterable;
6368
}
6469

6570
[$keyTypeNode, $itemTypeNode] = self::extractKeyAndItemTypeNodes($typeNode, $aliases);
6671

72+
// If there are no inner type constraints to validate, never wrap!
73+
if ($keyTypeNode === null && $itemTypeNode === null) {
74+
return $iterable;
75+
}
76+
6777
$prefix = ($paramName === 'return') ? "$function(): Return iterator" : "$function(): Iterator \$$paramName";
6878
$typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix);
6979

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Collections;
6+
7+
use ArrayIterator;
8+
use IteratorAggregate;
9+
use Traversable;
10+
11+
class ConcreteFileCollection implements IteratorAggregate
12+
{
13+
/**
14+
* @var array<int, string>
15+
*/
16+
private array $files = [];
17+
18+
public function add(string $file): void
19+
{
20+
$this->files[] = $file;
21+
}
22+
23+
public function getIterator(): Traversable
24+
{
25+
return new ArrayIterator($this->files);
26+
}
27+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Collections;
6+
7+
use Traversable;
8+
9+
class PluginConfiguration
10+
{
11+
public ConcreteFileCollection $styleFiles;
12+
13+
public function __construct(
14+
public ?ConcreteFileCollection $promotedFiles = null
15+
) {
16+
$this->styleFiles = $promotedFiles ?? new ConcreteFileCollection();
17+
}
18+
19+
public function getStyleFiles(): ConcreteFileCollection
20+
{
21+
return $this->styleFiles;
22+
}
23+
24+
public function setStyleFiles(ConcreteFileCollection $styleFiles): void
25+
{
26+
$this->styleFiles = $styleFiles;
27+
}
28+
29+
/**
30+
* @param ConcreteFileCollection $styleFiles
31+
*/
32+
public function setStyleFilesWithDocblock(ConcreteFileCollection $styleFiles): void
33+
{
34+
$this->styleFiles = $styleFiles;
35+
}
36+
37+
/**
38+
* @return ConcreteFileCollection
39+
*/
40+
public function getStyleFilesWithDocblock(): ConcreteFileCollection
41+
{
42+
return $this->styleFiles;
43+
}
44+
45+
public function setNullableFiles(?ConcreteFileCollection $files = null): void
46+
{
47+
if ($files !== null) {
48+
$this->styleFiles = $files;
49+
}
50+
}
51+
52+
/**
53+
* Legitimate generic Traversable parameter that SHOULD be wrapped to validate strings
54+
*
55+
* @param Traversable<string> $items
56+
*
57+
* @return list<string>
58+
*/
59+
public function processGenericTraversable(Traversable $items): array
60+
{
61+
$collected = [];
62+
foreach ($items as $item) {
63+
$collected[] = $item;
64+
}
65+
66+
return $collected;
67+
}
68+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Tests\Fixtures\Collections\ConcreteFileCollection;
6+
use TypePHP\Tests\Fixtures\Collections\PluginConfiguration;
7+
8+
describe('Concrete Traversable & Iterable Property Assignments (Shopware Bug Reproduction)', function () {
9+
describe('Un-annotated & DocBlock-Annotated Methods', function () {
10+
test('does not wrap concrete collection in IteratorProxy when setting and getting typed property (un-annotated)', function () {
11+
$config = new PluginConfiguration();
12+
$files = new ConcreteFileCollection();
13+
$files->add('style.css');
14+
15+
$config->setStyleFiles($files);
16+
17+
expect($config->getStyleFiles())->toBeInstanceOf(ConcreteFileCollection::class)
18+
->and($config->getStyleFiles())->toBe($files);
19+
});
20+
21+
test('does not wrap concrete collection when method has explicit @param ConcreteFileCollection docblock', function () {
22+
$config = new PluginConfiguration();
23+
$files = new ConcreteFileCollection();
24+
$files->add('custom.css');
25+
26+
$config->setStyleFilesWithDocblock($files);
27+
28+
expect($config->styleFiles)->toBeInstanceOf(ConcreteFileCollection::class)
29+
->and($config->styleFiles)->toBe($files);
30+
});
31+
32+
test('does not wrap concrete collection when method has explicit @return ConcreteFileCollection docblock', function () {
33+
$config = new PluginConfiguration();
34+
$files = new ConcreteFileCollection();
35+
$files->add('bundle.css');
36+
$config->setStyleFiles($files);
37+
38+
$returned = $config->getStyleFilesWithDocblock();
39+
40+
expect($returned)->toBeInstanceOf(ConcreteFileCollection::class)
41+
->and($returned)->toBe($files);
42+
});
43+
});
44+
45+
describe('Constructor Promotion & Nullable Properties', function () {
46+
test('preserves concrete collection type in constructor property promotion without wrapping in IteratorProxy', function () {
47+
$files = new ConcreteFileCollection();
48+
$files->add('promoted.css');
49+
50+
$config = new PluginConfiguration($files);
51+
52+
expect($config->promotedFiles)->toBeInstanceOf(ConcreteFileCollection::class)
53+
->and($config->promotedFiles)->toBe($files);
54+
});
55+
56+
test('handles nullable concrete collection parameter cleanly', function () {
57+
$config = new PluginConfiguration();
58+
59+
$config->setNullableFiles(null);
60+
expect($config->styleFiles)->toBeInstanceOf(ConcreteFileCollection::class);
61+
62+
$files = new ConcreteFileCollection();
63+
$files->add('new.css');
64+
$config->setNullableFiles($files);
65+
66+
expect($config->styleFiles)->toBe($files);
67+
});
68+
});
69+
70+
describe('Inline @var Assignments', function () {
71+
test('preserves concrete collection instance in inline @var variable assignment', function () {
72+
/** @var ConcreteFileCollection $localFiles */
73+
$localFiles = new ConcreteFileCollection();
74+
$localFiles->add('local.css');
75+
76+
expect($localFiles)->toBeInstanceOf(ConcreteFileCollection::class);
77+
});
78+
});
79+
80+
describe('Legitimate Generic Traversable Contracts', function () {
81+
test('correctly validates items when method explicitly declares @param Traversable<string>', function () {
82+
$config = new PluginConfiguration();
83+
$collection = new ConcreteFileCollection();
84+
$collection->add('valid_a.css');
85+
$collection->add('valid_b.css');
86+
87+
$result = $config->processGenericTraversable($collection);
88+
expect($result)->toBe(['valid_a.css', 'valid_b.css']);
89+
});
90+
});
91+
});

0 commit comments

Comments
 (0)