Skip to content

Commit 4da1c3c

Browse files
committed
Fix bug on callable and iterable wrapper where generic type failed to get subtituted during mid invokacation of callable
1 parent 6576944 commit 4da1c3c

10 files changed

Lines changed: 207 additions & 23 deletions

File tree

src/Internal/Checker/InlineChecker.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
use TypePHP\Wrapper\CallableWrapper;
3030

3131
/**
32-
* @internal Evaluates inline variable (@var) and class property validation rules.
32+
* Evaluates inline variable (@var) and class property validation rules.
33+
*
34+
* @internal
3335
*/
3436
final class InlineChecker
3537
{
@@ -243,8 +245,9 @@ private static function substitutePropertyGenerics(TypeNode $typeNode, object $o
243245
$constructorTarget = $className . '::__construct';
244246
$contract = ContractParser::parse($constructorTarget);
245247

246-
$boundTemplates = TemplateManager::getBoundTemplates('none', $object, $contract['templates']);
247-
$declaredTemplates = $contract['templates'];
248+
$allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];
249+
$boundTemplates = TemplateManager::getBoundTemplates('none', $object, $allTemplates);
250+
$declaredTemplates = $allTemplates;
248251

249252
if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) {
250253
$typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates);
@@ -363,4 +366,4 @@ private static function shouldValidateType(TypeNode $node, array $config): bool
363366

364367
return (bool) ($config['scalars'] ?? false);
365368
}
366-
}
369+
}

src/Wrapper/CallableWrapper.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
use TypePHP\Validator\TypeValidatorRegistry;
2020

2121
/**
22-
* @internal Wraps callables to enforce argument and return type contracts dynamically at runtime.
22+
* Wraps callables to enforce argument and return type contracts dynamically at runtime.
23+
*
24+
* @internal
2325
*/
2426
final class CallableWrapper
2527
{
@@ -31,7 +33,7 @@ public static function wrap(string $function, string $paramName, mixed $callable
3133
$contract = ContractParser::parse($function);
3234
$typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null);
3335
$aliases = $contract['aliases'] ?? [];
34-
$templates = $contract['templates'] ?? [];
36+
$templates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];
3537

3638
if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) {
3739
$typeNode = $aliases[$typeNode->name];
@@ -173,4 +175,4 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar
173175
}
174176
}
175177
}
176-
}
178+
}

src/Wrapper/IterableWrapper.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
use TypePHP\Validator\TypeValidatorRegistry;
1919

2020
/**
21-
* @internal Wraps Traversable objects and Generators to evaluate key and value type constraints lazily during iteration.
21+
* Wraps Traversable objects and Generators to evaluate key and value type constraints lazily during iteration.
22+
*
23+
* @internal
2224
*/
2325
final class IterableWrapper
2426
{
@@ -38,13 +40,12 @@ public static function wrap(string $function, string $paramName, mixed $iterable
3840
$contract = ContractParser::parse($function);
3941
$typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null);
4042

41-
// If no docblock contract exists, never wrap!
4243
if ($typeNode === null) {
4344
return $iterable;
4445
}
4546

4647
$aliases = $contract['aliases'] ?? [];
47-
$templates = $contract['templates'] ?? [];
48+
$templates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];
4849

4950
$thisObj = \is_object($thisOrClass) ? $thisOrClass : null;
5051
$boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates);
@@ -54,7 +55,6 @@ public static function wrap(string $function, string $paramName, mixed $iterable
5455
$typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj);
5556
}
5657

57-
// Only wrap generic iterable keywords, never concrete classes like FileCollection
5858
$baseName = '';
5959
if ($typeNode instanceof IdentifierTypeNode) {
6060
$baseName = strtolower(ltrim($typeNode->name, '\\'));
@@ -69,15 +69,13 @@ public static function wrap(string $function, string $paramName, mixed $iterable
6969

7070
[$keyTypeNode, $itemTypeNode] = self::extractKeyAndItemTypeNodes($typeNode, $aliases);
7171

72-
// If there are no inner type constraints to validate, never wrap!
7372
if ($keyTypeNode === null && $itemTypeNode === null) {
7473
return $iterable;
7574
}
7675

7776
$prefix = ($paramName === 'return') ? "$function(): Return iterator" : "$function(): Iterator \$$paramName";
7877
$typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix);
7978

80-
// Wrap delegated yield from arrays in a lazy generator
8179
if (\is_array($iterable)) {
8280
return self::wrapGenerator((function () use ($iterable) {
8381
yield from $iterable;
@@ -165,4 +163,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal
165163
yield $key => $value;
166164
}
167165
}
168-
}
166+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Collections;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Animal;
8+
9+
/**
10+
* Generic subclass extending BaseElementCollection with concrete Animal template
11+
*
12+
* @extends BaseElementCollection<Animal>
13+
*/
14+
class AnimalElementCollection extends BaseElementCollection
15+
{
16+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Collections;
6+
7+
use Closure;
8+
use IteratorAggregate;
9+
use Traversable;
10+
11+
/**
12+
* Fixture mimicking Shopware's Collection with class-level @template TElement
13+
*
14+
* @template TElement
15+
*
16+
* @implements IteratorAggregate<int, TElement>
17+
*/
18+
abstract class BaseElementCollection implements IteratorAggregate
19+
{
20+
/**
21+
* @var array<int, TElement>
22+
*/
23+
protected array $elements = [];
24+
25+
public function __construct()
26+
{
27+
}
28+
29+
/**
30+
* Directly populates elements without method-level contract inference
31+
*/
32+
public function setElementsDirectly(array $elements): void
33+
{
34+
$this->elements = $elements;
35+
}
36+
37+
/**
38+
* Method using class-level template TElement in Closure parameter
39+
*
40+
* @param Closure(TElement): bool $closure
41+
*/
42+
public function filter(Closure $closure): static
43+
{
44+
$filtered = array_filter($this->elements, $closure);
45+
46+
$instance = new static();
47+
$instance->setElementsDirectly(array_values($filtered));
48+
49+
return $instance;
50+
}
51+
52+
/**
53+
* Method returning generator with class-level template TElement
54+
*
55+
* @return Traversable<TElement>
56+
*/
57+
public function getIterator(): Traversable
58+
{
59+
yield from $this->elements;
60+
}
61+
62+
/**
63+
* Method accepting iterable with class-level template TElement
64+
*
65+
* @param iterable<TElement> $items
66+
*/
67+
public function mergeItems(iterable $items): void
68+
{
69+
foreach ($items as $item) {
70+
$this->elements[] = $item;
71+
}
72+
}
73+
74+
public function count(): int
75+
{
76+
return \count($this->elements);
77+
}
78+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Collections;
6+
7+
/**
8+
* Subclass extending BaseElementCollection without declaring a concrete template
9+
*/
10+
class UnparameterizedElementCollection extends BaseElementCollection
11+
{
12+
}

tests/Internal/PathMatcherTest.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22

33
declare(strict_types=1);
44

5-
namespace TypePHP\Tests\Internal;
6-
7-
use ReflectionClass;
85
use TypePHP\Internal\CacheManager;
96
use TypePHP\Internal\Config;
107
use TypePHP\Internal\PathMatcher;

tests/Internal/StreamWrapperTest.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22

33
declare(strict_types=1);
44

5-
namespace TypePHP\Tests\Internal;
6-
7-
use ReflectionClass;
85
use TypePHP\Contract\FileFilter;
96
use TypePHP\Internal\Config;
107
use TypePHP\Internal\StreamWrapper;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Tests\Fixtures\Collections\AnimalElementCollection;
6+
use TypePHP\Tests\Fixtures\Collections\UnparameterizedElementCollection;
7+
use TypePHP\Tests\Fixtures\Domain\Animal;
8+
use TypePHP\Tests\Fixtures\Domain\Car;
9+
use TypePHP\Tests\Fixtures\Domain\Dog;
10+
11+
describe('Class-Level Template Resolution in Callable & Iterator Parameters', function () {
12+
describe('Generic Subclasses (@extends Collection<Animal>)', function () {
13+
test('resolves class-level template TElement when filtering populated collection', function () {
14+
$collection = new AnimalElementCollection();
15+
$collection->setElementsDirectly([new Dog()]);
16+
17+
$result = $collection->filter(function (Animal $animal) {
18+
return true;
19+
});
20+
21+
expect($result->count())->toBe(1);
22+
});
23+
24+
test('resolves class-level template TElement when iterating through getIterator()', function () {
25+
$collection = new AnimalElementCollection();
26+
$collection->setElementsDirectly([new Dog(), new Dog()]);
27+
28+
$collected = [];
29+
foreach ($collection as $item) {
30+
$collected[] = $item;
31+
}
32+
33+
expect(\count($collected))->toBe(2)
34+
->and($collected[0])->toBeInstanceOf(Dog::class)
35+
;
36+
});
37+
38+
test('validates iterable parameter against class-level template TElement', function () {
39+
$collection = new AnimalElementCollection();
40+
$validIterator = new ArrayIterator([new Dog()]);
41+
42+
$collection->mergeItems($validIterator);
43+
expect($collection->count())->toBe(1);
44+
45+
$badIterator = new ArrayIterator([new Car()]);
46+
expect(fn () => $collection->mergeItems($badIterator))
47+
->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Animal')
48+
;
49+
});
50+
});
51+
52+
describe('Unparameterized Collections (Unbound TElement -> mixed)', function () {
53+
test('falls back TElement to mixed on filter() closures without throwing literal TElement errors', function () {
54+
$collection = new UnparameterizedElementCollection();
55+
$collection->setElementsDirectly([new Dog(), 'string_val', new stdClass()]);
56+
57+
$result = $collection->filter(function ($item) {
58+
return true;
59+
});
60+
61+
expect($result->count())->toBe(3);
62+
});
63+
64+
test('falls back TElement to mixed on getIterator() yields without throwing literal TElement errors', function () {
65+
$collection = new UnparameterizedElementCollection();
66+
$collection->setElementsDirectly([new Dog(), 42, ['array_val']]);
67+
68+
$yielded = [];
69+
foreach ($collection as $item) {
70+
$yielded[] = $item;
71+
}
72+
73+
expect(\count($yielded))->toBe(3);
74+
});
75+
76+
test('accepts mixed items on mergeItems() iterable parameter when collection is unparameterized', function () {
77+
$collection = new UnparameterizedElementCollection();
78+
$mixedIterator = new ArrayIterator([new Dog(), new Car(), 100]);
79+
80+
$collection->mergeItems($mixedIterator);
81+
expect($collection->count())->toBe(3);
82+
});
83+
});
84+
});

tests/TypeChecking/InheritanceAndAttributes/NamespaceResolutionTest.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22

33
declare(strict_types=1);
44

5-
namespace TypePHP\Tests\Feature;
6-
7-
use DateTime;
85
use TypePHP\Tests\Fixtures\Domain\Car;
96
use TypePHP\Tests\Fixtures\Domain\Cat as Feline;
107
use TypePHP\Tests\Fixtures\Domain\Dog;

0 commit comments

Comments
 (0)