Skip to content

Commit 6ad2fd3

Browse files
committed
Add tests for class-string template bounds and generic template validations and fix implementation
1 parent a23adac commit 6ad2fd3

3 files changed

Lines changed: 269 additions & 1 deletion

File tree

src/Internal/Checker/ParamChecker.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,13 @@ private static function resolveClassStringTemplate(GenericTypeNode $typeNode, mi
129129
if ($templateNode->bound !== null) {
130130
$resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj);
131131
$boundName = $resolvedBound instanceof IdentifierTypeNode ? $resolvedBound->name : (string) $resolvedBound;
132-
if (! is_a($val, $boundName, true)) {
132+
$lowerBound = strtolower($boundName);
133+
134+
if ($lowerBound === 'object' || $lowerBound === 'mixed') {
135+
if (! ClassNameValidator::isValid($val) || (! class_exists($val) && ! interface_exists($val) && ! trait_exists($val) && ! enum_exists($val))) {
136+
return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a valid class-string, ' . TypeFormatter::formatGivenValue($val) . ' given');
137+
}
138+
} elseif (! is_a($val, $boundName, true)) {
133139
return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundName . ", '" . $val . "' given");
134140
}
135141
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @template TAttribute of object
7+
*
8+
* @param class-string<TAttribute> $attributeClass
9+
*/
10+
function resolveObjectAttributeSim(string $class, string $attributeClass): bool
11+
{
12+
return true;
13+
}
14+
15+
/**
16+
* @template TMixed of mixed
17+
*
18+
* @param class-string<TMixed> $class
19+
*/
20+
function resolveMixedClassStringSim(string $class): bool
21+
{
22+
return true;
23+
}
24+
25+
/**
26+
* @template TDate of DateTimeInterface
27+
*
28+
* @param class-string<TDate> $class
29+
*/
30+
function resolveBoundedClassStringSim(string $class): bool
31+
{
32+
return true;
33+
}
34+
35+
describe('class-string<T> Template Bounds', function () {
36+
test('accepts class-string when template T is bounded by pseudo-type object', function () {
37+
expect(resolveObjectAttributeSim(stdClass::class, stdClass::class))->toBeTrue();
38+
expect(resolveObjectAttributeSim(DateTime::class, DateTimeImmutable::class))->toBeTrue();
39+
});
40+
41+
test('accepts class-string when template T is bounded by pseudo-type mixed', function () {
42+
expect(resolveMixedClassStringSim(stdClass::class))->toBeTrue();
43+
expect(resolveMixedClassStringSim(DateTime::class))->toBeTrue();
44+
});
45+
46+
test('accepts class-string matching concrete class or interface bound', function () {
47+
expect(resolveBoundedClassStringSim(DateTimeImmutable::class))->toBeTrue();
48+
expect(resolveBoundedClassStringSim(DateTime::class))->toBeTrue();
49+
});
50+
51+
test('rejects non-existent class-string when template T is bounded by object', function () {
52+
expect(fn() => resolveObjectAttributeSim(stdClass::class, 'NonExistentClass12345'))
53+
->toThrow(\TypeError::class, 'must be a valid class-string');
54+
});
55+
56+
test('rejects class-string that does not implement specific interface bound', function () {
57+
expect(fn() => resolveBoundedClassStringSim(stdClass::class))
58+
->toThrow(\TypeError::class, 'must be a class-string of DateTimeInterface');
59+
});
60+
});
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @template T of positive-int
7+
* @param T $value
8+
* @return T
9+
*/
10+
function testPositiveIntBound(mixed $value): mixed
11+
{
12+
return $value;
13+
}
14+
15+
/**
16+
* @template T of non-empty-string
17+
* @param T $text
18+
* @return T
19+
*/
20+
function testNonEmptyStringBound(mixed $text): mixed
21+
{
22+
return $text;
23+
}
24+
25+
/**
26+
* @template T of array{id: positive-int, role: 'admin'|'user'}
27+
* @param T $data
28+
* @return T
29+
*/
30+
function testArrayShapeBound(mixed $data): mixed
31+
{
32+
return $data;
33+
}
34+
35+
/**
36+
* @template T of list<positive-int>
37+
* @param T $items
38+
* @return T
39+
*/
40+
function testListBound(mixed $items): mixed
41+
{
42+
return $items;
43+
}
44+
45+
/**
46+
* @template T of int<1, 100>
47+
* @param T $percentage
48+
* @return T
49+
*/
50+
function testIntRangeBound(mixed $percentage): mixed
51+
{
52+
return $percentage;
53+
}
54+
55+
/**
56+
* @template T of 'active'|'pending'
57+
* @param T $status
58+
* @return T
59+
*/
60+
function testLiteralUnionBound(mixed $status): mixed
61+
{
62+
return $status;
63+
}
64+
65+
/**
66+
* @template T of Countable
67+
* @param class-string<T> $class
68+
*/
69+
function testClassStringInterfaceBound(string $class): bool
70+
{
71+
return true;
72+
}
73+
74+
/**
75+
* Default template with object upper bound (@template T of object = stdClass)
76+
*
77+
* @template T of object = stdClass
78+
*
79+
* @param mixed $value
80+
* @return T
81+
*/
82+
function testDefaultObjectBound(mixed $value): mixed
83+
{
84+
return $value;
85+
}
86+
87+
/**
88+
* Default template with int-range upper bound (@template T of int<1, 100> = 50)
89+
*
90+
* @template T of int<1, 100> = 50
91+
*
92+
* @param mixed $value
93+
* @return T
94+
*/
95+
function testDefaultIntRangeBound(mixed $value): mixed
96+
{
97+
return $value;
98+
}
99+
100+
/**
101+
* Template where T is inferred from $input, overriding default stdClass
102+
*
103+
* @template T of object = stdClass
104+
*
105+
* @param T $input
106+
* @param mixed $valueToReturn
107+
* @return T
108+
*/
109+
function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed
110+
{
111+
return $valueToReturn;
112+
}
113+
114+
describe('Generic Template Bounds Stress Test', function () {
115+
test('validates positive-int scalar bound', function () {
116+
expect(testPositiveIntBound(42))->toBe(42);
117+
118+
expect(fn() => testPositiveIntBound(-10))
119+
->toThrow(\TypeError::class, 'positive-int');
120+
expect(fn() => testPositiveIntBound(0))
121+
->toThrow(\TypeError::class, 'positive-int');
122+
});
123+
124+
test('validates non-empty-string scalar bound', function () {
125+
expect(testNonEmptyStringBound('hello'))->toBe('hello');
126+
127+
expect(fn() => testNonEmptyStringBound(''))
128+
->toThrow(\TypeError::class, 'non-empty-string');
129+
});
130+
131+
test('validates array shape template bound', function () {
132+
$valid = ['id' => 10, 'role' => 'admin'];
133+
expect(testArrayShapeBound($valid))->toBe($valid);
134+
expect(fn() => testArrayShapeBound(['id' => -5, 'role' => 'admin']))
135+
->toThrow(\TypeError::class, "['id']");
136+
137+
expect(fn() => testArrayShapeBound(['id' => 10, 'role' => 'superadmin']))
138+
->toThrow(\TypeError::class, "['role']");
139+
140+
expect(fn() => testArrayShapeBound(['id' => 10]))
141+
->toThrow(\TypeError::class, "missing required key 'role'");
142+
});
143+
144+
test('validates list template bound', function () {
145+
expect(testListBound([10, 20, 30]))->toBe([10, 20, 30]);
146+
147+
expect(fn() => testListBound([10, -5, 30]))
148+
->toThrow(\TypeError::class, '[1]');
149+
150+
expect(fn() => testListBound(['key' => 10]))
151+
->toThrow(\TypeError::class, 'must be a list');
152+
});
153+
154+
test('validates int range template bound', function () {
155+
expect(testIntRangeBound(50))->toBe(50);
156+
157+
expect(fn() => testIntRangeBound(150))
158+
->toThrow(\TypeError::class, '<= 100');
159+
expect(fn() => testIntRangeBound(0))
160+
->toThrow(\TypeError::class, '>= 1');
161+
});
162+
163+
test('validates literal union enum template bound', function () {
164+
expect(testLiteralUnionBound('active'))->toBe('active');
165+
expect(testLiteralUnionBound('pending'))->toBe('pending');
166+
167+
expect(fn() => testLiteralUnionBound('archived'))
168+
->toThrow(\TypeError::class, "('active' | 'pending')");
169+
});
170+
171+
test('validates class-string<T of Countable> interface bound', function () {
172+
expect(testClassStringInterfaceBound(ArrayObject::class))->toBeTrue();
173+
174+
expect(fn() => testClassStringInterfaceBound(stdClass::class))
175+
->toThrow(\TypeError::class, 'must be a class-string of Countable');
176+
});
177+
178+
test('uses default template type when template T is unbound', function () {
179+
$std = new stdClass();
180+
expect(testDefaultObjectBound($std))->toBe($std);
181+
182+
expect(fn() => testDefaultObjectBound(new DateTime()))
183+
->toThrow(\TypeError::class, 'Return value');
184+
});
185+
186+
test('uses default scalar literal type when template T is unbound', function () {
187+
expect(testDefaultIntRangeBound(50))->toBe(50);
188+
189+
190+
expect(fn() => testDefaultIntRangeBound(99))
191+
->toThrow(\TypeError::class, 'Return value');
192+
});
193+
194+
test('inferred template parameter from argument overrides default template type', function () {
195+
$dt = new DateTime();
196+
197+
expect(testInferredOverridesDefault($dt, $dt))->toBe($dt);
198+
199+
expect(fn() => testInferredOverridesDefault($dt, new stdClass()))
200+
->toThrow(\TypeError::class, 'Return value');
201+
});
202+
});

0 commit comments

Comments
 (0)