Skip to content

Commit b15fbcc

Browse files
committed
Support Container config with union types and intersection types
1 parent 5bb03c0 commit b15fbcc

File tree

4 files changed

+147
-47
lines changed

4 files changed

+147
-47
lines changed

docs/best-practices/controllers.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ all uppercase in any factory function like this:
392392
// Framework X also uses environment variables internally.
393393
// You may explicitly configure this built-in functionality like this:
394394
// 'X_LISTEN' => '0.0.0.0:8081'
395-
// 'X_LISTEN' => fn(string $PORT = '8080') => '0.0.0.0:' . $PORT
395+
// 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT
396396
'X_LISTEN' => '127.0.0.1:8080'
397397
]);
398398

@@ -404,7 +404,10 @@ all uppercase in any factory function like this:
404404
> ℹ️ **Passing environment variables**
405405
>
406406
> All environment variables defined on the process level will be made available
407-
> automatically. For temporary testing purposes, you may explicitly `export` or
407+
> automatically. Note that all environment variables are of type string by
408+
> definition, so may have to cast values or accept unions as required.
409+
>
410+
> For temporary testing purposes, you may explicitly `export` or
408411
> prefix environment variables to the command line. As a more permanent
409412
> solution, you may want to save your environment variables in your
410413
> [systemd configuration](deployment.md#systemd), [Docker settings](deployment.md#docker-containers),

src/Container.php

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -290,26 +290,13 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool
290290
\assert(\is_array($this->container));
291291
$type = $parameter->getType();
292292

293-
// abort for union types (PHP 8.0+) and intersection types (PHP 8.1+)
294-
// @phpstan-ignore-next-line for PHP < 8
295-
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart
296-
if ($parameter->isDefaultValueAvailable()) {
297-
return $parameter->getDefaultValue();
298-
}
299-
300-
throw new \Error(
301-
self::parameterError($parameter, $for) . ' expects unsupported type ' . $type
302-
);
303-
} // @codeCoverageIgnoreEnd
304-
305293
// load container variables if parameter name is known
306-
\assert($type === null || $type instanceof \ReflectionNamedType);
307294
if ($allowVariables && $this->hasVariable($parameter->getName())) {
308295
$value = $this->loadVariable($parameter->getName(), $depth);
309296

310297
// skip type checks and allow all values if expected type is undefined or mixed (PHP 8+)
311298
// allow null values if parameter is marked nullable or untyped or mixed
312-
if ($type === null || ($value === null && $parameter->allowsNull()) || $type->getName() === 'mixed' || $this->validateType($value, $type)) {
299+
if ($type === null || ($value === null && $parameter->allowsNull()) || ($type instanceof \ReflectionNamedType && $type->getName() === 'mixed') || $this->validateType($value, $type)) {
313300
return $value;
314301
}
315302

@@ -389,12 +376,31 @@ private function loadVariable(string $name, int $depth = 64) /*: object|string|i
389376

390377
/**
391378
* @param object|string|int|float|bool|null $value
392-
* @param \ReflectionNamedType $type
379+
* @param \ReflectionType $type
393380
* @throws void
394381
*/
395-
private function validateType($value, \ReflectionNamedType $type): bool
382+
private function validateType($value, \ReflectionType $type): bool
396383
{
384+
// check union types (PHP 8.0+) and intersection types (PHP 8.1+) and DNF types (PHP 8.2+)
385+
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart
386+
$early = $type instanceof \ReflectionUnionType;
387+
foreach ($type->getTypes() as $type) {
388+
// return early success if any union type matches
389+
// return early failure if any intersection type doesn't match
390+
if ($this->validateType($value, $type) === $early) {
391+
return $early;
392+
}
393+
}
394+
return !$early;
395+
} // @codeCoverageIgnoreEnd
396+
397+
// if we reach here, we handle only a single named type
398+
\assert($type instanceof \ReflectionNamedType);
397399
$type = $type->getName();
400+
401+
// nullable types and mixed already handled before entering this check
402+
\assert($type !== 'null' && $type !== 'mixed');
403+
398404
return (
399405
(\is_object($value) && $value instanceof $type) ||
400406
(\is_string($value) && $type === 'string') ||
@@ -424,14 +430,14 @@ private static function parameterError(\ReflectionParameter $parameter, string $
424430
}
425431

426432
/**
427-
* @param \ReflectionNamedType $type
433+
* @param \ReflectionType $type
428434
* @return string
429435
* @throws void
430436
* @see https://www.php.net/manual/en/reflectiontype.tostring.php (PHP 8+)
431437
*/
432-
private static function typeName(\ReflectionNamedType $type): string
438+
private static function typeName(\ReflectionType $type): string
433439
{
434-
return ($type->allowsNull() && $type->getName() !== 'mixed' ? '?' : '') . $type->getName();
440+
return $type instanceof \ReflectionNamedType ? ($type->allowsNull() && $type->getName() !== 'mixed' ? '?' : '') . $type->getName() : (string) $type;
435441
}
436442

437443
/**

tests/AppTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,14 +1648,14 @@ public function provideInvalidClasses(): \Generator
16481648
if (PHP_VERSION_ID >= 80000) {
16491649
yield [
16501650
InvalidConstructorUnion::class,
1651-
'Argument #1 ($value) of %s::__construct() expects unsupported type int|float'
1651+
'Argument #1 ($value) of %s::__construct() requires container config with type int|float, none given'
16521652
];
16531653
}
16541654

16551655
if (PHP_VERSION_ID >= 80100) {
16561656
yield [
16571657
InvalidConstructorIntersection::class,
1658-
'Argument #1 ($value) of %s::__construct() expects unsupported type Traversable&amp;ArrayAccess'
1658+
'Argument #1 ($value) of %s::__construct() requires container config with type Traversable&amp;ArrayAccess, none given'
16591659
];
16601660
}
16611661

tests/ContainerTest.php

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ public function __invoke(): ResponseInterface
743743
}
744744
};
745745

746-
$fn = $data = null;
746+
$fn = null;
747747
$fn = #[PHP8] fn(mixed $data = 42) => new Response(200, [], (string) json_encode($data)); // @phpstan-ignore-line
748748
$container = new Container([
749749
ResponseInterface::class => $fn,
@@ -1906,31 +1906,52 @@ public function testGetEnvReturnsStringFromMapFactory(): void
19061906
/**
19071907
* @requires PHP 8
19081908
*/
1909-
public function testGetEnvReturnsNullFromFactoryForUnsupportedUnionVariableWithNullDefaultEvenForKnownVariable(): void
1909+
public function testGetEnvReturnsStringFromFactoryFunctionWithUnionType(): void
19101910
{
19111911
$fn = null;
1912-
$fn = #[PHP8] function (string|int|null $bar = null) { return $bar; };
1912+
$fn = #[PHP8] function (string|int $X_UNION) { return (string) $X_UNION; };
19131913
$container = new Container([
19141914
'X_FOO' => $fn,
1915-
'bar' => 'ignored'
1915+
'X_UNION' => 42
19161916
]);
19171917

1918-
$this->assertNull($container->getEnv('X_FOO'));
1918+
$this->assertEquals('42', $container->getEnv('X_FOO'));
19191919
}
19201920

19211921
/**
1922-
* @requires PHP 8
1922+
* @requires PHP 8.1
19231923
*/
1924-
public function testGetEnvReturnsStringFromFactoryForUnsupportedUnionVariableWithStringDefaultEvenForKnownVariable(): void
1924+
public function testGetEnvReturnsStringFromFactoryFunctionWithIntersectionType(): void
19251925
{
1926-
$fn = null;
1927-
$fn = #[PHP8] function (string|int|null $bar = 'default') { return $bar; };
1926+
// eval to avoid syntax error on PHP < 8.1
1927+
$fn = eval('return function (\Traversable&\Stringable $X_UNION) { return (string) $X_UNION; };');
19281928
$container = new Container([
19291929
'X_FOO' => $fn,
1930-
'bar' => 'ignored'
1930+
'X_UNION' => new class implements \IteratorAggregate, \Stringable {
1931+
public function __toString(): string { return '42'; }
1932+
public function getIterator(): \Traversable { yield from []; }
1933+
}
19311934
]);
19321935

1933-
$this->assertEquals('default', $container->getEnv('X_FOO'));
1936+
$this->assertEquals('42', $container->getEnv('X_FOO'));
1937+
}
1938+
1939+
/**
1940+
* @requires PHP 8.2
1941+
*/
1942+
public function testGetEnvReturnsStringFromFactoryFunctionWithDnfType(): void
1943+
{
1944+
// eval to avoid syntax error on PHP < 8.2
1945+
$fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNION) { return (string) $X_UNION; };');
1946+
$container = new Container([
1947+
'X_FOO' => $fn,
1948+
'X_UNION' => new class implements \IteratorAggregate, \Stringable {
1949+
public function __toString(): string { return '42'; }
1950+
public function getIterator(): \Traversable { yield from []; }
1951+
}
1952+
]);
1953+
1954+
$this->assertEquals('42', $container->getEnv('X_FOO'));
19341955
}
19351956

19361957
public function testGetEnvReturnsNullFromFactoryForUnknownNullableVariableWithNullDefault(): void
@@ -1954,7 +1975,7 @@ public function testGetEnvReturnsStringFromFactoryForUnknownVariableWithStringDe
19541975
/**
19551976
* @requires PHP 8
19561977
*/
1957-
public function testGetEnvReturnsNullFromFactoryForUnknownAndUnsupportedUnionVariableWithNullDefault(): void
1978+
public function testGetEnvReturnsNullFromFactoryForUnknownUnionVariableWithNullDefault(): void
19581979
{
19591980
$fn = null;
19601981
$fn = #[PHP8] function (string|int|null $X_UNDEFINED = null) { return $X_UNDEFINED; };
@@ -1968,7 +1989,7 @@ public function testGetEnvReturnsNullFromFactoryForUnknownAndUnsupportedUnionVar
19681989
/**
19691990
* @requires PHP 8
19701991
*/
1971-
public function testGetEnvReturnsStringFromFactoryForUnknownAndUnsupportedUnionVariableWithStringDefault(): void
1992+
public function testGetEnvReturnsStringFromFactoryForUnknownUnionVariableWithStringDefault(): void
19721993
{
19731994
$fn = null;
19741995
$fn = #[PHP8] function (string|int|null $X_UNDEFINED = 'default') { return $X_UNDEFINED; };
@@ -2266,6 +2287,57 @@ public function testGetEnvThrowsWhenFactoryFunctionExpectsRequiredMixedEnvVariab
22662287
$container->getEnv('X_FOO');
22672288
}
22682289

2290+
/**
2291+
* @requires PHP 8
2292+
*/
2293+
public function testGetEnvThrowsWhenFactoryFunctionExpectsUnionTypeButNoneGiven(): void
2294+
{
2295+
$line = __LINE__ + 2;
2296+
$fn = null;
2297+
$fn = #[PHP8] function (string|int|null $X_UNDEFINED) { return $X_UNDEFINED; };
2298+
$container = new Container([
2299+
'X_FOO' => $fn
2300+
]);
2301+
2302+
$this->expectException(\Error::class);
2303+
$this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO requires container config with type string|int|null, none given');
2304+
$container->getEnv('X_FOO');
2305+
}
2306+
2307+
/**
2308+
* @requires PHP 8.1
2309+
*/
2310+
public function testGetEnvThrowsWhenFactoryFunctionExpectsIntersectionTypeButNoneGiven(): void
2311+
{
2312+
$line = __LINE__ + 2;
2313+
// eval to avoid syntax error on PHP < 8.1
2314+
$fn = eval('return function (\Traversable&\Stringable $X_UNDEFINED) { return (string) $X_UNDEFINED; };');
2315+
$container = new Container([
2316+
'X_FOO' => $fn
2317+
]);
2318+
2319+
$this->expectException(\Error::class);
2320+
$this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO requires container config with type Traversable&Stringable, none given');
2321+
$container->getEnv('X_FOO');
2322+
}
2323+
2324+
/**
2325+
* @requires PHP 8.2
2326+
*/
2327+
public function testGetEnvThrowsWhenFactoryFunctionExpectsDnfTypeButNoneGiven(): void
2328+
{
2329+
$line = __LINE__ + 2;
2330+
// eval to avoid syntax error on PHP < 8.2
2331+
$fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNDEFINED) { return (string) $X_UNDEFINED; };');
2332+
$container = new Container([
2333+
'X_FOO' => $fn
2334+
]);
2335+
2336+
$this->expectException(\Error::class);
2337+
$this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO requires container config with type (Traversable&Stringable)|string|float, none given');
2338+
$container->getEnv('X_FOO');
2339+
}
2340+
22692341
public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableIntArgumentButGivenString(): void
22702342
{
22712343
$line = __LINE__ + 2;
@@ -2282,35 +2354,54 @@ public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableIntArgumentBut
22822354
/**
22832355
* @requires PHP 8
22842356
*/
2285-
public function testGetEnvThrowsWhenFactoryFunctionExpectsUnsupportedUnionType(): void
2357+
public function testGetEnvThrowsWhenFactoryFunctionExpectsUnionTypeButWrongTypeGiven(): void
22862358
{
22872359
$line = __LINE__ + 2;
22882360
$fn = null;
22892361
$fn = #[PHP8] function (string|int $X_UNION) { return (string) $X_UNION; };
22902362
$container = new Container([
22912363
'X_FOO' => $fn,
2292-
'X_UNION' => 42
2364+
'X_UNION' => false
22932365
]);
22942366

2295-
$this->expectException(\Error::class);
2296-
$this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO expects unsupported type string|int');
2367+
$this->expectException(\TypeError::class);
2368+
$this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO must be of type string|int, false given');
22972369
$container->getEnv('X_FOO');
22982370
}
22992371

23002372
/**
2301-
* @requires PHP 8
2373+
* @requires PHP 8.1
23022374
*/
2303-
public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableUnionType(): void
2375+
public function testGetEnvThrowsWhenFactoryFunctionExpectsIntersectionTypeButWrongTypeGiven(): void
23042376
{
23052377
$line = __LINE__ + 2;
2306-
$fn = null;
2307-
$fn = #[PHP8] function (string|int|null $X_UNDEFINED) { return $X_UNDEFINED; };
2378+
// eval to avoid syntax error on PHP < 8.1
2379+
$fn = eval('return function (\Traversable&\ArrayAccess $X_INTERSECTION) { return var_export($X_INTERSECTION); };');
23082380
$container = new Container([
2309-
'X_FOO' => $fn
2381+
'X_FOO' => $fn,
2382+
'X_INTERSECTION' => false
23102383
]);
23112384

2312-
$this->expectException(\Error::class);
2313-
$this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO expects unsupported type string|int|null');
2385+
$this->expectException(\TypeError::class);
2386+
$this->expectExceptionMessage('Argument #1 ($X_INTERSECTION) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO must be of type Traversable&ArrayAccess, false given');
2387+
$container->getEnv('X_FOO');
2388+
}
2389+
2390+
/**
2391+
* @requires PHP 8.2
2392+
*/
2393+
public function testGetEnvThrowsWhenFactoryFunctionExpectsDnfTypeButWrongTypeGiven(): void
2394+
{
2395+
$line = __LINE__ + 2;
2396+
// eval to avoid syntax error on PHP < 8.2
2397+
$fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNION) { return (string) $X_UNION; };');
2398+
$container = new Container([
2399+
'X_FOO' => $fn,
2400+
'X_UNION' => null
2401+
]);
2402+
2403+
$this->expectException(\TypeError::class);
2404+
$this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO must be of type (Traversable&Stringable)|string|float, null given');
23142405
$container->getEnv('X_FOO');
23152406
}
23162407

0 commit comments

Comments
 (0)