Skip to content

Commit 7a0aed3

Browse files
authored
Internal improvements 3 (#23)
* Add WildcardConstantFixture and tests for version mode validation for baseline tdd * Add support for wildcard class constant patterns in ConstValidator and documentation * Enhance wildcard constant pattern validation for enums and update documentation
1 parent d013855 commit 7a0aed3

10 files changed

Lines changed: 199 additions & 11 deletions

File tree

docs/supported-types/primitives-and-scalars.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,63 @@ To prevent rounding artifacts from causing unexpected type failures, TypePHP eva
7474

7575
---
7676

77+
## Wildcard Constant Patterns (`Class::PREFIX_*` & `StatusEnum::*`)
78+
79+
TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard constant patterns (`Class::PREFIX_*` or `StatusEnum::*`).
80+
81+
* **Class Constants (`Class::PREFIX_*`):** TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant scalar values.
82+
* **Enums (`StatusEnum::*` or `StatusEnum::ACT*`):** Matches incoming **Enum case objects** against the wildcard case pattern.
83+
84+
```php
85+
class MigrationCollectionLoader
86+
{
87+
public const VERSION_SELECTION_ALL = 'all';
88+
public const VERSION_SELECTION_BLUE_GREEN = 'blue-green';
89+
private const VERSION_SELECTION_INTERNAL = 'internal-mode';
90+
91+
/**
92+
* @param self::VERSION_SELECTION_* $mode
93+
*/
94+
public function collectAllForVersion(string $mode): string
95+
{
96+
return $mode;
97+
}
98+
}
99+
100+
enum StatusEnum: string
101+
{
102+
case Active = 'active';
103+
case Inactive = 'inactive';
104+
case Pending = 'pending';
105+
}
106+
107+
class EnumProcessor
108+
{
109+
/**
110+
* @param StatusEnum::ACT* $status
111+
*/
112+
public static function processActive(StatusEnum $status): StatusEnum
113+
{
114+
return $status;
115+
}
116+
}
117+
118+
$loader = new MigrationCollectionLoader();
119+
120+
// Valid Class Constant Calls (Matches public and private VERSION_SELECTION_* constant values)
121+
$loader->collectAllForVersion('all');
122+
$loader->collectAllForVersion('internal-mode');
123+
124+
// Valid Enum Wildcard Call (Matches StatusEnum::ACT* -> StatusEnum::Active)
125+
EnumProcessor::processActive(StatusEnum::Active);
126+
127+
// Invalid Enum Wildcard Call (StatusEnum::Pending does not match StatusEnum::ACT*)
128+
EnumProcessor::processActive(StatusEnum::Pending);
129+
// Throws: TypeError: Argument $status must be a valid constant matching StatusEnum::ACT*
130+
```
131+
132+
---
133+
77134
## Integer Refinements and Ranges
78135

79136
TypePHP enforces exact value constraints and bounds on integer parameters:
@@ -358,4 +415,4 @@ function badHaltExecution(): string
358415

359416
badHaltExecution();
360417
// Throws: TypeError: badHaltExecution(): Return value must be of type never
361-
```
418+
```

src/Validator/ArrayShapeValidator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
9191

9292
return null;
9393
}
94-
}
94+
}

src/Validator/ConstValidator.php

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
use TypePHP\Internal\TypeFormatter;
1919

2020
/**
21-
* @internal Validates literal values, class constants, and PHP 8.1 Enum cases against ConstTypeNode ASTs.
21+
* @internal Validates literal values, class constants, and wildcard constant patterns (Class::PREFIX_*) against ConstTypeNode ASTs.
2222
*/
2323
final class ConstValidator implements TypeValidatorInterface
2424
{
25+
/**
26+
* @var array<string, array<int, mixed>>
27+
*/
28+
private static array $wildcardConstantCache = [];
29+
2530
public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage
2631
{
2732
/** @var ConstTypeNode $constTypeNode */
@@ -41,9 +46,24 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
4146
} elseif ($constExpr instanceof ConstExprFloatNode) {
4247
$expected = (float) $constExpr->value;
4348
} elseif ($constExpr instanceof ConstFetchNode) {
44-
$fqcnConstant = $constExpr->className !== ''
45-
? $constExpr->className . '::' . $constExpr->name
46-
: $constExpr->name;
49+
$className = $constExpr->className;
50+
$pattern = $constExpr->name;
51+
52+
if (str_contains($pattern, '*')) {
53+
$allowedValues = self::resolveWildcardConstantValues($className, $pattern);
54+
55+
if (! \in_array($value, $allowedValues, true)) {
56+
$fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern;
57+
58+
return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given');
59+
}
60+
61+
return null;
62+
}
63+
64+
$fqcnConstant = $className !== ''
65+
? $className . '::' . $pattern
66+
: $pattern;
4767

4868
if (\defined($fqcnConstant)) {
4969
$expected = \constant($fqcnConstant);
@@ -69,4 +89,36 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
6989

7090
return null;
7191
}
92+
93+
/**
94+
* Resolves and caches all values of class constants matching a wildcard pattern (e.g. PREFIX_*).
95+
*
96+
* @return array<int, mixed>
97+
*/
98+
private static function resolveWildcardConstantValues(string $className, string $pattern): array
99+
{
100+
$cacheKey = $className . '::' . $pattern;
101+
if (isset(self::$wildcardConstantCache[$cacheKey])) {
102+
return self::$wildcardConstantCache[$cacheKey];
103+
}
104+
105+
$values = [];
106+
107+
if ($className !== '' && (class_exists($className) || interface_exists($className))) {
108+
try {
109+
$refClass = new \ReflectionClass($className);
110+
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i';
111+
112+
foreach ($refClass->getConstants() as $cName => $cValue) {
113+
if (preg_match($regex, $cName) === 1) {
114+
$values[] = $cValue;
115+
}
116+
}
117+
} catch (\ReflectionException $e) {
118+
// Silently ignore reflection errors
119+
}
120+
}
121+
122+
return self::$wildcardConstantCache[$cacheKey] = $values;
123+
}
72124
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
class EnumWildcard
8+
{
9+
/**
10+
* @param StatusEnum::* $status
11+
*/
12+
public static function processEnumCase(StatusEnum $status): StatusEnum
13+
{
14+
return $status;
15+
}
16+
17+
/**
18+
* @param StatusEnum::ACT* $status
19+
*/
20+
public static function processPrefixEnumCase(StatusEnum $status): StatusEnum
21+
{
22+
return $status;
23+
}
24+
}

tests/Fixtures/Types/GlobalTypes.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
*/
1111
class GlobalTypes
1212
{
13-
}
13+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
class WildcardConstantFixture
8+
{
9+
public const VERSION_SELECTION_ALL = 'all';
10+
public const VERSION_SELECTION_BLUE_GREEN = 'blue-green';
11+
private const VERSION_SELECTION_INTERNAL = 'internal-mode';
12+
13+
/**
14+
* @param self::VERSION_SELECTION_* $mode
15+
*/
16+
public static function setVersionMode(string $mode): string
17+
{
18+
return $mode;
19+
}
20+
}

tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ function testKeylessImplicitTupleShape(array $tuple): bool
121121
*
122122
* @phpstan-type LocalTupleAlias array{list<positive-int>, list<non-empty-string>}
123123
* @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list<int>}
124+
*
124125
* @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple
125126
*
126127
* @param LocalTupleAlias $payload
@@ -153,7 +154,6 @@ function testReturnKeylessTuple(bool $valid): array
153154
return [[10, 20], 'bundle'];
154155
}
155156

156-
157157
describe('Class Object Arrays (Dog[])', function () {
158158
test('accepts array of matching class instances', function () {
159159
expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2);
@@ -379,4 +379,4 @@ function testReturnKeylessTuple(bool $valid): array
379379
->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int")
380380
;
381381
});
382-
});
382+
});

tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ public function __construct()
140140

141141
test('falls back gracefully to generic union error when no deep branch matches structure', function () {
142142
expect(fn () => testDeepUnionError('string_instead_of_array'))
143-
->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)');
143+
->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)')
144+
;
144145
});
145146
});

tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed
226226
expect(testInferredOverridesDefault($dt, $dt))->toBe($dt);
227227

228228
expect(fn () => testInferredOverridesDefault($dt, new stdClass()))
229-
->toThrow(TypeError::class, 'Return value');
229+
->toThrow(TypeError::class, 'Return value')
230+
;
230231
});
231232
});

tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php

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

33
declare(strict_types=1);
44

5+
use TypePHP\Tests\Fixtures\Types\EnumWildcard;
6+
use TypePHP\Tests\Fixtures\Types\StatusEnum;
7+
use TypePHP\Tests\Fixtures\Types\WildcardConstantFixture;
8+
59
/**
610
* 1. Integer Extended Types
711
*
@@ -140,3 +144,32 @@ function testLiteralUnionsParam(string $status, int $code): bool
140144
;
141145
});
142146
});
147+
148+
describe('Wildcard Constant Pattern Validation (self::PREFIX_*)', function () {
149+
test('accepts values matching wildcard constant patterns (including private constants)', function () {
150+
expect(WildcardConstantFixture::setVersionMode('all'))->toBe('all');
151+
expect(WildcardConstantFixture::setVersionMode('blue-green'))->toBe('blue-green');
152+
expect(WildcardConstantFixture::setVersionMode('internal-mode'))->toBe('internal-mode');
153+
});
154+
155+
test('throws TypeError on value not matching wildcard constant pattern', function () {
156+
expect(fn () => WildcardConstantFixture::setVersionMode('invalid_mode'))
157+
->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*')
158+
;
159+
});
160+
});
161+
162+
describe('Enum Wildcard Pattern Validation (StatusEnum::*)', function () {
163+
test('accepts Enum case objects matching wildcard patterns (StatusEnum::* & StatusEnum::ACT*)', function () {
164+
expect(EnumWildcard::processEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active);
165+
expect(EnumWildcard::processEnumCase(StatusEnum::Pending))->toBe(StatusEnum::Pending);
166+
167+
expect(EnumWildcard::processPrefixEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active);
168+
});
169+
170+
test('throws TypeError when Enum case object does not match prefix wildcard (e.g. Pending for StatusEnum::ACT*)', function () {
171+
expect(fn () => EnumWildcard::processPrefixEnumCase(StatusEnum::Pending))
172+
->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\StatusEnum::ACT*')
173+
;
174+
});
175+
});

0 commit comments

Comments
 (0)